add i18n system and improved plugin API with state management

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

8
.gitignore vendored
View File

@@ -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

View File

@@ -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() {

View File

@@ -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 });
}
}

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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
View File

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

View File

@@ -1,16 +1,16 @@
/**
* messageHandler.js
*
* 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 distribui.
* Cada plugin decide por conta própria se age ou ignora.
* Kernel knows no commandsonly 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);

View File

@@ -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,

View File

@@ -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() ?? ""}`
);
}
}

View File

@@ -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
View File

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

View File

@@ -1,30 +1,31 @@
/**
* scheduler.js
*
* 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
View File

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

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

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

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

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

View File

@@ -1,4 +1,4 @@
// ── Paleta ANSI ──────────────────────────────────────────────
// ── ANSI Palette ─────────────────────────────────────────────
export const c = {
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}` +

View File

@@ -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 = "") =>

View File

@@ -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}"`

View File

@@ -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"));

View File

@@ -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}`;
}

View File

@@ -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
View File

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