@manybot/manybot 5.2.6 → 5.3.0

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 CHANGED
@@ -16,7 +16,7 @@ Open-source framework for message automation, extensible via community plugins.
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Node.js >= 24.17.0
19
+ - Node.js >= 24
20
20
  - npm
21
21
 
22
22
  ## Getting started
@@ -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
+ }
@@ -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 && !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.message;
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.message;
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.message;
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.message?.videoMessage?.gifPlayback);
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.message;
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.message;
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
- const resolved = normalizeJid(store.resolveJid(normalizeJid(contactId)));
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 taking whichever key
392
- // happens to exist first, or we silently drop real fields.
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) ? { ...raw, ...resolvedInfo } : undefined;
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
  /**
@@ -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) => sock.groupParticipantsUpdate(jid, users, "add"), memberIds);
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
- return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "remove");
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
- return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "promote");
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
- return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "demote");
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
- requireChat();
900
- const code = await sock.groupInviteCode(chatJid);
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
- const pollListeners = new Set();
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
- if (!pollListeners.has(pluginName)) {
1008
- pollListeners.add(pluginName);
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 —
@@ -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 { DisconnectReason } from "@whiskeysockets/baileys";
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
- if (!shuttingDown)
57
- setTimeout(() => startBot(), 1000);
133
+ scheduleReconnect(1000);
58
134
  }
59
135
  else if (!shuttingDown) {
60
- logger.info(t("system.reconnecting", { secs: 5 }));
61
- setTimeout(() => startBot(), 5000);
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
- if (currentSock) {
111
- try {
112
- currentSock.ev?.removeAllListeners();
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 normally (reusing an already saved
125
- * session/QR), but instead of loading plugins, just waits for the
126
- * NEXT message to arrive from any chat, from any sender, whether
127
- * it's your own message or someone else's prints the normalized
128
- * JID and the chat name (if any), and exits.
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
- const { sock } = await createSocket();
137
- logger.info("[getid] Connecting... wait for the QR/pairing prompt if this is the first run.");
138
- await new Promise((resolve) => {
139
- let done = false;
140
- sock.ev.on("connection.update", (update) => {
141
- if (update.connection === "open") {
142
- logger.success("[getid] Connected. Send ANY message in the chat you want to identify.");
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
- sock.ev.on("messages.upsert", ({ messages, type }) => {
146
- if (done || type !== "notify")
147
- return;
148
- const msg = messages[0];
149
- const rawJid = msg?.key?.remoteJid;
150
- if (!rawJid)
151
- return;
152
- done = true;
153
- const jid = normalizeJid(rawJid);
154
- const name = msg?.pushName ?? "";
155
- logger.success(`[getid] JID: ${jid}`);
156
- if (name)
157
- logger.info(`[getid] Chat/sender: ${name}`);
158
- logger.info("[getid] Paste this value into CHATS in manybot.toml.");
159
- resolve();
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 async function createSocket() {
27
- const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
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 watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
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, watcher);
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 watcher = pluginWatchers.get(name);
271
- if (watcher) {
272
- watcher.close();
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 [name, watcher] of pluginWatchers.entries()) {
306
- watcher.close();
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()) {
@@ -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
  }
@@ -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
  }
@@ -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
  }
package/dist/main.js CHANGED
File without changes
@@ -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.2.6",
8
+ "version": "5.3.0",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {
12
- "node": ">=24.17.0"
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
  }