@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.
- package/dist/client/banner.js +35 -0
- package/dist/client/store.js +135 -0
- package/dist/config.js +363 -0
- package/dist/core/adapter.js +12 -0
- package/dist/core/capabilities.js +16 -0
- package/dist/core/types.js +6 -0
- package/dist/download/queue.js +49 -0
- package/dist/drivers/index.js +14 -0
- package/dist/drivers/patches/index.js +9 -0
- package/dist/drivers/patches/libsignal.js +16 -0
- package/dist/drivers/patches/patch.js +1 -0
- package/dist/drivers/whatsapp/adapter.js +7 -0
- package/dist/drivers/whatsapp/api/index.js +1311 -0
- package/dist/drivers/whatsapp/index.js +164 -0
- package/dist/drivers/whatsapp/loginPrompt.js +81 -0
- package/dist/drivers/whatsapp/messageHandler.js +111 -0
- package/dist/drivers/whatsapp/sdk/baileysSock.js +124 -0
- package/dist/i18n/index.js +202 -0
- package/dist/kernel/pluginApi.js +11 -0
- package/dist/kernel/pluginGuard.js +88 -0
- package/dist/kernel/pluginLoader.js +322 -0
- package/dist/kernel/scheduler.js +110 -0
- package/dist/kernel/sendGuard.js +121 -0
- package/dist/kernel/settingsDb.js +205 -0
- package/dist/locales/en.json +52 -0
- package/dist/locales/es.json +52 -0
- package/dist/locales/pt.json +52 -0
- package/dist/logger/logger.js +16 -0
- package/dist/main.js +82 -0
- package/dist/types.js +16 -0
- package/dist/utils/file.js +9 -0
- package/package.json +1 -1
|
@@ -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";
|