From f683496318ad0e8cdadc2f8a3be7e7ffa3ef5543 Mon Sep 17 00:00:00 2001 From: synt-xerror <169557594+synt-xerror@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:18:19 -0300 Subject: [PATCH] add i18n system and improved plugin API with state management --- .gitignore | 8 +- src/client/environment.js | 4 +- src/client/qrHandler.js | 13 +- src/client/whatsappClient.js | 21 ++-- src/config.js | 22 +++- src/download/queue.js | 21 ++-- src/i18n/index.js | 235 +++++++++++++++++++++++++++++++++++ src/kernel/messageHandler.js | 18 +-- src/kernel/pluginApi.js | 82 ++++++------ src/kernel/pluginGuard.js | 21 ++-- src/kernel/pluginLoader.js | 51 ++++---- src/kernel/pluginState.js | 99 +++++++++++++++ src/kernel/scheduler.js | 29 ++--- src/locales/en.json | 56 +++++++++ src/locales/es.json | 56 +++++++++ src/locales/pt.json | 56 +++++++++ src/logger/formatter.js | 24 ++-- src/logger/logger.js | 13 +- src/logger/messageContext.js | 12 +- src/main.js | 21 ++-- src/utils/botMsg.js | 4 +- src/utils/get_id.js | 20 +-- src/utils/pluginI18n.js | 129 +++++++++++++++++++ 23 files changed, 836 insertions(+), 179 deletions(-) create mode 100644 src/i18n/index.js create mode 100644 src/kernel/pluginState.js create mode 100644 src/locales/en.json create mode 100644 src/locales/es.json create mode 100644 src/locales/pt.json create mode 100644 src/utils/pluginI18n.js diff --git a/.gitignore b/.gitignore index 2d26081..6ef8cb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ env +src/plugins/ .wwebjs_auth .wwebjs_cache +.claude downloads src/node_modules/ node_modules/ @@ -8,4 +10,8 @@ cookies.txt bin/ mychats.txt manybot.conf -update.log \ No newline at end of file +update.log +logs/audio-error.log +logs/video-error.log +registry.json + diff --git a/src/client/environment.js b/src/client/environment.js index 2def205..409af4e 100644 --- a/src/client/environment.js +++ b/src/client/environment.js @@ -1,14 +1,14 @@ import os from "os"; /** - * Detecta se o processo está rodando dentro do Termux. + * Detect if running inside Termux. */ export const isTermux = (os.platform() === "linux" || os.platform() === "android") && 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} */ export function resolvePuppeteerConfig() { diff --git a/src/client/qrHandler.js b/src/client/qrHandler.js index 8abb3a1..8dd2214 100644 --- a/src/client/qrHandler.js +++ b/src/client/qrHandler.js @@ -1,25 +1,26 @@ import qrcode from "qrcode-terminal"; import path from "path"; import { logger } from "../logger/logger.js"; +import { t } from "../i18n/index.js"; import { isTermux } from "./environment.js"; const QR_PATH = path.resolve("qr.png"); /** - * Exibe ou salva o QR Code conforme o ambiente. - * @param {string} qr — string bruta do evento "qr" + * Display or save QR Code based on environment. + * @param {string} qr — raw string from "qr" event */ export async function handleQR(qr) { if (isTermux) { try { await QRCode.toFile(QR_PATH, qr, { width: 400 }); - logger.info(`QR Code salvo em: ${QR_PATH}`); - logger.info(`Abra com: termux-open qr.png`); + logger.info(t("system.qrSaved", { path: QR_PATH })); + logger.info(t("system.qrOpen")); } catch (err) { - logger.error("Falha ao salvar QR Code:", err.message); + logger.error(t("system.qrSaveFailed"), err.message); } } else { - logger.info("Escaneie o QR Code abaixo:"); + logger.info(t("system.qrScan")); qrcode.generate(qr, { small: true }); } } \ No newline at end of file diff --git a/src/client/whatsappClient.js b/src/client/whatsappClient.js index fadbeca..2646be5 100644 --- a/src/client/whatsappClient.js +++ b/src/client/whatsappClient.js @@ -1,19 +1,20 @@ import pkg from "whatsapp-web.js"; import { CLIENT_ID } from "../config.js"; import { logger } from "../logger/logger.js"; +import { t } from "../i18n/index.js"; import { isTermux, resolvePuppeteerConfig } from "./environment.js"; import { handleQR } from "./qrHandler.js"; import { printBanner } from "./banner.js"; export const { Client, LocalAuth, MessageMedia } = pkg; -// ── Ambiente ───────────────────────────────────────────────── +// ── Environment ─────────────────────────────────────────────── logger.info(isTermux - ? "Ambiente: Termux — usando Chromium do sistema" - : `Ambiente: ${process.platform} — usando Puppeteer padrão` + ? t("system.environmentTermux") + : t("system.environment", { platform: process.platform, puppeteer: "system Puppeteer" }) ); -// ── Instância ───────────────────────────────────────────────── +// ── Instance ────────────────────────────────────────────────── export const client = new Client({ authStrategy: new LocalAuth({ clientId: CLIENT_ID }), puppeteer: { @@ -27,20 +28,20 @@ export const client = new Client({ }, }); -// ── Eventos ─────────────────────────────────────────────────── +// ── Events ──────────────────────────────────────────────────── client.on("qr", handleQR); client.on("ready", () => { printBanner(); - logger.success("WhatsApp conectado e pronto!"); - logger.info(`Client ID: ${CLIENT_ID}`); + logger.success(t("system.connected")); + logger.info(t("system.clientId", { id: CLIENT_ID })); }); client.on("disconnected", (reason) => { - logger.warn(`Desconectado — motivo: ${reason}`); - logger.info("Reconectando em 5s..."); + logger.warn(t("system.disconnected", { reason })); + logger.info(t("system.reconnecting", { seconds: 5 })); setTimeout(() => { - logger.info("Reinicializando cliente..."); + logger.info(t("system.reinitializing")); client.initialize(); }, 5000); }); diff --git a/src/config.js b/src/config.js index 313b794..25ce3c9 100644 --- a/src/config.js +++ b/src/config.js @@ -1,11 +1,13 @@ /** * config.js * - * Lê e parseia o manybot.conf. - * Suporta listas multilinhas e comentários inline. + * Reads and parses manybot.conf. + * Supports multiline lists and inline comments. */ import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; function parseConf(raw) { const lines = raw.split("\n"); @@ -57,15 +59,23 @@ function parseConf(raw) { 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); export const CLIENT_ID = config.CLIENT_ID ?? "bot_permanente"; export const CMD_PREFIX = config.CMD_PREFIX ?? "!"; 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 ?? []; -/** Exporta o config completo para plugins que precisam de valores customizados */ -export const CONFIG = config; \ No newline at end of file +/** 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; diff --git a/src/download/queue.js b/src/download/queue.js index 23ac1fa..bde5310 100644 --- a/src/download/queue.js +++ b/src/download/queue.js @@ -1,18 +1,19 @@ /** * src/download/queue.js * - * Fila de execução sequencial para jobs pesados (downloads, conversões). - * Garante que apenas um job roda por vez — sem sobrecarregar yt-dlp ou ffmpeg. + * Sequential execution queue for heavy jobs (downloads, conversions). + * 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. - * A fila só garante a sequência e trata erros. + * Plugin passes a `workFn` that does everything: download, convert, send. + * Queue only handles sequence and error handling. * - * Uso: + * Usage: * 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 { t } from "../i18n/index.js"; /** * @typedef {{ @@ -26,10 +27,10 @@ let queue = []; 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} errorFn — async (err) => void — chamado se workFn lançar + * @param {Function} workFn — async () => void — all plugin logic + * @param {Function} errorFn — async (err) => void — called if workFn throws */ export function enqueue(workFn, errorFn) { queue.push({ workFn, errorFn }); @@ -48,7 +49,7 @@ async function processJob({ workFn, errorFn }) { try { await workFn(); } catch (err) { - logger.error(`Falha no job — ${err.message}`); + logger.error(t("system.downloadJobFailed", { message: err.message })); try { await errorFn(err); } catch { } } } \ No newline at end of file diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..7b62fcc --- /dev/null +++ b/src/i18n/index.js @@ -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 }; diff --git a/src/kernel/messageHandler.js b/src/kernel/messageHandler.js index 705945b..7ff7254 100644 --- a/src/kernel/messageHandler.js +++ b/src/kernel/messageHandler.js @@ -1,16 +1,16 @@ /** * messageHandler.js * - * Pipeline central de uma mensagem recebida. + * Central pipeline for received messages. * - * Ordem: - * 1. Filtra chats não permitidos (CHATS do .conf) - * — se CHATS estiver vazio, aceita todos os chats - * 2. Loga a mensagem - * 3. Passa o contexto para todos os plugins ativos + * Order: + * 1. Filter allowed chats (CHATS from .conf) + * — if CHATS is empty, accepts all chats + * 2. Log the message + * 3. Pass context to all active plugins * - * O kernel não conhece nenhum comando — só distribui. - * Cada plugin decide por conta própria se age ou ignora. + * Kernel knows no commands — only distributes. + * Each plugin decides on its own whether to act or ignore. */ import { CHATS } from "../config.js"; @@ -26,7 +26,7 @@ export async function handleMessage(msg) { const chat = await msg.getChat(); const chatId = getChatId(chat); - // CHATS vazio = aceita todos os chats + // CHATS empty = accepts all chats if (CHATS.length > 0 && !CHATS.includes(chatId)) return; const ctx = await buildMessageContext(msg, chat); diff --git a/src/kernel/pluginApi.js b/src/kernel/pluginApi.js index 45c1981..5bb849b 100644 --- a/src/kernel/pluginApi.js +++ b/src/kernel/pluginApi.js @@ -1,11 +1,11 @@ /** * pluginApi.js * - * Monta o objeto `api` que cada plugin recebe. - * Plugins só podem fazer o que está aqui — nunca tocam no client diretamente. + * Builds the `api` object each plugin receives. + * Plugins can only do what's here — never touch client directly. * - * O `chat` já vem filtrado pelo kernel (só chats permitidos no .conf), - * então plugins não precisam e não podem escolher destino. + * `chat` is already filtered by kernel (only allowed chats from .conf), + * so plugins don't need and can't choose destination. */ import { logger } from "../logger/logger.js"; @@ -21,9 +21,9 @@ const { MessageMedia } = pkg; * @returns {object} api */ /** - * API de setup — sem contexto de mensagem. - * Passada para plugin.setup(api) na inicialização. - * Só tem sendTo e variantes, log e schedule. + * Setup API — without message context. + * Passed to plugin.setup(api) during initialization. + * Only has sendTo variants, log and schedule. */ export function buildSetupApi(client) { return { @@ -63,44 +63,44 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { return { - // ── Leitura de mensagem ────────────────────────────────── + // ── Message reading ───────────────────────────────────── msg: { - /** Corpo da mensagem */ + /** Message body */ body: msg.body ?? "", - /** Tipo: "chat", "image", "video", "audio", "ptt", "sticker", "document" */ + /** Type: "chat", "image", "video", "audio", "ptt", "sticker", "document" */ type: msg.type, - /** true se a mensagem veio do próprio bot */ + /** true if message came from bot itself */ fromMe: msg.fromMe, - /** ID de quem enviou (ex: "5511999999999@c.us") */ + /** Sender ID (ex: "5511999999999@c.us") */ 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+)?@.*$/, ""), /** Tokens: ["!video", "https://..."] */ 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" */ is(cmd) { 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, - /** 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, /** - * Baixa a mídia da mensagem. - * Retorna um objeto neutro { mimetype, data } — sem expor MessageMedia. + * Download message media. + * Returns neutral object { mimetype, data } — without exposing MessageMedia. * @returns {Promise<{ mimetype: string, data: string } | null>} */ async downloadMedia() { @@ -109,11 +109,11 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { 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, /** - * Retorna a mensagem citada, se existir. + * Returns quoted message if exists. * @returns {Promise} */ 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 */ 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 */ 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 {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} [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 */ 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} [caption] */ @@ -179,9 +179,9 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { }, /** - * Envia uma figurinha (sticker). - * Aceita filePath (string) ou buffer (Buffer) — o plugin nunca precisa - * saber que MessageMedia existe. + * Send a sticker. + * Accepts filePath (string) or buffer (Buffer) — plugin never needs + * to know MessageMedia exists. * @param {string | Buffer} source */ async sendSticker(source) { @@ -191,10 +191,10 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { 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} 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} filePath * @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} 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} filePath * @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 | Buffer} source */ @@ -246,12 +246,12 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { 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`). - * Retorna null se o plugin não existir ou estiver desativado. - * @param {string} name — nome do plugin (pasta em /plugins) + * Return public API of another plugin (what it exported in `exports`). + * Returns null if plugin doesn't exist or is disabled. + * @param {string} name — plugin name (folder in /plugins) * @returns {any|null} */ getPlugin(name) { @@ -267,7 +267,7 @@ export function buildApi({ msg, chat, client, pluginRegistry }) { success: (...a) => logger.success(...a), }, - // ── Info do chat atual ─────────────────────────────────── + // ── Current chat info ──────────────────────────────────── chat: { id: currentChat.id._serialized, diff --git a/src/kernel/pluginGuard.js b/src/kernel/pluginGuard.js index a8910c9..7cd7a5f 100644 --- a/src/kernel/pluginGuard.js +++ b/src/kernel/pluginGuard.js @@ -1,20 +1,21 @@ /** * 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 + * Runs a plugin safely. + * If plugin throws an error: + * - Logs error with context + * - Marks plugin as "error" in registry + * - 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 { t } from "../i18n/index.js"; import { pluginRegistry } from "./pluginLoader.js"; /** - * @param {object} plugin — entrada do pluginRegistry + * @param {object} plugin — pluginRegistry entry * @param {object} context — { msg, chat, api } */ export async function runPlugin(plugin, context) { @@ -23,14 +24,14 @@ export async function runPlugin(plugin, context) { try { await plugin.run(context); } catch (err) { - // Desativa o plugin para não continuar quebrando + // Disable plugin to prevent further breakage 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() ?? ""}` + t("system.pluginDisabledAfterError", { name: plugin.name, message: err.message }), + `\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}` ); } } \ No newline at end of file diff --git a/src/kernel/pluginLoader.js b/src/kernel/pluginLoader.js index a2db207..ac7fe06 100644 --- a/src/kernel/pluginLoader.js +++ b/src/kernel/pluginLoader.js @@ -1,26 +1,28 @@ /** * 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 + * Responsible for: + * 1. Reading active plugins from manybot.conf (PLUGINS=[...]) + * 2. Loading each plugin from /plugins folder + * 3. Registering in pluginRegistry with status and public exports + * 4. Exposing pluginRegistry to kernel and pluginApi + * */ import fs from "fs"; import path from "path"; 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, * 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 = { ... }` + * run: async function({ msg, chat, api }) — plugin default function + * exports: any — what plugin exposed via `export const api = { ... }` * error: Error | null * } * @@ -29,14 +31,14 @@ const PLUGINS_DIR = path.resolve("plugins"); export const pluginRegistry = new Map(); /** - * Carrega todos os plugins ativos listados em `activePlugins`. - * Chamado uma vez na inicialização do bot. + * Load all active plugins listed in `activePlugins`. + * 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) { if (!fs.existsSync(PLUGINS_DIR)) { - logger.warn("Pasta /plugins não encontrada. Nenhum plugin carregado."); + logger.warn(t("system.pluginsFolderNotFound")); return; } @@ -48,14 +50,17 @@ export async function loadPlugins(activePlugins) { const ativos = [...pluginRegistry.values()].filter(p => p.status === "active").length; 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. - * Executado uma vez após o bot conectar ao WhatsApp. + * Call setup(api) on all plugins that export it. + * 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) { for (const plugin of pluginRegistry.values()) { @@ -63,7 +68,7 @@ export async function setupPlugins(api) { try { await plugin.setup(api); } 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"); 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 }); return; } @@ -84,9 +89,9 @@ async function loadPlugin(name) { try { 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") { - 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, { @@ -98,9 +103,9 @@ async function loadPlugin(name) { error: null, }); - logger.info(`Plugin carregado: ${name}`); + logger.info(t("system.pluginLoaded", { name })); } 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 }); } } \ No newline at end of file diff --git a/src/kernel/pluginState.js b/src/kernel/pluginState.js new file mode 100644 index 0000000..843946e --- /dev/null +++ b/src/kernel/pluginState.js @@ -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 + * 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 }} + */ +export function getStats() { + const byPlugin = {}; + for (const info of runningPlugins.values()) { + byPlugin[info.pluginName] = (byPlugin[info.pluginName] || 0) + 1; + } + return { + total: runningPlugins.size, + byPlugin + }; +} diff --git a/src/kernel/scheduler.js b/src/kernel/scheduler.js index 7b067db..1c7ce5b 100644 --- a/src/kernel/scheduler.js +++ b/src/kernel/scheduler.js @@ -1,30 +1,31 @@ /** * 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). + * Allows plugins to register scheduled tasks via cron. + * Uses node-cron underneath, but plugins never import node-cron directly — + * they only call api.schedule(cron, fn). * - * Uso no plugin: + * Usage in plugin: * 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 { 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 = []; /** - * 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) + * Register a cron task. + * @param {string} expression — cron expression e.g., "0 9 * * 1" + * @param {Function} fn — async function to execute + * @param {string} pluginName — plugin name (for logging) */ export function schedule(expression, fn, pluginName = "unknown") { if (!cron.validate(expression)) { - logger.warn(`Plugin "${pluginName}" registrou expressão cron inválida: "${expression}"`); + logger.warn(t("system.schedulerInvalidCron", { name: pluginName, expression })); return; } @@ -32,15 +33,15 @@ export function schedule(expression, fn, pluginName = "unknown") { try { await fn(); } 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 }); - 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() { tasks.forEach(({ task }) => task.stop()); tasks.length = 0; diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..434693e --- /dev/null +++ b/src/locales/en.json @@ -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" + } +} diff --git a/src/locales/es.json b/src/locales/es.json new file mode 100644 index 0000000..e7ee4cf --- /dev/null +++ b/src/locales/es.json @@ -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" + } +} diff --git a/src/locales/pt.json b/src/locales/pt.json new file mode 100644 index 0000000..292ade0 --- /dev/null +++ b/src/locales/pt.json @@ -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" + } +} diff --git a/src/logger/formatter.js b/src/logger/formatter.js index 241762c..f4f5b05 100644 --- a/src/logger/formatter.js +++ b/src/logger/formatter.js @@ -1,4 +1,4 @@ -// ── Paleta ANSI ────────────────────────────────────────────── +// ── ANSI Palette ───────────────────────────────────────────── export const c = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", 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 now = () => { - if (process.argv[2] === "--systemd") return ""; - return `[${new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" })}]`; -}; +export const now = () => + `[${new Date().toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" })}]`; export const formatType = (type) => ({ sticker: `${c.magenta}sticker${c.reset}`, - image: `${c.cyan}imagem${c.reset}`, - video: `${c.cyan}vídeo${c.reset}`, - audio: `${c.cyan}áudio${c.reset}`, - ptt: `${c.cyan}áudio${c.reset}`, - document: `${c.cyan}arquivo${c.reset}`, + image: `${c.cyan}imagen${c.reset}`, + video: `${c.cyan}video${c.reset}`, + audio: `${c.cyan}audio${c.reset}`, + ptt: `${c.cyan}audio${c.reset}`, + document: `${c.cyan}archivo${c.reset}`, chat: `${c.white}texto${c.reset}`, }[type] ?? `${c.gray}${type}${c.reset}`); export const formatContext = (chatName, isGroup) => isGroup - ? `${c.bold}${chatName}${c.reset} ${c.dim}(grupo)${c.reset}` - : `${c.bold}${chatName}${c.reset} ${c.dim}(privado)${c.reset}`; + ? `${c.bold}${chatName}${c.reset} ${c.dim}(group)${c.reset}` + : `${c.bold}${chatName}${c.reset} ${c.dim}(private)${c.reset}`; export const formatBody = (body, isCommand) => body?.trim() ? `${isCommand ? c.yellow : c.green}"${body.length > 200 ? body.slice(0, 200) + "..." : body}"${c.reset}` - : `${c.dim}${c.reset}`; + : `${c.dim}${c.reset}`; export const formatReply = (quotedName, quotedNumber, quotedPreview) => `\n${c.gray} ↩ Para: ${c.reset}${c.white}${quotedName}${c.reset} ${c.dim}+${quotedNumber}${c.reset}` + diff --git a/src/logger/logger.js b/src/logger/logger.js index f517a74..7a97bd2 100644 --- a/src/logger/logger.js +++ b/src/logger/logger.js @@ -2,10 +2,11 @@ import { c, now, formatType, formatContext, formatBody, formatReply, } from "./formatter.js"; +import { t } from "../i18n/index.js"; /** - * Logger central do ManyBot. - * Cada método lida apenas com saída — sem lógica de negócio ou I/O externo. + * ManyBot central logger. + * Each method only handles output — no business logic or external I/O. */ export const logger = { 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), /** - * 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 */ msg(ctx) { const { chatName, isGroup, senderName, senderNumber, type, body, quoted } = ctx; - const context = isGroup ? `${chatName} (grupo)` : chatName; - const reply = quoted ? ` → Responde ${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}`); + const context = isGroup ? `${chatName} (${t("log.context.group")})` : chatName; + 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}— ${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 = "") => diff --git a/src/logger/messageContext.js b/src/logger/messageContext.js index 23d1853..9ee3956 100644 --- a/src/logger/messageContext.js +++ b/src/logger/messageContext.js @@ -1,7 +1,7 @@ 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 * @returns {Promise} */ @@ -12,8 +12,8 @@ export async function getNumber(msg) { } /** - * Monta o contexto completo de uma mensagem para logging. - * Resolve contato, quoted message e metadados do chat. + * Build full message context for logging. + * Resolves contact, quoted message and chat metadata. * * @param {import("whatsapp-web.js").Message} msg * @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. - * Retorna null em caso de erro ou ausência. + * Resolve quoted message data if exists. + * Returns null on error or if not present. * * @param {import("whatsapp-web.js").Message} msg * @returns {Promise<{ name: string, number: string, preview: string } | null>} @@ -68,7 +68,7 @@ async function resolveQuotedMessage(msg) { try { const contact = await client.getContactById(quoted.from); 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() ? `"${quoted.body.length > 80 ? quoted.body.slice(0, 80) + "…" : quoted.body}"` diff --git a/src/main.js b/src/main.js index aa0ea6b..4575a09 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,8 @@ /** * main.js * - * Ponto de entrada do ManyBot. - * Inicializa o cliente WhatsApp e carrega os plugins. + * ManyBot entry point. + * Initializes WhatsApp client and loads plugins. */ import client from "./client/whatsappClient.js"; @@ -11,20 +11,21 @@ import { loadPlugins, setupPlugins } from "./kernel/pluginLoader.js"; import { buildSetupApi } from "./kernel/pluginApi.js"; import { logger } from "./logger/logger.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) => { - 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) => { 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); client.on("message_create", async (msg) => { @@ -32,8 +33,8 @@ client.on("message_create", async (msg) => { await handleMessage(msg); } catch (err) { logger.error( - `Falha ao processar — ${err.message}`, - `\n Stack: ${err.stack?.split("\n")[1]?.trim() ?? ""}` + `${t("errors.messageProcess")} — ${err.message}`, + `\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}` ); } }); @@ -43,4 +44,4 @@ client.on("ready", async () => { }); client.initialize(); console.log("\n"); -logger.info("Cliente inicializado. Aguardando conexão com WhatsApp..."); \ No newline at end of file +logger.info(t("bot.initialized")); \ No newline at end of file diff --git a/src/utils/botMsg.js b/src/utils/botMsg.js index a3cc2e0..e382014 100644 --- a/src/utils/botMsg.js +++ b/src/utils/botMsg.js @@ -1,5 +1,5 @@ import { BOT_PREFIX } from "../config.js"; -export function botMsg(texto) { - return `${BOT_PREFIX}\n${texto}`; +export function botMsg(text) { + return `${BOT_PREFIX}\n${text}`; } \ No newline at end of file diff --git a/src/utils/get_id.js b/src/utils/get_id.js index 7e5c8e7..1c41d5b 100644 --- a/src/utils/get_id.js +++ b/src/utils/get_id.js @@ -1,6 +1,6 @@ /** - * Utilitário CLI para descobrir IDs de chats/grupos. - * Uso: node src/utils/get_id.js grupos|contatos| + * CLI utility to discover chat/group IDs. + * Usage: node src/utils/get_id.js groups|contacts| */ import pkg from "whatsapp-web.js"; import qrcode from "qrcode-terminal"; @@ -12,7 +12,7 @@ const { Client, LocalAuth } = pkg; const arg = process.argv[2]; if (!arg) { - console.log("Uso: node get_id.js grupos|contatos|"); + console.log("Usage: node get_id.js groups|contacts|"); process.exit(0); } @@ -30,29 +30,29 @@ const client = new Client({ }); client.on("qr", (qr) => { - console.log("[QR] Escaneie para autenticar:"); + console.log("[QR] Scan to authenticate:"); qrcode.generate(qr, { small: true }); }); client.on("ready", async () => { - console.log("[OK] Conectado. Buscando chats...\n"); + console.log("[OK] Connected. Searching chats...\n"); const chats = await client.getChats(); const search = arg.toLowerCase(); const filtered = - search === "grupos" ? chats.filter(c => c.isGroup) : - search === "contatos" ? chats.filter(c => !c.isGroup) : + search === "groups" ? chats.filter(c => c.isGroup) : + search === "contacts" ? chats.filter(c => !c.isGroup) : chats.filter(c => (c.name || c.id.user).toLowerCase().includes(search)); if (!filtered.length) { - console.log("Nenhum resultado encontrado."); + console.log("No results found."); } else { filtered.forEach(c => { 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("Grupo: ", c.isGroup); + console.log("Group: ", c.isGroup); }); } diff --git a/src/utils/pluginI18n.js b/src/utils/pluginI18n.js new file mode 100644 index 0000000..469e001 --- /dev/null +++ b/src/utils/pluginI18n.js @@ -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 }; \ No newline at end of file