add i18n system and improved plugin API with state management
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
update.log
|
||||
logs/audio-error.log
|
||||
logs/video-error.log
|
||||
registry.json
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
/** 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;
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
235
src/i18n/index.js
Normal file
235
src/i18n/index.js
Normal 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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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<import("whatsapp-web.js").Message|null>}
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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() ?? ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
99
src/kernel/pluginState.js
Normal file
99
src/kernel/pluginState.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
56
src/locales/en.json
Normal file
56
src/locales/en.json
Normal 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
56
src/locales/es.json
Normal 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
56
src/locales/pt.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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}<mídia>${c.reset}`;
|
||||
: `${c.dim}<media>${c.reset}`;
|
||||
|
||||
export const formatReply = (quotedName, quotedNumber, quotedPreview) =>
|
||||
`\n${c.gray} ↩ Para: ${c.reset}${c.white}${quotedName}${c.reset} ${c.dim}+${quotedNumber}${c.reset}` +
|
||||
|
||||
@@ -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 = "") =>
|
||||
|
||||
@@ -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<string>}
|
||||
*/
|
||||
@@ -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}"`
|
||||
|
||||
21
src/main.js
21
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...");
|
||||
logger.info(t("bot.initialized"));
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Utilitário CLI para descobrir IDs de chats/grupos.
|
||||
* Uso: node src/utils/get_id.js grupos|contatos|<nome>
|
||||
* CLI utility to discover chat/group IDs.
|
||||
* Usage: node src/utils/get_id.js groups|contacts|<name>
|
||||
*/
|
||||
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|<nome>");
|
||||
console.log("Usage: node get_id.js groups|contacts|<name>");
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
129
src/utils/pluginI18n.js
Normal file
129
src/utils/pluginI18n.js
Normal 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 };
|
||||
Reference in New Issue
Block a user