mercury-agent 0.4.7 → 0.4.8

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.
@@ -1,632 +1,635 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import makeWASocket, {
4
- areJidsSameUser,
5
- Browsers,
6
- DisconnectReason,
7
- fetchLatestWaWebVersion,
8
- jidDecode,
9
- makeCacheableSignalKeyStore,
10
- type proto,
11
- useMultiFileAuthState,
12
- type WAMessage,
13
- type WASocket,
14
- } from "@whiskeysockets/baileys";
15
- import {
16
- type Adapter,
17
- type AdapterPostableMessage,
18
- type ChatInstance,
19
- type EmojiValue,
20
- type FetchOptions,
21
- type FetchResult,
22
- type FormattedContent,
23
- Message,
24
- NotImplementedError,
25
- parseMarkdown,
26
- type RawMessage,
27
- stringifyMarkdown,
28
- type ThreadInfo,
29
- type WebhookOptions,
30
- } from "chat";
31
- import { logger } from "../logger.js";
32
- import { normalizeChatMarkdown } from "../text/markdown.js";
33
- import { applyRtlDirection } from "../text/rtl.js";
34
- import { detectWhatsAppMedia } from "./whatsapp-media.js";
35
-
36
- type WhatsAppThreadId = {
37
- chatJid: string;
38
- threadJid: string;
39
- };
40
-
41
- function extractText(message?: proto.IMessage | null): string {
42
- if (!message) return "";
43
- return (
44
- message.conversation ||
45
- message.extendedTextMessage?.text ||
46
- message.imageMessage?.caption ||
47
- message.videoMessage?.caption ||
48
- message.documentMessage?.caption ||
49
- ""
50
- );
51
- }
52
-
53
- function getContextInfo(
54
- message?: proto.IMessage | null,
55
- ): proto.IContextInfo | undefined {
56
- if (!message) return undefined;
57
- const contextInfo =
58
- message.extendedTextMessage?.contextInfo ||
59
- message.imageMessage?.contextInfo ||
60
- message.videoMessage?.contextInfo ||
61
- message.documentMessage?.contextInfo ||
62
- message.buttonsResponseMessage?.contextInfo ||
63
- message.templateButtonReplyMessage?.contextInfo ||
64
- message.listResponseMessage?.contextInfo;
65
-
66
- return contextInfo ?? undefined;
67
- }
68
-
69
- function buildReplyContext(
70
- message?: proto.IMessage | null,
71
- pushNames?: Map<string, string>,
72
- ): string | undefined {
73
- const contextInfo = getContextInfo(message);
74
- if (!contextInfo?.quotedMessage) return undefined;
75
-
76
- const quotedText = extractText(contextInfo.quotedMessage).trim();
77
- const quotedJid = contextInfo.participant || "unknown";
78
- const quotedName =
79
- pushNames?.get(quotedJid) || quotedJid.split("@")[0] || "unknown";
80
- const quotedMessageId = contextInfo.stanzaId || "unknown";
81
-
82
- // Check if quoted message has media
83
- const quotedMedia = detectWhatsAppMedia(contextInfo.quotedMessage);
84
-
85
- const attrs = [
86
- `name="${quotedName}"`,
87
- `jid="${quotedJid}"`,
88
- `message_id="${quotedMessageId}"`,
89
- ];
90
-
91
- if (quotedMedia) {
92
- attrs.push(`media_type="${quotedMedia.type}"`);
93
- attrs.push(`media_mime="${quotedMedia.mimeType}"`);
94
- }
95
-
96
- const contentParts: string[] = [];
97
- if (quotedText) {
98
- contentParts.push(quotedText);
99
- }
100
- if (quotedMedia && !quotedText) {
101
- // If no caption, describe the media
102
- const typeLabel =
103
- quotedMedia.type === "voice" ? "voice note" : quotedMedia.type;
104
- contentParts.push(`[${typeLabel}]`);
105
- }
106
-
107
- const lines = [
108
- `<reply_to ${attrs.join(" ")}>`,
109
- contentParts.join("\n") || "",
110
- "</reply_to>",
111
- ];
112
-
113
- return lines.join("\n");
114
- }
115
-
116
- function postableToText(message: AdapterPostableMessage): string {
117
- if (typeof message === "string") return message;
118
- if (typeof message === "object" && message !== null) {
119
- if ("markdown" in message && typeof message.markdown === "string")
120
- return message.markdown;
121
- if ("ast" in message && message.ast) return stringifyMarkdown(message.ast);
122
- if ("raw" in message && typeof message.raw === "string") return message.raw;
123
- }
124
- return "";
125
- }
126
-
127
- export type WhatsAppQrStatus =
128
- | { status: "authenticated"; phoneNumber?: string }
129
- | { status: "waiting"; qr: string }
130
- | { status: "disconnected" };
131
-
132
- export interface WhatsAppAdapterOptions {
133
- userName?: string;
134
- authDir?: string;
135
- }
136
-
137
- export class WhatsAppBaileysAdapter
138
- implements Adapter<WhatsAppThreadId, proto.IWebMessageInfo>
139
- {
140
- readonly name = "whatsapp";
141
- readonly userName: string;
142
-
143
- private chat?: ChatInstance;
144
- private sock?: WASocket;
145
- private connected = false;
146
- private readonly authDir: string;
147
- private readonly outgoingQueue: Array<{ jid: string; text: string }> = [];
148
- private flushing = false;
149
- private connectedAtMs = 0;
150
- private seenMessageIds = new Set<string>();
151
- private reconnectAttempt = 0;
152
- private readonly pushNames = new Map<string, string>();
153
- private currentQr: string | null = null;
154
-
155
- /** Called when the bot is removed or leaves a WhatsApp group. */
156
- onGroupRemoval?: (chatJid: string) => void;
157
-
158
- constructor(options?: WhatsAppAdapterOptions) {
159
- this.userName = options?.userName ?? "mercury";
160
- this.authDir =
161
- options?.authDir ?? path.join(process.cwd(), ".mercury", "whatsapp-auth");
162
- }
163
-
164
- /**
165
- * Get current QR status for API endpoint
166
- */
167
- getQrStatus(): WhatsAppQrStatus {
168
- if (this.connected) {
169
- const userJid = this.sock?.user?.id;
170
- const phoneNumber = userJid?.split(":")[0];
171
- return { status: "authenticated", phoneNumber };
172
- }
173
- if (this.currentQr) {
174
- return { status: "waiting", qr: this.currentQr };
175
- }
176
- return { status: "disconnected" };
177
- }
178
-
179
- get socket(): WASocket | undefined {
180
- return this.sock;
181
- }
182
-
183
- get botUserId(): string | undefined {
184
- const jid = this.sock?.user?.id;
185
- if (!jid) return undefined;
186
- return jid.split(":")[0];
187
- }
188
-
189
- async initialize(chat: ChatInstance): Promise<void> {
190
- this.chat = chat;
191
- logger.info("WhatsApp adapter initialize", { authDir: this.authDir });
192
-
193
- // No creds yet: still connect so Baileys can emit a QR (dashboard /auth/whatsapp, or mercury auth whatsapp).
194
- await this.connect();
195
- }
196
-
197
- private async connect(): Promise<void> {
198
- fs.mkdirSync(this.authDir, { recursive: true });
199
- const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
200
- const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
201
- version: undefined,
202
- }));
203
-
204
- const waLogger = {
205
- level: "silent",
206
- child: () => waLogger,
207
- trace: () => undefined,
208
- debug: () => undefined,
209
- info: () => undefined,
210
- warn: () => undefined,
211
- error: () => undefined,
212
- fatal: () => undefined,
213
- };
214
-
215
- const sock = makeWASocket({
216
- version,
217
- auth: {
218
- creds: state.creds,
219
- keys: makeCacheableSignalKeyStore(state.keys, waLogger),
220
- },
221
- logger: waLogger,
222
- browser: Browsers.macOS("Chrome"),
223
- });
224
-
225
- sock.ev.on("creds.update", saveCreds);
226
-
227
- sock.ev.on("connection.update", (update) => {
228
- const { connection, lastDisconnect, qr } = update;
229
-
230
- // Track QR code for API endpoint
231
- if (qr) {
232
- this.currentQr = qr;
233
- logger.info("whatsapp qr code generated");
234
- }
235
-
236
- if (connection === "open") {
237
- this.connected = true;
238
- this.currentQr = null; // Clear QR once connected
239
- this.connectedAtMs = Date.now();
240
- this.seenMessageIds.clear();
241
- this.reconnectAttempt = 0;
242
- logger.info("WhatsApp connection open");
243
- void this.flushOutgoingQueue();
244
- return;
245
- }
246
-
247
- if (connection === "close") {
248
- this.connected = false;
249
- this.currentQr = null;
250
- const reason = (
251
- lastDisconnect?.error as { output?: { statusCode?: number } }
252
- )?.output?.statusCode;
253
- logger.warn("WhatsApp connection closed", { reason });
254
- if (reason !== DisconnectReason.loggedOut) {
255
- const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 60_000);
256
- this.reconnectAttempt++;
257
- logger.info("WhatsApp reconnecting", {
258
- attempt: this.reconnectAttempt,
259
- delayMs: delay,
260
- });
261
- setTimeout(() => {
262
- void this.connect();
263
- }, delay);
264
- }
265
- }
266
- });
267
-
268
- sock.ev.on("messages.upsert", ({ messages, type }) => {
269
- if (type !== "notify") return;
270
-
271
- for (const msg of messages) {
272
- void this.handleIncomingMessage(msg);
273
- }
274
- });
275
-
276
- sock.ev.on("group-participants.update", ({ id, participants, action }) => {
277
- if (action !== "remove") return;
278
- const botJid = this.sock?.user?.id;
279
- const botLid = this.sock?.user?.lid;
280
- const selfRemoved = participants.some(
281
- (p) =>
282
- (botJid != null && areJidsSameUser(p.id, botJid)) ||
283
- (botLid != null && p.lid != null && areJidsSameUser(p.lid, botLid)),
284
- );
285
- if (selfRemoved) {
286
- logger.info("WhatsApp: removed from group", { chatJid: id });
287
- this.onGroupRemoval?.(id);
288
- }
289
- });
290
-
291
- this.sock = sock;
292
- }
293
-
294
- async handleWebhook(
295
- _request: Request,
296
- _options?: WebhookOptions,
297
- ): Promise<Response> {
298
- return new Response(
299
- "WhatsApp adapter uses Baileys socket, no webhook required.",
300
- { status: 202 },
301
- );
302
- }
303
-
304
- channelIdFromThreadId(threadId: string): string {
305
- const parts = threadId.split(":");
306
- return `whatsapp:${parts[1]}`;
307
- }
308
-
309
- encodeThreadId(platformData: WhatsAppThreadId): string {
310
- return `whatsapp:${platformData.chatJid}:${platformData.threadJid}`;
311
- }
312
-
313
- decodeThreadId(threadId: string): WhatsAppThreadId {
314
- const parts = threadId.split(":");
315
- if (parts.length < 3 || parts[0] !== "whatsapp") {
316
- throw new Error(`Invalid WhatsApp thread ID: ${threadId}`);
317
- }
318
- return {
319
- chatJid: parts[1],
320
- threadJid: parts.slice(2).join(":"),
321
- };
322
- }
323
-
324
- async postMessage(
325
- threadId: string,
326
- message: AdapterPostableMessage,
327
- ): Promise<RawMessage<proto.IWebMessageInfo>> {
328
- const { chatJid } = this.decodeThreadId(threadId);
329
- const text = postableToText(message).trim();
330
- if (!text) {
331
- throw new Error("Cannot send empty WhatsApp message");
332
- }
333
-
334
- if (!this.connected || !this.sock) {
335
- this.outgoingQueue.push({ jid: chatJid, text });
336
- logger.warn("WhatsApp queued outbound", {
337
- chatJid,
338
- queueSize: this.outgoingQueue.length,
339
- });
340
- return { id: `queued-${Date.now()}`, threadId, raw: {} };
341
- }
342
-
343
- logger.info("WhatsApp outbound", { chatJid, preview: text.slice(0, 120) });
344
- const sent = await this.sock.sendMessage(chatJid, {
345
- text: applyRtlDirection(normalizeChatMarkdown(text)),
346
- });
347
- if (!sent) {
348
- throw new Error("WhatsApp sendMessage returned no message");
349
- }
350
- return {
351
- id: sent.key?.id ?? `${Date.now()}`,
352
- threadId,
353
- raw: sent,
354
- };
355
- }
356
-
357
- async editMessage(
358
- _threadId: string,
359
- _messageId: string,
360
- _message: AdapterPostableMessage,
361
- ): Promise<RawMessage<proto.IWebMessageInfo>> {
362
- throw new NotImplementedError(
363
- "WhatsApp does not support generic message edit in this adapter",
364
- );
365
- }
366
-
367
- async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
368
- throw new NotImplementedError(
369
- "WhatsApp delete is not implemented in this adapter",
370
- );
371
- }
372
-
373
- async addReaction(
374
- _threadId: string,
375
- _messageId: string,
376
- _emoji: EmojiValue | string,
377
- ): Promise<void> {
378
- throw new NotImplementedError(
379
- "WhatsApp reactions are not implemented in this adapter",
380
- );
381
- }
382
-
383
- async removeReaction(
384
- _threadId: string,
385
- _messageId: string,
386
- _emoji: EmojiValue | string,
387
- ): Promise<void> {
388
- throw new NotImplementedError(
389
- "WhatsApp reactions are not implemented in this adapter",
390
- );
391
- }
392
-
393
- async fetchMessages(
394
- _threadId: string,
395
- _options?: FetchOptions,
396
- ): Promise<FetchResult<proto.IWebMessageInfo>> {
397
- return { messages: [] };
398
- }
399
-
400
- async fetchThread(threadId: string): Promise<ThreadInfo> {
401
- const { chatJid } = this.decodeThreadId(threadId);
402
- return {
403
- id: threadId,
404
- channelId: `whatsapp:${chatJid}`,
405
- isDM: !chatJid.endsWith("@g.us"),
406
- metadata: { chatJid },
407
- };
408
- }
409
-
410
- parseMessage(raw: proto.IWebMessageInfo): Message<proto.IWebMessageInfo> {
411
- const key = raw.key;
412
- const remoteJid = key?.remoteJid ?? "unknown@s.whatsapp.net";
413
- const sender = key?.participant || remoteJid;
414
- const senderName = raw.pushName || sender.split("@")[0] || "unknown";
415
- const baseText = extractText(raw.message).trim();
416
- const replyContext = buildReplyContext(raw.message, this.pushNames);
417
- const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
418
- const threadId = this.encodeThreadId({
419
- chatJid: remoteJid,
420
- threadJid: remoteJid,
421
- });
422
-
423
- return new Message({
424
- id: key?.id ?? `${Date.now()}`,
425
- threadId,
426
- text,
427
- formatted: parseMarkdown(text),
428
- raw,
429
- author: {
430
- userId: sender,
431
- userName: senderName,
432
- fullName: senderName,
433
- isBot: "unknown",
434
- isMe: Boolean(key?.fromMe),
435
- },
436
- metadata: {
437
- dateSent: new Date(
438
- Number(raw.messageTimestamp ?? Date.now() / 1000) * 1000,
439
- ),
440
- edited: false,
441
- },
442
- attachments: [],
443
- });
444
- }
445
-
446
- renderFormatted(content: FormattedContent): string {
447
- return stringifyMarkdown(content);
448
- }
449
-
450
- async startTyping(threadId: string): Promise<void> {
451
- const { chatJid } = this.decodeThreadId(threadId);
452
- if (!this.sock || !this.connected) return;
453
- await this.sock.presenceSubscribe(chatJid);
454
- await this.sock.sendPresenceUpdate("composing", chatJid);
455
- }
456
-
457
- async shutdown(): Promise<void> {
458
- this.connected = false;
459
- this.sock?.end(undefined);
460
- }
461
-
462
- /**
463
- * Handle an incoming WhatsApp message.
464
- * Downloads media if present and enabled.
465
- */
466
- private async handleIncomingMessage(msg: WAMessage): Promise<void> {
467
- if (!msg.message) return;
468
- if (msg.key.fromMe) return;
469
-
470
- const remoteJid = msg.key.remoteJid;
471
- if (!remoteJid || remoteJid === "status@broadcast") return;
472
-
473
- const messageId = msg.key.id;
474
- if (messageId) {
475
- if (this.seenMessageIds.has(messageId)) return;
476
- this.seenMessageIds.add(messageId);
477
- if (this.seenMessageIds.size > 5000) {
478
- const ids = [...this.seenMessageIds];
479
- this.seenMessageIds = new Set(ids.slice(ids.length - 2500));
480
- }
481
- }
482
-
483
- const tsMs = Number(msg.messageTimestamp ?? 0) * 1000;
484
- if (this.connectedAtMs && tsMs > 0 && tsMs < this.connectedAtMs - 10_000) {
485
- logger.debug("WhatsApp skipping backlog message", {
486
- remoteJid,
487
- messageId,
488
- tsMs,
489
- });
490
- return;
491
- }
492
-
493
- const sender = msg.key.participant || remoteJid;
494
- const senderName = msg.pushName || sender.split("@")[0] || "unknown";
495
-
496
- // Track push names for reply context resolution
497
- if (msg.pushName && sender) {
498
- this.pushNames.set(sender, msg.pushName);
499
- }
500
-
501
- let baseText = extractText(msg.message).trim();
502
- const replyContext = buildReplyContext(msg.message, this.pushNames);
503
-
504
- // WhatsApp @-mentions embed JIDs in text (e.g. "@52669955764381").
505
- // Replace the bot's JID mention with the configured userName so trigger matching works.
506
- const contextInfo = getContextInfo(msg.message);
507
- const mentionedJids = contextInfo?.mentionedJid ?? [];
508
- const botJid = this.sock?.user?.id;
509
- const botLid = this.sock?.user?.lid;
510
- const isBotJid = (jid: string) =>
511
- (botJid && areJidsSameUser(jid, botJid)) ||
512
- (botLid && areJidsSameUser(jid, botLid));
513
-
514
- // Check if this is a reply to one of our messages
515
- const quotedParticipant = contextInfo?.participant;
516
- const isReplyToBot = quotedParticipant
517
- ? isBotJid(quotedParticipant)
518
- : false;
519
-
520
- // Replace bot's JID mention with configured userName so trigger patterns match
521
- for (const jid of mentionedJids) {
522
- if (isBotJid(jid)) {
523
- const user = jidDecode(jid)?.user;
524
- if (user) {
525
- baseText = baseText.replace(
526
- new RegExp(`@${user}\\b`, "g"),
527
- `@${this.userName}`,
528
- );
529
- }
530
- }
531
- }
532
-
533
- // Detect media presence (download happens in bridge layer)
534
- const mediaInfo = detectWhatsAppMedia(msg.message);
535
- const hasMedia = mediaInfo !== null;
536
-
537
- // Add media description to text if no caption
538
- if (hasMedia && !baseText) {
539
- const typeLabel =
540
- mediaInfo.type === "voice" ? "voice note" : mediaInfo.type;
541
- baseText = `[Sent ${typeLabel}]`;
542
- }
543
-
544
- const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
545
- if (!text && !hasMedia) return;
546
-
547
- const threadId = this.encodeThreadId({
548
- chatJid: remoteJid,
549
- threadJid: remoteJid,
550
- });
551
-
552
- logger.info("WhatsApp inbound", {
553
- remoteJid,
554
- sender,
555
- isReply: Boolean(replyContext),
556
- isReplyToBot,
557
- hasMedia,
558
- mediaType: mediaInfo?.type,
559
- preview: text.slice(0, 120),
560
- });
561
-
562
- const _isDM = !remoteJid.endsWith("@g.us");
563
-
564
- const incoming = new Message<proto.IWebMessageInfo>({
565
- id: msg.key.id ?? `${Date.now()}`,
566
- threadId,
567
- text: text || "[Media message]",
568
- formatted: parseMarkdown(text || "[Media message]"),
569
- raw: msg,
570
- isMention: true, // always true — router handles trigger matching
571
- author: {
572
- userId: sender,
573
- userName: senderName,
574
- fullName: senderName,
575
- isBot: "unknown",
576
- isMe: false,
577
- },
578
- metadata: {
579
- dateSent: new Date(
580
- Number(msg.messageTimestamp ?? Date.now() / 1000) * 1000,
581
- ),
582
- edited: false,
583
- // Store reply flag and platform IDs in metadata for downstream consumers
584
- // Using spread to add custom properties (not in MessageMetadata type)
585
- ...({
586
- isReplyToBot,
587
- replyToMessageId: contextInfo?.stanzaId ?? undefined,
588
- platformMessageId: msg.key.id ?? undefined,
589
- } as Record<string, unknown>),
590
- },
591
- attachments: [],
592
- });
593
-
594
- // Mark message as read (blue ticks) immediately on receipt
595
- if (this.sock && msg.key.remoteJid) {
596
- try {
597
- await this.sock.readMessages([msg.key]);
598
- } catch {
599
- // Best-effort — don't block message processing
600
- }
601
- }
602
-
603
- this.chat?.processMessage(this, threadId, incoming);
604
- }
605
-
606
- private async flushOutgoingQueue(): Promise<void> {
607
- if (!this.sock || !this.connected || this.flushing) return;
608
- this.flushing = true;
609
- try {
610
- if (this.outgoingQueue.length > 0) {
611
- logger.info("WhatsApp flushing outbound queue", {
612
- count: this.outgoingQueue.length,
613
- });
614
- }
615
- while (this.outgoingQueue.length > 0) {
616
- const item = this.outgoingQueue.shift();
617
- if (!item) continue;
618
- await this.sock.sendMessage(item.jid, {
619
- text: applyRtlDirection(normalizeChatMarkdown(item.text)),
620
- });
621
- }
622
- } finally {
623
- this.flushing = false;
624
- }
625
- }
626
- }
627
-
628
- export function createWhatsAppBaileysAdapter(
629
- options?: WhatsAppAdapterOptions,
630
- ): WhatsAppBaileysAdapter {
631
- return new WhatsAppBaileysAdapter(options);
632
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import makeWASocket, {
4
+ areJidsSameUser,
5
+ DisconnectReason,
6
+ fetchLatestWaWebVersion,
7
+ jidDecode,
8
+ makeCacheableSignalKeyStore,
9
+ type proto,
10
+ useMultiFileAuthState,
11
+ type WAMessage,
12
+ type WASocket,
13
+ } from "@whiskeysockets/baileys";
14
+ import {
15
+ type Adapter,
16
+ type AdapterPostableMessage,
17
+ type ChatInstance,
18
+ type EmojiValue,
19
+ type FetchOptions,
20
+ type FetchResult,
21
+ type FormattedContent,
22
+ Message,
23
+ NotImplementedError,
24
+ parseMarkdown,
25
+ type RawMessage,
26
+ stringifyMarkdown,
27
+ type ThreadInfo,
28
+ type WebhookOptions,
29
+ } from "chat";
30
+ import { logger } from "../logger.js";
31
+ import { normalizeChatMarkdown } from "../text/markdown.js";
32
+ import { applyRtlDirection } from "../text/rtl.js";
33
+ import { detectWhatsAppMedia } from "./whatsapp-media.js";
34
+
35
+ type WhatsAppThreadId = {
36
+ chatJid: string;
37
+ threadJid: string;
38
+ };
39
+
40
+ function extractText(message?: proto.IMessage | null): string {
41
+ if (!message) return "";
42
+ return (
43
+ message.conversation ||
44
+ message.extendedTextMessage?.text ||
45
+ message.imageMessage?.caption ||
46
+ message.videoMessage?.caption ||
47
+ message.documentMessage?.caption ||
48
+ ""
49
+ );
50
+ }
51
+
52
+ function getContextInfo(
53
+ message?: proto.IMessage | null,
54
+ ): proto.IContextInfo | undefined {
55
+ if (!message) return undefined;
56
+ const contextInfo =
57
+ message.extendedTextMessage?.contextInfo ||
58
+ message.imageMessage?.contextInfo ||
59
+ message.videoMessage?.contextInfo ||
60
+ message.documentMessage?.contextInfo ||
61
+ message.buttonsResponseMessage?.contextInfo ||
62
+ message.templateButtonReplyMessage?.contextInfo ||
63
+ message.listResponseMessage?.contextInfo;
64
+
65
+ return contextInfo ?? undefined;
66
+ }
67
+
68
+ function buildReplyContext(
69
+ message?: proto.IMessage | null,
70
+ pushNames?: Map<string, string>,
71
+ ): string | undefined {
72
+ const contextInfo = getContextInfo(message);
73
+ if (!contextInfo?.quotedMessage) return undefined;
74
+
75
+ const quotedText = extractText(contextInfo.quotedMessage).trim();
76
+ const quotedJid = contextInfo.participant || "unknown";
77
+ const quotedName =
78
+ pushNames?.get(quotedJid) || quotedJid.split("@")[0] || "unknown";
79
+ const quotedMessageId = contextInfo.stanzaId || "unknown";
80
+
81
+ // Check if quoted message has media
82
+ const quotedMedia = detectWhatsAppMedia(contextInfo.quotedMessage);
83
+
84
+ const attrs = [
85
+ `name="${quotedName}"`,
86
+ `jid="${quotedJid}"`,
87
+ `message_id="${quotedMessageId}"`,
88
+ ];
89
+
90
+ if (quotedMedia) {
91
+ attrs.push(`media_type="${quotedMedia.type}"`);
92
+ attrs.push(`media_mime="${quotedMedia.mimeType}"`);
93
+ }
94
+
95
+ const contentParts: string[] = [];
96
+ if (quotedText) {
97
+ contentParts.push(quotedText);
98
+ }
99
+ if (quotedMedia && !quotedText) {
100
+ // If no caption, describe the media
101
+ const typeLabel =
102
+ quotedMedia.type === "voice" ? "voice note" : quotedMedia.type;
103
+ contentParts.push(`[${typeLabel}]`);
104
+ }
105
+
106
+ const lines = [
107
+ `<reply_to ${attrs.join(" ")}>`,
108
+ contentParts.join("\n") || "",
109
+ "</reply_to>",
110
+ ];
111
+
112
+ return lines.join("\n");
113
+ }
114
+
115
+ function postableToText(message: AdapterPostableMessage): string {
116
+ if (typeof message === "string") return message;
117
+ if (typeof message === "object" && message !== null) {
118
+ if ("markdown" in message && typeof message.markdown === "string")
119
+ return message.markdown;
120
+ if ("ast" in message && message.ast) return stringifyMarkdown(message.ast);
121
+ if ("raw" in message && typeof message.raw === "string") return message.raw;
122
+ }
123
+ return "";
124
+ }
125
+
126
+ export type WhatsAppQrStatus =
127
+ | { status: "authenticated"; phoneNumber?: string }
128
+ | { status: "waiting"; qr: string }
129
+ | { status: "disconnected" };
130
+
131
+ export interface WhatsAppAdapterOptions {
132
+ userName?: string;
133
+ authDir?: string;
134
+ }
135
+
136
+ export class WhatsAppBaileysAdapter
137
+ implements Adapter<WhatsAppThreadId, proto.IWebMessageInfo>
138
+ {
139
+ readonly name = "whatsapp";
140
+ readonly userName: string;
141
+
142
+ private chat?: ChatInstance;
143
+ private sock?: WASocket;
144
+ private connected = false;
145
+ private readonly authDir: string;
146
+ private readonly outgoingQueue: Array<{ jid: string; text: string }> = [];
147
+ private flushing = false;
148
+ private connectedAtMs = 0;
149
+ private seenMessageIds = new Set<string>();
150
+ private reconnectAttempt = 0;
151
+ private readonly pushNames = new Map<string, string>();
152
+ private currentQr: string | null = null;
153
+
154
+ /** Called when the bot is removed or leaves a WhatsApp group. */
155
+ onGroupRemoval?: (chatJid: string) => void;
156
+
157
+ constructor(options?: WhatsAppAdapterOptions) {
158
+ this.userName = options?.userName ?? "mercury";
159
+ this.authDir =
160
+ options?.authDir ?? path.join(process.cwd(), ".mercury", "whatsapp-auth");
161
+ }
162
+
163
+ /**
164
+ * Get current QR status for API endpoint
165
+ */
166
+ getQrStatus(): WhatsAppQrStatus {
167
+ if (this.connected) {
168
+ const userJid = this.sock?.user?.id;
169
+ const phoneNumber = userJid?.split(":")[0];
170
+ return { status: "authenticated", phoneNumber };
171
+ }
172
+ if (this.currentQr) {
173
+ return { status: "waiting", qr: this.currentQr };
174
+ }
175
+ return { status: "disconnected" };
176
+ }
177
+
178
+ get socket(): WASocket | undefined {
179
+ return this.sock;
180
+ }
181
+
182
+ get botUserId(): string | undefined {
183
+ const jid = this.sock?.user?.id;
184
+ if (!jid) return undefined;
185
+ return jid.split(":")[0];
186
+ }
187
+
188
+ async initialize(chat: ChatInstance): Promise<void> {
189
+ this.chat = chat;
190
+ logger.info("WhatsApp adapter initialize", { authDir: this.authDir });
191
+
192
+ // No creds yet: still connect so Baileys can emit a QR (dashboard /auth/whatsapp, or mercury auth whatsapp).
193
+ await this.connect();
194
+ }
195
+
196
+ private async connect(): Promise<void> {
197
+ fs.mkdirSync(this.authDir, { recursive: true });
198
+ const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
199
+ const { version } = await fetchLatestWaWebVersion({}).catch(() => ({
200
+ version: undefined,
201
+ }));
202
+
203
+ const waLogger = {
204
+ level: "silent",
205
+ child: () => waLogger,
206
+ trace: () => undefined,
207
+ debug: () => undefined,
208
+ info: () => undefined,
209
+ warn: () => undefined,
210
+ error: () => undefined,
211
+ fatal: () => undefined,
212
+ };
213
+
214
+ const sock = makeWASocket({
215
+ version,
216
+ auth: {
217
+ creds: state.creds,
218
+ keys: makeCacheableSignalKeyStore(state.keys, waLogger),
219
+ },
220
+ logger: waLogger,
221
+ browser: [
222
+ process.env.MERCURY_WHATSAPP_DEVICE_NAME || "Mercury",
223
+ "Chrome",
224
+ "22.0",
225
+ ],
226
+ });
227
+
228
+ sock.ev.on("creds.update", saveCreds);
229
+
230
+ sock.ev.on("connection.update", (update) => {
231
+ const { connection, lastDisconnect, qr } = update;
232
+
233
+ // Track QR code for API endpoint
234
+ if (qr) {
235
+ this.currentQr = qr;
236
+ logger.info("whatsapp qr code generated");
237
+ }
238
+
239
+ if (connection === "open") {
240
+ this.connected = true;
241
+ this.currentQr = null; // Clear QR once connected
242
+ this.connectedAtMs = Date.now();
243
+ this.seenMessageIds.clear();
244
+ this.reconnectAttempt = 0;
245
+ logger.info("WhatsApp connection open");
246
+ void this.flushOutgoingQueue();
247
+ return;
248
+ }
249
+
250
+ if (connection === "close") {
251
+ this.connected = false;
252
+ this.currentQr = null;
253
+ const reason = (
254
+ lastDisconnect?.error as { output?: { statusCode?: number } }
255
+ )?.output?.statusCode;
256
+ logger.warn("WhatsApp connection closed", { reason });
257
+ if (reason !== DisconnectReason.loggedOut) {
258
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 60_000);
259
+ this.reconnectAttempt++;
260
+ logger.info("WhatsApp reconnecting", {
261
+ attempt: this.reconnectAttempt,
262
+ delayMs: delay,
263
+ });
264
+ setTimeout(() => {
265
+ void this.connect();
266
+ }, delay);
267
+ }
268
+ }
269
+ });
270
+
271
+ sock.ev.on("messages.upsert", ({ messages, type }) => {
272
+ if (type !== "notify") return;
273
+
274
+ for (const msg of messages) {
275
+ void this.handleIncomingMessage(msg);
276
+ }
277
+ });
278
+
279
+ sock.ev.on("group-participants.update", ({ id, participants, action }) => {
280
+ if (action !== "remove") return;
281
+ const botJid = this.sock?.user?.id;
282
+ const botLid = this.sock?.user?.lid;
283
+ const selfRemoved = participants.some(
284
+ (p) =>
285
+ (botJid != null && areJidsSameUser(p.id, botJid)) ||
286
+ (botLid != null && p.lid != null && areJidsSameUser(p.lid, botLid)),
287
+ );
288
+ if (selfRemoved) {
289
+ logger.info("WhatsApp: removed from group", { chatJid: id });
290
+ this.onGroupRemoval?.(id);
291
+ }
292
+ });
293
+
294
+ this.sock = sock;
295
+ }
296
+
297
+ async handleWebhook(
298
+ _request: Request,
299
+ _options?: WebhookOptions,
300
+ ): Promise<Response> {
301
+ return new Response(
302
+ "WhatsApp adapter uses Baileys socket, no webhook required.",
303
+ { status: 202 },
304
+ );
305
+ }
306
+
307
+ channelIdFromThreadId(threadId: string): string {
308
+ const parts = threadId.split(":");
309
+ return `whatsapp:${parts[1]}`;
310
+ }
311
+
312
+ encodeThreadId(platformData: WhatsAppThreadId): string {
313
+ return `whatsapp:${platformData.chatJid}:${platformData.threadJid}`;
314
+ }
315
+
316
+ decodeThreadId(threadId: string): WhatsAppThreadId {
317
+ const parts = threadId.split(":");
318
+ if (parts.length < 3 || parts[0] !== "whatsapp") {
319
+ throw new Error(`Invalid WhatsApp thread ID: ${threadId}`);
320
+ }
321
+ return {
322
+ chatJid: parts[1],
323
+ threadJid: parts.slice(2).join(":"),
324
+ };
325
+ }
326
+
327
+ async postMessage(
328
+ threadId: string,
329
+ message: AdapterPostableMessage,
330
+ ): Promise<RawMessage<proto.IWebMessageInfo>> {
331
+ const { chatJid } = this.decodeThreadId(threadId);
332
+ const text = postableToText(message).trim();
333
+ if (!text) {
334
+ throw new Error("Cannot send empty WhatsApp message");
335
+ }
336
+
337
+ if (!this.connected || !this.sock) {
338
+ this.outgoingQueue.push({ jid: chatJid, text });
339
+ logger.warn("WhatsApp queued outbound", {
340
+ chatJid,
341
+ queueSize: this.outgoingQueue.length,
342
+ });
343
+ return { id: `queued-${Date.now()}`, threadId, raw: {} };
344
+ }
345
+
346
+ logger.info("WhatsApp outbound", { chatJid, preview: text.slice(0, 120) });
347
+ const sent = await this.sock.sendMessage(chatJid, {
348
+ text: applyRtlDirection(normalizeChatMarkdown(text)),
349
+ });
350
+ if (!sent) {
351
+ throw new Error("WhatsApp sendMessage returned no message");
352
+ }
353
+ return {
354
+ id: sent.key?.id ?? `${Date.now()}`,
355
+ threadId,
356
+ raw: sent,
357
+ };
358
+ }
359
+
360
+ async editMessage(
361
+ _threadId: string,
362
+ _messageId: string,
363
+ _message: AdapterPostableMessage,
364
+ ): Promise<RawMessage<proto.IWebMessageInfo>> {
365
+ throw new NotImplementedError(
366
+ "WhatsApp does not support generic message edit in this adapter",
367
+ );
368
+ }
369
+
370
+ async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
371
+ throw new NotImplementedError(
372
+ "WhatsApp delete is not implemented in this adapter",
373
+ );
374
+ }
375
+
376
+ async addReaction(
377
+ _threadId: string,
378
+ _messageId: string,
379
+ _emoji: EmojiValue | string,
380
+ ): Promise<void> {
381
+ throw new NotImplementedError(
382
+ "WhatsApp reactions are not implemented in this adapter",
383
+ );
384
+ }
385
+
386
+ async removeReaction(
387
+ _threadId: string,
388
+ _messageId: string,
389
+ _emoji: EmojiValue | string,
390
+ ): Promise<void> {
391
+ throw new NotImplementedError(
392
+ "WhatsApp reactions are not implemented in this adapter",
393
+ );
394
+ }
395
+
396
+ async fetchMessages(
397
+ _threadId: string,
398
+ _options?: FetchOptions,
399
+ ): Promise<FetchResult<proto.IWebMessageInfo>> {
400
+ return { messages: [] };
401
+ }
402
+
403
+ async fetchThread(threadId: string): Promise<ThreadInfo> {
404
+ const { chatJid } = this.decodeThreadId(threadId);
405
+ return {
406
+ id: threadId,
407
+ channelId: `whatsapp:${chatJid}`,
408
+ isDM: !chatJid.endsWith("@g.us"),
409
+ metadata: { chatJid },
410
+ };
411
+ }
412
+
413
+ parseMessage(raw: proto.IWebMessageInfo): Message<proto.IWebMessageInfo> {
414
+ const key = raw.key;
415
+ const remoteJid = key?.remoteJid ?? "unknown@s.whatsapp.net";
416
+ const sender = key?.participant || remoteJid;
417
+ const senderName = raw.pushName || sender.split("@")[0] || "unknown";
418
+ const baseText = extractText(raw.message).trim();
419
+ const replyContext = buildReplyContext(raw.message, this.pushNames);
420
+ const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
421
+ const threadId = this.encodeThreadId({
422
+ chatJid: remoteJid,
423
+ threadJid: remoteJid,
424
+ });
425
+
426
+ return new Message({
427
+ id: key?.id ?? `${Date.now()}`,
428
+ threadId,
429
+ text,
430
+ formatted: parseMarkdown(text),
431
+ raw,
432
+ author: {
433
+ userId: sender,
434
+ userName: senderName,
435
+ fullName: senderName,
436
+ isBot: "unknown",
437
+ isMe: Boolean(key?.fromMe),
438
+ },
439
+ metadata: {
440
+ dateSent: new Date(
441
+ Number(raw.messageTimestamp ?? Date.now() / 1000) * 1000,
442
+ ),
443
+ edited: false,
444
+ },
445
+ attachments: [],
446
+ });
447
+ }
448
+
449
+ renderFormatted(content: FormattedContent): string {
450
+ return stringifyMarkdown(content);
451
+ }
452
+
453
+ async startTyping(threadId: string): Promise<void> {
454
+ const { chatJid } = this.decodeThreadId(threadId);
455
+ if (!this.sock || !this.connected) return;
456
+ await this.sock.presenceSubscribe(chatJid);
457
+ await this.sock.sendPresenceUpdate("composing", chatJid);
458
+ }
459
+
460
+ async shutdown(): Promise<void> {
461
+ this.connected = false;
462
+ this.sock?.end(undefined);
463
+ }
464
+
465
+ /**
466
+ * Handle an incoming WhatsApp message.
467
+ * Downloads media if present and enabled.
468
+ */
469
+ private async handleIncomingMessage(msg: WAMessage): Promise<void> {
470
+ if (!msg.message) return;
471
+ if (msg.key.fromMe) return;
472
+
473
+ const remoteJid = msg.key.remoteJid;
474
+ if (!remoteJid || remoteJid === "status@broadcast") return;
475
+
476
+ const messageId = msg.key.id;
477
+ if (messageId) {
478
+ if (this.seenMessageIds.has(messageId)) return;
479
+ this.seenMessageIds.add(messageId);
480
+ if (this.seenMessageIds.size > 5000) {
481
+ const ids = [...this.seenMessageIds];
482
+ this.seenMessageIds = new Set(ids.slice(ids.length - 2500));
483
+ }
484
+ }
485
+
486
+ const tsMs = Number(msg.messageTimestamp ?? 0) * 1000;
487
+ if (this.connectedAtMs && tsMs > 0 && tsMs < this.connectedAtMs - 10_000) {
488
+ logger.debug("WhatsApp skipping backlog message", {
489
+ remoteJid,
490
+ messageId,
491
+ tsMs,
492
+ });
493
+ return;
494
+ }
495
+
496
+ const sender = msg.key.participant || remoteJid;
497
+ const senderName = msg.pushName || sender.split("@")[0] || "unknown";
498
+
499
+ // Track push names for reply context resolution
500
+ if (msg.pushName && sender) {
501
+ this.pushNames.set(sender, msg.pushName);
502
+ }
503
+
504
+ let baseText = extractText(msg.message).trim();
505
+ const replyContext = buildReplyContext(msg.message, this.pushNames);
506
+
507
+ // WhatsApp @-mentions embed JIDs in text (e.g. "@52669955764381").
508
+ // Replace the bot's JID mention with the configured userName so trigger matching works.
509
+ const contextInfo = getContextInfo(msg.message);
510
+ const mentionedJids = contextInfo?.mentionedJid ?? [];
511
+ const botJid = this.sock?.user?.id;
512
+ const botLid = this.sock?.user?.lid;
513
+ const isBotJid = (jid: string) =>
514
+ (botJid && areJidsSameUser(jid, botJid)) ||
515
+ (botLid && areJidsSameUser(jid, botLid));
516
+
517
+ // Check if this is a reply to one of our messages
518
+ const quotedParticipant = contextInfo?.participant;
519
+ const isReplyToBot = quotedParticipant
520
+ ? isBotJid(quotedParticipant)
521
+ : false;
522
+
523
+ // Replace bot's JID mention with configured userName so trigger patterns match
524
+ for (const jid of mentionedJids) {
525
+ if (isBotJid(jid)) {
526
+ const user = jidDecode(jid)?.user;
527
+ if (user) {
528
+ baseText = baseText.replace(
529
+ new RegExp(`@${user}\\b`, "g"),
530
+ `@${this.userName}`,
531
+ );
532
+ }
533
+ }
534
+ }
535
+
536
+ // Detect media presence (download happens in bridge layer)
537
+ const mediaInfo = detectWhatsAppMedia(msg.message);
538
+ const hasMedia = mediaInfo !== null;
539
+
540
+ // Add media description to text if no caption
541
+ if (hasMedia && !baseText) {
542
+ const typeLabel =
543
+ mediaInfo.type === "voice" ? "voice note" : mediaInfo.type;
544
+ baseText = `[Sent ${typeLabel}]`;
545
+ }
546
+
547
+ const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
548
+ if (!text && !hasMedia) return;
549
+
550
+ const threadId = this.encodeThreadId({
551
+ chatJid: remoteJid,
552
+ threadJid: remoteJid,
553
+ });
554
+
555
+ logger.info("WhatsApp inbound", {
556
+ remoteJid,
557
+ sender,
558
+ isReply: Boolean(replyContext),
559
+ isReplyToBot,
560
+ hasMedia,
561
+ mediaType: mediaInfo?.type,
562
+ preview: text.slice(0, 120),
563
+ });
564
+
565
+ const _isDM = !remoteJid.endsWith("@g.us");
566
+
567
+ const incoming = new Message<proto.IWebMessageInfo>({
568
+ id: msg.key.id ?? `${Date.now()}`,
569
+ threadId,
570
+ text: text || "[Media message]",
571
+ formatted: parseMarkdown(text || "[Media message]"),
572
+ raw: msg,
573
+ isMention: true, // always true — router handles trigger matching
574
+ author: {
575
+ userId: sender,
576
+ userName: senderName,
577
+ fullName: senderName,
578
+ isBot: "unknown",
579
+ isMe: false,
580
+ },
581
+ metadata: {
582
+ dateSent: new Date(
583
+ Number(msg.messageTimestamp ?? Date.now() / 1000) * 1000,
584
+ ),
585
+ edited: false,
586
+ // Store reply flag and platform IDs in metadata for downstream consumers
587
+ // Using spread to add custom properties (not in MessageMetadata type)
588
+ ...({
589
+ isReplyToBot,
590
+ replyToMessageId: contextInfo?.stanzaId ?? undefined,
591
+ platformMessageId: msg.key.id ?? undefined,
592
+ } as Record<string, unknown>),
593
+ },
594
+ attachments: [],
595
+ });
596
+
597
+ // Mark message as read (blue ticks) immediately on receipt
598
+ if (this.sock && msg.key.remoteJid) {
599
+ try {
600
+ await this.sock.readMessages([msg.key]);
601
+ } catch {
602
+ // Best-effort — don't block message processing
603
+ }
604
+ }
605
+
606
+ this.chat?.processMessage(this, threadId, incoming);
607
+ }
608
+
609
+ private async flushOutgoingQueue(): Promise<void> {
610
+ if (!this.sock || !this.connected || this.flushing) return;
611
+ this.flushing = true;
612
+ try {
613
+ if (this.outgoingQueue.length > 0) {
614
+ logger.info("WhatsApp flushing outbound queue", {
615
+ count: this.outgoingQueue.length,
616
+ });
617
+ }
618
+ while (this.outgoingQueue.length > 0) {
619
+ const item = this.outgoingQueue.shift();
620
+ if (!item) continue;
621
+ await this.sock.sendMessage(item.jid, {
622
+ text: applyRtlDirection(normalizeChatMarkdown(item.text)),
623
+ });
624
+ }
625
+ } finally {
626
+ this.flushing = false;
627
+ }
628
+ }
629
+ }
630
+
631
+ export function createWhatsAppBaileysAdapter(
632
+ options?: WhatsAppAdapterOptions,
633
+ ): WhatsAppBaileysAdapter {
634
+ return new WhatsAppBaileysAdapter(options);
635
+ }