@bot-wa/baileys 0.0.0-stage → 0.2.0-alpha.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.
@@ -0,0 +1,858 @@
1
+ /**
2
+ * BaileysProvider — WhatsApp transport implementing {@link IProvider} v2.
3
+ *
4
+ * Owns the Baileys socket lifecycle (connect, QR/pairing, reconnect), translates
5
+ * every Baileys event into a normalized {@link InboundEvent}, maps outbound
6
+ * {@link OutboundContent} to Baileys content, and resolves LID/PN identity
7
+ * internally so core never sees a WhatsApp concept.
8
+ */
9
+ import { Boom } from '@hapi/boom';
10
+ import NodeCache from '@cacheable/node-cache';
11
+ import makeWASocket, { fetchLatestBaileysVersion, makeCacheableSignalKeyStore, toNumber, useMultiFileAuthState, downloadMediaMessage, } from '@whiskeysockets/baileys';
12
+ import pino from 'pino';
13
+ import { toBaileysLogger } from './logger.js';
14
+ import { ProviderError, RateLimitedError, RecipientUnreachableError, PayloadTooLargeError, } from '@bot-wa/core';
15
+ import { classifyDisconnect, backoffDelay, MAX_RECONNECT_ATTEMPTS } from './reconnect.js';
16
+ import { putBounded } from './boundedMap.js';
17
+ import { resolveBrowser } from './browser.js';
18
+ import { IdentityStore, canonicalKey } from './identity.js';
19
+ import { normalize, isLidUser, isPnUser } from './jid.js';
20
+ import { PollTracker, POLL_CACHE_MAX, decryptVote, fromStoredPoll, pollCreationOf, pollUpdateOf, recordVote, tally, toStoredPoll, } from './polls.js';
21
+ import { contextInfoOf, toInbound } from './toInbound.js';
22
+ import { parseBaileysMediaRef, serializeBaileysMediaRef } from './mediaRef.js';
23
+ import { toCallEvent } from './toCallEvent.js';
24
+ import { toOutbound } from './toOutbound.js';
25
+ import { RateLimiter } from './RateLimiter.js';
26
+ import { assertSessionDirectorySafe, removeSessionDirectory, resolveSessionDirectory, } from './sessionPath.js';
27
+ const LID_CACHE_MAX = 2000;
28
+ const MSG_CACHE_MAX = 2000;
29
+ /** LID lookups per message for mentioned users (the quoted author is always looked up). */
30
+ const MAX_MENTION_LOOKUPS = 64;
31
+ const cacheKey = (k) => `${k.remoteJid}:${k.id}`;
32
+ /**
33
+ * The Baileys key of a message ref: the native `WAMessage` key when the ref came
34
+ * from this provider, otherwise one rebuilt from the ref (e.g. a ref read back
35
+ * from a store, whose `native` did not survive serialisation).
36
+ */
37
+ function keyOfRef(ref, fromMe = false) {
38
+ const native = ref.native;
39
+ if (native && typeof native === 'object' && native.key?.id)
40
+ return native.key;
41
+ return { remoteJid: ref.chatId, id: ref.id, fromMe };
42
+ }
43
+ /**
44
+ * Map a raw error thrown by `sock.sendMessage` to a typed {@link ProviderError},
45
+ * so callers can `catch (e instanceof RateLimitedError)` portably across
46
+ * transports. Unknown errors are wrapped in a generic ProviderError (with the
47
+ * original attached as `cause`) rather than leaking a Baileys/Boom internal.
48
+ */
49
+ export function mapSendError(err, chatId) {
50
+ if (err instanceof ProviderError)
51
+ return err;
52
+ const boom = err instanceof Boom ? err : undefined;
53
+ const status = boom?.output?.statusCode;
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ if (status === 429) {
56
+ const retryAfter = Number(boom?.output?.headers?.['retry-after']) * 1000;
57
+ return new RateLimitedError(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 1000, message);
58
+ }
59
+ if (status === 413 || /too large|payload.*large|exceeds/i.test(message)) {
60
+ return new PayloadTooLargeError(message);
61
+ }
62
+ if (status === 403 || status === 401 || /forbidden|not-authorized|unauthorized|blocked/i.test(message)) {
63
+ return new RecipientUnreachableError(chatId, message);
64
+ }
65
+ const wrapped = new ProviderError(message || 'Baileys send failed');
66
+ wrapped.cause = err;
67
+ return wrapped;
68
+ }
69
+ export class BaileysProvider {
70
+ id = 'baileys';
71
+ capabilities = {
72
+ contentKinds: ['text', 'image', 'video', 'audio', 'voice', 'document', 'sticker', 'location', 'contact', 'poll', 'reaction', 'buttons', 'list', 'card'],
73
+ maxTextLength: 65536,
74
+ buttons: false, lists: false, polls: true, reactions: true, edit: false,
75
+ typing: true, presence: true, readReceipts: true, groups: true, templates: false,
76
+ interactionsRequireAck: false, pushIngress: false,
77
+ exclusiveSession: true, // one WhatsApp Web socket per session: with N replicas only the elected one connects
78
+ streaming: 'none', // no live preview: ctx.stream() sends sentence chunks with typing
79
+ forms: 'degraded', // flow().form() asks field by field
80
+ textFormat: 'whatsapp', // WhatsApp markup: *bold* _italic_ ~strike~
81
+ };
82
+ config;
83
+ logger;
84
+ /** The logger handed to Baileys (`makeWASocket`, the signal key store, media downloads). */
85
+ waLogger;
86
+ sock = null;
87
+ msgRetryCounterCache = new NodeCache({ stdTTL: 0, useClones: false });
88
+ authState = null;
89
+ version = [0, 0, 0];
90
+ reconnectPending = false;
91
+ /** Timer of the scheduled reconnect, cleared by stop() so it never outlives the session. */
92
+ reconnectTimer = null;
93
+ reconnectAttempts = 0;
94
+ isShuttingDown = false;
95
+ pairingCodeRequested = false;
96
+ lidCache = new Map();
97
+ msgCache = new Map();
98
+ /** Polls seen by this process (sent or received), so their votes can be decrypted. */
99
+ polls = new PollTracker(POLL_CACHE_MAX);
100
+ /** One pending {@link PollStore} read per poll id, shared by concurrent votes. */
101
+ pollLoads = new Map();
102
+ /** Tail of each poll's save chain, so writes reach the store in order. */
103
+ pollSaves = new Map();
104
+ identity = new IdentityStore();
105
+ /** Call id → group JID of group calls in progress (see {@link toCallEvent}). */
106
+ callGroups = new Map();
107
+ rateLimiter;
108
+ listeners = new Set();
109
+ constructor(config = {}) {
110
+ this.config = config;
111
+ this.logger = config.logger ?? pino({
112
+ level: config.debug ? 'debug' : 'error',
113
+ });
114
+ this.waLogger = toBaileysLogger(this.logger);
115
+ this.rateLimiter = new RateLimiter(config.rateLimit);
116
+ // Validate eagerly, before any filesystem or network operation.
117
+ resolveSessionDirectory(this.config.sessionsDir, this.config.session);
118
+ if (Array.isArray(config.browser)) {
119
+ this.logger.warn({ browser: config.browser }, 'custom browser tuple overrides the Baileys default (macOS Chrome) handshake; WhatsApp may close the connection with 428 before the QR');
120
+ }
121
+ }
122
+ get connection() { return this.sock; }
123
+ /**
124
+ * The bot's canonical id: its normalised PN (`creds.me.id` without the device
125
+ * suffix), the same id its own reactions and votes carry. Core matches
126
+ * `groupMode: 'mentions'` against it, and mentions are canonical too.
127
+ */
128
+ get selfId() {
129
+ return this.self().id || null;
130
+ }
131
+ get socket() { return this.sock; }
132
+ get auth() {
133
+ if (!this.authState)
134
+ throw new Error('Provider not started');
135
+ return this.authState;
136
+ }
137
+ get sessionDir() {
138
+ return resolveSessionDirectory(this.config.sessionsDir, this.config.session);
139
+ }
140
+ onEvent(listener) {
141
+ this.listeners.add(listener);
142
+ return () => this.listeners.delete(listener);
143
+ }
144
+ fan(e) {
145
+ for (const listener of this.listeners) {
146
+ try {
147
+ void Promise.resolve(listener(e))
148
+ .catch((error) => this.logger.error({ err: error, eventKind: e.kind }, 'provider event listener failed'));
149
+ }
150
+ catch (error) {
151
+ this.logger.error({ err: error, eventKind: e.kind }, 'provider event listener failed');
152
+ }
153
+ }
154
+ }
155
+ async start() {
156
+ this.pairingCodeRequested = false;
157
+ this.isShuttingDown = false;
158
+ const sessionDir = assertSessionDirectorySafe(this.config.sessionsDir, this.config.session);
159
+ this.authState = await useMultiFileAuthState(sessionDir);
160
+ // Never throws: on a network/parse failure Baileys falls back to the WA Web
161
+ // version bundled with the installed release (isLatest: false, error set).
162
+ const versionInfo = await fetchLatestBaileysVersion();
163
+ this.version = versionInfo.version;
164
+ this.logger.debug({ version: this.version, latest: versionInfo.isLatest, err: versionInfo.error }, 'baileys version');
165
+ await this.buildSocket();
166
+ }
167
+ async stop() {
168
+ this.cancelReconnect();
169
+ this.isShuttingDown = true;
170
+ this.rateLimiter.reset();
171
+ if (this.sock) {
172
+ try {
173
+ this.sock.end(new Error('shutdown'));
174
+ }
175
+ catch { /* socket may already be closed */ }
176
+ this.sock = null;
177
+ }
178
+ }
179
+ /**
180
+ * Explicit destructive logout. Unlike {@link stop}, this unlinks the device
181
+ * and removes its local auth state, so a new QR/pairing flow will be required.
182
+ */
183
+ async logoutAndDeleteSession() {
184
+ this.cancelReconnect();
185
+ this.isShuttingDown = true;
186
+ this.rateLimiter.reset();
187
+ if (this.sock) {
188
+ try {
189
+ await this.sock.logout();
190
+ }
191
+ finally {
192
+ this.sock = null;
193
+ }
194
+ }
195
+ removeSessionDirectory(this.config.sessionsDir, this.config.session);
196
+ this.authState = null;
197
+ }
198
+ async send(chatId, content, options) {
199
+ if (!this.sock)
200
+ throw new ProviderError('Provider not connected');
201
+ // Everything below rejects through mapSendError, so send() only ever rejects
202
+ // with ProviderError subclasses (rate-limiter timeouts and resets included).
203
+ try {
204
+ const waContent = await toOutbound(content, this.config.remoteMedia);
205
+ const waOptions = {};
206
+ if (options?.quoted?.native)
207
+ waOptions.quoted = options.quoted.native;
208
+ const resolvedJid = await this.resolveJid(chatId);
209
+ await this.rateLimiter.acquire(chatId);
210
+ if (!this.sock)
211
+ throw new ProviderError('Provider not connected');
212
+ const sent = (await this.sock.sendMessage(resolvedJid, waContent, waOptions));
213
+ // Serve retry requests (getMessage) and decrypt votes on polls we send.
214
+ if (sent?.key) {
215
+ this.rememberMessage(sent);
216
+ await this.rememberPoll(sent);
217
+ }
218
+ return { ref: { id: sent.key.id ?? '', chatId, native: sent }, raw: sent };
219
+ }
220
+ catch (err) {
221
+ throw mapSendError(err, chatId);
222
+ }
223
+ }
224
+ /**
225
+ * Download inbound media. `ref.token` is the original `WAMessage` (set by the
226
+ * inbound mapper); expired media is re-requested from the sender's phone.
227
+ */
228
+ async downloadMedia(ref) {
229
+ const message = ref.token;
230
+ if (!message || typeof message !== 'object' || !message.message) {
231
+ throw new ProviderError('downloadMedia requires the original WAMessage (MediaRef.token from an inbound message)');
232
+ }
233
+ const sock = this.sock;
234
+ if (!sock)
235
+ throw new ProviderError('Provider not connected');
236
+ try {
237
+ return await downloadMediaMessage(message, 'buffer', {}, {
238
+ logger: this.waLogger,
239
+ reuploadRequest: sock.updateMediaMessage,
240
+ });
241
+ }
242
+ catch (err) {
243
+ const wrapped = new ProviderError(`Baileys media download failed: ${err instanceof Error ? err.message : String(err)}`);
244
+ wrapped.cause = err;
245
+ throw wrapped;
246
+ }
247
+ }
248
+ /**
249
+ * Durable inbox: the media content (no thumbnail, caption or quoted context) and the raw
250
+ * message key, enough to download the media again in another process. `undefined` for
251
+ * view-once media unless `durableMedia.viewOnce` is set.
252
+ */
253
+ serializeMediaRef(ref) {
254
+ return serializeBaileysMediaRef(ref, { viewOnce: this.config.durableMedia?.viewOnce === true });
255
+ }
256
+ /**
257
+ * Rebuild the token of a replayed media message: the original `WAMessage` while it is still
258
+ * in this process's message cache, otherwise one holding only the journaled media content.
259
+ * Expired CDN media is re-requested from the sender's phone on download, as for live media.
260
+ * @throws {ProviderError} on an invalid, unknown-version or tampered reference.
261
+ */
262
+ rehydrateMediaRef(stored, ref) {
263
+ const { key, message } = parseBaileysMediaRef(stored, ref.kind);
264
+ const cached = this.msgCache.get(cacheKey(key));
265
+ return { ...ref, token: cached?.message ? cached : message };
266
+ }
267
+ async markRead(refs) {
268
+ const keys = refs.map((r) => keyOfRef(r));
269
+ if (keys.length)
270
+ await this.sock?.readMessages(keys);
271
+ }
272
+ async typing(chatId, state = 'typing') {
273
+ const presence = state === 'typing' ? 'composing' : state;
274
+ await this.sock?.sendPresenceUpdate(presence, chatId);
275
+ }
276
+ async deleteMessage(ref) {
277
+ if (!this.sock)
278
+ throw new ProviderError('Provider not connected');
279
+ const key = keyOfRef(ref, true);
280
+ await this.sock.sendMessage(key.remoteJid ?? ref.chatId, { delete: key });
281
+ }
282
+ /* Identity resolution (internal) --------------------------------------- */
283
+ async resolveJid(jid) {
284
+ if (!jid.endsWith('@lid'))
285
+ return jid;
286
+ return (await this.getPNForLID(jid)) ?? jid;
287
+ }
288
+ /**
289
+ * Resolve a LID to its phone-number JID, or `undefined` when no mapping is
290
+ * known (e.g. a username-only contact). The mapping store answers with a
291
+ * device-qualified JID (`<user>:<device>@s.whatsapp.net`); it is normalised to
292
+ * the user JID so the cache, the identity store and outbound sends agree.
293
+ */
294
+ async getPNForLID(lid) {
295
+ const cached = this.lidCache.get(lid);
296
+ if (cached)
297
+ return cached;
298
+ const raw = await this.sock?.signalRepository.lidMapping.getPNForLID(lid).catch(() => null);
299
+ const pn = raw ? normalize(raw) : undefined;
300
+ if (pn) {
301
+ this.cacheLid(lid, pn);
302
+ this.identity.observe(lid, pn);
303
+ }
304
+ return pn;
305
+ }
306
+ cacheLid(a, b) { putBounded(this.lidCache, a, b, LID_CACHE_MAX); }
307
+ cacheLidMapping(update) {
308
+ if (!update || typeof update !== 'object')
309
+ return;
310
+ const pairs = Array.isArray(update) ? update : [update];
311
+ for (const entry of pairs) {
312
+ if (entry && typeof entry === 'object' && 'lid' in entry && 'pn' in entry) {
313
+ const { lid, pn } = entry;
314
+ if (lid && pn) {
315
+ this.cacheLid(lid, pn);
316
+ this.identity.observe(lid, pn);
317
+ }
318
+ }
319
+ }
320
+ }
321
+ rememberMessage(m) {
322
+ if (!m.key?.id)
323
+ return;
324
+ this.msgCache.set(cacheKey(m.key), m);
325
+ if (this.msgCache.size > MSG_CACHE_MAX) {
326
+ const oldest = this.msgCache.keys().next();
327
+ if (!oldest.done)
328
+ this.msgCache.delete(oldest.value);
329
+ }
330
+ }
331
+ // ---------------------------------------------------------------------------
332
+ async buildSocket() {
333
+ if (!this.authState)
334
+ throw new Error('auth state missing — call start() first');
335
+ // Omit the key entirely when unset: makeWASocket spreads our config over its
336
+ // defaults, so `browser: undefined` would erase rc14's default identity.
337
+ const browser = resolveBrowser(this.config.browser);
338
+ const make = makeWASocket.default ?? makeWASocket;
339
+ this.sock = make({
340
+ version: this.version,
341
+ logger: this.waLogger,
342
+ auth: { creds: this.authState.state.creds, keys: makeCacheableSignalKeyStore(this.authState.state.keys, this.waLogger) },
343
+ ...(browser ? { browser } : {}),
344
+ msgRetryCounterCache: this.msgRetryCounterCache,
345
+ generateHighQualityLinkPreview: true,
346
+ markOnlineOnConnect: this.config.markOnlineOnConnect ?? false,
347
+ syncFullHistory: this.config.syncFullHistory ?? false,
348
+ getMessage: async (key) => this.msgCache.get(cacheKey(key))?.message ?? undefined,
349
+ });
350
+ this.sock.ev.process(async (events) => this.handleEvents(events));
351
+ }
352
+ /**
353
+ * Translate one batch of Baileys events. Every user/chat id emitted here goes
354
+ * through the same LID/PN canonicalisation as messages ({@link canonicalKey} /
355
+ * {@link IdentityStore.resolve}), whatever the event kind.
356
+ */
357
+ async handleEvents(events) {
358
+ if (events['connection.update'])
359
+ await this.onConnectionUpdate(events['connection.update']);
360
+ if (events['creds.update'])
361
+ await this.authState.saveCreds();
362
+ // Learn new LID↔PN pairs first so the rest of the batch canonicalises with them.
363
+ if (events['lid-mapping.update'])
364
+ this.cacheLidMapping(events['lid-mapping.update']);
365
+ if (events['messages.upsert'])
366
+ await this.onMessagesUpsert(events['messages.upsert']);
367
+ // Baileys 7 never fills `messages.update[].update.pollUpdates` (its vote
368
+ // decryption is commented out); votes are decrypted from the upsert instead.
369
+ if (events['messages.reaction'])
370
+ await this.onReactions(events['messages.reaction']);
371
+ if (events['call'])
372
+ await this.onCalls(events['call']);
373
+ if (events['group-participants.update'])
374
+ await this.onGroupParticipants(events['group-participants.update']);
375
+ if (events['presence.update'])
376
+ await this.onPresence(events['presence.update']);
377
+ }
378
+ acceptsChat(jid) {
379
+ if (jid === 'status@broadcast')
380
+ return false;
381
+ if ((this.config.ignoreBroadcasts ?? true) && jid.endsWith('@broadcast'))
382
+ return false;
383
+ if ((this.config.ignoreNewsletters ?? true) && jid.endsWith('@newsletter'))
384
+ return false;
385
+ return true;
386
+ }
387
+ async onMessagesUpsert({ messages, type }) {
388
+ // Learn the bot's own LID↔PN pair so mentions of its LID canonicalise to selfId.
389
+ this.self();
390
+ for (const message of messages) {
391
+ const jid = message.key?.remoteJidAlt ?? message.key?.remoteJid ?? '';
392
+ const accepted = this.acceptsChat(jid);
393
+ // Cache upserts, `append` included (Baileys emits the bot's own sends and
394
+ // offline deliveries as `append`): getMessage() retries and poll vote
395
+ // decryption both need them. Only messages from chats the bot listens to,
396
+ // plus its own wherever they are, take a slot in these bounded caches, so
397
+ // a flood from ignored chats (status, broadcasts, newsletters) cannot
398
+ // evict the ones that matter.
399
+ if (accepted || message.key?.fromMe) {
400
+ this.rememberMessage(message);
401
+ await this.rememberPoll(message);
402
+ }
403
+ if (!accepted)
404
+ continue;
405
+ // An encrypted vote is a poll-vote event, never a message.
406
+ if (pollUpdateOf(message)) {
407
+ await this.onPollVote(message);
408
+ continue;
409
+ }
410
+ if (type !== 'notify' || message.key?.fromMe)
411
+ continue;
412
+ // Seed identity from the authoritative mapping for bare-LID keys.
413
+ await this.seedIdentity(message.key);
414
+ await this.seedQuotedIdentity(message);
415
+ this.fan({ kind: 'message', message: toInbound(message, this.identity) });
416
+ }
417
+ }
418
+ async onPollVote(message) {
419
+ const update = pollUpdateOf(message);
420
+ const creationKey = update?.pollCreationMessageKey;
421
+ const pollId = creationKey?.id ?? undefined;
422
+ const poll = update && pollId ? this.polls.get(pollId) ?? await this.loadPoll(pollId) : undefined;
423
+ if (!update || !creationKey || !poll) {
424
+ // Typically a poll sent before a restart (no pollStore), or evicted from the bounded cache.
425
+ this.logger.debug({ pollId }, 'poll vote for a poll this process has not seen; cannot decrypt it');
426
+ return;
427
+ }
428
+ const key = message.key;
429
+ const pollKey = poll.message.key;
430
+ for (const k of [key, pollKey, creationKey])
431
+ await this.seedIdentity(k);
432
+ const self = this.self();
433
+ const { chatId, senderId, isGroup, rawParticipant } = canonicalKey(key, this.identity, message.participant);
434
+ // The vote names its poll by id only, its key derivation does not cover the
435
+ // chat, and every member knows a poll's id and secret. Bind it to the
436
+ // tracked poll's own key: Baileys rewrites the creation key's remoteJid to
437
+ // the vote's chat, so that key cannot vouch for the chat.
438
+ if (!this.overlaps(this.jidCandidates(pollKey.remoteJid, pollKey.remoteJidAlt), this.jidCandidates(key.remoteJid, key.remoteJidAlt))) {
439
+ this.logger.debug({ pollId }, 'poll vote from another chat than its poll; dropped');
440
+ return;
441
+ }
442
+ const creators = this.creatorForms(pollKey);
443
+ const claimed = this.creatorForms(creationKey);
444
+ if (!this.overlaps(creators, claimed)) {
445
+ // Another poll reusing the id (ids are chosen by the sending client).
446
+ this.logger.debug({ pollId }, 'poll vote names another creator than its poll; dropped');
447
+ return;
448
+ }
449
+ if (key.id && this.polls.hasVote(poll, key.id)) {
450
+ this.logger.debug({ pollId }, 'poll vote already counted (redelivered); dropped');
451
+ return;
452
+ }
453
+ const voters = key.fromMe
454
+ ? self.jids
455
+ : isGroup
456
+ ? this.jidCandidates(rawParticipant, key.participantAlt)
457
+ : this.jidCandidates(key.remoteJid, key.remoteJidAlt);
458
+ // Same person, so the creation key's forms are safe extra candidates.
459
+ const decrypted = decryptVote(update.vote, poll, [...new Set([...creators, ...claimed])], voters);
460
+ if (!decrypted) {
461
+ this.logger.warn({ pollId }, 'failed to decrypt poll vote');
462
+ return;
463
+ }
464
+ const voterId = key.fromMe ? self.id : senderId;
465
+ const at = toNumber(update.senderTimestampMs) || toNumber(message.messageTimestamp) * 1000 || Date.now();
466
+ if (!recordVote(poll, voterId, decrypted.hashes, at, (id) => this.identity.canonical(id)))
467
+ return;
468
+ if (key.id)
469
+ this.polls.markVote(poll, key.id);
470
+ this.polls.touch(poll);
471
+ await this.savePoll(poll);
472
+ this.fan({
473
+ kind: 'poll-vote',
474
+ vote: { ref: { id: poll.id, chatId, native: poll.message }, senderId: voterId, options: tally(poll) },
475
+ });
476
+ }
477
+ /* Poll cache + optional persistence ----------------------------------- */
478
+ /**
479
+ * Track a poll creation message so its votes can be decrypted, persisting it
480
+ * when a {@link PollStore} is configured. A poll id already in the store keeps
481
+ * the stored poll: like the in-memory cache, a later poll reusing an id (ids
482
+ * are chosen by the sending client) never replaces the first one.
483
+ */
484
+ async rememberPoll(message) {
485
+ const id = message.key?.id;
486
+ if (!id || this.polls.get(id) || !pollCreationOf(message)?.secret?.length)
487
+ return;
488
+ const store = this.config.pollStore;
489
+ if (!store) {
490
+ this.polls.remember(message);
491
+ return;
492
+ }
493
+ let stored;
494
+ try {
495
+ stored = await this.loadPoll(id, true);
496
+ }
497
+ catch {
498
+ // The store is unreachable: track in memory only, never overwrite blindly.
499
+ this.polls.remember(message);
500
+ return;
501
+ }
502
+ if (stored)
503
+ return;
504
+ const tracked = this.polls.track(message);
505
+ if (tracked?.isNew)
506
+ await this.savePoll(tracked.poll);
507
+ }
508
+ /**
509
+ * Load a poll from the {@link PollStore} into the in-memory cache. A
510
+ * malformed entry is deleted. Store failures are logged and read as a miss,
511
+ * unless `rethrow` is set.
512
+ */
513
+ async loadPoll(id, rethrow = false) {
514
+ if (!this.config.pollStore)
515
+ return undefined;
516
+ // Concurrent votes (separate upsert batches run in parallel) share one read,
517
+ // so they all record on the same object and no vote is lost.
518
+ let pending = this.pollLoads.get(id);
519
+ if (!pending) {
520
+ pending = this.readPoll(id).finally(() => this.pollLoads.delete(id));
521
+ this.pollLoads.set(id, pending);
522
+ }
523
+ try {
524
+ return await pending;
525
+ }
526
+ catch (err) {
527
+ if (rethrow)
528
+ throw err;
529
+ return undefined;
530
+ }
531
+ }
532
+ async readPoll(id) {
533
+ const store = this.config.pollStore;
534
+ let raw;
535
+ try {
536
+ raw = await store.get(id);
537
+ }
538
+ catch (err) {
539
+ this.logger.warn({ err, pollId: id }, 'poll store get failed');
540
+ throw err;
541
+ }
542
+ // A poll tracked while the read was pending (e.g. its own send) wins.
543
+ const cached = this.polls.get(id);
544
+ if (cached)
545
+ return cached;
546
+ if (raw === undefined || raw === null)
547
+ return undefined;
548
+ const poll = fromStoredPoll(id, raw);
549
+ if (!poll) {
550
+ this.logger.warn({ pollId: id }, 'poll store entry is malformed; deleting it');
551
+ await store.delete(id).catch((err) => this.logger.warn({ err, pollId: id }, 'poll store delete failed'));
552
+ return undefined;
553
+ }
554
+ this.polls.adopt(poll);
555
+ return poll;
556
+ }
557
+ /**
558
+ * Persist a poll (creation or new tally). Saves of one poll run one after
559
+ * another and each serialises the poll when it runs, so the last write always
560
+ * holds the latest tally. Failures are logged, never thrown.
561
+ */
562
+ async savePoll(poll) {
563
+ const store = this.config.pollStore;
564
+ if (!store)
565
+ return;
566
+ const write = async () => {
567
+ try {
568
+ await store.set(poll.id, toStoredPoll(poll));
569
+ }
570
+ catch (err) {
571
+ this.logger.warn({ err, pollId: poll.id }, 'poll store set failed');
572
+ }
573
+ };
574
+ const next = (this.pollSaves.get(poll.id) ?? Promise.resolve()).then(write);
575
+ this.pollSaves.set(poll.id, next);
576
+ try {
577
+ await next;
578
+ }
579
+ finally {
580
+ if (this.pollSaves.get(poll.id) === next)
581
+ this.pollSaves.delete(poll.id);
582
+ }
583
+ }
584
+ async onReactions(reactions) {
585
+ for (const { key, reaction } of reactions) {
586
+ // `key` is the REACTED-TO message; `reaction.key` is the reactor's own key.
587
+ const reactorKey = reaction.key ?? undefined;
588
+ if (reactorKey)
589
+ await this.seedIdentity(reactorKey);
590
+ const { chatId, senderId } = canonicalKey(reactorKey ?? { remoteJid: key.remoteJid }, this.identity);
591
+ this.fan({
592
+ kind: 'reaction',
593
+ reaction: {
594
+ ref: { id: key.id ?? '', chatId, native: key },
595
+ emoji: reaction.text ?? '',
596
+ senderId: reactorKey?.fromMe ? this.self().id : senderId,
597
+ removed: !reaction.text,
598
+ },
599
+ });
600
+ }
601
+ }
602
+ async onCalls(calls) {
603
+ for (const call of calls) {
604
+ const from = await this.canonicalId(call.from, call.callerPn);
605
+ const groupId = call.groupJid ? await this.canonicalId(call.groupJid) : undefined;
606
+ this.fan({ kind: 'call', event: toCallEvent(call, from, groupId, this.callGroups) });
607
+ }
608
+ }
609
+ async onGroupParticipants(e) {
610
+ const participants = [];
611
+ // Baileys 7 sends participant objects ({ id, phoneNumber?, lid? }); accept bare JIDs too.
612
+ for (const p of (e.participants ?? [])) {
613
+ const id = typeof p === 'string'
614
+ ? await this.canonicalId(p)
615
+ : await this.canonicalId(p?.id, p?.phoneNumber ?? p?.lid);
616
+ if (id)
617
+ participants.push(id);
618
+ }
619
+ const actor = e.author || e.authorPn ? await this.canonicalId(e.author, e.authorPn) : '';
620
+ this.fan({
621
+ kind: 'group',
622
+ event: {
623
+ chatId: await this.canonicalId(e.id),
624
+ action: e.action === 'modify' ? 'update' : e.action,
625
+ participants,
626
+ actor: actor || undefined,
627
+ },
628
+ });
629
+ }
630
+ async onPresence(p) {
631
+ const presences = {};
632
+ for (const [jid, presence] of Object.entries(p.presences ?? {})) {
633
+ presences[await this.canonicalId(jid)] = presence;
634
+ }
635
+ this.fan({ kind: 'presence', event: { chatId: await this.canonicalId(p.id), presences } });
636
+ }
637
+ /* Identity helpers for events ------------------------------------------ */
638
+ async seedIdentity(key) {
639
+ const candidates = [key.remoteJid, key.participant];
640
+ for (const jid of candidates) {
641
+ if (!jid || !jid.endsWith('@lid') || this.identity.knowsPnFor(jid))
642
+ continue;
643
+ await this.getPNForLID(jid).catch(() => undefined);
644
+ }
645
+ }
646
+ /**
647
+ * Look up the PN of bare-LID users a message quotes or mentions, so
648
+ * `quoted.senderId` and `mentions` canonicalise like the sender does.
649
+ */
650
+ async seedQuotedIdentity(message) {
651
+ const ctx = contextInfoOf(message.message);
652
+ if (!ctx)
653
+ return;
654
+ // Bounded: a mention-everyone message can name hundreds of members.
655
+ const jids = [ctx.participant, ...(ctx.mentionedJid ?? []).slice(0, MAX_MENTION_LOOKUPS)];
656
+ for (const jid of jids) {
657
+ const lid = jid ? normalize(jid) : '';
658
+ if (!isLidUser(lid) || this.identity.knowsPnFor(lid))
659
+ continue;
660
+ await this.getPNForLID(lid).catch(() => undefined);
661
+ }
662
+ }
663
+ /**
664
+ * Canonical id for a bare JID from a non-message event. `alt` is the twin the
665
+ * event carries (PN or LID); without a PN twin, a LID is first looked up in
666
+ * the signal mapping store, as for message keys.
667
+ */
668
+ async canonicalId(jid, alt) {
669
+ const lid = jid ? normalize(jid) : '';
670
+ if (isLidUser(lid) && !(alt && isPnUser(alt)) && !this.identity.knowsPnFor(lid)) {
671
+ await this.getPNForLID(lid).catch(() => undefined);
672
+ }
673
+ return this.identity.resolve(jid, alt);
674
+ }
675
+ /** The bot's own canonical id and the JID forms (LID, PN) it signs with. */
676
+ self() {
677
+ const me = this.authState?.state?.creds?.me;
678
+ const pn = me?.id ? normalize(me.id) : '';
679
+ const lid = me?.lid ? normalize(me.lid) : '';
680
+ if (pn && lid)
681
+ this.identity.observe(lid, pn);
682
+ return { id: pn || lid, jids: [lid, pn].filter(Boolean) };
683
+ }
684
+ /** Normalised JIDs plus their learned twins, deduplicated, in order. */
685
+ jidCandidates(...jids) {
686
+ const out = new Set();
687
+ for (const jid of jids) {
688
+ const n = jid ? normalize(jid) : '';
689
+ if (!n)
690
+ continue;
691
+ out.add(n);
692
+ const twin = this.identity.alias(n);
693
+ if (twin)
694
+ out.add(twin);
695
+ }
696
+ return [...out];
697
+ }
698
+ /**
699
+ * The JID forms of a poll's creator per a message key: the bot (both forms)
700
+ * for its own polls, otherwise the group participant or the DM partner, with
701
+ * learned twins. Works for the poll's own key and for a vote's
702
+ * `pollCreationMessageKey` (which Baileys rewrites to our perspective:
703
+ * `fromMe` when the bot created it, the voter as participant when unset).
704
+ */
705
+ creatorForms(key) {
706
+ if (key.fromMe)
707
+ return this.self().jids;
708
+ return (key.remoteJid ?? '').endsWith('@g.us')
709
+ ? this.jidCandidates(key.participant, key.participantAlt)
710
+ : this.jidCandidates(key.remoteJid, key.remoteJidAlt);
711
+ }
712
+ /** Whether two JID-form lists name the same user or chat. */
713
+ overlaps(a, b) {
714
+ return a.some((jid) => b.includes(jid));
715
+ }
716
+ mapReason(statusCode) {
717
+ const action = classifyDisconnect(statusCode);
718
+ return action === 'wipe' ? 'sessionInvalid'
719
+ : action === 'stop' ? 'sessionTakenOver'
720
+ : 'transient';
721
+ }
722
+ async onConnectionUpdate(u) {
723
+ const { connection, lastDisconnect, qr } = u;
724
+ if (qr) {
725
+ this.fan({ kind: 'connection', update: { state: 'authenticating', authPayload: qr } });
726
+ this.renderQr(qr);
727
+ if (this.config.pairingCode && !this.authState?.state?.creds?.registered && !this.pairingCodeRequested && this.sock) {
728
+ this.pairingCodeRequested = true;
729
+ try {
730
+ const code = await this.sock.requestPairingCode(this.config.pairingCode.replace(/\D/g, ''));
731
+ this.fan({ kind: 'connection', update: { state: 'authenticating', authPayload: code } });
732
+ }
733
+ catch (error) {
734
+ this.logger.warn({ err: error }, 'pairing code request failed — falling back to QR');
735
+ }
736
+ }
737
+ }
738
+ if (connection === 'connecting')
739
+ this.fan({ kind: 'connection', update: { state: 'connecting' } });
740
+ if (connection === 'open') {
741
+ this.reconnectAttempts = 0;
742
+ this.fan({ kind: 'connection', update: { state: 'ready' } });
743
+ }
744
+ if (connection === 'close') {
745
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
746
+ const message = lastDisconnect?.error?.message;
747
+ const reason = this.mapReason(statusCode);
748
+ this.fan({ kind: 'connection', update: { state: 'closed', reason, detail: { code: statusCode, message } } });
749
+ if (this.config.reconnect === false || this.isShuttingDown || this.reconnectPending)
750
+ return;
751
+ const action = classifyDisconnect(statusCode);
752
+ if (action === 'wipe') {
753
+ this.logger.warn({ statusCode, detail: message }, 'session invalid — wiping');
754
+ try {
755
+ removeSessionDirectory(this.config.sessionsDir, this.config.session);
756
+ }
757
+ catch (error) {
758
+ this.logger.error({ err: error, statusCode }, 'refusing to wipe an unsafe session path');
759
+ this.fan({ kind: 'connection', update: { state: 'closed', detail: new Error('unsafe session path') } });
760
+ return;
761
+ }
762
+ this.reconnectAttempts = 0;
763
+ this.scheduleReconnect(0);
764
+ return;
765
+ }
766
+ if (action === 'stop') {
767
+ this.logger.warn({ statusCode, detail: message }, 'not reconnecting: session taken over (conflict) or forbidden.');
768
+ return;
769
+ }
770
+ if (action === 'reconnect-now') {
771
+ this.scheduleReconnect(0);
772
+ return;
773
+ }
774
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
775
+ this.logger.error({ statusCode, detail: message, attempts: this.reconnectAttempts }, `giving up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`);
776
+ return;
777
+ }
778
+ const delay = backoffDelay(this.reconnectAttempts);
779
+ this.reconnectAttempts++;
780
+ this.logger.warn({ statusCode, detail: message, attempt: this.reconnectAttempts, delayMs: delay }, 'connection closed — reconnecting with backoff');
781
+ this.scheduleReconnect(delay);
782
+ }
783
+ }
784
+ scheduleReconnect(delayMs) {
785
+ if (this.reconnectPending || this.isShuttingDown)
786
+ return;
787
+ this.reconnectPending = true;
788
+ this.reconnectTimer = setTimeout(() => {
789
+ this.reconnectTimer = null;
790
+ this.reconnectPending = false;
791
+ if (this.isShuttingDown)
792
+ return;
793
+ void this.start().catch((error) => this.onReconnectStartFailed(error));
794
+ }, delayMs);
795
+ }
796
+ /** Drop a scheduled reconnect, so a later start() does not race a stale timer into a second socket. */
797
+ cancelReconnect() {
798
+ if (this.reconnectTimer)
799
+ clearTimeout(this.reconnectTimer);
800
+ this.reconnectTimer = null;
801
+ this.reconnectPending = false;
802
+ }
803
+ /**
804
+ * A `start()` invoked from a scheduled reconnect rejected BEFORE a socket was
805
+ * built (e.g. `fetchLatestBaileysVersion()` hit a network blip, or the auth
806
+ * state failed to load). No socket means no future `connection: 'close'` event
807
+ * will ever fire to drive the retry — so if we merely logged here the provider
808
+ * would stay dead permanently. We must reschedule the retry ourselves, with
809
+ * backoff, until the cap is reached.
810
+ */
811
+ onReconnectStartFailed(error) {
812
+ if (this.isShuttingDown)
813
+ return;
814
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
815
+ this.logger.error({ err: error, attempts: this.reconnectAttempts }, `giving up: start() failed ${MAX_RECONNECT_ATTEMPTS} times during reconnect`);
816
+ this.fan({ kind: 'connection', update: { state: 'closed', reason: 'transient', detail: { message: 'reconnect exhausted' } } });
817
+ return;
818
+ }
819
+ const delay = backoffDelay(this.reconnectAttempts);
820
+ this.reconnectAttempts++;
821
+ this.logger.warn({ err: error, attempt: this.reconnectAttempts, delayMs: delay }, 'reconnect start() failed — retrying with backoff');
822
+ this.scheduleReconnect(delay);
823
+ }
824
+ renderQr(qr) {
825
+ const cfg = this.config.qr ?? { terminal: true, small: true };
826
+ if (cfg.terminal) {
827
+ import('qrcode-terminal').then((mod) => {
828
+ const cjs = mod;
829
+ const qrmod = cjs.default ?? mod;
830
+ qrmod.generate(qr, { small: cfg.small ?? true }, (output) => process.stdout.write(`${output}\n`));
831
+ }).catch((err) => this.logger.warn({ err }, 'failed to render QR in terminal'));
832
+ }
833
+ if (cfg.file) {
834
+ const file = typeof cfg.file === 'string' ? cfg.file : `${this.config.session ?? 'default'}.qr.png`;
835
+ void (async () => {
836
+ try {
837
+ const [{ image }, { createWriteStream }] = await Promise.all([import('qr-image'), import('fs')]);
838
+ // Hook error/finish on BOTH ends of the pipe so an async write failure
839
+ // (bad path, no permission, disk full) is surfaced instead of crashing
840
+ // the process with an unhandled 'error' event.
841
+ await new Promise((resolve, reject) => {
842
+ const src = image(qr, { type: 'png' });
843
+ const out = createWriteStream(file);
844
+ src.on('error', reject);
845
+ out.on('error', reject);
846
+ out.on('finish', () => resolve());
847
+ src.pipe(out);
848
+ });
849
+ }
850
+ catch (err) {
851
+ this.logger.warn({ err }, `failed to write QR image to ${file}`);
852
+ }
853
+ })();
854
+ }
855
+ if (cfg.handler)
856
+ void cfg.handler(qr);
857
+ }
858
+ }