@manybot/manybot 5.2.6 → 5.3.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/README.md +1 -1
- package/dist/client/cache.js +60 -0
- package/dist/client/store.js +49 -3
- package/dist/drivers/whatsapp/api/index.js +149 -24
- package/dist/drivers/whatsapp/index.js +234 -54
- package/dist/drivers/whatsapp/sdk/baileysSock.js +22 -3
- package/dist/kernel/pluginLoader.js +39 -7
- package/dist/locales/en.json +17 -1
- package/dist/locales/es.json +17 -1
- package/dist/locales/pt.json +17 -1
- package/dist/utils/clipboard.js +44 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client/cache.ts
|
|
3
|
+
*
|
|
4
|
+
* Persists the store's chats/contacts/lidMap snapshot to disk.
|
|
5
|
+
*
|
|
6
|
+
* Why: an already-linked WhatsApp session doesn't get a full history
|
|
7
|
+
* resync on reconnect — only a partial, non-deterministic slice of it
|
|
8
|
+
* (see drivers/whatsapp/index.ts). Each fresh process starts with an
|
|
9
|
+
* empty in-memory store, so without a cache, chats seen in a previous
|
|
10
|
+
* run can silently disappear from the current one.
|
|
11
|
+
*
|
|
12
|
+
* Consumers must always merge (store.hydrate), never replace: a stale
|
|
13
|
+
* cache is still strictly better than an empty store.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "fs/promises";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { CONFIG_DIR, CLIENT_ID } from "#config";
|
|
18
|
+
import { logger } from "#logger";
|
|
19
|
+
export const DEFAULT_MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
20
|
+
function cacheFilePath(clientId = CLIENT_ID) {
|
|
21
|
+
return path.join(CONFIG_DIR, "cache", `${clientId}.chats.json`);
|
|
22
|
+
}
|
|
23
|
+
async function readCacheFile(clientId) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(cacheFilePath(clientId), "utf-8");
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e.code !== "ENOENT") {
|
|
30
|
+
logger.warn(`[cache] Failed to read chat cache: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Loads the last saved snapshot, or null if none exists / it's corrupt. */
|
|
36
|
+
export async function loadChatCache(clientId = CLIENT_ID) {
|
|
37
|
+
const file = await readCacheFile(clientId);
|
|
38
|
+
return file?.snapshot ?? null;
|
|
39
|
+
}
|
|
40
|
+
/** Whether the cache on disk was saved within `maxAgeMs`. Informational only — never gates hydration. */
|
|
41
|
+
export async function isCacheFresh(clientId = CLIENT_ID, maxAgeMs = DEFAULT_MAX_CACHE_AGE_MS) {
|
|
42
|
+
const file = await readCacheFile(clientId);
|
|
43
|
+
if (!file)
|
|
44
|
+
return false;
|
|
45
|
+
return Date.now() - file.savedAt < maxAgeMs;
|
|
46
|
+
}
|
|
47
|
+
/** Writes the store's current snapshot to disk (atomic write via tmp + rename). */
|
|
48
|
+
export async function saveChatCache(store, clientId = CLIENT_ID) {
|
|
49
|
+
const target = cacheFilePath(clientId);
|
|
50
|
+
const tmp = `${target}.tmp`;
|
|
51
|
+
const file = { savedAt: Date.now(), snapshot: store.toJSON() };
|
|
52
|
+
try {
|
|
53
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
54
|
+
await fs.writeFile(tmp, JSON.stringify(file), "utf-8");
|
|
55
|
+
await fs.rename(tmp, target);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
logger.warn(`[cache] Failed to save chat cache: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/client/store.js
CHANGED
|
@@ -30,6 +30,9 @@ export function createStore() {
|
|
|
30
30
|
return jid;
|
|
31
31
|
return lidMap.get(jid) ?? jid;
|
|
32
32
|
}
|
|
33
|
+
function forgetLid(lid) {
|
|
34
|
+
lidMap.delete(lid);
|
|
35
|
+
}
|
|
33
36
|
// Max messages kept per chat (prevents unbounded memory growth)
|
|
34
37
|
const MAX_MSGS_PER_CHAT = 200;
|
|
35
38
|
function upsertChat(chat) {
|
|
@@ -37,11 +40,16 @@ export function createStore() {
|
|
|
37
40
|
chatsMap.set(chat.id, { id: chat.id, name });
|
|
38
41
|
}
|
|
39
42
|
function upsertContact(contact) {
|
|
43
|
+
// Don't let a later, poorer sync round (e.g. a bare history-sync stub)
|
|
44
|
+
// blank out a name/notify/verifiedName we already learned from an
|
|
45
|
+
// earlier, richer one (e.g. a live message's pushName) — only accept
|
|
46
|
+
// a field when this contact object actually carries it.
|
|
47
|
+
const existing = contacts[contact.id];
|
|
40
48
|
contacts[contact.id] = {
|
|
41
49
|
id: contact.id,
|
|
42
|
-
name: contact.name,
|
|
43
|
-
notify: contact.notify,
|
|
44
|
-
verifiedName: contact.verifiedName,
|
|
50
|
+
name: contact.name ?? existing?.name,
|
|
51
|
+
notify: contact.notify ?? existing?.notify,
|
|
52
|
+
verifiedName: contact.verifiedName ?? existing?.verifiedName,
|
|
45
53
|
};
|
|
46
54
|
// Baileys' Contact carries both the traditional JID (id) and the LID
|
|
47
55
|
// (lid) once known — learn the mapping either direction.
|
|
@@ -66,6 +74,12 @@ export function createStore() {
|
|
|
66
74
|
}
|
|
67
75
|
}
|
|
68
76
|
function bind(ev) {
|
|
77
|
+
ev.on("messaging-history.set", ({ chats, contacts }) => {
|
|
78
|
+
for (const chat of chats)
|
|
79
|
+
upsertChat(chat);
|
|
80
|
+
for (const contact of contacts)
|
|
81
|
+
upsertContact(contact);
|
|
82
|
+
});
|
|
69
83
|
ev.on("chats.upsert", (newChats) => {
|
|
70
84
|
for (const chat of newChats)
|
|
71
85
|
upsertChat(chat);
|
|
@@ -100,6 +114,18 @@ export function createStore() {
|
|
|
100
114
|
const key = msg.key;
|
|
101
115
|
learnLid(key.participantAlt, key.participant);
|
|
102
116
|
learnLid(key.remoteJidAlt, key.remoteJid);
|
|
117
|
+
// pushName only ever arrives on a live message — contact/history
|
|
118
|
+
// sync alone won't give us a name for someone who never messaged
|
|
119
|
+
// and isn't a saved contact. Worth capturing here, since it's the
|
|
120
|
+
// one extra source we have (feeds the on-disk cache too).
|
|
121
|
+
const pushName = msg.pushName;
|
|
122
|
+
const senderId = key.participant ?? msg.key.remoteJid;
|
|
123
|
+
if (pushName && senderId && !msg.key.fromMe && !senderId.endsWith("@g.us")) {
|
|
124
|
+
const existing = contacts[senderId];
|
|
125
|
+
if (existing?.notify !== pushName) {
|
|
126
|
+
contacts[senderId] = { ...existing, id: senderId, notify: pushName };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
103
129
|
}
|
|
104
130
|
});
|
|
105
131
|
ev.on("messages.update", (updates) => {
|
|
@@ -122,6 +148,22 @@ export function createStore() {
|
|
|
122
148
|
}
|
|
123
149
|
});
|
|
124
150
|
}
|
|
151
|
+
function toJSON() {
|
|
152
|
+
return {
|
|
153
|
+
chats: [...chatsMap.values()],
|
|
154
|
+
contacts: { ...contacts },
|
|
155
|
+
lidMap: [...lidMap.entries()],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function hydrate(snapshot) {
|
|
159
|
+
for (const chat of snapshot.chats ?? [])
|
|
160
|
+
chatsMap.set(chat.id, chat);
|
|
161
|
+
for (const [id, contact] of Object.entries(snapshot.contacts ?? {})) {
|
|
162
|
+
contacts[id] = { ...contacts[id], ...contact };
|
|
163
|
+
}
|
|
164
|
+
for (const [lid, pn] of snapshot.lidMap ?? [])
|
|
165
|
+
learnLid(lid, pn);
|
|
166
|
+
}
|
|
125
167
|
return {
|
|
126
168
|
chats: {
|
|
127
169
|
get: (id) => chatsMap.get(id) ?? null,
|
|
@@ -130,6 +172,10 @@ export function createStore() {
|
|
|
130
172
|
contacts,
|
|
131
173
|
messages,
|
|
132
174
|
resolveJid,
|
|
175
|
+
learnLid,
|
|
176
|
+
forgetLid,
|
|
133
177
|
bind,
|
|
178
|
+
toJSON,
|
|
179
|
+
hydrate,
|
|
134
180
|
};
|
|
135
181
|
}
|
|
@@ -21,10 +21,17 @@ import { readFileSync } from "fs";
|
|
|
21
21
|
import path from "path";
|
|
22
22
|
import { waitForSendSlot, simulateState, typingDuration, mediaDuration } from "#sendguard";
|
|
23
23
|
import { buildSettingsApi } from "#settingsdb";
|
|
24
|
-
import { downloadMediaMessage, getAggregateVotesInPollMessage, decryptPollVote, jidNormalizedUser } from "@whiskeysockets/baileys";
|
|
24
|
+
import { downloadMediaMessage, getAggregateVotesInPollMessage, decryptPollVote, jidNormalizedUser, normalizeMessageContent } from "@whiskeysockets/baileys";
|
|
25
25
|
// ── Message body / type helpers ───────────────────────────────────────────────
|
|
26
|
+
// WhatsApp wraps media in ephemeralMessage/viewOnceMessage/etc when sent as
|
|
27
|
+
// disappearing or view-once — the actual imageMessage/videoMessage/... sits
|
|
28
|
+
// one level deeper. Reading msg.message directly misses all of that (this
|
|
29
|
+
// was the cause of hasMedia silently returning false for such messages).
|
|
30
|
+
function unwrap(msg) {
|
|
31
|
+
return normalizeMessageContent(msg.message) ?? undefined;
|
|
32
|
+
}
|
|
26
33
|
function getMsgBody(msg) {
|
|
27
|
-
const m = msg
|
|
34
|
+
const m = unwrap(msg);
|
|
28
35
|
if (!m)
|
|
29
36
|
return "";
|
|
30
37
|
return (m.conversation ??
|
|
@@ -37,7 +44,7 @@ function getMsgBody(msg) {
|
|
|
37
44
|
"");
|
|
38
45
|
}
|
|
39
46
|
function getMsgType(msg) {
|
|
40
|
-
const m = msg
|
|
47
|
+
const m = unwrap(msg);
|
|
41
48
|
if (!m)
|
|
42
49
|
return "unknown";
|
|
43
50
|
if (m.conversation || m.extendedTextMessage)
|
|
@@ -69,11 +76,11 @@ function getMsgType(msg) {
|
|
|
69
76
|
return "unknown";
|
|
70
77
|
}
|
|
71
78
|
function msgHasMedia(msg) {
|
|
72
|
-
const m = msg
|
|
79
|
+
const m = unwrap(msg);
|
|
73
80
|
return !!(m?.imageMessage || m?.videoMessage || m?.audioMessage || m?.documentMessage || m?.stickerMessage);
|
|
74
81
|
}
|
|
75
82
|
function msgIsGif(msg) {
|
|
76
|
-
return !!(msg
|
|
83
|
+
return !!(unwrap(msg)?.videoMessage?.gifPlayback);
|
|
77
84
|
}
|
|
78
85
|
/** Sender JID — group participant or DM remote JID, normalized. */
|
|
79
86
|
function getMsgSender(msg, store) {
|
|
@@ -81,7 +88,7 @@ function getMsgSender(msg, store) {
|
|
|
81
88
|
return normalizeJid(store.resolveJid(raw));
|
|
82
89
|
}
|
|
83
90
|
function getContextInfo(msg) {
|
|
84
|
-
const m = msg
|
|
91
|
+
const m = unwrap(msg);
|
|
85
92
|
return (m?.extendedTextMessage?.contextInfo ??
|
|
86
93
|
m?.imageMessage?.contextInfo ??
|
|
87
94
|
m?.videoMessage?.contextInfo ??
|
|
@@ -90,7 +97,7 @@ function getContextInfo(msg) {
|
|
|
90
97
|
null);
|
|
91
98
|
}
|
|
92
99
|
function getMsgMimetype(msg) {
|
|
93
|
-
const m = msg
|
|
100
|
+
const m = unwrap(msg);
|
|
94
101
|
return (m?.imageMessage?.mimetype ??
|
|
95
102
|
m?.videoMessage?.mimetype ??
|
|
96
103
|
m?.audioMessage?.mimetype ??
|
|
@@ -382,17 +389,92 @@ function buildContactsApi(sock, store, botJid) {
|
|
|
382
389
|
/**
|
|
383
390
|
* Get a normalized contact object by JID.
|
|
384
391
|
* @param {string} contactId
|
|
392
|
+
* @param {{groupId?: string}} [opts] — when `contactId` is a raw `@lid`, this
|
|
393
|
+
* always cross-checks it against Baileys' own protocol-level
|
|
394
|
+
* `sock.signalRepository.lidMapping` first (populated from real Signal
|
|
395
|
+
* session/identity resolution — not a heuristic, and doesn't need a
|
|
396
|
+
* group). If `opts.groupId` is also given and that didn't resolve it,
|
|
397
|
+
* falls back to a live `groupMetadata()` call for that group as a
|
|
398
|
+
* second attempt (NOTE: on Baileys 6.7.x, `groupMetadata()` is known to
|
|
399
|
+
* still return a bare `@lid` with no `phoneNumber` for some
|
|
400
|
+
* participants — see WhiskeySockets/Baileys#1505 — so this second
|
|
401
|
+
* attempt is best-effort and won't always help). Either source, once
|
|
402
|
+
* it yields a phone-based JID, corrects the store's own `lidMap` too,
|
|
403
|
+
* which is only ever learned from past messages/contact syncs and can
|
|
404
|
+
* go stale or, rarely, end up mapped to the wrong person (e.g. after a
|
|
405
|
+
* `@lid` gets reassigned). Every cross-check is best-effort: on any
|
|
406
|
+
* failure this silently falls back to whatever the store already has
|
|
407
|
+
* — never throws because of the cross-check itself.
|
|
385
408
|
* @returns {Promise<object|null>}
|
|
386
409
|
*/
|
|
387
|
-
async get(contactId) {
|
|
388
|
-
|
|
410
|
+
async get(contactId, opts) {
|
|
411
|
+
if (contactId.endsWith("@lid")) {
|
|
412
|
+
let freshPn;
|
|
413
|
+
// 1) Baileys' own protocol-level LID↔PN store — populated as part of
|
|
414
|
+
// real Signal session/identity handling, so it's authoritative when
|
|
415
|
+
// it has an answer (unlike our own heuristic lidMap). Not part of
|
|
416
|
+
// the public v6.7 TS surface, hence the cast; guarded with a
|
|
417
|
+
// typeof check since older/patched Baileys builds may not have it.
|
|
418
|
+
try {
|
|
419
|
+
const lidMapping = sock.signalRepository?.lidMapping;
|
|
420
|
+
if (typeof lidMapping?.getPNForLID === "function") {
|
|
421
|
+
freshPn = await lidMapping.getPNForLID(contactId);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
logger.warn(`[contacts.get] signalRepository.lidMapping cross-check failed for "${contactId}" — ${err.message}`);
|
|
426
|
+
}
|
|
427
|
+
// 2) Fall back to live groupMetadata() when we know the group and
|
|
428
|
+
// the signal repository didn't have an answer. Best-effort — see
|
|
429
|
+
// the Baileys 6.7.x caveat in the doc comment above.
|
|
430
|
+
if (!freshPn && opts?.groupId) {
|
|
431
|
+
try {
|
|
432
|
+
const meta = await sock.groupMetadata(opts.groupId);
|
|
433
|
+
const participant = meta.participants.find((p) => normalizeJid(p.id) === normalizeJid(contactId));
|
|
434
|
+
freshPn = participant?.phoneNumber;
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
logger.warn(`[contacts.get] live groupMetadata cross-check failed for "${contactId}" in "${opts.groupId}" — ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (freshPn)
|
|
441
|
+
store.learnLid(contactId, normalizeJid(freshPn));
|
|
442
|
+
}
|
|
443
|
+
const normalizedContactId = normalizeJid(contactId);
|
|
444
|
+
let resolved = normalizeJid(store.resolveJid(normalizedContactId));
|
|
445
|
+
// Sanity guard: a @lid resolving to the bot's own JID is only ever
|
|
446
|
+
// legitimate when we were actually looking up the bot itself. Any
|
|
447
|
+
// other case means the stored lidMap entry is stale/wrong — e.g. a
|
|
448
|
+
// @lid that got reassigned to a different WhatsApp account since we
|
|
449
|
+
// learned it (see the best-effort cross-check above, which doesn't
|
|
450
|
+
// always catch this — WhiskeySockets/Baileys#1505). Serving it
|
|
451
|
+
// anyway would silently hand the bot owner's own identity out as
|
|
452
|
+
// some other member's. Discard the bad mapping so it can be
|
|
453
|
+
// relearned correctly, and treat this lookup as unresolved instead.
|
|
454
|
+
if (botJid &&
|
|
455
|
+
normalizedContactId.endsWith("@lid") &&
|
|
456
|
+
resolved === normalizeJid(botJid) &&
|
|
457
|
+
normalizedContactId !== normalizeJid(botJid)) {
|
|
458
|
+
logger.warn(`[contacts.get] "${contactId}" resolved to the bot's own JID — discarding stale lidMap entry, treating as unresolved.`);
|
|
459
|
+
store.forgetLid(normalizedContactId);
|
|
460
|
+
resolved = normalizedContactId;
|
|
461
|
+
}
|
|
389
462
|
// The same person's data can be split across the raw (e.g. @lid) and
|
|
390
463
|
// store-resolved (PN) keys — one may have `notify` and the other
|
|
391
|
-
// `verifiedName`/`name`. Merge instead of
|
|
392
|
-
//
|
|
464
|
+
// `verifiedName`/`name`. Merge field-by-field instead of spreading
|
|
465
|
+
// whole objects: upsertContact() always writes all three fields, so
|
|
466
|
+
// a plain `{ ...raw, ...resolvedInfo }` lets resolvedInfo's explicit
|
|
467
|
+
// `undefined` silently clobber a real value already present in raw
|
|
468
|
+
// (or vice versa) — which is how a contact could resolve correctly
|
|
469
|
+
// by id yet still come back with a null pushname/name.
|
|
393
470
|
const raw = store.contacts[contactId];
|
|
394
471
|
const resolvedInfo = store.contacts[resolved];
|
|
395
|
-
const info = (raw || resolvedInfo) ? {
|
|
472
|
+
const info = (raw || resolvedInfo) ? {
|
|
473
|
+
id: resolved,
|
|
474
|
+
name: resolvedInfo?.name ?? raw?.name,
|
|
475
|
+
notify: resolvedInfo?.notify ?? raw?.notify,
|
|
476
|
+
verifiedName: resolvedInfo?.verifiedName ?? raw?.verifiedName,
|
|
477
|
+
} : undefined;
|
|
396
478
|
return normalizeContact(resolved, info, botJid, sock);
|
|
397
479
|
},
|
|
398
480
|
/**
|
|
@@ -548,7 +630,7 @@ export function buildMessageContext(msg, sock, store, guardOptions = {}) {
|
|
|
548
630
|
async getContact() {
|
|
549
631
|
const info = store.contacts[sender]
|
|
550
632
|
?? store.contacts[store.resolveJid(msg.key.participant ?? "")];
|
|
551
|
-
const botJid = sock.user?.id ?
|
|
633
|
+
const botJid = sock.user?.id ? jidNormalizedUser(sock.user.id) : null;
|
|
552
634
|
return normalizeContact(sender, info, botJid, sock);
|
|
553
635
|
},
|
|
554
636
|
};
|
|
@@ -843,6 +925,28 @@ function buildAdminApi(sock, chatJid) {
|
|
|
843
925
|
throw new Error(`Group not found: ${jid}`);
|
|
844
926
|
return meta;
|
|
845
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* Baileys' `groupParticipantsUpdate()` resolves normally even when
|
|
930
|
+
* WhatsApp rejected some (or all) of the requested participants — it
|
|
931
|
+
* returns an array with a per-participant `status` code (`'200'` =
|
|
932
|
+
* success; anything else is a rejection — e.g. `'403'`/`'401'` not
|
|
933
|
+
* authorized, often because the target's privacy settings block being
|
|
934
|
+
* added by a non-contact; `'409'` already a participant; `'408'`
|
|
935
|
+
* partial/timeout). A caller that only awaits the promise sees a
|
|
936
|
+
* "successful" resolution even when nobody was actually
|
|
937
|
+
* added/removed/promoted/demoted — silently reporting failure as
|
|
938
|
+
* success. Throw here so `ctx.admin.add/kick/promote/demote` produce a
|
|
939
|
+
* real rejection callers can catch, instead of a false positive.
|
|
940
|
+
*/
|
|
941
|
+
function assertParticipantsUpdateOk(action, results) {
|
|
942
|
+
if (!Array.isArray(results))
|
|
943
|
+
return; // unexpected shape — nothing to validate
|
|
944
|
+
const failed = results.filter((r) => r?.status && r.status !== "200");
|
|
945
|
+
if (failed.length > 0) {
|
|
946
|
+
const detail = failed.map((r) => `${r.jid ?? "?"}=${r.status}`).join(", ");
|
|
947
|
+
throw new Error(`groupParticipantsUpdate("${action}") rejeitado para: ${detail}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
846
950
|
function createTargetableAction(action, memberIds) {
|
|
847
951
|
const users = norm(memberIds);
|
|
848
952
|
const executeCurrent = async () => { requireChat(); return action(chatJid, users); };
|
|
@@ -862,22 +966,32 @@ function buildAdminApi(sock, chatJid) {
|
|
|
862
966
|
return {
|
|
863
967
|
/** @param {string|string[]} memberIds */
|
|
864
968
|
add(memberIds) {
|
|
865
|
-
return createTargetableAction((jid, users) =>
|
|
969
|
+
return createTargetableAction(async (jid, users) => {
|
|
970
|
+
const results = await sock.groupParticipantsUpdate(jid, users, "add");
|
|
971
|
+
assertParticipantsUpdateOk("add", results);
|
|
972
|
+
return results;
|
|
973
|
+
}, memberIds);
|
|
866
974
|
},
|
|
867
975
|
/** @param {string|string[]} memberIds */
|
|
868
976
|
async kick(memberIds) {
|
|
869
977
|
requireChat();
|
|
870
|
-
|
|
978
|
+
const results = await sock.groupParticipantsUpdate(chatJid, norm(memberIds), "remove");
|
|
979
|
+
assertParticipantsUpdateOk("remove", results);
|
|
980
|
+
return results;
|
|
871
981
|
},
|
|
872
982
|
/** @param {string|string[]} memberIds */
|
|
873
983
|
async promote(memberIds) {
|
|
874
984
|
requireChat();
|
|
875
|
-
|
|
985
|
+
const results = await sock.groupParticipantsUpdate(chatJid, norm(memberIds), "promote");
|
|
986
|
+
assertParticipantsUpdateOk("promote", results);
|
|
987
|
+
return results;
|
|
876
988
|
},
|
|
877
989
|
/** @param {string|string[]} memberIds */
|
|
878
990
|
async demote(memberIds) {
|
|
879
991
|
requireChat();
|
|
880
|
-
|
|
992
|
+
const results = await sock.groupParticipantsUpdate(chatJid, norm(memberIds), "demote");
|
|
993
|
+
assertParticipantsUpdateOk("demote", results);
|
|
994
|
+
return results;
|
|
881
995
|
},
|
|
882
996
|
/** @param {string} name */
|
|
883
997
|
async setSubject(name) {
|
|
@@ -895,9 +1009,11 @@ function buildAdminApi(sock, chatJid) {
|
|
|
895
1009
|
const buffer = Buffer.isBuffer(source) ? source : readFileSync(source);
|
|
896
1010
|
return sock.updateProfilePicture(chatJid, buffer);
|
|
897
1011
|
},
|
|
898
|
-
async getInviteLink() {
|
|
899
|
-
|
|
900
|
-
|
|
1012
|
+
async getInviteLink(groupId) {
|
|
1013
|
+
const jid = groupId ?? chatJid;
|
|
1014
|
+
if (!jid)
|
|
1015
|
+
throw new Error("This admin operation requires a runtime group context.");
|
|
1016
|
+
const code = await sock.groupInviteCode(jid);
|
|
901
1017
|
return `https://chat.whatsapp.com/${code}`;
|
|
902
1018
|
},
|
|
903
1019
|
async revokeInvite() {
|
|
@@ -928,7 +1044,11 @@ function buildMeApi(sock) {
|
|
|
928
1044
|
}
|
|
929
1045
|
// ── Poll API ──────────────────────────────────────────────────────────────────
|
|
930
1046
|
const pollRegistry = new Map();
|
|
931
|
-
|
|
1047
|
+
// Rebinding must happen per socket instance — a plugin name alone doesn't
|
|
1048
|
+
// tell us whether the listener is bound to the CURRENT (post-reconnect)
|
|
1049
|
+
// sock.ev or a dead one from before. WeakMap keyed by sock lets old
|
|
1050
|
+
// entries fall off automatically once that socket is garbage collected.
|
|
1051
|
+
const pollListenersBySocket = new WeakMap();
|
|
932
1052
|
/**
|
|
933
1053
|
* Tracks votes for an active poll.
|
|
934
1054
|
* Obtained via ctx.poll.create().
|
|
@@ -1004,8 +1124,13 @@ function buildPollApi(sock, store, rawJid, guardOptions, pluginName) {
|
|
|
1004
1124
|
// with no dedup — so we must keep only the latest entry per voter ourselves,
|
|
1005
1125
|
// or retracted/changed votes keep counting alongside the new one.
|
|
1006
1126
|
const pollVotesByCreationId = new Map();
|
|
1007
|
-
|
|
1008
|
-
|
|
1127
|
+
let boundPlugins = pollListenersBySocket.get(sock);
|
|
1128
|
+
if (!boundPlugins) {
|
|
1129
|
+
boundPlugins = new Set();
|
|
1130
|
+
pollListenersBySocket.set(sock, boundPlugins);
|
|
1131
|
+
}
|
|
1132
|
+
if (!boundPlugins.has(pluginName)) {
|
|
1133
|
+
boundPlugins.add(pluginName);
|
|
1009
1134
|
const meId = sock.user?.id ? jidNormalizedUser(sock.user.id) : "me";
|
|
1010
1135
|
// WhatsApp doesn't consistently use the same JID shape (LID vs PN) for
|
|
1011
1136
|
// pollCreatorJid/voterJid when deriving the poll-vote decryption key —
|
|
@@ -1143,7 +1268,7 @@ function buildPollApi(sock, store, rawJid, guardOptions, pluginName) {
|
|
|
1143
1268
|
}
|
|
1144
1269
|
// ── Base API (shared between setup and runtime) ───────────────────────────────
|
|
1145
1270
|
function buildBaseApi(sock, store, pluginRegistry, pluginName) {
|
|
1146
|
-
const botJid = sock.user?.id ?
|
|
1271
|
+
const botJid = sock.user?.id ? jidNormalizedUser(sock.user.id) : null;
|
|
1147
1272
|
if (!botJid)
|
|
1148
1273
|
logger.warn("[pluginApi] botId is null — socket may not be ready yet.");
|
|
1149
1274
|
return {
|
|
@@ -10,33 +10,110 @@
|
|
|
10
10
|
* - Plugin context building (via buildApi)
|
|
11
11
|
* - Event handling and plugin execution
|
|
12
12
|
*/
|
|
13
|
-
import { createSocket, normalizeJid, AUTH_DIR } from "./sdk/baileysSock.js";
|
|
13
|
+
import { createSocket, normalizeJid, sessionDir, AUTH_DIR, store as sharedStore } from "./sdk/baileysSock.js";
|
|
14
14
|
import { handleMessage } from "./messageHandler.js";
|
|
15
15
|
import { loadPlugins, setupPlugins } from "#kernel/pluginLoader.js";
|
|
16
16
|
import { logger } from "#logger";
|
|
17
17
|
import { PLUGINS, CLIENT_ID } from "#config";
|
|
18
18
|
import { t } from "#i18n";
|
|
19
19
|
import { printBanner } from "#client/banner.js";
|
|
20
|
-
import {
|
|
20
|
+
import { loadChatCache, saveChatCache, isCacheFresh } from "#client/cache.js";
|
|
21
|
+
import { DisconnectReason, normalizeMessageContent } from "@whiskeysockets/baileys";
|
|
21
22
|
import fs from "fs/promises";
|
|
23
|
+
import * as clack from "@clack/prompts";
|
|
24
|
+
import { copyToClipboard } from "#utils/clipboard.js";
|
|
22
25
|
let state = "BOOT";
|
|
23
26
|
let shuttingDown = false;
|
|
24
27
|
let currentSock = null;
|
|
25
28
|
let currentStore = null;
|
|
29
|
+
let reconnectTimer = null;
|
|
30
|
+
let connecting = false;
|
|
31
|
+
let reconnectAttempts = 0;
|
|
32
|
+
let cacheHydrated = false;
|
|
33
|
+
let cacheSaveTimer = null;
|
|
34
|
+
const RECONNECT_BASE_MS = 1000;
|
|
35
|
+
const RECONNECT_MAX_MS = 60000;
|
|
36
|
+
const CACHE_SAVE_INTERVAL_MS = 5 * 60 * 1000; // 5min
|
|
37
|
+
/**
|
|
38
|
+
* Loads the on-disk cache and merges it into `store` (union, never
|
|
39
|
+
* overwrite — see client/cache.ts). Runs once per process: the shared
|
|
40
|
+
* store singleton already accumulates across reconnects, so re-hydrating
|
|
41
|
+
* later would just redo a no-op merge.
|
|
42
|
+
*/
|
|
43
|
+
async function hydrateFromCache(store) {
|
|
44
|
+
if (cacheHydrated)
|
|
45
|
+
return;
|
|
46
|
+
cacheHydrated = true;
|
|
47
|
+
const snapshot = await loadChatCache();
|
|
48
|
+
if (!snapshot)
|
|
49
|
+
return;
|
|
50
|
+
store.hydrate(snapshot);
|
|
51
|
+
const fresh = await isCacheFresh();
|
|
52
|
+
const count = snapshot.chats.length;
|
|
53
|
+
const key = fresh ? "system.cacheLoaded" : "system.cacheLoadedStale";
|
|
54
|
+
logger.info(`[cache] ${t(key, { count })}`);
|
|
55
|
+
}
|
|
56
|
+
function startCacheAutosave(store) {
|
|
57
|
+
if (cacheSaveTimer)
|
|
58
|
+
return;
|
|
59
|
+
cacheSaveTimer = setInterval(() => { saveChatCache(store); }, CACHE_SAVE_INTERVAL_MS);
|
|
60
|
+
}
|
|
61
|
+
function stopCacheAutosave() {
|
|
62
|
+
if (!cacheSaveTimer)
|
|
63
|
+
return;
|
|
64
|
+
clearInterval(cacheSaveTimer);
|
|
65
|
+
cacheSaveTimer = null;
|
|
66
|
+
}
|
|
67
|
+
function nextBackoffMs() {
|
|
68
|
+
const delay = RECONNECT_BASE_MS * 2 ** reconnectAttempts;
|
|
69
|
+
reconnectAttempts++;
|
|
70
|
+
return Math.min(delay, RECONNECT_MAX_MS);
|
|
71
|
+
}
|
|
72
|
+
function teardownSock(sock) {
|
|
73
|
+
if (!sock)
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
sock.ev.removeAllListeners();
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
try {
|
|
80
|
+
sock.end(undefined);
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
}
|
|
84
|
+
function scheduleReconnect(delayMs) {
|
|
85
|
+
if (shuttingDown)
|
|
86
|
+
return;
|
|
87
|
+
if (reconnectTimer)
|
|
88
|
+
clearTimeout(reconnectTimer);
|
|
89
|
+
reconnectTimer = setTimeout(() => {
|
|
90
|
+
reconnectTimer = null;
|
|
91
|
+
startBot();
|
|
92
|
+
}, delayMs);
|
|
93
|
+
}
|
|
26
94
|
async function startBot() {
|
|
95
|
+
if (connecting)
|
|
96
|
+
return;
|
|
97
|
+
connecting = true;
|
|
98
|
+
await hydrateFromCache(sharedStore);
|
|
99
|
+
const previousSock = currentSock;
|
|
27
100
|
const { sock, store } = await createSocket();
|
|
101
|
+
teardownSock(previousSock);
|
|
28
102
|
currentSock = sock;
|
|
29
103
|
currentStore = store;
|
|
104
|
+
connecting = false;
|
|
30
105
|
// ── Normal bot mode ─────────────────────────────────────────────────────────
|
|
31
106
|
sock.ev.on("connection.update", async (update) => {
|
|
32
107
|
const { connection, lastDisconnect } = update;
|
|
33
108
|
if (connection === "open") {
|
|
34
109
|
state = "READY_INIT";
|
|
110
|
+
reconnectAttempts = 0;
|
|
35
111
|
logger.success(t("system.connected"));
|
|
36
112
|
logger.info(t("system.clientId", { id: CLIENT_ID }));
|
|
37
113
|
printBanner();
|
|
38
114
|
await loadPlugins(PLUGINS);
|
|
39
115
|
await setupPlugins(sock, store);
|
|
116
|
+
startCacheAutosave(store);
|
|
40
117
|
// buffer anti-replay / sync ghost messages
|
|
41
118
|
setTimeout(() => { state = "READY"; }, 2000);
|
|
42
119
|
}
|
|
@@ -53,12 +130,12 @@ async function startBot() {
|
|
|
53
130
|
catch (e) {
|
|
54
131
|
logger.error(`[whatsapp] Failed to remove session dir: ${e.message}`);
|
|
55
132
|
}
|
|
56
|
-
|
|
57
|
-
setTimeout(() => startBot(), 1000);
|
|
133
|
+
scheduleReconnect(1000);
|
|
58
134
|
}
|
|
59
135
|
else if (!shuttingDown) {
|
|
60
|
-
|
|
61
|
-
|
|
136
|
+
const delay = nextBackoffMs();
|
|
137
|
+
logger.info(t("system.reconnecting", { secs: Math.round(delay / 1000) }));
|
|
138
|
+
scheduleReconnect(delay);
|
|
62
139
|
}
|
|
63
140
|
}
|
|
64
141
|
});
|
|
@@ -87,7 +164,7 @@ async function startBot() {
|
|
|
87
164
|
}
|
|
88
165
|
/** Quick body extraction to avoid importing helpers here. */
|
|
89
166
|
function getBodyQuick(msg) {
|
|
90
|
-
const m = msg.message;
|
|
167
|
+
const m = normalizeMessageContent(msg.message);
|
|
91
168
|
if (!m)
|
|
92
169
|
return "";
|
|
93
170
|
return (m.conversation ??
|
|
@@ -97,7 +174,7 @@ function getBodyQuick(msg) {
|
|
|
97
174
|
"");
|
|
98
175
|
}
|
|
99
176
|
function msgHasMediaQuick(msg) {
|
|
100
|
-
const m = msg.message;
|
|
177
|
+
const m = normalizeMessageContent(msg.message);
|
|
101
178
|
return !!(m?.imageMessage || m?.videoMessage || m?.audioMessage || m?.documentMessage || m?.stickerMessage);
|
|
102
179
|
}
|
|
103
180
|
export const whatsappDriver = {
|
|
@@ -107,62 +184,165 @@ export const whatsappDriver = {
|
|
|
107
184
|
},
|
|
108
185
|
async disconnect() {
|
|
109
186
|
shuttingDown = true;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
catch { }
|
|
115
|
-
try {
|
|
116
|
-
currentSock.end(undefined);
|
|
117
|
-
}
|
|
118
|
-
catch { }
|
|
119
|
-
currentSock = null;
|
|
120
|
-
currentStore = null;
|
|
187
|
+
reconnectAttempts = 0;
|
|
188
|
+
if (reconnectTimer) {
|
|
189
|
+
clearTimeout(reconnectTimer);
|
|
190
|
+
reconnectTimer = null;
|
|
121
191
|
}
|
|
192
|
+
stopCacheAutosave();
|
|
193
|
+
if (currentStore)
|
|
194
|
+
await saveChatCache(currentStore);
|
|
195
|
+
teardownSock(currentSock);
|
|
196
|
+
currentSock = null;
|
|
197
|
+
currentStore = null;
|
|
122
198
|
},
|
|
123
199
|
/**
|
|
124
|
-
* Diagnostic mode: connects
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* Intentionally does not go through the CHATS/dedup filters of
|
|
131
|
-
* handleMessage: the only goal here is to discover the JID so you can
|
|
132
|
-
* configure CHATS afterward, so there's no reason to filter
|
|
133
|
-
* anything out yet.
|
|
200
|
+
* Diagnostic mode: connects on its own session (separate from the
|
|
201
|
+
* running bot's, so it doesn't compete for the same WhatsApp Web
|
|
202
|
+
* slot), waits for the initial chat sync, then shows an interactive
|
|
203
|
+
* list — arrow keys to navigate, Enter to pick. The selected chat's
|
|
204
|
+
* id is normalized, resolved from `@lid` to the real phone-based JID
|
|
205
|
+
* when known, copied to the clipboard, and printed.
|
|
134
206
|
*/
|
|
135
207
|
async getId() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
208
|
+
logger.info(`[getid] ${t("getid.connecting")}`);
|
|
209
|
+
await hydrateFromCache(sharedStore);
|
|
210
|
+
const CONNECT_TIMEOUT_MS = 25000;
|
|
211
|
+
const MAX_ATTEMPTS = 3;
|
|
212
|
+
const MAX_ROUNDS = 2;
|
|
213
|
+
const getidAuthDir = `${CLIENT_ID}-getid`;
|
|
214
|
+
let sock = null;
|
|
215
|
+
let store = null;
|
|
216
|
+
for (let round = 1; round <= MAX_ROUNDS && !sock; round++) {
|
|
217
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS && !sock; attempt++) {
|
|
218
|
+
const created = await createSocket(getidAuthDir);
|
|
219
|
+
// Not a spinner here on purpose: if this session isn't paired yet,
|
|
220
|
+
// Baileys prints the QR/pairing code through the normal logger
|
|
221
|
+
// right after this — a spinner redrawing the line would bury it,
|
|
222
|
+
// so the person never gets a chance to approve it on their phone
|
|
223
|
+
// and the connection hangs forever waiting for "open".
|
|
224
|
+
const opened = await new Promise((resolve) => {
|
|
225
|
+
let settled = false;
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
if (!settled) {
|
|
228
|
+
settled = true;
|
|
229
|
+
resolve(false);
|
|
230
|
+
}
|
|
231
|
+
}, CONNECT_TIMEOUT_MS);
|
|
232
|
+
created.sock.ev.on("connection.update", (update) => {
|
|
233
|
+
if (settled)
|
|
234
|
+
return;
|
|
235
|
+
if (update.connection === "open") {
|
|
236
|
+
settled = true;
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
resolve(true);
|
|
239
|
+
}
|
|
240
|
+
else if (update.connection === "close") {
|
|
241
|
+
const code = update.lastDisconnect?.error?.output?.statusCode;
|
|
242
|
+
settled = true;
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
logger.warn(`[getid] ${t("getid.connectFailed", { reason: String(code ?? "?") })}`);
|
|
245
|
+
resolve(false);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
if (opened) {
|
|
250
|
+
sock = created.sock;
|
|
251
|
+
store = created.store;
|
|
252
|
+
break;
|
|
143
253
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
254
|
+
try {
|
|
255
|
+
created.sock.ev?.removeAllListeners();
|
|
256
|
+
created.sock.end(undefined);
|
|
257
|
+
}
|
|
258
|
+
catch { }
|
|
259
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
260
|
+
logger.info(`[getid] ${t("getid.retrying", { attempt: attempt + 1, max: MAX_ATTEMPTS })}`);
|
|
261
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Exhausted every attempt this round without ever reaching "open" —
|
|
265
|
+
// likely a corrupted/stuck -getid session (not the normal first-pairing
|
|
266
|
+
// 515, which already succeeds within a round). Wipe it and re-pair
|
|
267
|
+
// from scratch instead of forcing the person to rerun the command.
|
|
268
|
+
if (!sock && round < MAX_ROUNDS) {
|
|
269
|
+
logger.warn(`[getid] ${t("getid.sessionWiped", { round: round + 1, max: MAX_ROUNDS })}`);
|
|
270
|
+
try {
|
|
271
|
+
await fs.rm(sessionDir(getidAuthDir), { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
logger.error(`[getid] Failed to remove session dir: ${e.message}`);
|
|
275
|
+
}
|
|
276
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!sock || !store) {
|
|
280
|
+
logger.error(`[getid] ${t("getid.connectGaveUp")}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const s = clack.spinner();
|
|
284
|
+
s.start(t("getid.syncingChats"));
|
|
285
|
+
// History sync arrives as several independent streams (chats,
|
|
286
|
+
// contacts, push-names...), each firing its own `isLatest: true` when
|
|
287
|
+
// THAT stream ends — not when everything is done. Stopping on the
|
|
288
|
+
// first one cuts the others short (fewer chats, missing names).
|
|
289
|
+
// Instead, wait for a quiet period with no sync activity at all.
|
|
290
|
+
let lastActivityAt = null;
|
|
291
|
+
const markActivity = () => { lastActivityAt = Date.now(); };
|
|
292
|
+
sock.ev.on("messaging-history.set", markActivity);
|
|
293
|
+
sock.ev.on("chats.upsert", markActivity);
|
|
294
|
+
sock.ev.on("contacts.upsert", markActivity);
|
|
295
|
+
sock.ev.on("contacts.update", markActivity);
|
|
296
|
+
const QUIET_MS = 2000;
|
|
297
|
+
const deadline = Date.now() + 20000;
|
|
298
|
+
while (Date.now() < deadline) {
|
|
299
|
+
if (lastActivityAt !== null && Date.now() - lastActivityAt >= QUIET_MS)
|
|
300
|
+
break;
|
|
301
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
302
|
+
}
|
|
303
|
+
const chats = store.chats.all();
|
|
304
|
+
s.stop(t("getid.chatsFound", { count: chats.length }));
|
|
305
|
+
await saveChatCache(store);
|
|
162
306
|
try {
|
|
163
307
|
sock.ev?.removeAllListeners();
|
|
164
308
|
sock.end(undefined);
|
|
165
309
|
}
|
|
166
310
|
catch { }
|
|
311
|
+
if (chats.length === 0) {
|
|
312
|
+
logger.warn(`[getid] ${t("getid.noChatsSynced")}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const options = chats
|
|
316
|
+
.map((chat) => {
|
|
317
|
+
const isGroup = chat.id.endsWith("@g.us");
|
|
318
|
+
const resolved = normalizeJid(store.resolveJid(chat.id));
|
|
319
|
+
const contact = store.contacts[chat.id] ?? store.contacts[resolved];
|
|
320
|
+
const hasChatName = chat.name && chat.name !== chat.id.split("@")[0];
|
|
321
|
+
const displayName = hasChatName ? chat.name : contact?.name ?? contact?.notify ?? resolved;
|
|
322
|
+
return {
|
|
323
|
+
value: resolved,
|
|
324
|
+
label: `${isGroup ? "👥" : "👤"} ${displayName}`,
|
|
325
|
+
hint: resolved,
|
|
326
|
+
};
|
|
327
|
+
})
|
|
328
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
329
|
+
const picked = await clack.multiselect({
|
|
330
|
+
message: t("getid.pickPrompt"),
|
|
331
|
+
options,
|
|
332
|
+
required: true,
|
|
333
|
+
});
|
|
334
|
+
if (clack.isCancel(picked)) {
|
|
335
|
+
clack.cancel(t("getid.cancelled"));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const ids = picked;
|
|
339
|
+
const joined = ids.join("\n");
|
|
340
|
+
const copied = await copyToClipboard(joined);
|
|
341
|
+
if (copied) {
|
|
342
|
+
logger.success(`[getid] ${t("getid.copied", { count: ids.length, ids: joined })}`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
logger.warn(`[getid] ${t("getid.copyFailed", { count: ids.length, ids: joined })}`);
|
|
346
|
+
}
|
|
167
347
|
},
|
|
168
348
|
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Creates and returns a Baileys WASocket.
|
|
5
5
|
* Handles auth state persistence, QR/pairing-code display, and reconnection.
|
|
6
6
|
*/
|
|
7
|
-
import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, } from "@whiskeysockets/baileys";
|
|
7
|
+
import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, proto, } from "@whiskeysockets/baileys";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import qrcode from "qrcode-terminal";
|
|
10
10
|
import { CONFIG_DIR, CLIENT_ID } from "#config";
|
|
@@ -23,8 +23,12 @@ export const store = createStore();
|
|
|
23
23
|
* Reconnection is the caller's responsibility — call createSocket() again
|
|
24
24
|
* on `connection.close`.
|
|
25
25
|
*/
|
|
26
|
-
export
|
|
27
|
-
|
|
26
|
+
export function sessionDir(authDirName) {
|
|
27
|
+
return path.join(CONFIG_DIR, "sessions", authDirName);
|
|
28
|
+
}
|
|
29
|
+
export async function createSocket(authDirName = CLIENT_ID) {
|
|
30
|
+
const authDir = sessionDir(authDirName);
|
|
31
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
28
32
|
const { version } = await fetchLatestBaileysVersion();
|
|
29
33
|
// Already-valid session (creds registered in .manybot/sessions) → skips
|
|
30
34
|
// LOGIN_METHOD/PHONE_NUMBER and the interactive login flow entirely, and
|
|
@@ -47,6 +51,13 @@ export async function createSocket() {
|
|
|
47
51
|
logger: pino({ level: "silent" }),
|
|
48
52
|
generateHighQualityLinkPreview: false,
|
|
49
53
|
syncFullHistory: false,
|
|
54
|
+
// Without this, Baileys' default for syncFullHistory:false is
|
|
55
|
+
// `() => false`, which disables ALL history sync — not just full
|
|
56
|
+
// messages, but the chat list, contacts, and LID mappings too (see
|
|
57
|
+
// https://github.com/WhiskeySockets/Baileys — SocketConfig docs).
|
|
58
|
+
// This keeps the initial/recent sync (needed for store.chats and
|
|
59
|
+
// LID→phone resolution) while still skipping the full download.
|
|
60
|
+
shouldSyncHistoryMessage: ({ syncType }) => syncType !== proto.HistorySync.HistorySyncType.FULL,
|
|
50
61
|
// Required for Baileys to decrypt incoming poll votes (and to retry
|
|
51
62
|
// sends) — it looks up the original message by key internally.
|
|
52
63
|
// Without this, pollUpdates never resolve even though the vote event
|
|
@@ -57,6 +68,14 @@ export async function createSocket() {
|
|
|
57
68
|
},
|
|
58
69
|
});
|
|
59
70
|
store.bind(sock.ev);
|
|
71
|
+
// Each plugin's setup() can attach its own listeners via api.events (see
|
|
72
|
+
// buildEventsApi in api/index.ts), on top of the driver's own and the
|
|
73
|
+
// store's. That easily exceeds Node's default cap of 10 with a handful
|
|
74
|
+
// of plugins — it's expected, not a leak. Reconnects don't add to this:
|
|
75
|
+
// createSocket() always returns a fresh emitter and the caller tears
|
|
76
|
+
// down the previous one.
|
|
77
|
+
const evAsEmitter = sock.ev;
|
|
78
|
+
evAsEmitter.setMaxListeners?.(50);
|
|
60
79
|
sock.ev.on("creds.update", saveCreds);
|
|
61
80
|
// QR code — only if the chosen method was "qr" (intentionally ignores
|
|
62
81
|
// PHONE_NUMBER even if one was saved from a previous choice).
|
|
@@ -20,6 +20,36 @@ export const pluginRegistry = new Map();
|
|
|
20
20
|
let globalSock = null;
|
|
21
21
|
let globalStore = null;
|
|
22
22
|
const pluginWatchers = new Map();
|
|
23
|
+
// fs.watch's `recursive: true` emulates recursion on Linux by opening one
|
|
24
|
+
// inotify watch per subdirectory — a plugin shipping its own node_modules
|
|
25
|
+
// can blow past the OS's fs.inotify.max_user_watches (ENOSPC). Walk the
|
|
26
|
+
// tree ourselves and skip directories that don't need watching.
|
|
27
|
+
const IGNORED_WATCH_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache"]);
|
|
28
|
+
function watchDirRecursive(rootDir, onChange) {
|
|
29
|
+
const watchers = [];
|
|
30
|
+
function walk(dir) {
|
|
31
|
+
try {
|
|
32
|
+
watchers.push(fs.watch(dir, onChange));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (entry.isDirectory() && !IGNORED_WATCH_DIRS.has(entry.name)) {
|
|
46
|
+
walk(path.join(dir, entry.name));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(rootDir);
|
|
51
|
+
return watchers;
|
|
52
|
+
}
|
|
23
53
|
let configWatcher = null;
|
|
24
54
|
/**
|
|
25
55
|
* Load all active plugins listed in `activePlugins`.
|
|
@@ -248,7 +278,7 @@ export function watchPluginDirectory(name) {
|
|
|
248
278
|
return;
|
|
249
279
|
try {
|
|
250
280
|
let watchTimeout = null;
|
|
251
|
-
const
|
|
281
|
+
const watchers = watchDirRecursive(dir, (eventType, filename) => {
|
|
252
282
|
if (watchTimeout)
|
|
253
283
|
clearTimeout(watchTimeout);
|
|
254
284
|
watchTimeout = setTimeout(async () => {
|
|
@@ -256,7 +286,7 @@ export function watchPluginDirectory(name) {
|
|
|
256
286
|
await reloadPlugin(name);
|
|
257
287
|
}, 500);
|
|
258
288
|
});
|
|
259
|
-
pluginWatchers.set(name,
|
|
289
|
+
pluginWatchers.set(name, watchers);
|
|
260
290
|
}
|
|
261
291
|
catch (e) {
|
|
262
292
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
@@ -267,9 +297,10 @@ export function watchPluginDirectory(name) {
|
|
|
267
297
|
* Stop watching a plugin's directory.
|
|
268
298
|
*/
|
|
269
299
|
export function unwatchPlugin(name) {
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
272
|
-
|
|
300
|
+
const watchers = pluginWatchers.get(name);
|
|
301
|
+
if (watchers) {
|
|
302
|
+
for (const w of watchers)
|
|
303
|
+
w.close();
|
|
273
304
|
pluginWatchers.delete(name);
|
|
274
305
|
}
|
|
275
306
|
}
|
|
@@ -302,8 +333,9 @@ export async function cleanupPlugins() {
|
|
|
302
333
|
configWatcher.close();
|
|
303
334
|
configWatcher = null;
|
|
304
335
|
}
|
|
305
|
-
for (const
|
|
306
|
-
|
|
336
|
+
for (const watchers of pluginWatchers.values()) {
|
|
337
|
+
for (const w of watchers)
|
|
338
|
+
w.close();
|
|
307
339
|
}
|
|
308
340
|
pluginWatchers.clear();
|
|
309
341
|
for (const plugin of pluginRegistry.values()) {
|
package/dist/locales/en.json
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"schedulerRegistered": "Schedule registered — plugin \"{{name}}\" → \"{{expression}}\"",
|
|
32
32
|
"downloadJobFailed": "Download job failed — {{message}}",
|
|
33
33
|
"reconnecting": "Reconnecting in {{secs}}s...",
|
|
34
|
-
"sessionExpired": "Session expired or logged out — removing local session and restarting..."
|
|
34
|
+
"sessionExpired": "Session expired or logged out — removing local session and restarting...",
|
|
35
|
+
"cacheLoaded": "{{count}} chat(s) loaded from cache.",
|
|
36
|
+
"cacheLoadedStale": "{{count}} chat(s) loaded from cache (stale)."
|
|
35
37
|
},
|
|
36
38
|
"errors": {
|
|
37
39
|
"stack": "Stack",
|
|
@@ -50,5 +52,19 @@
|
|
|
50
52
|
"outroQr": "Scan the QR code that will appear next.",
|
|
51
53
|
"outroPhone": "Wait for the pairing code that will appear next.",
|
|
52
54
|
"cancelled": "Login cancelled."
|
|
55
|
+
},
|
|
56
|
+
"getid": {
|
|
57
|
+
"connecting": "Connecting (own session, won't disturb the running bot)...",
|
|
58
|
+
"syncingChats": "Syncing chats...",
|
|
59
|
+
"chatsFound": "{{count}} chat(s) found.",
|
|
60
|
+
"noChatsSynced": "No chats synced yet. Make sure this WhatsApp account already has some conversation history and try again.",
|
|
61
|
+
"pickPrompt": "Pick one or more chats (↑↓ + Space to select, Enter to confirm).",
|
|
62
|
+
"cancelled": "Cancelled.",
|
|
63
|
+
"copied": "{{count}} id(s) copied to the clipboard:\n{{ids}}",
|
|
64
|
+
"copyFailed": "Couldn't access the clipboard — copy manually:\n{{ids}}",
|
|
65
|
+
"connectFailed": "Connection attempt failed — reason: {{reason}}",
|
|
66
|
+
"retrying": "Retrying ({{attempt}}/{{max}})...",
|
|
67
|
+
"sessionWiped": "Session discarded, pairing again ({{round}}/{{max}})...",
|
|
68
|
+
"connectGaveUp": "Couldn't connect after several attempts. Check your network and try again."
|
|
53
69
|
}
|
|
54
70
|
}
|
package/dist/locales/es.json
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"schedulerRegistered": "Programación registrada — plugin \"{{name}}\" → \"{{expression}}\"",
|
|
32
32
|
"downloadJobFailed": "Error en el trabajo de descarga — {{message}}",
|
|
33
33
|
"reconnecting": "Reconectando en {{secs}}s...",
|
|
34
|
-
"sessionExpired": "Sesión expirada o cerrada — eliminando sesión local y reiniciando..."
|
|
34
|
+
"sessionExpired": "Sesión expirada o cerrada — eliminando sesión local y reiniciando...",
|
|
35
|
+
"cacheLoaded": "{{count}} chat(s) cargado(s) desde la caché.",
|
|
36
|
+
"cacheLoadedStale": "{{count}} chat(s) cargado(s) desde la caché (desactualizada)."
|
|
35
37
|
},
|
|
36
38
|
"errors": {
|
|
37
39
|
"stack": "Stack",
|
|
@@ -50,5 +52,19 @@
|
|
|
50
52
|
"outroQr": "Escanea el código QR que aparecerá a continuación.",
|
|
51
53
|
"outroPhone": "Espera el código de emparejamiento que aparecerá a continuación.",
|
|
52
54
|
"cancelled": "Inicio de sesión cancelado."
|
|
55
|
+
},
|
|
56
|
+
"getid": {
|
|
57
|
+
"connecting": "Conectando (sesión propia, no afecta al bot en ejecución)...",
|
|
58
|
+
"syncingChats": "Sincronizando chats...",
|
|
59
|
+
"chatsFound": "{{count}} chat(s) encontrado(s).",
|
|
60
|
+
"noChatsSynced": "Aún no hay chats sincronizados. Asegúrate de que esta cuenta ya tenga historial de conversaciones e intenta de nuevo.",
|
|
61
|
+
"pickPrompt": "Elige uno o más chats (↑↓ + Espacio para marcar, Enter para confirmar).",
|
|
62
|
+
"cancelled": "Cancelado.",
|
|
63
|
+
"copied": "{{count}} ID(s) copiado(s) al portapapeles:\n{{ids}}",
|
|
64
|
+
"copyFailed": "No se pudo acceder al portapapeles — cópialo manualmente:\n{{ids}}",
|
|
65
|
+
"connectFailed": "El intento de conexión falló — motivo: {{reason}}",
|
|
66
|
+
"retrying": "Reintentando ({{attempt}}/{{max}})...",
|
|
67
|
+
"sessionWiped": "Sesión descartada, emparejando de nuevo ({{round}}/{{max}})...",
|
|
68
|
+
"connectGaveUp": "No se pudo conectar tras varios intentos. Revisa tu conexión e intenta de nuevo."
|
|
53
69
|
}
|
|
54
70
|
}
|
package/dist/locales/pt.json
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"schedulerRegistered": "Agendamento registrado — plugin \"{{name}}\" → \"{{expression}}\"",
|
|
32
32
|
"downloadJobFailed": "Falha no job de download — {{message}}",
|
|
33
33
|
"reconnecting": "Reconectando em {{secs}}s...",
|
|
34
|
-
"sessionExpired": "Sessão expirada ou desconectada — removendo sessão local e reiniciando..."
|
|
34
|
+
"sessionExpired": "Sessão expirada ou desconectada — removendo sessão local e reiniciando...",
|
|
35
|
+
"cacheLoaded": "{{count}} conversa(s) carregada(s) do cache.",
|
|
36
|
+
"cacheLoadedStale": "{{count}} conversa(s) carregada(s) do cache (desatualizado)."
|
|
35
37
|
},
|
|
36
38
|
"errors": {
|
|
37
39
|
"stack": "Stack",
|
|
@@ -50,5 +52,19 @@
|
|
|
50
52
|
"outroQr": "Escaneie o QR code que vai aparecer a seguir.",
|
|
51
53
|
"outroPhone": "Aguarde o código de pareamento que vai aparecer a seguir.",
|
|
52
54
|
"cancelled": "Login cancelado."
|
|
55
|
+
},
|
|
56
|
+
"getid": {
|
|
57
|
+
"connecting": "Conectando (sessão própria, não afeta o bot em execução)...",
|
|
58
|
+
"syncingChats": "Sincronizando conversas...",
|
|
59
|
+
"chatsFound": "{{count}} conversa(s) encontrada(s).",
|
|
60
|
+
"noChatsSynced": "Nenhuma conversa sincronizada ainda. Certifique-se de que essa conta já tem histórico de conversas e tente novamente.",
|
|
61
|
+
"pickPrompt": "Escolha uma ou mais conversas (↑↓ + Espaço para marcar, Enter para confirmar).",
|
|
62
|
+
"cancelled": "Cancelado.",
|
|
63
|
+
"copied": "{{count}} ID(s) copiado(s) para a área de transferência:\n{{ids}}",
|
|
64
|
+
"copyFailed": "Não foi possível acessar a área de transferência — copie manualmente:\n{{ids}}",
|
|
65
|
+
"connectFailed": "Tentativa de conexão falhou — motivo: {{reason}}",
|
|
66
|
+
"retrying": "Tentando novamente ({{attempt}}/{{max}})...",
|
|
67
|
+
"sessionWiped": "Sessão descartada, tentando parear novamente ({{round}}/{{max}})...",
|
|
68
|
+
"connectGaveUp": "Não foi possível conectar após várias tentativas. Verifique sua conexão e tente de novo."
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clipboard.ts
|
|
3
|
+
*
|
|
4
|
+
* Writes text to the system clipboard using whatever OS-native tool is
|
|
5
|
+
* available. No npm dependency — just spawns pbcopy/clip/xclip/xsel/wl-copy.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
function tryCommand(cmd, args, text) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
let child;
|
|
11
|
+
try {
|
|
12
|
+
child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
resolve(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
child.on("error", () => resolve(false));
|
|
19
|
+
child.on("close", (code) => resolve(code === 0));
|
|
20
|
+
child.stdin.on("error", () => { });
|
|
21
|
+
child.stdin.write(text);
|
|
22
|
+
child.stdin.end();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Copies `text` to the system clipboard.
|
|
27
|
+
* Returns false (never throws) if no supported clipboard tool is found.
|
|
28
|
+
*/
|
|
29
|
+
export async function copyToClipboard(text) {
|
|
30
|
+
const candidates = process.platform === "darwin"
|
|
31
|
+
? [["pbcopy", []]]
|
|
32
|
+
: process.platform === "win32"
|
|
33
|
+
? [["clip", []]]
|
|
34
|
+
: [
|
|
35
|
+
["xclip", ["-selection", "clipboard"]],
|
|
36
|
+
["xsel", ["--clipboard", "--input"]],
|
|
37
|
+
["wl-copy", []],
|
|
38
|
+
];
|
|
39
|
+
for (const [cmd, args] of candidates) {
|
|
40
|
+
if (await tryCommand(cmd, args, text))
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
"name": "SyntaxError!",
|
|
6
6
|
"email": "me@stxerr.dev"
|
|
7
7
|
},
|
|
8
|
-
"version": "5.
|
|
8
|
+
"version": "5.3.1",
|
|
9
9
|
"license": "GPL-3.0-only",
|
|
10
10
|
"private": false,
|
|
11
11
|
"engines": {
|
|
12
|
-
"node": ">=24.
|
|
12
|
+
"node": ">=24.0.0"
|
|
13
13
|
},
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"build": "tsc && node -e \"fs.mkdirSync('dist/locales', { recursive: true }); fs.cpSync('src/locales', 'dist/locales', { recursive: true });\"",
|
|
28
|
+
"build": "npm i && tsc && node -e \"fs.mkdirSync('dist/locales', { recursive: true }); fs.cpSync('src/locales', 'dist/locales', { recursive: true });\"",
|
|
29
29
|
"start": "node dist/main.js",
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
@@ -59,5 +59,10 @@
|
|
|
59
59
|
"#config": "./dist/config.js",
|
|
60
60
|
"#main": "./dist/main.js",
|
|
61
61
|
"#types": "./dist/types.js"
|
|
62
|
+
},
|
|
63
|
+
"allowScripts": {
|
|
64
|
+
"@whiskeysockets/baileys@6.7.23": true,
|
|
65
|
+
"esbuild@0.28.1": true,
|
|
66
|
+
"protobufjs@7.6.4": true
|
|
62
67
|
}
|
|
63
68
|
}
|