[repo] reorganization using plugins instead of built-in commands, [plugin] new game: forca, [dev] changes on log format, [config] removed botMsg, [plugin] on stickers, you can create just one sending an image or replying to one, [setup] exec permissions and comand to install Chrome

This commit is contained in:
synt-xerror
2026-03-23 21:25:09 -03:00
parent f9911f6cf3
commit c75b6249c1
44 changed files with 1589 additions and 786 deletions

View File

@@ -0,0 +1,48 @@
/**
* messageHandler.js
*
* Pipeline central de uma mensagem recebida.
*
* Ordem:
* 1. Filtra chats não permitidos (CHATS do .conf)
* 2. Loga a mensagem
* 3. Passa o contexto para todos os plugins ativos
*
* O kernel não conhece nenhum comando — só distribui.
* Cada plugin decide por conta própria se age ou ignora.
*/
import { CHATS } from "../config.js";
import { getChatId } from "../utils/getChatId.js";
import { buildApi } from "./pluginApi.js";
import { pluginRegistry } from "./pluginLoader.js";
import { runPlugin } from "./pluginGuard.js";
import { buildMessageContext } from "../logger/messageContext.js";
import { logger } from "../logger/logger.js";
/**
* @param {import("whatsapp-web.js").Message} msg
*/
export async function handleMessage(msg) {
const chat = await msg.getChat();
const chatId = getChatId(chat);
// Filtra chats não autorizados
if (!CHATS.includes(chatId)) return;
// Loga a mensagem recebida
const ctx = await buildMessageContext(msg, chat);
logger.msg(ctx);
// Monta a api que será passada para os plugins
const api = buildApi({ msg, chat, pluginRegistry });
// Distribui para todos os plugins ativos
const context = { msg: api.msg, chat: api.chat, api };
for (const plugin of pluginRegistry.values()) {
await runPlugin(plugin, context);
}
logger.done("message_create", `de +${ctx.senderNumber}`);
}

187
src/kernel/pluginApi.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* pluginApi.js
*
* Monta o objeto `api` que cada plugin recebe.
* Plugins só podem fazer o que está aqui — nunca tocam no client diretamente.
*
* O `chat` já vem filtrado pelo kernel (só chats permitidos no .conf),
* então plugins não precisam e não podem escolher destino.
*/
import { logger } from "../logger/logger.js";
import pkg from "whatsapp-web.js";
const { MessageMedia } = pkg;
/**
* @param {object} params
* @param {import("whatsapp-web.js").Message} params.msg
* @param {import("whatsapp-web.js").Chat} params.chat
* @param {Map<string, any>} params.pluginRegistry
* @returns {object} api
*/
export function buildApi({ msg, chat, pluginRegistry }) {
// ── Helpers internos ──────────────────────────────────────
const currentChat = chat;
return {
// ── Leitura de mensagem ──────────────────────────────────
msg: {
/** Corpo da mensagem */
body: msg.body ?? "",
/** Tipo: "chat", "image", "video", "audio", "ptt", "sticker", "document" */
type: msg.type,
/** true se a mensagem veio do próprio bot */
fromMe: msg.fromMe,
/** ID de quem enviou (ex: "5511999999999@c.us") */
sender: msg.author || msg.from,
/** Nome de exibição de quem enviou */
senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
/** Tokens: ["!video", "https://..."] */
args: msg.body?.trim().split(/\s+/) ?? [],
/**
* Verifica se a mensagem é um comando específico.
* @param {string} cmd — ex: "!hello"
*/
is(cmd) {
return msg.body?.trim().toLowerCase().startsWith(cmd.toLowerCase());
},
/** true se a mensagem tem mídia anexada */
hasMedia: msg.hasMedia,
/** true se a mídia é um GIF (vídeo curto em loop) */
isGif: msg._data?.isGif ?? false,
/**
* Baixa a mídia da mensagem.
* Retorna um objeto neutro { mimetype, data } — sem expor MessageMedia.
* @returns {Promise<{ mimetype: string, data: string } | null>}
*/
async downloadMedia() {
const media = await msg.downloadMedia();
if (!media) return null;
return { mimetype: media.mimetype, data: media.data };
},
/** true se a mensagem é uma resposta a outra */
hasReply: msg.hasQuotedMsg,
/**
* Retorna a mensagem citada, se existir.
* @returns {Promise<import("whatsapp-web.js").Message|null>}
*/
async getReply() {
if (!msg.hasQuotedMsg) return null;
return msg.getQuotedMessage();
},
/**
* Responde diretamente à mensagem (com quote).
* @param {string} text
*/
async reply(text) {
return msg.reply(text);
},
},
// ── Envio para o chat atual ──────────────────────────────
/**
* Envia texto simples.
* @param {string} text
*/
async send(text) {
return currentChat.sendMessage(text);
},
/**
* Envia uma mídia (imagem, vídeo, áudio, documento).
* @param {import("whatsapp-web.js").MessageMedia} media
* @param {string} [caption]
*/
async sendMedia(media, caption = "") {
return currentChat.sendMessage(media, { caption });
},
/**
* Envia um arquivo de vídeo a partir de um caminho local.
* @param {string} filePath
* @param {string} [caption]
*/
async sendVideo(filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { caption });
},
/**
* Envia um arquivo de áudio a partir de um caminho local.
* @param {string} filePath
*/
async sendAudio(filePath) {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { sendAudioAsVoice: true });
},
/**
* Envia uma imagem a partir de um caminho local.
* @param {string} filePath
* @param {string} [caption]
*/
async sendImage(filePath, caption = "") {
const media = MessageMedia.fromFilePath(filePath);
return currentChat.sendMessage(media, { caption });
},
/**
* Envia uma figurinha (sticker).
* Aceita filePath (string) ou buffer (Buffer) — o plugin nunca precisa
* saber que MessageMedia existe.
* @param {string | Buffer} source
*/
async sendSticker(source) {
const media = typeof source === "string"
? MessageMedia.fromFilePath(source)
: new MessageMedia("image/webp", source.toString("base64"));
return currentChat.sendMessage(media, { sendMediaAsSticker: true });
},
// ── Acesso a outros plugins ──────────────────────────────
/**
* Retorna a API pública de outro plugin (o que ele exportou em `exports`).
* Retorna null se o plugin não existir ou estiver desativado.
* @param {string} name — nome do plugin (pasta em /plugins)
* @returns {any|null}
*/
getPlugin(name) {
return pluginRegistry.get(name)?.exports ?? null;
},
// ── Logger ───────────────────────────────────────────────
log: {
info: (...a) => logger.info(...a),
warn: (...a) => logger.warn(...a),
error: (...a) => logger.error(...a),
success: (...a) => logger.success(...a),
},
// ── Info do chat atual ───────────────────────────────────
chat: {
id: currentChat.id._serialized,
name: currentChat.name || currentChat.id.user,
isGroup: /@g\.us$/.test(currentChat.id._serialized),
},
};
}

36
src/kernel/pluginGuard.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* pluginGuard.js
*
* Executa um plugin com segurança.
* Se o plugin lançar um erro:
* - Loga o erro com contexto
* - Marca o plugin como "error" no registry
* - Nunca derruba o bot
*
* Plugins desativados ou com erro são ignorados silenciosamente.
*/
import { logger } from "../logger/logger.js";
import { pluginRegistry } from "./pluginLoader.js";
/**
* @param {object} plugin — entrada do pluginRegistry
* @param {object} context — { msg, chat, api }
*/
export async function runPlugin(plugin, context) {
if (plugin.status !== "active") return;
try {
await plugin.run(context);
} catch (err) {
// Desativa o plugin para não continuar quebrando
plugin.status = "error";
plugin.error = err;
pluginRegistry.set(plugin.name, plugin);
logger.error(
`Plugin "${plugin.name}" desativado após erro: ${err.message}`,
`\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}`
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* pluginLoader.js
*
* Responsável por:
* 1. Ler quais plugins estão ativos no manybot.conf (PLUGINS=[...])
* 2. Carregar cada plugin da pasta /plugins
* 3. Registrar no pluginRegistry com status e exports públicos
* 4. Expor o pluginRegistry para o kernel e para a pluginApi
*/
import fs from "fs";
import path from "path";
import { logger } from "../logger/logger.js";
const PLUGINS_DIR = path.resolve("src/plugins");
/**
* Cada entrada no registry:
* {
* name: string,
* status: "active" | "disabled" | "error",
* run: async function({ msg, chat, api }) — a função default do plugin
* exports: any — o que o plugin expôs via `export const api = { ... }`
* error: Error | null
* }
*
* @type {Map<string, object>}
*/
export const pluginRegistry = new Map();
/**
* Carrega todos os plugins ativos listados em `activePlugins`.
* Chamado uma vez na inicialização do bot.
*
* @param {string[]} activePlugins — nomes dos plugins ativos (do .conf)
*/
export async function loadPlugins(activePlugins) {
if (!fs.existsSync(PLUGINS_DIR)) {
logger.warn("Pasta /plugins não encontrada. Nenhum plugin carregado.");
return;
}
for (const name of activePlugins) {
await loadPlugin(name);
}
const total = pluginRegistry.size;
const ativos = [...pluginRegistry.values()].filter(p => p.status === "active").length;
const erros = total - ativos;
logger.success(`Plugins carregados: ${ativos} ativos${erros ? `, ${erros} com erro` : ""}`);
}
/**
* Carrega um único plugin pelo nome.
* @param {string} name
*/
async function loadPlugin(name) {
const pluginPath = path.join(PLUGINS_DIR, name, "index.js");
if (!fs.existsSync(pluginPath)) {
logger.warn(`Plugin "${name}" não encontrado em ${pluginPath}`);
pluginRegistry.set(name, { name, status: "disabled", run: null, exports: null, error: null });
return;
}
try {
const mod = await import(pluginPath);
// O plugin deve exportar uma função default — essa é a função chamada a cada mensagem
if (typeof mod.default !== "function") {
throw new Error(`Plugin "${name}" não exporta uma função default`);
}
pluginRegistry.set(name, {
name,
status: "active",
run: mod.default,
exports: mod.api ?? null, // exports públicos opcionais (api de outros plugins)
error: null,
});
logger.info(`Plugin carregado: ${name}`);
} catch (err) {
logger.error(`Falha ao carregar plugin "${name}": ${err.message}`);
pluginRegistry.set(name, { name, status: "error", run: null, exports: null, error: err });
}
}

47
src/kernel/scheduler.js Normal file
View File

@@ -0,0 +1,47 @@
/**
* scheduler.js
*
* Permite que plugins registrem tarefas agendadas via cron.
* Usa node-cron por baixo, mas plugins nunca importam node-cron diretamente —
* eles chamam apenas api.schedule(cron, fn).
*
* Uso no plugin:
* import { schedule } from "many";
* schedule("0 9 * * 1", async () => { await api.send("Bom dia!"); });
*/
import cron from "node-cron";
import { logger } from "../logger/logger.js";
/** Lista de tasks ativas (para eventual teardown) */
const tasks = [];
/**
* Registra uma tarefa cron.
* @param {string} expression — expressão cron ex: "0 9 * * 1"
* @param {Function} fn — função async a executar
* @param {string} pluginName — nome do plugin (para log)
*/
export function schedule(expression, fn, pluginName = "unknown") {
if (!cron.validate(expression)) {
logger.warn(`Plugin "${pluginName}" registrou expressão cron inválida: "${expression}"`);
return;
}
const task = cron.schedule(expression, async () => {
try {
await fn();
} catch (err) {
logger.error(`Erro no agendamento do plugin "${pluginName}": ${err.message}`);
}
});
tasks.push({ pluginName, expression, task });
logger.info(`Agendamento registrado — plugin "${pluginName}" → "${expression}"`);
}
/** Para todos os agendamentos (útil no shutdown) */
export function stopAll() {
tasks.forEach(({ task }) => task.stop());
tasks.length = 0;
}