@manybot/manybot 5.2.0 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * drivers/whatsapp/index.ts
3
+ *
4
+ * Main WhatsApp driver entry point.
5
+ * Implements the BotDriver interface for WhatsApp (Baileys).
6
+ *
7
+ * Responsibilities:
8
+ * - Socket lifecycle (connect/disconnect)
9
+ * - Message routing to kernel
10
+ * - Plugin context building (via buildApi)
11
+ * - Event handling and plugin execution
12
+ */
13
+ import { createSocket, normalizeJid, AUTH_DIR } from "./sdk/baileysSock.js";
14
+ import { handleMessage } from "./messageHandler.js";
15
+ import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.js";
16
+ import { logger } from "#logger";
17
+ import { PLUGINS, CLIENT_ID } from "#config";
18
+ import { t } from "#i18n";
19
+ import { printBanner } from "#client/banner.js";
20
+ import { DisconnectReason } from "@whiskeysockets/baileys";
21
+ import fs from "fs/promises";
22
+ let state = "BOOT";
23
+ let shuttingDown = false;
24
+ let currentSock = null;
25
+ let currentStore = null;
26
+ async function startBot() {
27
+ const { sock, store } = await createSocket();
28
+ currentSock = sock;
29
+ currentStore = store;
30
+ // ── Normal bot mode ─────────────────────────────────────────────────────────
31
+ sock.ev.on("connection.update", async (update) => {
32
+ const { connection, lastDisconnect } = update;
33
+ if (connection === "open") {
34
+ state = "READY_INIT";
35
+ logger.success(t("system.connected"));
36
+ logger.info(t("system.clientId", { id: CLIENT_ID }));
37
+ printBanner();
38
+ await loadPlugins(PLUGINS);
39
+ await setupPlugins(sock, store);
40
+ // buffer anti-replay / sync ghost messages
41
+ setTimeout(() => { state = "READY"; }, 2000);
42
+ }
43
+ if (connection === "close") {
44
+ const code = lastDisconnect?.error?.output?.statusCode;
45
+ const loggedOut = code === DisconnectReason.loggedOut;
46
+ state = "BOOT";
47
+ logger.warn(t("system.disconnected", { reason: String(code) }));
48
+ if (loggedOut) {
49
+ logger.warn(t("system.sessionExpired"));
50
+ try {
51
+ await fs.rm(AUTH_DIR, { recursive: true, force: true });
52
+ }
53
+ catch (e) {
54
+ logger.error(`[whatsapp] Failed to remove session dir: ${e.message}`);
55
+ }
56
+ if (!shuttingDown)
57
+ setTimeout(() => startBot(), 1000);
58
+ }
59
+ else if (!shuttingDown) {
60
+ logger.info(t("system.reconnecting", { secs: 5 }));
61
+ setTimeout(() => startBot(), 5000);
62
+ }
63
+ }
64
+ });
65
+ // Incoming messages
66
+ sock.ev.on("messages.upsert", async ({ messages, type }) => {
67
+ if (type !== "notify" || state !== "READY")
68
+ return;
69
+ for (const msg of messages) {
70
+ // Skip empty messages (e.g. presence updates)
71
+ const body = getBodyQuick(msg);
72
+ if (!body && !msgHasMediaQuick(msg))
73
+ continue;
74
+ try {
75
+ await handleMessage(msg, sock, store);
76
+ }
77
+ catch (e) {
78
+ const err = e instanceof Error ? e : new Error(String(e));
79
+ logger.error(`${err.message}\n${err.stack}`);
80
+ }
81
+ }
82
+ });
83
+ }
84
+ /** Quick body extraction to avoid importing helpers here. */
85
+ function getBodyQuick(msg) {
86
+ const m = msg.message;
87
+ if (!m)
88
+ return "";
89
+ return (m.conversation ??
90
+ m.extendedTextMessage?.text ??
91
+ m.imageMessage?.caption ??
92
+ m.videoMessage?.caption ??
93
+ "");
94
+ }
95
+ function msgHasMediaQuick(msg) {
96
+ const m = msg.message;
97
+ return !!(m?.imageMessage || m?.videoMessage || m?.audioMessage || m?.documentMessage || m?.stickerMessage);
98
+ }
99
+ export const whatsappDriver = {
100
+ async connect() {
101
+ shuttingDown = false;
102
+ await startBot();
103
+ },
104
+ async disconnect() {
105
+ shuttingDown = true;
106
+ if (currentSock) {
107
+ try {
108
+ currentSock.ev?.removeAllListeners();
109
+ }
110
+ catch { }
111
+ try {
112
+ currentSock.end(undefined);
113
+ }
114
+ catch { }
115
+ currentSock = null;
116
+ currentStore = null;
117
+ }
118
+ },
119
+ /**
120
+ * Diagnostic mode: connects normally (reusing an already saved
121
+ * session/QR), but instead of loading plugins, just waits for the
122
+ * NEXT message to arrive — from any chat, from any sender, whether
123
+ * it's your own message or someone else's — prints the normalized
124
+ * JID and the chat name (if any), and exits.
125
+ *
126
+ * Intentionally does not go through the CHATS/fromMe/dedup filters of
127
+ * handleMessage: the only goal here is to discover the JID so you can
128
+ * configure CHATS/TEST_CHAT afterward, so there's no reason to filter
129
+ * anything out yet.
130
+ */
131
+ async getId() {
132
+ const { sock } = await createSocket();
133
+ logger.info("[getid] Connecting... wait for the QR/pairing prompt if this is the first run.");
134
+ await new Promise((resolve) => {
135
+ let done = false;
136
+ sock.ev.on("connection.update", (update) => {
137
+ if (update.connection === "open") {
138
+ logger.success("[getid] Connected. Send ANY message in the chat you want to identify.");
139
+ }
140
+ });
141
+ sock.ev.on("messages.upsert", ({ messages, type }) => {
142
+ if (done || type !== "notify")
143
+ return;
144
+ const msg = messages[0];
145
+ const rawJid = msg?.key?.remoteJid;
146
+ if (!rawJid)
147
+ return;
148
+ done = true;
149
+ const jid = normalizeJid(rawJid);
150
+ const name = msg?.pushName ?? "";
151
+ logger.success(`[getid] JID: ${jid}`);
152
+ if (name)
153
+ logger.info(`[getid] Chat/sender: ${name}`);
154
+ logger.info("[getid] Paste this value into CHATS or TEST_CHAT in manybot.toml.");
155
+ resolve();
156
+ });
157
+ });
158
+ try {
159
+ sock.ev?.removeAllListeners();
160
+ sock.end(undefined);
161
+ }
162
+ catch { }
163
+ },
164
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * loginPrompt.ts
3
+ *
4
+ * Interactive first-login flow. Only called when there is no valid
5
+ * session yet in .manybot/sessions — if one already exists, the driver
6
+ * skips this entirely and connects directly.
7
+ *
8
+ * Uses @clack/prompts: pure ANSI, no GUI/display, so it works the same
9
+ * over SSH, tmux, containers, etc — it just needs a normal TTY.
10
+ */
11
+ import * as clack from "@clack/prompts";
12
+ import { CONFIG, persistConfigValue } from "#config";
13
+ import { t } from "#i18n";
14
+ function cancelAndExit() {
15
+ clack.cancel(t("onboarding.cancelled"));
16
+ process.exit(1);
17
+ }
18
+ /**
19
+ * Asks which login method to use and saves the choice to manybot.toml
20
+ * (LOGIN_METHOD), so it won't be asked again next time.
21
+ */
22
+ async function promptLoginMethod() {
23
+ const choice = await clack.select({
24
+ message: t("onboarding.methodPrompt"),
25
+ options: [
26
+ { value: "phone", label: t("onboarding.methodPhone"), hint: t("onboarding.methodPhoneHint") },
27
+ { value: "qr", label: t("onboarding.methodQr"), hint: t("onboarding.methodQrHint") },
28
+ ],
29
+ });
30
+ if (clack.isCancel(choice))
31
+ cancelAndExit();
32
+ await persistConfigValue("LOGIN_METHOD", choice);
33
+ return choice;
34
+ }
35
+ /**
36
+ * Asks for the phone number (with country code) and saves it to
37
+ * manybot.toml (PHONE_NUMBER).
38
+ */
39
+ async function promptPhoneNumber() {
40
+ const phone = await clack.text({
41
+ message: t("onboarding.phonePrompt"),
42
+ placeholder: "5511999999999",
43
+ validate(value) {
44
+ if (!/^\d{8,15}$/.test(value.trim())) {
45
+ return t("onboarding.phoneValidation");
46
+ }
47
+ },
48
+ });
49
+ if (clack.isCancel(phone))
50
+ cancelAndExit();
51
+ const clean = phone.trim();
52
+ await persistConfigValue("PHONE_NUMBER", clean);
53
+ return clean;
54
+ }
55
+ /**
56
+ * Resolves the login method for this run:
57
+ * - LOGIN_METHOD already configured → use it directly, no prompts.
58
+ * - Not configured → show the menu, save the choice.
59
+ * - Method is "phone" but no PHONE_NUMBER saved → ask for it and save it.
60
+ *
61
+ * Should only be called when there is NO valid session yet — a valid
62
+ * session skips this module entirely (see baileysSock.ts).
63
+ */
64
+ export async function resolveLoginMethod() {
65
+ let method = CONFIG.LOGIN_METHOD;
66
+ let phone = CONFIG.PHONE_NUMBER;
67
+ const needsMethod = method !== "phone" && method !== "qr";
68
+ const needsPhone = !needsMethod && method === "phone" && !phone;
69
+ if (!needsMethod && !needsPhone) {
70
+ return { method: method, phone: method === "phone" ? phone : null };
71
+ }
72
+ clack.intro(t("onboarding.intro"));
73
+ if (needsMethod) {
74
+ method = await promptLoginMethod();
75
+ }
76
+ if (method === "phone" && !phone) {
77
+ phone = await promptPhoneNumber();
78
+ }
79
+ clack.outro(method === "qr" ? t("onboarding.outroQr") : t("onboarding.outroPhone"));
80
+ return { method: method, phone: method === "phone" ? phone : null };
81
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * drivers/whatsapp/messageHandler.ts
3
+ *
4
+ * WhatsApp message pipeline.
5
+ * Moved from kernel/messageHandler.ts to keep all WhatsApp logic together.
6
+ *
7
+ * Order:
8
+ * 1. Filter allowed chats (CHATS from config)
9
+ * 2. Per-chat incoming debounce (prevents command spam)
10
+ * 3. Pass context to all active plugins
11
+ *
12
+ * Each plugin decides whether to act or ignore.
13
+ */
14
+ import { CHATS, TEST_CHAT } from "#config";
15
+ import { buildApi, buildChatFromMsg } from "./api/index.js";
16
+ import { pluginRegistry } from "#kernel/pluginLoader.js";
17
+ import { runPlugin } from "#kernel/pluginGuard.js";
18
+ import { normalizeJid, toPresenceCapable } from "./sdk/baileysSock.js";
19
+ const INCOMING_DEBOUNCE_MS = 0;
20
+ const lastProcessedAt = new Map();
21
+ // ── Dedup of already-processed messages ────────────────────────────────────
22
+ // WhatsApp resends messages without a delivery/read confirmation (the
23
+ // protocol's own retry, usually up to 3 times) when the socket reconnects.
24
+ // Without this, the same msg.key.id would arrive again as "notify" and be
25
+ // reprocessed.
26
+ const SEEN_TTL_MS = 10 * 60 * 1000; // 10 min is enough for WA's retries
27
+ const seenMessageIds = new Map();
28
+ function alreadyProcessed(id) {
29
+ if (!id)
30
+ return false;
31
+ const now = Date.now();
32
+ // lazy cleanup of expired entries
33
+ for (const [key, ts] of seenMessageIds) {
34
+ if (now - ts > SEEN_TTL_MS)
35
+ seenMessageIds.delete(key);
36
+ }
37
+ if (seenMessageIds.has(id))
38
+ return true;
39
+ seenMessageIds.set(id, now);
40
+ return false;
41
+ }
42
+ /**
43
+ * @param {WAProtoMsg} msg - raw Baileys message
44
+ * @param {WASocket} sock
45
+ * @param {WAStore} store
46
+ */
47
+ export async function handleMessage(msg, sock, store) {
48
+ const msgTimestamp = Number(msg.messageTimestamp);
49
+ if (msgTimestamp) {
50
+ const nowInSeconds = Math.floor(Date.now() / 1000);
51
+ const messageAge = nowInSeconds - msgTimestamp;
52
+ if (messageAge > 60) {
53
+ return;
54
+ }
55
+ }
56
+ const rawJid = msg.key.remoteJid ?? "";
57
+ const jid = normalizeJid(rawJid);
58
+ if (CHATS.length > 0 && !CHATS.includes(jid)) {
59
+ return;
60
+ }
61
+ const isTestChat = !!TEST_CHAT && jid === normalizeJid(TEST_CHAT);
62
+ if (msg.key.fromMe && !isTestChat) {
63
+ return;
64
+ }
65
+ if (alreadyProcessed(msg.key.id)) {
66
+ return;
67
+ }
68
+ // Mark as read/delivered to reduce the chance WhatsApp resends it
69
+ sock.readMessages?.([msg.key]).catch(() => { });
70
+ // Debounce rapid bursts per chat
71
+ if (INCOMING_DEBOUNCE_MS > 0) {
72
+ const now = Date.now();
73
+ const last = lastProcessedAt.get(jid) ?? 0;
74
+ const gap = now - last;
75
+ if (gap < INCOMING_DEBOUNCE_MS) {
76
+ const wait = INCOMING_DEBOUNCE_MS - gap;
77
+ await new Promise(r => setTimeout(r, wait));
78
+ }
79
+ lastProcessedAt.set(jid, Date.now());
80
+ }
81
+ // Build a WAChat adapter from the message metadata
82
+ const chat = await buildChatFromMsg(msg, store, sock);
83
+ for (const plugin of pluginRegistry.values()) {
84
+ const ctx = buildApi({
85
+ msg,
86
+ chat,
87
+ sock,
88
+ store,
89
+ pluginRegistry,
90
+ pluginName: plugin.name,
91
+ guardOptions: plugin.guardOptions,
92
+ });
93
+ const useTyping = plugin.guardOptions?.typing !== false;
94
+ let typingInterval;
95
+ if (useTyping) {
96
+ // Refresh presence every 4s so WhatsApp doesn't auto-clear it
97
+ typingInterval = setInterval(() => {
98
+ toPresenceCapable(sock).setPresence(rawJid, "composing").catch(() => { });
99
+ }, 4000);
100
+ }
101
+ try {
102
+ await runPlugin(plugin, ctx);
103
+ }
104
+ finally {
105
+ if (useTyping) {
106
+ clearInterval(typingInterval);
107
+ toPresenceCapable(sock).setPresence(rawJid, "paused").catch(() => { });
108
+ }
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * baileysSock.ts
3
+ *
4
+ * Creates and returns a Baileys WASocket.
5
+ * Handles auth state persistence, QR/pairing-code display, and reconnection.
6
+ */
7
+ import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, } from "@whiskeysockets/baileys";
8
+ import path from "path";
9
+ import qrcode from "qrcode-terminal";
10
+ import { CONFIG_DIR, CLIENT_ID } from "#config";
11
+ import { resolveLoginMethod } from "../loginPrompt.js";
12
+ import { logger } from "#logger";
13
+ import { t } from "#i18n";
14
+ import { createStore } from "#client/store.js";
15
+ import { CapabilitySet } from "#core/capabilities.js";
16
+ import pino from "pino";
17
+ // ── Auth path ─────────────────────────────────────────────────────────────────
18
+ export const AUTH_DIR = path.join(CONFIG_DIR, "sessions", CLIENT_ID);
19
+ // ── Shared store (survives socket reconnects) ─────────────────────────────────
20
+ export const store = createStore();
21
+ /**
22
+ * Create a new Baileys socket with persistent auth and store binding.
23
+ * Reconnection is the caller's responsibility — call createSocket() again
24
+ * on `connection.close`.
25
+ */
26
+ export async function createSocket() {
27
+ const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
28
+ const { version } = await fetchLatestBaileysVersion();
29
+ // Already-valid session (creds registered in .manybot/sessions) → skips
30
+ // LOGIN_METHOD/PHONE_NUMBER and the interactive login flow entirely, and
31
+ // connects directly. This also avoids reopening QR/pairing on normal
32
+ // reconnects (network drop, etc), since only the FIRST run ever gets
33
+ // here without `registered`.
34
+ const alreadyRegistered = !!state.creds.registered;
35
+ const { method, phone } = alreadyRegistered
36
+ ? { method: null, phone: null }
37
+ : await resolveLoginMethod();
38
+ const sock = makeWASocket({
39
+ version,
40
+ auth: state,
41
+ printQRInTerminal: false,
42
+ // Recognized browser signature. A custom name (e.g. "ManyBot") works
43
+ // fine for QR, but WhatsApp rejects phone-number pairing when the
44
+ // browser doesn't match one of the known signatures — resulting in
45
+ // "Couldn't link device" on the phone even with the correct code.
46
+ browser: Browsers.ubuntu("Chrome"),
47
+ logger: pino({ level: "silent" }),
48
+ generateHighQualityLinkPreview: false,
49
+ syncFullHistory: false,
50
+ // Required for Baileys to decrypt incoming poll votes (and to retry
51
+ // sends) — it looks up the original message by key internally.
52
+ // Without this, pollUpdates never resolve even though the vote event
53
+ // arrives: ctx.poll.results()/onVote() silently stay at zero.
54
+ getMessage: async (key) => {
55
+ const stored = store.messages.get(key.remoteJid ?? "")?.get(key.id ?? "");
56
+ return stored?.message ?? undefined;
57
+ },
58
+ });
59
+ store.bind(sock.ev);
60
+ sock.ev.on("creds.update", saveCreds);
61
+ // QR code — only if the chosen method was "qr" (intentionally ignores
62
+ // PHONE_NUMBER even if one was saved from a previous choice).
63
+ let qrDisplayed = false;
64
+ sock.ev.on("connection.update", (update) => {
65
+ const { qr, connection } = update;
66
+ if (qr && method === "qr") {
67
+ qrDisplayed = true;
68
+ logger.info(t("system.qrScan"));
69
+ qrcode.generate(qr, { small: true });
70
+ }
71
+ if (connection === "open" && qrDisplayed) {
72
+ console.clear();
73
+ qrDisplayed = false;
74
+ }
75
+ });
76
+ // Number pairing — only if the chosen method was "phone".
77
+ if (method === "phone" && phone) {
78
+ if (!/^\d{8,15}$/.test(phone)) {
79
+ logger.error(t("system.phoneNumberInvalid", { number: phone }));
80
+ return { sock, store };
81
+ }
82
+ // Allow the socket to handshake before requesting the pairing code
83
+ setTimeout(async () => {
84
+ try {
85
+ const code = await sock.requestPairingCode(phone);
86
+ logger.info(t("system.pairingCodeTitle"));
87
+ logger.info(t("system.pairingCodeValue", { code }));
88
+ logger.info(t("system.pairingCodeInstructions"));
89
+ }
90
+ catch (e) {
91
+ logger.error(`[baileysSock] Pairing code request failed: ${e.message}`);
92
+ }
93
+ }, 3000);
94
+ }
95
+ return { sock, store };
96
+ }
97
+ /**
98
+ * Normalize a Baileys JID to the @c.us format used in ManyBot configs.
99
+ * Groups (@g.us) and broadcasts are passed through unchanged.
100
+ *
101
+ * @param {string} jid
102
+ * @returns {string}
103
+ */
104
+ export function normalizeJid(jid) {
105
+ if (!jid)
106
+ return jid;
107
+ return jid
108
+ .replace(/@s\.whatsapp\.net$/, "@c.us")
109
+ .replace(/:\d+@/, "@");
110
+ }
111
+ /**
112
+ * Minimal PresenceCapable view over a raw socket. Transitional shim used
113
+ * by kernel code that still works with a raw sock instead of a full
114
+ * PlatformAdapter — goes away once that code is migrated.
115
+ *
116
+ * @param {WASocket} sock
117
+ * @returns {PresenceCapable}
118
+ */
119
+ export function toPresenceCapable(sock) {
120
+ return {
121
+ capabilities: new CapabilitySet(["presence"]),
122
+ setPresence: (chatId, state) => sock.sendPresenceUpdate(state, chatId),
123
+ };
124
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * i18n/index.ts
3
+ *
4
+ * Internationalization system for ManyBot.
5
+ * Loads translations based on LANGUAGE configuration.
6
+ * Fallback is always English (en).
7
+ *
8
+ * Plugins can use createPluginT() to have isolated i18n.
9
+ */
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { fileURLToPath } from "url";
13
+ import { CONFIG } from "#config";
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const LOCALES_DIR = path.join(__dirname, "..", "locales");
16
+ // Default language (fallback)
17
+ const DEFAULT_LANG = "en";
18
+ // Cache of loaded translations
19
+ const translations = new Map();
20
+ /**
21
+ * Loads a translation JSON file
22
+ * @param {string} lang - language code (en, pt, es)
23
+ * @returns {object|null}
24
+ */
25
+ function loadLocale(lang) {
26
+ if (translations.has(lang)) {
27
+ return translations.get(lang) ?? null;
28
+ }
29
+ const filePath = path.join(LOCALES_DIR, `${lang}.json`);
30
+ try {
31
+ if (!fs.existsSync(filePath)) {
32
+ return null;
33
+ }
34
+ const content = fs.readFileSync(filePath, "utf8");
35
+ const data = JSON.parse(content);
36
+ translations.set(lang, data);
37
+ return data;
38
+ }
39
+ catch (e) {
40
+ console.error(`[i18n] Failed to load locale ${lang}:`, e.message);
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Gets configured language or default
46
+ * @returns {string}
47
+ */
48
+ function getConfiguredLang() {
49
+ const lang = CONFIG.LANGUAGE?.trim().toLowerCase();
50
+ if (!lang)
51
+ return DEFAULT_LANG;
52
+ // Check if file exists
53
+ const filePath = path.join(LOCALES_DIR, `${lang}.json`);
54
+ if (!fs.existsSync(filePath)) {
55
+ console.warn(`[i18n] Language "${lang}" not found, falling back to "${DEFAULT_LANG}"`);
56
+ return DEFAULT_LANG;
57
+ }
58
+ return lang;
59
+ }
60
+ // Load languages
61
+ const currentLang = getConfiguredLang();
62
+ const currentTranslations = loadLocale(currentLang) || {};
63
+ const fallbackTranslations = loadLocale(DEFAULT_LANG) || {};
64
+ /**
65
+ * Gets a nested value from an object using dot path
66
+ * @param {object} obj
67
+ * @param {string} key - path like "system.connected"
68
+ * @returns {string|undefined}
69
+ */
70
+ function getNestedValue(obj, key) {
71
+ const parts = key.split(".");
72
+ let current = obj;
73
+ for (const part of parts) {
74
+ if (current === null || current === undefined || typeof current !== "object") {
75
+ return undefined;
76
+ }
77
+ current = current[part];
78
+ }
79
+ return current;
80
+ }
81
+ /**
82
+ * Replaces placeholders {{key}} with values from context
83
+ * @param {string} str
84
+ * @param {object} context
85
+ * @returns {string}
86
+ */
87
+ function interpolate(str, context = {}) {
88
+ return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
89
+ return context[key] !== undefined ? String(context[key]) : match;
90
+ });
91
+ }
92
+ /**
93
+ * Main translation function
94
+ * @param {string} key - translation key (e.g., "system.connected")
95
+ * @param {object} context - values to interpolate {{key}}
96
+ * @returns {string}
97
+ */
98
+ export function t(key, context = {}) {
99
+ // Try current language first
100
+ let value = getNestedValue(currentTranslations, key);
101
+ // Fallback to English if not found
102
+ if (value === undefined) {
103
+ value = getNestedValue(fallbackTranslations, key);
104
+ }
105
+ // If still not found, return the key
106
+ if (value === undefined) {
107
+ return key;
108
+ }
109
+ // If not string, convert
110
+ if (typeof value !== "string") {
111
+ return String(value);
112
+ }
113
+ // Interpolate values
114
+ return interpolate(value, context);
115
+ }
116
+ /**
117
+ * Creates an isolated translation function for a plugin.
118
+ * Plugins should have their own locale/ folder with en.json, es.json, etc.
119
+ *
120
+ * Usage in plugin:
121
+ * import { createPluginT } from "../../i18n/index.ts";
122
+ * const { t } = createPluginT(import.meta.url);
123
+ *
124
+ * Folder structure:
125
+ * myPlugin/
126
+ * index.ts
127
+ * locale/
128
+ * en.json
129
+ * es.json
130
+ * pt.json
131
+ *
132
+ * @param {string} pluginMetaUrl - import.meta.url from the plugin
133
+ * @returns {{ t: Function, lang: string }}
134
+ */
135
+ export function createPluginT(pluginMetaUrl) {
136
+ const pluginDir = path.dirname(fileURLToPath(pluginMetaUrl));
137
+ const pluginLocaleDir = path.join(pluginDir, "locale");
138
+ // Get bot's configured language
139
+ const targetLang = currentLang;
140
+ // Load plugin translations
141
+ let pluginTranslations = {};
142
+ let pluginFallback = {};
143
+ try {
144
+ // Try to load the configured language
145
+ const targetPath = path.join(pluginLocaleDir, `${targetLang}.json`);
146
+ if (fs.existsSync(targetPath)) {
147
+ pluginTranslations = JSON.parse(fs.readFileSync(targetPath, "utf8"));
148
+ }
149
+ // Always load English as fallback
150
+ const fallbackPath = path.join(pluginLocaleDir, `${DEFAULT_LANG}.json`);
151
+ if (fs.existsSync(fallbackPath)) {
152
+ pluginFallback = JSON.parse(fs.readFileSync(fallbackPath, "utf8"));
153
+ }
154
+ }
155
+ catch (err) {
156
+ // Silent fail - plugin may not have translations
157
+ }
158
+ /**
159
+ * Plugin-specific translation function
160
+ * @param {string} key
161
+ * @param {object} context
162
+ * @returns {string}
163
+ */
164
+ function pluginT(key, context = {}) {
165
+ // Try plugin's target language first
166
+ let value = getNestedValue(pluginTranslations, key);
167
+ // Fallback to plugin's English
168
+ if (value === undefined) {
169
+ value = getNestedValue(pluginFallback, key);
170
+ }
171
+ // If still not found, return the key
172
+ if (value === undefined) {
173
+ return key;
174
+ }
175
+ if (typeof value !== "string") {
176
+ return String(value);
177
+ }
178
+ return interpolate(value, context);
179
+ }
180
+ return { t: pluginT, lang: targetLang };
181
+ }
182
+ /**
183
+ * Reloads translations (useful for hot-reload)
184
+ */
185
+ export function reloadTranslations() {
186
+ translations.clear();
187
+ const lang = getConfiguredLang();
188
+ const newTranslations = loadLocale(lang) || {};
189
+ const newFallback = loadLocale(DEFAULT_LANG) || {};
190
+ // Update references
191
+ Object.assign(currentTranslations, newTranslations);
192
+ Object.assign(fallbackTranslations, newFallback);
193
+ console.log(`[i18n] Translations reloaded for language: ${lang}`);
194
+ }
195
+ /**
196
+ * Returns current language
197
+ * @returns {string}
198
+ */
199
+ export function getCurrentLang() {
200
+ return currentLang;
201
+ }
202
+ export default { t, createPluginT, reloadTranslations, getCurrentLang };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * kernel/pluginApi.ts (DEPRECATED)
3
+ *
4
+ * This file is now a re-export from the WhatsApp driver.
5
+ * Plugins should continue to work without any changes.
6
+ *
7
+ * All logic is now in: drivers/whatsapp/api/
8
+ * This file exists for backward compatibility.
9
+ */
10
+ // Re-export everything from the WhatsApp driver's plugin API
11
+ export * from "#drivers/whatsapp/api/index.js";