add i18n system and improved plugin API with state management

This commit is contained in:
synt-xerror
2026-04-21 11:18:19 -03:00
parent 18821dd951
commit f683496318
23 changed files with 836 additions and 179 deletions

6
.gitignore vendored
View File

@@ -1,6 +1,8 @@
env env
src/plugins/
.wwebjs_auth .wwebjs_auth
.wwebjs_cache .wwebjs_cache
.claude
downloads downloads
src/node_modules/ src/node_modules/
node_modules/ node_modules/
@@ -9,3 +11,7 @@ bin/
mychats.txt mychats.txt
manybot.conf manybot.conf
update.log update.log
logs/audio-error.log
logs/video-error.log
registry.json

View File

@@ -1,14 +1,14 @@
import os from "os"; import os from "os";
/** /**
* Detecta se o processo está rodando dentro do Termux. * Detect if running inside Termux.
*/ */
export const isTermux = export const isTermux =
(os.platform() === "linux" || os.platform() === "android") && (os.platform() === "linux" || os.platform() === "android") &&
process.env.PREFIX?.startsWith("/data/data/com.termux"); process.env.PREFIX?.startsWith("/data/data/com.termux");
/** /**
* Retorna a config de Puppeteer adequada ao ambiente. * Return Puppeteer config suitable for the environment.
* @returns {import("puppeteer").LaunchOptions} * @returns {import("puppeteer").LaunchOptions}
*/ */
export function resolvePuppeteerConfig() { export function resolvePuppeteerConfig() {

View File

@@ -1,25 +1,26 @@
import qrcode from "qrcode-terminal"; import qrcode from "qrcode-terminal";
import path from "path"; import path from "path";
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
import { isTermux } from "./environment.js"; import { isTermux } from "./environment.js";
const QR_PATH = path.resolve("qr.png"); const QR_PATH = path.resolve("qr.png");
/** /**
* Exibe ou salva o QR Code conforme o ambiente. * Display or save QR Code based on environment.
* @param {string} qr — string bruta do evento "qr" * @param {string} qr — raw string from "qr" event
*/ */
export async function handleQR(qr) { export async function handleQR(qr) {
if (isTermux) { if (isTermux) {
try { try {
await QRCode.toFile(QR_PATH, qr, { width: 400 }); await QRCode.toFile(QR_PATH, qr, { width: 400 });
logger.info(`QR Code salvo em: ${QR_PATH}`); logger.info(t("system.qrSaved", { path: QR_PATH }));
logger.info(`Abra com: termux-open qr.png`); logger.info(t("system.qrOpen"));
} catch (err) { } catch (err) {
logger.error("Falha ao salvar QR Code:", err.message); logger.error(t("system.qrSaveFailed"), err.message);
} }
} else { } else {
logger.info("Escaneie o QR Code abaixo:"); logger.info(t("system.qrScan"));
qrcode.generate(qr, { small: true }); qrcode.generate(qr, { small: true });
} }
} }

View File

@@ -1,19 +1,20 @@
import pkg from "whatsapp-web.js"; import pkg from "whatsapp-web.js";
import { CLIENT_ID } from "../config.js"; import { CLIENT_ID } from "../config.js";
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
import { isTermux, resolvePuppeteerConfig } from "./environment.js"; import { isTermux, resolvePuppeteerConfig } from "./environment.js";
import { handleQR } from "./qrHandler.js"; import { handleQR } from "./qrHandler.js";
import { printBanner } from "./banner.js"; import { printBanner } from "./banner.js";
export const { Client, LocalAuth, MessageMedia } = pkg; export const { Client, LocalAuth, MessageMedia } = pkg;
// ── Ambiente ───────────────────────────────────────────────── // ── Environment ───────────────────────────────────────────────
logger.info(isTermux logger.info(isTermux
? "Ambiente: Termux — usando Chromium do sistema" ? t("system.environmentTermux")
: `Ambiente: ${process.platform} — usando Puppeteer padrão` : t("system.environment", { platform: process.platform, puppeteer: "system Puppeteer" })
); );
// ── Instância ───────────────────────────────────────────────── // ── Instance ──────────────────────────────────────────────────
export const client = new Client({ export const client = new Client({
authStrategy: new LocalAuth({ clientId: CLIENT_ID }), authStrategy: new LocalAuth({ clientId: CLIENT_ID }),
puppeteer: { puppeteer: {
@@ -27,20 +28,20 @@ export const client = new Client({
}, },
}); });
// ── Eventos ─────────────────────────────────────────────────── // ── Events ───────────────────────────────────────────────────
client.on("qr", handleQR); client.on("qr", handleQR);
client.on("ready", () => { client.on("ready", () => {
printBanner(); printBanner();
logger.success("WhatsApp conectado e pronto!"); logger.success(t("system.connected"));
logger.info(`Client ID: ${CLIENT_ID}`); logger.info(t("system.clientId", { id: CLIENT_ID }));
}); });
client.on("disconnected", (reason) => { client.on("disconnected", (reason) => {
logger.warn(`Desconectado — motivo: ${reason}`); logger.warn(t("system.disconnected", { reason }));
logger.info("Reconectando em 5s..."); logger.info(t("system.reconnecting", { seconds: 5 }));
setTimeout(() => { setTimeout(() => {
logger.info("Reinicializando cliente..."); logger.info(t("system.reinitializing"));
client.initialize(); client.initialize();
}, 5000); }, 5000);
}); });

View File

@@ -1,11 +1,13 @@
/** /**
* config.js * config.js
* *
* Lê e parseia o manybot.conf. * Reads and parses manybot.conf.
* Suporta listas multilinhas e comentários inline. * Supports multiline lists and inline comments.
*/ */
import fs from "fs"; import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
function parseConf(raw) { function parseConf(raw) {
const lines = raw.split("\n"); const lines = raw.split("\n");
@@ -57,15 +59,23 @@ function parseConf(raw) {
return result; return result;
} }
const raw = fs.readFileSync("manybot.conf", "utf8"); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const filePath = path.join(__dirname, "../manybot.conf");
const raw = fs.readFileSync(filePath, "utf8");
const config = parseConf(raw); const config = parseConf(raw);
export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente"; export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente";
export const CMD_PREFIX = config.CMD_PREFIX ?? "!"; export const CMD_PREFIX = config.CMD_PREFIX ?? "!";
export const CHATS = config.CHATS ?? []; export const CHATS = config.CHATS ?? [];
/** Lista de plugins ativos — ex: PLUGINS=[video, audio, hello] */ /** Active plugin list — e.g., PLUGINS=[video, audio, hello] */
export const PLUGINS = config.PLUGINS ?? []; export const PLUGINS = config.PLUGINS ?? [];
/** Exporta o config completo para plugins que precisam de valores customizados */ /** Bot language — e.g., LANGUAGE=en (fallback: en) */
export const LANGUAGE = config.LANGUAGE ?? "en";
/** Export full config for plugins that need custom values */
export const CONFIG = config; export const CONFIG = config;

View File

@@ -1,18 +1,19 @@
/** /**
* src/download/queue.js * src/download/queue.js
* *
* Fila de execução sequencial para jobs pesados (downloads, conversões). * Sequential execution queue for heavy jobs (downloads, conversions).
* Garante que apenas um job roda por vez — sem sobrecarregar yt-dlp ou ffmpeg. * Ensures only one job runs at a time — without overloading yt-dlp or ffmpeg.
* *
* O plugin passa uma `workFn` que faz tudo: baixar, converter, enviar. * Plugin passes a `workFn` that does everything: download, convert, send.
* A fila só garante a sequência e trata erros. * Queue only handles sequence and error handling.
* *
* Uso: * Usage:
* import { enqueue } from "../../src/download/queue.js"; * import { enqueue } from "../../src/download/queue.js";
* enqueue(async () => { ... toda a lógica do plugin ... }, onError); * enqueue(async () => { ... all plugin logic ... }, onError);
*/ */
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
/** /**
* @typedef {{ * @typedef {{
@@ -26,10 +27,10 @@ let queue = [];
let processing = false; let processing = false;
/** /**
* Adiciona um job à fila e inicia o processamento se estiver idle. * Add job to queue and start processing if idle.
* *
* @param {Function} workFn — async () => void — toda a lógica do plugin * @param {Function} workFn — async () => void — all plugin logic
* @param {Function} errorFn — async (err) => void — chamado se workFn lançar * @param {Function} errorFn — async (err) => void — called if workFn throws
*/ */
export function enqueue(workFn, errorFn) { export function enqueue(workFn, errorFn) {
queue.push({ workFn, errorFn }); queue.push({ workFn, errorFn });
@@ -48,7 +49,7 @@ async function processJob({ workFn, errorFn }) {
try { try {
await workFn(); await workFn();
} catch (err) { } catch (err) {
logger.error(`Falha no job — ${err.message}`); logger.error(t("system.downloadJobFailed", { message: err.message }));
try { await errorFn(err); } catch { } try { await errorFn(err); } catch { }
} }
} }

235
src/i18n/index.js Normal file
View File

@@ -0,0 +1,235 @@
/**
* i18n/index.js
*
* Internationalization system for ManyBot.
* Loads translations based on LANGUAGE configuration.
* Fallback is always English (en).
*
* Plugins can use createPluginT() to have isolated i18n.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { CONFIG } from "../config.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.join(__dirname, "..", "locales");
// Default language (fallback)
const DEFAULT_LANG = "en";
// Cache of loaded translations
const translations = new Map();
/**
* Loads a translation JSON file
* @param {string} lang - language code (en, pt, es)
* @returns {object|null}
*/
function loadLocale(lang) {
if (translations.has(lang)) {
return translations.get(lang);
}
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
try {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(content);
translations.set(lang, data);
return data;
} catch (err) {
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
return null;
}
}
/**
* Gets configured language or default
* @returns {string}
*/
function getConfiguredLang() {
const lang = CONFIG.LANGUAGE?.trim().toLowerCase();
if (!lang) return DEFAULT_LANG;
// Check if file exists
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
if (!fs.existsSync(filePath)) {
console.warn(`[i18n] Language "${lang}" not found, falling back to "${DEFAULT_LANG}"`);
return DEFAULT_LANG;
}
return lang;
}
// Load languages
const currentLang = getConfiguredLang();
const currentTranslations = loadLocale(currentLang) || {};
const fallbackTranslations = loadLocale(DEFAULT_LANG) || {};
/**
* Gets a nested value from an object using dot path
* @param {object} obj
* @param {string} key - path like "system.connected"
* @returns {string|undefined}
*/
function getNestedValue(obj, key) {
const parts = key.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Replaces placeholders {{key}} with values from context
* @param {string} str
* @param {object} context
* @returns {string}
*/
function interpolate(str, context = {}) {
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return context[key] !== undefined ? String(context[key]) : match;
});
}
/**
* Main translation function
* @param {string} key - translation key (e.g., "system.connected")
* @param {object} context - values to interpolate {{key}}
* @returns {string}
*/
export function t(key, context = {}) {
// Try current language first
let value = getNestedValue(currentTranslations, key);
// Fallback to English if not found
if (value === undefined) {
value = getNestedValue(fallbackTranslations, key);
}
// If still not found, return the key
if (value === undefined) {
return key;
}
// If not string, convert
if (typeof value !== "string") {
return String(value);
}
// Interpolate values
return interpolate(value, context);
}
/**
* Creates an isolated translation function for a plugin.
* Plugins should have their own locale/ folder with en.json, es.json, etc.
*
* Usage in plugin:
* import { createPluginT } from "../../i18n/index.js";
* const { t } = createPluginT(import.meta.url);
*
* Folder structure:
* myPlugin/
* index.js
* locale/
* en.json
* es.json
* pt.json
*
* @param {string} pluginMetaUrl - import.meta.url from the plugin
* @returns {{ t: Function, lang: string }}
*/
export function createPluginT(pluginMetaUrl) {
const pluginDir = path.dirname(fileURLToPath(pluginMetaUrl));
const pluginLocaleDir = path.join(pluginDir, "locale");
// Get bot's configured language
const targetLang = currentLang;
// Load plugin translations
let pluginTranslations = {};
let pluginFallback = {};
try {
// Try to load the configured language
const targetPath = path.join(pluginLocaleDir, `${targetLang}.json`);
if (fs.existsSync(targetPath)) {
pluginTranslations = JSON.parse(fs.readFileSync(targetPath, "utf8"));
}
// Always load English as fallback
const fallbackPath = path.join(pluginLocaleDir, `${DEFAULT_LANG}.json`);
if (fs.existsSync(fallbackPath)) {
pluginFallback = JSON.parse(fs.readFileSync(fallbackPath, "utf8"));
}
} catch (err) {
// Silent fail - plugin may not have translations
}
/**
* Plugin-specific translation function
* @param {string} key
* @param {object} context
* @returns {string}
*/
function pluginT(key, context = {}) {
// Try plugin's target language first
let value = getNestedValue(pluginTranslations, key);
// Fallback to plugin's English
if (value === undefined) {
value = getNestedValue(pluginFallback, key);
}
// If still not found, return the key
if (value === undefined) {
return key;
}
if (typeof value !== "string") {
return String(value);
}
return interpolate(value, context);
}
return { t: pluginT, lang: targetLang };
}
/**
* Reloads translations (useful for hot-reload)
*/
export function reloadTranslations() {
translations.clear();
const lang = getConfiguredLang();
const newTranslations = loadLocale(lang) || {};
const newFallback = loadLocale(DEFAULT_LANG) || {};
// Update references
Object.assign(currentTranslations, newTranslations);
Object.assign(fallbackTranslations, newFallback);
console.log(`[i18n] Translations reloaded for language: ${lang}`);
}
/**
* Returns current language
* @returns {string}
*/
export function getCurrentLang() {
return currentLang;
}
export default { t, createPluginT, reloadTranslations, getCurrentLang };

View File

@@ -1,16 +1,16 @@
/** /**
* messageHandler.js * messageHandler.js
* *
* Pipeline central de uma mensagem recebida. * Central pipeline for received messages.
* *
* Ordem: * Order:
* 1. Filtra chats não permitidos (CHATS do .conf) * 1. Filter allowed chats (CHATS from .conf)
* — se CHATS estiver vazio, aceita todos os chats * — if CHATS is empty, accepts all chats
* 2. Loga a mensagem * 2. Log the message
* 3. Passa o contexto para todos os plugins ativos * 3. Pass context to all active plugins
* *
* O kernel não conhece nenhum comando distribui. * Kernel knows no commandsonly distributes.
* Cada plugin decide por conta própria se age ou ignora. * Each plugin decides on its own whether to act or ignore.
*/ */
import { CHATS } from "../config.js"; import { CHATS } from "../config.js";
@@ -26,7 +26,7 @@ export async function handleMessage(msg) {
const chat = await msg.getChat(); const chat = await msg.getChat();
const chatId = getChatId(chat); const chatId = getChatId(chat);
// CHATS vazio = aceita todos os chats // CHATS empty = accepts all chats
if (CHATS.length > 0 && !CHATS.includes(chatId)) return; if (CHATS.length > 0 && !CHATS.includes(chatId)) return;
const ctx = await buildMessageContext(msg, chat); const ctx = await buildMessageContext(msg, chat);

View File

@@ -1,11 +1,11 @@
/** /**
* pluginApi.js * pluginApi.js
* *
* Monta o objeto `api` que cada plugin recebe. * Builds the `api` object each plugin receives.
* Plugins só podem fazer o que está aqui — nunca tocam no client diretamente. * Plugins can only do what's here — never touch client directly.
* *
* O `chat` já vem filtrado pelo kernel (só chats permitidos no .conf), * `chat` is already filtered by kernel (only allowed chats from .conf),
* então plugins não precisam e não podem escolher destino. * so plugins don't need and can't choose destination.
*/ */
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
@@ -21,9 +21,9 @@ const { MessageMedia } = pkg;
* @returns {object} api * @returns {object} api
*/ */
/** /**
* API de setup — sem contexto de mensagem. * Setup API — without message context.
* Passada para plugin.setup(api) na inicialização. * Passed to plugin.setup(api) during initialization.
* Só tem sendTo e variantes, log e schedule. * Only has sendTo variants, log and schedule.
*/ */
export function buildSetupApi(client) { export function buildSetupApi(client) {
return { return {
@@ -63,44 +63,44 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
return { return {
// ── Leitura de mensagem ────────────────────────────────── // ── Message reading ─────────────────────────────────────
msg: { msg: {
/** Corpo da mensagem */ /** Message body */
body: msg.body ?? "", body: msg.body ?? "",
/** Tipo: "chat", "image", "video", "audio", "ptt", "sticker", "document" */ /** Type: "chat", "image", "video", "audio", "ptt", "sticker", "document" */
type: msg.type, type: msg.type,
/** true se a mensagem veio do próprio bot */ /** true if message came from bot itself */
fromMe: msg.fromMe, fromMe: msg.fromMe,
/** ID de quem enviou (ex: "5511999999999@c.us") */ /** Sender ID (ex: "5511999999999@c.us") */
sender: msg.author || msg.from, sender: msg.author || msg.from,
/** Nome de exibição de quem enviou */ /** Display name of sender */
senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""), senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
/** Tokens: ["!video", "https://..."] */ /** Tokens: ["!video", "https://..."] */
args: msg.body?.trim().split(/\s+/) ?? [], args: msg.body?.trim().split(/\s+/) ?? [],
/** /**
* Verifica se a mensagem é um comando específico. * Check if message is a specific command.
* @param {string} cmd — ex: "!hello" * @param {string} cmd — ex: "!hello"
*/ */
is(cmd) { is(cmd) {
return msg.body?.trim().toLowerCase().startsWith(cmd.toLowerCase()); return msg.body?.trim().toLowerCase().startsWith(cmd.toLowerCase());
}, },
/** true se a mensagem tem mídia anexada */ /** true if message has attached media */
hasMedia: msg.hasMedia, hasMedia: msg.hasMedia,
/** true se a mídia é um GIF (vídeo curto em loop) */ /** true if media is a GIF (short looping video) */
isGif: msg._data?.isGif ?? false, isGif: msg._data?.isGif ?? false,
/** /**
* Baixa a mídia da mensagem. * Download message media.
* Retorna um objeto neutro { mimetype, data } — sem expor MessageMedia. * Returns neutral object { mimetype, data } — without exposing MessageMedia.
* @returns {Promise<{ mimetype: string, data: string } | null>} * @returns {Promise<{ mimetype: string, data: string } | null>}
*/ */
async downloadMedia() { async downloadMedia() {
@@ -109,11 +109,11 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
return { mimetype: media.mimetype, data: media.data }; return { mimetype: media.mimetype, data: media.data };
}, },
/** true se a mensagem é uma resposta a outra */ /** true if message is a reply to another */
hasReply: msg.hasQuotedMsg, hasReply: msg.hasQuotedMsg,
/** /**
* Retorna a mensagem citada, se existir. * Returns quoted message if exists.
* @returns {Promise<import("whatsapp-web.js").Message|null>} * @returns {Promise<import("whatsapp-web.js").Message|null>}
*/ */
async getReply() { async getReply() {
@@ -122,7 +122,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Responde diretamente à mensagem (com quote). * Reply directly to message (with quote).
* @param {string} text * @param {string} text
*/ */
async reply(text) { async reply(text) {
@@ -130,10 +130,10 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
}, },
// ── Envio para o chat atual ────────────────────────────── // ── Send to current chat ─────────────────────────────────
/** /**
* Envia texto simples. * Send plain text.
* @param {string} text * @param {string} text
*/ */
async send(text) { async send(text) {
@@ -141,7 +141,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia uma mídia (imagem, vídeo, áudio, documento). * Send media (image, video, audio, document).
* @param {import("whatsapp-web.js").MessageMedia} media * @param {import("whatsapp-web.js").MessageMedia} media
* @param {string} [caption] * @param {string} [caption]
*/ */
@@ -150,7 +150,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia um arquivo de vídeo a partir de um caminho local. * Send video file from local path.
* @param {string} filePath * @param {string} filePath
* @param {string} [caption] * @param {string} [caption]
*/ */
@@ -160,7 +160,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia um arquivo de áudio a partir de um caminho local. * Send audio file from local path.
* @param {string} filePath * @param {string} filePath
*/ */
async sendAudio(filePath) { async sendAudio(filePath) {
@@ -169,7 +169,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia uma imagem a partir de um caminho local. * Send image from local path.
* @param {string} filePath * @param {string} filePath
* @param {string} [caption] * @param {string} [caption]
*/ */
@@ -179,9 +179,9 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia uma figurinha (sticker). * Send a sticker.
* Aceita filePath (string) ou buffer (Buffer) — o plugin nunca precisa * Accepts filePath (string) or buffer (Buffer) — plugin never needs
* saber que MessageMedia existe. * to know MessageMedia exists.
* @param {string | Buffer} source * @param {string | Buffer} source
*/ */
async sendSticker(source) { async sendSticker(source) {
@@ -191,10 +191,10 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
return currentChat.sendMessage(media, { sendMediaAsSticker: true }); return currentChat.sendMessage(media, { sendMediaAsSticker: true });
}, },
// ── Envio para chat específico ─────────────────────────── // ── Send to specific chat ───────────────────────────────
/** /**
* Envia texto para um chat específico por ID. * Send text to specific chat by ID.
* @param {string} chatId * @param {string} chatId
* @param {string} text * @param {string} text
*/ */
@@ -203,7 +203,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia vídeo para um chat específico por ID. * Send video to specific chat by ID.
* @param {string} chatId * @param {string} chatId
* @param {string} filePath * @param {string} filePath
* @param {string} [caption] * @param {string} [caption]
@@ -214,7 +214,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia áudio para um chat específico por ID. * Send audio to specific chat by ID.
* @param {string} chatId * @param {string} chatId
* @param {string} filePath * @param {string} filePath
*/ */
@@ -224,7 +224,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia imagem para um chat específico por ID. * Send image to specific chat by ID.
* @param {string} chatId * @param {string} chatId
* @param {string} filePath * @param {string} filePath
* @param {string} [caption] * @param {string} [caption]
@@ -235,7 +235,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
}, },
/** /**
* Envia figurinha para um chat específico por ID. * Send sticker to specific chat by ID.
* @param {string} chatId * @param {string} chatId
* @param {string | Buffer} source * @param {string | Buffer} source
*/ */
@@ -246,12 +246,12 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
return client.sendMessage(chatId, media, { sendMediaAsSticker: true }); return client.sendMessage(chatId, media, { sendMediaAsSticker: true });
}, },
// ── Acesso a outros plugins ────────────────────────────── // ── Access other plugins ─────────────────────────────────
/** /**
* Retorna a API pública de outro plugin (o que ele exportou em `exports`). * Return public API of another plugin (what it exported in `exports`).
* Retorna null se o plugin não existir ou estiver desativado. * Returns null if plugin doesn't exist or is disabled.
* @param {string} name — nome do plugin (pasta em /plugins) * @param {string} name — plugin name (folder in /plugins)
* @returns {any|null} * @returns {any|null}
*/ */
getPlugin(name) { getPlugin(name) {
@@ -267,7 +267,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) {
success: (...a) => logger.success(...a), success: (...a) => logger.success(...a),
}, },
// ── Info do chat atual ─────────────────────────────────── // ── Current chat info ────────────────────────────────────
chat: { chat: {
id: currentChat.id._serialized, id: currentChat.id._serialized,

View File

@@ -1,20 +1,21 @@
/** /**
* pluginGuard.js * pluginGuard.js
* *
* Executa um plugin com segurança. * Runs a plugin safely.
* Se o plugin lançar um erro: * If plugin throws an error:
* - Loga o erro com contexto * - Logs error with context
* - Marca o plugin como "error" no registry * - Marks plugin as "error" in registry
* - Nunca derruba o bot * - Never crashes the bot
* *
* Plugins desativados ou com erro são ignorados silenciosamente. * Disabled or errored plugins are silently ignored.
*/ */
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
import { pluginRegistry } from "./pluginLoader.js"; import { pluginRegistry } from "./pluginLoader.js";
/** /**
* @param {object} plugin — entrada do pluginRegistry * @param {object} plugin — pluginRegistry entry
* @param {object} context — { msg, chat, api } * @param {object} context — { msg, chat, api }
*/ */
export async function runPlugin(plugin, context) { export async function runPlugin(plugin, context) {
@@ -23,14 +24,14 @@ export async function runPlugin(plugin, context) {
try { try {
await plugin.run(context); await plugin.run(context);
} catch (err) { } catch (err) {
// Desativa o plugin para não continuar quebrando // Disable plugin to prevent further breakage
plugin.status = "error"; plugin.status = "error";
plugin.error = err; plugin.error = err;
pluginRegistry.set(plugin.name, plugin); pluginRegistry.set(plugin.name, plugin);
logger.error( logger.error(
`Plugin "${plugin.name}" desativado após erro: ${err.message}`, t("system.pluginDisabledAfterError", { name: plugin.name, message: err.message }),
`\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}` `\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}`
); );
} }
} }

View File

@@ -1,26 +1,28 @@
/** /**
* pluginLoader.js * pluginLoader.js
* *
* Responsável por: * Responsible for:
* 1. Ler quais plugins estão ativos no manybot.conf (PLUGINS=[...]) * 1. Reading active plugins from manybot.conf (PLUGINS=[...])
* 2. Carregar cada plugin da pasta /plugins * 2. Loading each plugin from /plugins folder
* 3. Registrar no pluginRegistry com status e exports públicos * 3. Registering in pluginRegistry with status and public exports
* 4. Expor o pluginRegistry para o kernel e para a pluginApi * 4. Exposing pluginRegistry to kernel and pluginApi
*
*/ */
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
const PLUGINS_DIR = path.resolve("plugins"); const PLUGINS_DIR = path.resolve("src/plugins");
/** /**
* Cada entrada no registry: * Each entry in registry:
* { * {
* name: string, * name: string,
* status: "active" | "disabled" | "error", * status: "active" | "disabled" | "error",
* run: async function({ msg, chat, api }) — a função default do plugin * run: async function({ msg, chat, api }) — plugin default function
* exports: any — o que o plugin expôs via `export const api = { ... }` * exports: any — what plugin exposed via `export const api = { ... }`
* error: Error | null * error: Error | null
* } * }
* *
@@ -29,14 +31,14 @@ const PLUGINS_DIR = path.resolve("plugins");
export const pluginRegistry = new Map(); export const pluginRegistry = new Map();
/** /**
* Carrega todos os plugins ativos listados em `activePlugins`. * Load all active plugins listed in `activePlugins`.
* Chamado uma vez na inicialização do bot. * Called once during bot initialization.
* *
* @param {string[]} activePlugins — nomes dos plugins ativos (do .conf) * @param {string[]} activePlugins — active plugin names (from .conf)
*/ */
export async function loadPlugins(activePlugins) { export async function loadPlugins(activePlugins) {
if (!fs.existsSync(PLUGINS_DIR)) { if (!fs.existsSync(PLUGINS_DIR)) {
logger.warn("Pasta /plugins não encontrada. Nenhum plugin carregado."); logger.warn(t("system.pluginsFolderNotFound"));
return; return;
} }
@@ -48,14 +50,17 @@ export async function loadPlugins(activePlugins) {
const ativos = [...pluginRegistry.values()].filter(p => p.status === "active").length; const ativos = [...pluginRegistry.values()].filter(p => p.status === "active").length;
const erros = total - ativos; const erros = total - ativos;
logger.success(`Plugins carregados: ${ativos} ativos${erros ? `, ${erros} com erro` : ""}`); logger.success(t("system.pluginsLoaded", {
count: ativos,
errors: erros ? t("system.pluginsLoadedWithErrors", { count: erros }) : ""
}));
} }
/** /**
* Chama setup(api) em todos os plugins que o exportarem. * Call setup(api) on all plugins that export it.
* Executado uma vez após o bot conectar ao WhatsApp. * Executed once after bot connects to WhatsApp.
* *
* @param {object} api — api sem contexto de mensagem (só sendTo, log, schedule...) * @param {object} api — api without message context (only sendTo, log, schedule...)
*/ */
export async function setupPlugins(api) { export async function setupPlugins(api) {
for (const plugin of pluginRegistry.values()) { for (const plugin of pluginRegistry.values()) {
@@ -63,7 +68,7 @@ export async function setupPlugins(api) {
try { try {
await plugin.setup(api); await plugin.setup(api);
} catch (err) { } catch (err) {
logger.error(`Falha no setup do plugin "${plugin.name}": ${err.message}`); logger.error(t("system.pluginSetupFailed", { name: plugin.name, message: err.message }));
} }
} }
} }
@@ -76,7 +81,7 @@ async function loadPlugin(name) {
const pluginPath = path.join(PLUGINS_DIR, name, "index.js"); const pluginPath = path.join(PLUGINS_DIR, name, "index.js");
if (!fs.existsSync(pluginPath)) { if (!fs.existsSync(pluginPath)) {
logger.warn(`Plugin "${name}" não encontrado em ${pluginPath}`); logger.warn(t("system.pluginNotFound", { name, path: pluginPath }));
pluginRegistry.set(name, { name, status: "disabled", run: null, exports: null, error: null }); pluginRegistry.set(name, { name, status: "disabled", run: null, exports: null, error: null });
return; return;
} }
@@ -84,9 +89,9 @@ async function loadPlugin(name) {
try { try {
const mod = await import(pluginPath); const mod = await import(pluginPath);
// O plugin deve exportar uma função default — essa é a função chamada a cada mensagem // Plugin must export a default function — this is called on every message
if (typeof mod.default !== "function") { if (typeof mod.default !== "function") {
throw new Error(`Plugin "${name}" não exporta uma função default`); throw new Error(`Plugin "${name}" does not export a default function`);
} }
pluginRegistry.set(name, { pluginRegistry.set(name, {
@@ -98,9 +103,9 @@ async function loadPlugin(name) {
error: null, error: null,
}); });
logger.info(`Plugin carregado: ${name}`); logger.info(t("system.pluginLoaded", { name }));
} catch (err) { } catch (err) {
logger.error(`Falha ao carregar plugin "${name}": ${err.message}`); logger.error(t("system.pluginLoadFailed", { name, message: err.message }));
pluginRegistry.set(name, { name, status: "error", run: null, exports: null, error: err }); pluginRegistry.set(name, { name, status: "error", run: null, exports: null, error: err });
} }
} }

99
src/kernel/pluginState.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* pluginState.js
*
* Tracks plugin execution state per chat.
* Used to implement the service vs non-service behavior:
* - Services (service: true) can run regardless of state
* - Non-services are blocked when another plugin is running in the same chat
*/
import { logger } from "../logger/logger.js";
/**
* Map<chatId, { pluginName: string, startedAt: Date }>
* Tracks which plugin is currently "holding the lock" in each chat
*/
const runningPlugins = new Map();
/**
* Check if any plugin is currently running in a specific chat
* @param {string} chatId - Chat ID (serialized)
* @returns {boolean}
*/
export function isPluginRunning(chatId) {
return runningPlugins.has(chatId);
}
/**
* Get info about the plugin running in a chat
* @param {string} chatId - Chat ID (serialized)
* @returns {{ pluginName: string, startedAt: Date } | null}
*/
export function getRunningPlugin(chatId) {
return runningPlugins.get(chatId) ?? null;
}
/**
* Mark a plugin as running in a chat
* @param {string} chatId - Chat ID (serialized)
* @param {string} pluginName - Name of the plugin taking the lock
*/
export function startPluginRun(chatId, pluginName) {
runningPlugins.set(chatId, {
pluginName,
startedAt: new Date()
});
logger.debug(`Plugin "${pluginName}" started in chat ${chatId}`);
}
/**
* Mark a plugin as finished in a chat
* @param {string} chatId - Chat ID (serialized)
* @param {string} pluginName - Name of the plugin releasing the lock
*/
export function endPluginRun(chatId, pluginName) {
const current = runningPlugins.get(chatId);
if (current && current.pluginName === pluginName) {
runningPlugins.delete(chatId);
logger.debug(`Plugin "${pluginName}" ended in chat ${chatId}`);
}
}
/**
* Force clear the running state for a chat
* Useful for cleanup or admin commands
* @param {string} chatId - Chat ID (serialized)
*/
export function clearPluginRun(chatId) {
runningPlugins.delete(chatId);
}
/**
* Get all chats where a specific plugin is running
* @param {string} pluginName - Plugin name
* @returns {string[]} Array of chat IDs
*/
export function getChatsWithPlugin(pluginName) {
const chats = [];
for (const [chatId, info] of runningPlugins.entries()) {
if (info.pluginName === pluginName) {
chats.push(chatId);
}
}
return chats;
}
/**
* Get stats about running plugins
* @returns {{ total: number, byPlugin: Record<string, number> }}
*/
export function getStats() {
const byPlugin = {};
for (const info of runningPlugins.values()) {
byPlugin[info.pluginName] = (byPlugin[info.pluginName] || 0) + 1;
}
return {
total: runningPlugins.size,
byPlugin
};
}

View File

@@ -1,30 +1,31 @@
/** /**
* scheduler.js * scheduler.js
* *
* Permite que plugins registrem tarefas agendadas via cron. * Allows plugins to register scheduled tasks via cron.
* Usa node-cron por baixo, mas plugins nunca importam node-cron diretamente * Uses node-cron underneath, but plugins never import node-cron directly
* eles chamam apenas api.schedule(cron, fn). * they only call api.schedule(cron, fn).
* *
* Uso no plugin: * Usage in plugin:
* import { schedule } from "many"; * import { schedule } from "many";
* schedule("0 9 * * 1", async () => { await api.send("Bom dia!"); }); * schedule("0 9 * * 1", async () => { await api.send("Good morning!"); });
*/ */
import cron from "node-cron"; import cron from "node-cron";
import { logger } from "../logger/logger.js"; import { logger } from "../logger/logger.js";
import { t } from "../i18n/index.js";
/** Lista de tasks ativas (para eventual teardown) */ /** List of active tasks (for eventual teardown) */
const tasks = []; const tasks = [];
/** /**
* Registra uma tarefa cron. * Register a cron task.
* @param {string} expression — expressão cron ex: "0 9 * * 1" * @param {string} expression — cron expression e.g., "0 9 * * 1"
* @param {Function} fn — função async a executar * @param {Function} fn — async function to execute
* @param {string} pluginName — nome do plugin (para log) * @param {string} pluginName — plugin name (for logging)
*/ */
export function schedule(expression, fn, pluginName = "unknown") { export function schedule(expression, fn, pluginName = "unknown") {
if (!cron.validate(expression)) { if (!cron.validate(expression)) {
logger.warn(`Plugin "${pluginName}" registrou expressão cron inválida: "${expression}"`); logger.warn(t("system.schedulerInvalidCron", { name: pluginName, expression }));
return; return;
} }
@@ -32,15 +33,15 @@ export function schedule(expression, fn, pluginName = "unknown") {
try { try {
await fn(); await fn();
} catch (err) { } catch (err) {
logger.error(`Erro no agendamento do plugin "${pluginName}": ${err.message}`); logger.error(t("system.schedulerError", { name: pluginName, message: err.message }));
} }
}); });
tasks.push({ pluginName, expression, task }); tasks.push({ pluginName, expression, task });
logger.info(`Agendamento registrado — plugin "${pluginName}" → "${expression}"`); logger.info(t("system.schedulerRegistered", { name: pluginName, expression }));
} }
/** Para todos os agendamentos (útil no shutdown) */ /** Stop all schedules (useful for shutdown) */
export function stopAll() { export function stopAll() {
tasks.forEach(({ task }) => task.stop()); tasks.forEach(({ task }) => task.stop());
tasks.length = 0; tasks.length = 0;

56
src/locales/en.json Normal file
View File

@@ -0,0 +1,56 @@
{
"bot": {
"starting": "Starting ManyBot...",
"initialized": "Client initialized. Waiting for WhatsApp connection...",
"ready": "Bot is ready!",
"error": {
"uncaught": "Uncaught exception",
"unhandled": "Unhandled rejection"
}
},
"log": {
"info": "INFO",
"success": "OK",
"warn": "WARN",
"error": "ERROR",
"msg": "MSG",
"cmd": "CMD",
"done": "DONE",
"context": {
"group": "group",
"from": "From",
"type": "Type",
"replyTo": "replies to"
}
},
"system": {
"environment": "Environment: {{platform}} — using {{puppeteer}}",
"environmentTermux": "Environment: Termux — using system Chromium",
"connected": "WhatsApp connected and ready!",
"disconnected": "Disconnected — reason: {{reason}}",
"reconnecting": "Reconnecting in {{seconds}}s...",
"reinitializing": "Reinitializing client...",
"qrSaved": "QR Code saved to: {{path}}",
"qrOpen": "Open with: termux-open qr.png",
"qrSaveFailed": "Failed to save QR Code:",
"qrScan": "Scan the QR Code below:",
"clientId": "Client ID: {{id}}",
"pluginsFolderNotFound": "Plugins folder not found. No plugins loaded.",
"pluginsLoaded": "Plugins loaded: {{count}} active{{errors}}",
"pluginsLoadedWithErrors": ", {{count}} with error",
"pluginSetupFailed": "Plugin \"{{name}}\" setup failed: {{message}}",
"pluginNotFound": "Plugin \"{{name}}\" not found at {{path}}",
"pluginLoaded": "Plugin loaded: {{name}}",
"pluginLoadFailed": "Failed to load plugin \"{{name}}\": {{message}}",
"pluginDisabledAfterError": "Plugin \"{{name}}\" disabled after error: {{message}}",
"schedulerInvalidCron": "Plugin \"{{name}}\" registered invalid cron expression: \"{{expression}}\"",
"schedulerError": "Plugin \"{{name}}\" scheduling error: {{message}}",
"schedulerRegistered": "Schedule registered — plugin \"{{name}}\" → \"{{expression}}\"",
"downloadJobFailed": "Download job failed — {{message}}"
},
"errors": {
"pluginLoad": "Failed to load plugin",
"messageProcess": "Failed to process message",
"stack": "Stack"
}
}

56
src/locales/es.json Normal file
View File

@@ -0,0 +1,56 @@
{
"bot": {
"starting": "Iniciando ManyBot...",
"initialized": "Cliente inicializado. Esperando conexión con WhatsApp...",
"ready": "¡Bot está listo!",
"error": {
"uncaught": "Excepción no capturada",
"unhandled": "Rechazo no manejado"
}
},
"log": {
"info": "INFO",
"success": "OK",
"warn": "WARN",
"error": "ERROR",
"msg": "MSG",
"cmd": "CMD",
"done": "DONE",
"context": {
"group": "grupo",
"from": "De",
"type": "Tipo",
"replyTo": "Responde a"
}
},
"system": {
"environment": "Entorno: {{platform}} — usando {{puppeteer}}",
"environmentTermux": "Entorno: Termux — usando Chromium del sistema",
"connected": "¡WhatsApp conectado y listo!",
"disconnected": "Desconectado — motivo: {{reason}}",
"reconnecting": "Reconectando en {{seconds}}s...",
"reinitializing": "Reinicializando cliente...",
"qrSaved": "Código QR guardado en: {{path}}",
"qrOpen": "Abrir con: termux-open qr.png",
"qrSaveFailed": "Error al guardar el Código QR:",
"qrScan": "Escanea el Código QR abajo:",
"clientId": "Client ID: {{id}}",
"pluginsFolderNotFound": "Carpeta de plugins no encontrada. Ningún plugin cargado.",
"pluginsLoaded": "Plugins cargados: {{count}} activos{{errors}}",
"pluginsLoadedWithErrors": ", {{count}} con error",
"pluginSetupFailed": "Error en la configuración del plugin \"{{name}}\": {{message}}",
"pluginNotFound": "Plugin \"{{name}}\" no encontrado en {{path}}",
"pluginLoaded": "Plugin cargado: {{name}}",
"pluginLoadFailed": "Error al cargar el plugin \"{{name}}\": {{message}}",
"pluginDisabledAfterError": "Plugin \"{{name}}\" desactivado después del error: {{message}}",
"schedulerInvalidCron": "Plugin \"{{name}}\" registró expresión cron inválida: \"{{expression}}\"",
"schedulerError": "Error en la programación del plugin \"{{name}}\": {{message}}",
"schedulerRegistered": "Programación registrada — plugin \"{{name}}\" → \"{{expression}}\"",
"downloadJobFailed": "Error en el trabajo de descarga — {{message}}"
},
"errors": {
"pluginLoad": "Error al cargar el plugin",
"messageProcess": "Error al procesar el mensaje",
"stack": "Stack"
}
}

56
src/locales/pt.json Normal file
View File

@@ -0,0 +1,56 @@
{
"bot": {
"starting": "Iniciando ManyBot...",
"initialized": "Cliente inicializado. Aguardando conexão com WhatsApp...",
"ready": "Bot está pronto!",
"error": {
"uncaught": "Exceção não capturada",
"unhandled": "Rejeição não tratada"
}
},
"log": {
"info": "INFO",
"success": "OK",
"warn": "WARN",
"error": "ERRO",
"msg": "MSG",
"cmd": "CMD",
"done": "DONE",
"context": {
"group": "grupo",
"from": "De",
"type": "Tipo",
"replyTo": "Responde"
}
},
"system": {
"environment": "Ambiente: {{platform}} — usando {{puppeteer}}",
"environmentTermux": "Ambiente: Termux — usando Chromium do sistema",
"connected": "WhatsApp conectado e pronto!",
"disconnected": "Desconectado — motivo: {{reason}}",
"reconnecting": "Reconectando em {{seconds}}s...",
"reinitializing": "Reinicializando cliente...",
"qrSaved": "QR Code salvo em: {{path}}",
"qrOpen": "Abra com: termux-open qr.png",
"qrSaveFailed": "Falha ao salvar QR Code:",
"qrScan": "Escaneie o QR Code abaixo:",
"clientId": "Client ID: {{id}}",
"pluginsFolderNotFound": "Pasta de plugins não encontrada. Nenhum plugin carregado.",
"pluginsLoaded": "Plugins carregados: {{count}} ativos{{errors}}",
"pluginsLoadedWithErrors": ", {{count}} com erro",
"pluginSetupFailed": "Falha na configuração do plugin \"{{name}}\": {{message}}",
"pluginNotFound": "Plugin \"{{name}}\" não encontrado em {{path}}",
"pluginLoaded": "Plugin carregado: {{name}}",
"pluginLoadFailed": "Falha ao carregar plugin \"{{name}}\": {{message}}",
"pluginDisabledAfterError": "Plugin \"{{name}}\" desativado após erro: {{message}}",
"schedulerInvalidCron": "Plugin \"{{name}}\" registrou expressão cron inválida: \"{{expression}}\"",
"schedulerError": "Erro no agendamento do plugin \"{{name}}\": {{message}}",
"schedulerRegistered": "Agendamento registrado — plugin \"{{name}}\" → \"{{expression}}\"",
"downloadJobFailed": "Falha no job de download — {{message}}"
},
"errors": {
"pluginLoad": "Falha ao carregar plugin",
"messageProcess": "Falha ao processar mensagem",
"stack": "Stack"
}
}

View File

@@ -1,4 +1,4 @@
// ── Paleta ANSI ────────────────────────────────────────────── // ── ANSI Palette ─────────────────────────────────────────────
export const c = { export const c = {
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m", green: "\x1b[32m", yellow: "\x1b[33m", cyan: "\x1b[36m",
@@ -8,30 +8,28 @@ export const c = {
export const SEP = `${c.gray}${"─".repeat(52)}${c.reset}`; export const SEP = `${c.gray}${"─".repeat(52)}${c.reset}`;
export const now = () => { export const now = () =>
if (process.argv[2] === "--systemd") return ""; `[${new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" })}]`;
return `[${new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" })}]`;
};
export const formatType = (type) => ({ export const formatType = (type) => ({
sticker: `${c.magenta}sticker${c.reset}`, sticker: `${c.magenta}sticker${c.reset}`,
image: `${c.cyan}imagem${c.reset}`, image: `${c.cyan}imagen${c.reset}`,
video: `${c.cyan}vídeo${c.reset}`, video: `${c.cyan}video${c.reset}`,
audio: `${c.cyan}áudio${c.reset}`, audio: `${c.cyan}audio${c.reset}`,
ptt: `${c.cyan}áudio${c.reset}`, ptt: `${c.cyan}audio${c.reset}`,
document: `${c.cyan}arquivo${c.reset}`, document: `${c.cyan}archivo${c.reset}`,
chat: `${c.white}texto${c.reset}`, chat: `${c.white}texto${c.reset}`,
}[type] ?? `${c.gray}${type}${c.reset}`); }[type] ?? `${c.gray}${type}${c.reset}`);
export const formatContext = (chatName, isGroup) => export const formatContext = (chatName, isGroup) =>
isGroup isGroup
? `${c.bold}${chatName}${c.reset} ${c.dim}(grupo)${c.reset}` ? `${c.bold}${chatName}${c.reset} ${c.dim}(group)${c.reset}`
: `${c.bold}${chatName}${c.reset} ${c.dim}(privado)${c.reset}`; : `${c.bold}${chatName}${c.reset} ${c.dim}(private)${c.reset}`;
export const formatBody = (body, isCommand) => export const formatBody = (body, isCommand) =>
body?.trim() body?.trim()
? `${isCommand ? c.yellow : c.green}"${body.length > 200 ? body.slice(0, 200) + "..." : body}"${c.reset}` ? `${isCommand ? c.yellow : c.green}"${body.length > 200 ? body.slice(0, 200) + "..." : body}"${c.reset}`
: `${c.dim}<mídia>${c.reset}`; : `${c.dim}<media>${c.reset}`;
export const formatReply = (quotedName, quotedNumber, quotedPreview) => export const formatReply = (quotedName, quotedNumber, quotedPreview) =>
`\n${c.gray} ↩ Para: ${c.reset}${c.white}${quotedName}${c.reset} ${c.dim}+${quotedNumber}${c.reset}` + `\n${c.gray} ↩ Para: ${c.reset}${c.white}${quotedName}${c.reset} ${c.dim}+${quotedNumber}${c.reset}` +

View File

@@ -2,10 +2,11 @@ import {
c, now, c, now,
formatType, formatContext, formatBody, formatReply, formatType, formatContext, formatBody, formatReply,
} from "./formatter.js"; } from "./formatter.js";
import { t } from "../i18n/index.js";
/** /**
* Logger central do ManyBot. * ManyBot central logger.
* Cada método lida apenas com saída — sem lógica de negócio ou I/O externo. * Each method only handles output — no business logic or external I/O.
*/ */
export const logger = { export const logger = {
info: (...a) => console.log(`${c.gray}${now()}${c.reset}${c.cyan}INFO ${c.reset}`, ...a), info: (...a) => console.log(`${c.gray}${now()}${c.reset}${c.cyan}INFO ${c.reset}`, ...a),
@@ -14,14 +15,14 @@ export const logger = {
error: (...a) => console.log(`${c.gray}${now()}${c.reset}${c.red}ERROR ${c.reset}`, ...a), error: (...a) => console.log(`${c.gray}${now()}${c.reset}${c.red}ERROR ${c.reset}`, ...a),
/** /**
* Loga uma mensagem recebida a partir de um contexto já resolvido. * Log a received message from a resolved context.
* @param {import("./messageContext.js").MessageContext} ctx * @param {import("./messageContext.js").MessageContext} ctx
*/ */
msg(ctx) { msg(ctx) {
const { chatName, isGroup, senderName, senderNumber, type, body, quoted } = ctx; const { chatName, isGroup, senderName, senderNumber, type, body, quoted } = ctx;
const context = isGroup ? `${chatName} (grupo)` : chatName; const context = isGroup ? `${chatName} (${t("log.context.group")})` : chatName;
const reply = quoted ? `Responde ${quoted.name} +${quoted.number}: "${quoted.preview}"` : ""; const reply = quoted ? `${t("log.context.replyTo")} ${quoted.name} +${quoted.number}: "${quoted.preview}"` : "";
console.log(`\n${c.gray}${now()}${c.reset}${c.cyan}MSG${c.reset} ${context} ${c.gray}De:${c.reset} ${c.white}${senderName}${c.reset} ${c.dim}+${senderNumber}${c.reset} ${c.gray}Tipo:${c.reset} ${type}${c.green}"${body}"${c.reset}${c.gray}${reply}${c.reset}`); console.log(`\n${c.gray}${now()}${c.reset}${c.cyan}MSG${c.reset} ${context} ${c.gray}${t("log.context.from")}:${c.reset} ${c.white}${senderName}${c.reset} ${c.dim}+${senderNumber}${c.reset} ${c.gray}${t("log.context.type")}:${c.reset} ${type}${c.green}"${body}"${c.reset}${c.gray}${reply}${c.reset}`);
}, },
cmd: (cmd, extra = "") => cmd: (cmd, extra = "") =>

View File

@@ -1,7 +1,7 @@
import client from "../client/whatsappClient.js"; import client from "../client/whatsappClient.js";
/** /**
* Extrai o número limpo de uma mensagem. * Extract clean number from message.
* @param {import("whatsapp-web.js").Message} msg * @param {import("whatsapp-web.js").Message} msg
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
@@ -12,8 +12,8 @@ export async function getNumber(msg) {
} }
/** /**
* Monta o contexto completo de uma mensagem para logging. * Build full message context for logging.
* Resolve contato, quoted message e metadados do chat. * Resolves contact, quoted message and chat metadata.
* *
* @param {import("whatsapp-web.js").Message} msg * @param {import("whatsapp-web.js").Message} msg
* @param {import("whatsapp-web.js").Chat} chat * @param {import("whatsapp-web.js").Chat} chat
@@ -51,8 +51,8 @@ export async function buildMessageContext(msg, chat, botPrefix) {
} }
/** /**
* Resolve os dados da mensagem citada, se existir. * Resolve quoted message data if exists.
* Retorna null em caso de erro ou ausência. * Returns null on error or if not present.
* *
* @param {import("whatsapp-web.js").Message} msg * @param {import("whatsapp-web.js").Message} msg
* @returns {Promise<{ name: string, number: string, preview: string } | null>} * @returns {Promise<{ name: string, number: string, preview: string } | null>}
@@ -68,7 +68,7 @@ async function resolveQuotedMessage(msg) {
try { try {
const contact = await client.getContactById(quoted.from); const contact = await client.getContactById(quoted.from);
quotedName = contact?.pushname || contact?.formattedName || quotedNumber; quotedName = contact?.pushname || contact?.formattedName || quotedNumber;
} catch { /* contato não encontrado — usa o número */ } } catch { /* contact not found — use number */ }
const quotedPreview = quoted.body?.trim() const quotedPreview = quoted.body?.trim()
? `"${quoted.body.length > 80 ? quoted.body.slice(0, 80) + "…" : quoted.body}"` ? `"${quoted.body.length > 80 ? quoted.body.slice(0, 80) + "…" : quoted.body}"`

View File

@@ -1,8 +1,8 @@
/** /**
* main.js * main.js
* *
* Ponto de entrada do ManyBot. * ManyBot entry point.
* Inicializa o cliente WhatsApp e carrega os plugins. * Initializes WhatsApp client and loads plugins.
*/ */
import client from "./client/whatsappClient.js"; import client from "./client/whatsappClient.js";
@@ -11,20 +11,21 @@ import { loadPlugins, setupPlugins } from "./kernel/pluginLoader.js";
import { buildSetupApi } from "./kernel/pluginApi.js"; import { buildSetupApi } from "./kernel/pluginApi.js";
import { logger } from "./logger/logger.js"; import { logger } from "./logger/logger.js";
import { PLUGINS } from "./config.js"; import { PLUGINS } from "./config.js";
import { t } from "./i18n/index.js";
logger.info("Iniciando ManyBot..."); logger.info(t("bot.starting"));
// Rede de segurança global — nenhum erro deve derrubar o bot // Global safety net — no error should crash the bot
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
logger.error(`uncaughtException${err.message}`, `\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}`); logger.error(`${t("bot.error.uncaught")}${err.message}`, `\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}`);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason); const msg = reason instanceof Error ? reason.message : String(reason);
logger.error(`unhandledRejection${msg}`); logger.error(`${t("bot.error.unhandled")}${msg}`);
}); });
// Carrega plugins antes de conectar // Load plugins before connecting
await loadPlugins(PLUGINS); await loadPlugins(PLUGINS);
client.on("message_create", async (msg) => { client.on("message_create", async (msg) => {
@@ -32,8 +33,8 @@ client.on("message_create", async (msg) => {
await handleMessage(msg); await handleMessage(msg);
} catch (err) { } catch (err) {
logger.error( logger.error(
`Falha ao processar${err.message}`, `${t("errors.messageProcess")}${err.message}`,
`\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}` `\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}`
); );
} }
}); });
@@ -43,4 +44,4 @@ client.on("ready", async () => {
}); });
client.initialize(); client.initialize();
console.log("\n"); console.log("\n");
logger.info("Cliente inicializado. Aguardando conexão com WhatsApp..."); logger.info(t("bot.initialized"));

View File

@@ -1,5 +1,5 @@
import { BOT_PREFIX } from "../config.js"; import { BOT_PREFIX } from "../config.js";
export function botMsg(texto) { export function botMsg(text) {
return `${BOT_PREFIX}\n${texto}`; return `${BOT_PREFIX}\n${text}`;
} }

View File

@@ -1,6 +1,6 @@
/** /**
* Utilitário CLI para descobrir IDs de chats/grupos. * CLI utility to discover chat/group IDs.
* Uso: node src/utils/get_id.js grupos|contatos|<nome> * Usage: node src/utils/get_id.js groups|contacts|<name>
*/ */
import pkg from "whatsapp-web.js"; import pkg from "whatsapp-web.js";
import qrcode from "qrcode-terminal"; import qrcode from "qrcode-terminal";
@@ -12,7 +12,7 @@ const { Client, LocalAuth } = pkg;
const arg = process.argv[2]; const arg = process.argv[2];
if (!arg) { if (!arg) {
console.log("Uso: node get_id.js grupos|contatos|<nome>"); console.log("Usage: node get_id.js groups|contacts|<name>");
process.exit(0); process.exit(0);
} }
@@ -30,29 +30,29 @@ const client = new Client({
}); });
client.on("qr", (qr) => { client.on("qr", (qr) => {
console.log("[QR] Escaneie para autenticar:"); console.log("[QR] Scan to authenticate:");
qrcode.generate(qr, { small: true }); qrcode.generate(qr, { small: true });
}); });
client.on("ready", async () => { client.on("ready", async () => {
console.log("[OK] Conectado. Buscando chats...\n"); console.log("[OK] Connected. Searching chats...\n");
const chats = await client.getChats(); const chats = await client.getChats();
const search = arg.toLowerCase(); const search = arg.toLowerCase();
const filtered = const filtered =
search === "grupos" ? chats.filter(c => c.isGroup) : search === "groups" ? chats.filter(c => c.isGroup) :
search === "contatos" ? chats.filter(c => !c.isGroup) : search === "contacts" ? chats.filter(c => !c.isGroup) :
chats.filter(c => (c.name || c.id.user).toLowerCase().includes(search)); chats.filter(c => (c.name || c.id.user).toLowerCase().includes(search));
if (!filtered.length) { if (!filtered.length) {
console.log("Nenhum resultado encontrado."); console.log("No results found.");
} else { } else {
filtered.forEach(c => { filtered.forEach(c => {
console.log("─".repeat(40)); console.log("─".repeat(40));
console.log("Nome: ", c.name || c.id.user); console.log("Name: ", c.name || c.id.user);
console.log("ID: ", c.id._serialized); console.log("ID: ", c.id._serialized);
console.log("Grupo: ", c.isGroup); console.log("Group: ", c.isGroup);
}); });
} }

129
src/utils/pluginI18n.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* src/utils/pluginI18n.js
*
* Independent i18n system for plugins.
* Plugins load their own translations from locale/ folder.
* Completely separate from bot core i18n.
*
* Usage in plugin:
* import { createPluginI18n } from "../utils/pluginI18n.js";
* const { t } = createPluginI18n(import.meta.url);
*
* Folder structure:
* myPlugin/
* index.js
* locale/
* en.json (required - fallback)
* pt.json
* es.json
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { LANGUAGE } from "../config.js";
// Default/fallback language
const DEFAULT_LANG = "en";
/**
* Gets a nested value from an object using dot path
* @param {object} obj
* @param {string} key - path like "error.notFound"
* @returns {string|undefined}
*/
function getNestedValue(obj, key) {
const parts = key.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Replaces placeholders {{key}} with values from context
* @param {string} str
* @param {object} context
* @returns {string}
*/
function interpolate(str, context = {}) {
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return context[key] !== undefined ? String(context[key]) : match;
});
}
/**
* Load translations for a plugin
* @param {string} localeDir - path to plugin's locale folder
* @param {string} lang - target language
* @returns {{ translations: object, fallback: object }}
*/
function loadTranslations(localeDir, lang) {
let translations = {};
let fallback = {};
try {
const targetPath = path.join(localeDir, `${lang}.json`);
if (fs.existsSync(targetPath)) {
translations = JSON.parse(fs.readFileSync(targetPath, "utf8"));
}
const fallbackPath = path.join(localeDir, `${DEFAULT_LANG}.json`);
if (fs.existsSync(fallbackPath)) {
fallback = JSON.parse(fs.readFileSync(fallbackPath, "utf8"));
}
} catch {
// Silent fail - plugin may not have translations
}
return { translations, fallback };
}
/**
* Creates an isolated translation function for a plugin.
* Language priority: PLUGIN_LANG env var > manybot.conf LANGUAGE > en
*
* @param {string} pluginMetaUrl - import.meta.url from the plugin
* @returns {{ t: Function, lang: string }}
*/
export function createPluginI18n(pluginMetaUrl) {
const pluginDir = path.dirname(fileURLToPath(pluginMetaUrl));
const localeDir = path.join(pluginDir, "locale");
const targetLang =
process.env.PLUGIN_LANG?.trim().toLowerCase() ||
LANGUAGE?.trim().toLowerCase() ||
DEFAULT_LANG;
const { translations, fallback } = loadTranslations(localeDir, targetLang);
/**
* Translation function
* @param {string} key - translation key (e.g., "error.notFound")
* @param {object} context - values to interpolate {{key}}
* @returns {string}
*/
function t(key, context = {}) {
let value = getNestedValue(translations, key);
if (value === undefined) {
value = getNestedValue(fallback, key);
}
if (value === undefined) return key;
if (typeof value !== "string") return String(value);
return interpolate(value, context);
}
return { t, lang: targetLang };
}
export default { createPluginI18n };