mercury-agent 0.4.5

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.
Files changed (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/container/Dockerfile +127 -0
  4. package/container/Dockerfile.base +109 -0
  5. package/container/Dockerfile.power +17 -0
  6. package/container/agent-package.json +8 -0
  7. package/container/build.sh +54 -0
  8. package/docs/TODOS.md +147 -0
  9. package/docs/auth/dashboard.md +28 -0
  10. package/docs/auth/overview.md +109 -0
  11. package/docs/auth/whatsapp.md +173 -0
  12. package/docs/configuration.md +54 -0
  13. package/docs/container-lifecycle.md +349 -0
  14. package/docs/context-architecture.md +87 -0
  15. package/docs/deployment.md +199 -0
  16. package/docs/extensions.md +375 -0
  17. package/docs/graceful-shutdown.md +62 -0
  18. package/docs/kb-distillation.md +77 -0
  19. package/docs/media/overview.md +140 -0
  20. package/docs/media/whatsapp.md +171 -0
  21. package/docs/memory.md +137 -0
  22. package/docs/permissions.md +217 -0
  23. package/docs/pipeline.md +228 -0
  24. package/docs/prd-chat-memory.md +76 -0
  25. package/docs/prd-config-load.md +82 -0
  26. package/docs/rate-limiting.md +166 -0
  27. package/docs/scheduler.md +288 -0
  28. package/docs/setup-discord.md +100 -0
  29. package/docs/setup-slack.md +119 -0
  30. package/docs/setup-whatsapp.md +94 -0
  31. package/docs/subagents.md +166 -0
  32. package/docs/web-search.md +62 -0
  33. package/examples/extensions/README.md +12 -0
  34. package/examples/extensions/charts/index.ts +13 -0
  35. package/examples/extensions/charts/skill/SKILL.md +98 -0
  36. package/examples/extensions/gws/README.md +52 -0
  37. package/examples/extensions/gws/index.ts +106 -0
  38. package/examples/extensions/gws/skill/SKILL.md +57 -0
  39. package/examples/extensions/gws/skill/references/calendar.md +101 -0
  40. package/examples/extensions/gws/skill/references/docs.md +65 -0
  41. package/examples/extensions/gws/skill/references/drive.md +79 -0
  42. package/examples/extensions/gws/skill/references/gmail.md +85 -0
  43. package/examples/extensions/gws/skill/references/sheets.md +60 -0
  44. package/examples/extensions/napkin/index.ts +821 -0
  45. package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
  46. package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
  47. package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -0
  49. package/examples/extensions/pdf/index.ts +23 -0
  50. package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
  51. package/examples/extensions/pdf/skill/SKILL.md +314 -0
  52. package/examples/extensions/pdf/skill/forms.md +294 -0
  53. package/examples/extensions/pdf/skill/reference.md +612 -0
  54. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
  55. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
  56. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
  57. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
  58. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
  59. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
  60. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
  61. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
  62. package/examples/extensions/permission-guard/index.ts +65 -0
  63. package/examples/extensions/pinchtab/index.ts +199 -0
  64. package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
  65. package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
  66. package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
  67. package/examples/extensions/pinchtab/skill/references/api.md +297 -0
  68. package/examples/extensions/pinchtab/skill/references/env.md +45 -0
  69. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
  70. package/examples/extensions/tradestation/host/refresh.ts +102 -0
  71. package/examples/extensions/tradestation/index.ts +153 -0
  72. package/examples/extensions/tradestation/skill/SKILL.md +67 -0
  73. package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
  74. package/examples/extensions/voice-synth/index.ts +94 -0
  75. package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
  76. package/examples/extensions/voice-transcribe/index.ts +381 -0
  77. package/examples/extensions/voice-transcribe/requirements.txt +8 -0
  78. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
  79. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
  80. package/examples/extensions/web-search/index.ts +22 -0
  81. package/examples/extensions/web-search/skill/SKILL.md +114 -0
  82. package/examples/extensions/web-search/skill/references/apartments.md +178 -0
  83. package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
  84. package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
  85. package/examples/extensions/web-search/skill/references/flights.md +133 -0
  86. package/examples/extensions/web-search/skill/references/hotels.md +148 -0
  87. package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
  88. package/examples/extensions/yahoo-mail/cli/package.json +13 -0
  89. package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
  90. package/examples/extensions/yahoo-mail/index.ts +57 -0
  91. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
  92. package/package.json +106 -0
  93. package/resources/agents/explore.md +50 -0
  94. package/resources/agents/worker.md +24 -0
  95. package/resources/builtin-extensions.txt +3 -0
  96. package/resources/connection-env-vars.json +25 -0
  97. package/resources/extensions/.gitkeep +0 -0
  98. package/resources/pi-extensions/subagent/agents.ts +126 -0
  99. package/resources/pi-extensions/subagent/index.ts +964 -0
  100. package/resources/profiles/coding/AGENTS.md +43 -0
  101. package/resources/profiles/coding/mercury-profile.yaml +15 -0
  102. package/resources/profiles/general/AGENTS.md +31 -0
  103. package/resources/profiles/general/mercury-profile.yaml +15 -0
  104. package/resources/profiles/research/AGENTS.md +40 -0
  105. package/resources/profiles/research/mercury-profile.yaml +15 -0
  106. package/resources/skills/config/SKILL.md +25 -0
  107. package/resources/skills/context/SKILL.md +33 -0
  108. package/resources/skills/conversation-recap/SKILL.md +19 -0
  109. package/resources/skills/media/SKILL.md +27 -0
  110. package/resources/skills/mutes/SKILL.md +31 -0
  111. package/resources/skills/permissions/SKILL.md +19 -0
  112. package/resources/skills/preferences/SKILL.md +31 -0
  113. package/resources/skills/recall/SKILL.md +24 -0
  114. package/resources/skills/roles/SKILL.md +18 -0
  115. package/resources/skills/spaces/SKILL.md +18 -0
  116. package/resources/skills/tasks/SKILL.md +45 -0
  117. package/resources/templates/AGENTS.md +157 -0
  118. package/resources/templates/env.template +34 -0
  119. package/resources/templates/mercury.example.yaml +75 -0
  120. package/src/adapters/discord-native.ts +534 -0
  121. package/src/adapters/discord.ts +38 -0
  122. package/src/adapters/setup.ts +89 -0
  123. package/src/adapters/slack.ts +9 -0
  124. package/src/adapters/whatsapp-media.ts +337 -0
  125. package/src/adapters/whatsapp.ts +629 -0
  126. package/src/agent/api-socket.ts +127 -0
  127. package/src/agent/container-entry.ts +967 -0
  128. package/src/agent/container-error.ts +49 -0
  129. package/src/agent/container-runner.ts +1272 -0
  130. package/src/agent/model-capabilities-core.ts +23 -0
  131. package/src/agent/model-capabilities.ts +231 -0
  132. package/src/agent/pi-failure-class.ts +83 -0
  133. package/src/agent/pi-jsonl-parser.ts +306 -0
  134. package/src/agent/preferences-prompt.ts +20 -0
  135. package/src/agent/user-error-messages.ts +78 -0
  136. package/src/bridges/discord.ts +171 -0
  137. package/src/bridges/slack.ts +177 -0
  138. package/src/bridges/teams.ts +160 -0
  139. package/src/bridges/telegram.ts +571 -0
  140. package/src/bridges/whatsapp.ts +290 -0
  141. package/src/chat-shim.ts +259 -0
  142. package/src/cli/mercury.ts +2508 -0
  143. package/src/cli/mrctl-http.ts +27 -0
  144. package/src/cli/mrctl.ts +611 -0
  145. package/src/cli/whatsapp-auth.ts +260 -0
  146. package/src/config-file.ts +397 -0
  147. package/src/config-model-chain.ts +30 -0
  148. package/src/config.ts +316 -0
  149. package/src/core/api-types.ts +58 -0
  150. package/src/core/api.ts +105 -0
  151. package/src/core/commands.ts +76 -0
  152. package/src/core/conversation.ts +47 -0
  153. package/src/core/handler.ts +206 -0
  154. package/src/core/media.ts +200 -0
  155. package/src/core/mute-duration.ts +22 -0
  156. package/src/core/outbox.ts +76 -0
  157. package/src/core/permissions.ts +192 -0
  158. package/src/core/profiles.ts +245 -0
  159. package/src/core/rate-limiter.ts +127 -0
  160. package/src/core/router.ts +191 -0
  161. package/src/core/routes/chat.ts +172 -0
  162. package/src/core/routes/config-builtin.ts +107 -0
  163. package/src/core/routes/config.ts +81 -0
  164. package/src/core/routes/connections.ts +190 -0
  165. package/src/core/routes/console.ts +668 -0
  166. package/src/core/routes/control.ts +46 -0
  167. package/src/core/routes/conversations.ts +66 -0
  168. package/src/core/routes/dashboard.ts +2491 -0
  169. package/src/core/routes/extensions.ts +37 -0
  170. package/src/core/routes/index.ts +14 -0
  171. package/src/core/routes/media.ts +72 -0
  172. package/src/core/routes/messages.ts +37 -0
  173. package/src/core/routes/mutes.ts +89 -0
  174. package/src/core/routes/prefs.ts +95 -0
  175. package/src/core/routes/roles.ts +125 -0
  176. package/src/core/routes/spaces.ts +60 -0
  177. package/src/core/routes/storage.ts +126 -0
  178. package/src/core/routes/tasks.ts +189 -0
  179. package/src/core/routes/tradestation.ts +268 -0
  180. package/src/core/routes/tts.ts +51 -0
  181. package/src/core/runtime.ts +1140 -0
  182. package/src/core/space-queue.ts +103 -0
  183. package/src/core/storage-cleanup.ts +140 -0
  184. package/src/core/storage-guard.ts +24 -0
  185. package/src/core/task-scheduler.ts +132 -0
  186. package/src/core/telegram-format.ts +178 -0
  187. package/src/core/trigger.ts +142 -0
  188. package/src/dashboard/index.html +729 -0
  189. package/src/dashboard/tokens.css +53 -0
  190. package/src/extensions/api.ts +252 -0
  191. package/src/extensions/catalog.ts +117 -0
  192. package/src/extensions/config-registry.ts +83 -0
  193. package/src/extensions/context.ts +36 -0
  194. package/src/extensions/hooks.ts +156 -0
  195. package/src/extensions/image-builder.ts +617 -0
  196. package/src/extensions/installer.ts +306 -0
  197. package/src/extensions/jobs.ts +122 -0
  198. package/src/extensions/loader.ts +271 -0
  199. package/src/extensions/permission-guard.ts +52 -0
  200. package/src/extensions/reserved.ts +28 -0
  201. package/src/extensions/skills.ts +123 -0
  202. package/src/extensions/types.ts +462 -0
  203. package/src/logger.ts +174 -0
  204. package/src/main.ts +586 -0
  205. package/src/server.ts +391 -0
  206. package/src/storage/db.ts +1624 -0
  207. package/src/storage/memory.ts +45 -0
  208. package/src/storage/pi-auth.ts +95 -0
  209. package/src/text/markdown.ts +117 -0
  210. package/src/text/rtl.ts +38 -0
  211. package/src/tradestation/host-api.ts +77 -0
  212. package/src/tradestation/pending-orders.ts +69 -0
  213. package/src/tts/azure.ts +52 -0
  214. package/src/tts/google.ts +128 -0
  215. package/src/tts/index.ts +8 -0
  216. package/src/tts/language.ts +20 -0
  217. package/src/tts/synthesize.ts +133 -0
  218. package/src/types.ts +295 -0
@@ -0,0 +1,629 @@
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 readonly 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) this.seenMessageIds.clear();
478
+ }
479
+
480
+ const tsMs = Number(msg.messageTimestamp ?? 0) * 1000;
481
+ if (this.connectedAtMs && tsMs > 0 && tsMs < this.connectedAtMs - 10_000) {
482
+ logger.debug("WhatsApp skipping backlog message", {
483
+ remoteJid,
484
+ messageId,
485
+ tsMs,
486
+ });
487
+ return;
488
+ }
489
+
490
+ const sender = msg.key.participant || remoteJid;
491
+ const senderName = msg.pushName || sender.split("@")[0] || "unknown";
492
+
493
+ // Track push names for reply context resolution
494
+ if (msg.pushName && sender) {
495
+ this.pushNames.set(sender, msg.pushName);
496
+ }
497
+
498
+ let baseText = extractText(msg.message).trim();
499
+ const replyContext = buildReplyContext(msg.message, this.pushNames);
500
+
501
+ // WhatsApp @-mentions embed JIDs in text (e.g. "@52669955764381").
502
+ // Replace the bot's JID mention with the configured userName so trigger matching works.
503
+ const contextInfo = getContextInfo(msg.message);
504
+ const mentionedJids = contextInfo?.mentionedJid ?? [];
505
+ const botJid = this.sock?.user?.id;
506
+ const botLid = this.sock?.user?.lid;
507
+ const isBotJid = (jid: string) =>
508
+ (botJid && areJidsSameUser(jid, botJid)) ||
509
+ (botLid && areJidsSameUser(jid, botLid));
510
+
511
+ // Check if this is a reply to one of our messages
512
+ const quotedParticipant = contextInfo?.participant;
513
+ const isReplyToBot = quotedParticipant
514
+ ? isBotJid(quotedParticipant)
515
+ : false;
516
+
517
+ // Replace bot's JID mention with configured userName so trigger patterns match
518
+ for (const jid of mentionedJids) {
519
+ if (isBotJid(jid)) {
520
+ const user = jidDecode(jid)?.user;
521
+ if (user) {
522
+ baseText = baseText.replace(
523
+ new RegExp(`@${user}\\b`, "g"),
524
+ `@${this.userName}`,
525
+ );
526
+ }
527
+ }
528
+ }
529
+
530
+ // Detect media presence (download happens in bridge layer)
531
+ const mediaInfo = detectWhatsAppMedia(msg.message);
532
+ const hasMedia = mediaInfo !== null;
533
+
534
+ // Add media description to text if no caption
535
+ if (hasMedia && !baseText) {
536
+ const typeLabel =
537
+ mediaInfo.type === "voice" ? "voice note" : mediaInfo.type;
538
+ baseText = `[Sent ${typeLabel}]`;
539
+ }
540
+
541
+ const text = [baseText, replyContext].filter(Boolean).join("\n\n").trim();
542
+ if (!text && !hasMedia) return;
543
+
544
+ const threadId = this.encodeThreadId({
545
+ chatJid: remoteJid,
546
+ threadJid: remoteJid,
547
+ });
548
+
549
+ logger.info("WhatsApp inbound", {
550
+ remoteJid,
551
+ sender,
552
+ isReply: Boolean(replyContext),
553
+ isReplyToBot,
554
+ hasMedia,
555
+ mediaType: mediaInfo?.type,
556
+ preview: text.slice(0, 120),
557
+ });
558
+
559
+ const _isDM = !remoteJid.endsWith("@g.us");
560
+
561
+ const incoming = new Message<proto.IWebMessageInfo>({
562
+ id: msg.key.id ?? `${Date.now()}`,
563
+ threadId,
564
+ text: text || "[Media message]",
565
+ formatted: parseMarkdown(text || "[Media message]"),
566
+ raw: msg,
567
+ isMention: true, // always true — router handles trigger matching
568
+ author: {
569
+ userId: sender,
570
+ userName: senderName,
571
+ fullName: senderName,
572
+ isBot: "unknown",
573
+ isMe: false,
574
+ },
575
+ metadata: {
576
+ dateSent: new Date(
577
+ Number(msg.messageTimestamp ?? Date.now() / 1000) * 1000,
578
+ ),
579
+ edited: false,
580
+ // Store reply flag and platform IDs in metadata for downstream consumers
581
+ // Using spread to add custom properties (not in MessageMetadata type)
582
+ ...({
583
+ isReplyToBot,
584
+ replyToMessageId: contextInfo?.stanzaId ?? undefined,
585
+ platformMessageId: msg.key.id ?? undefined,
586
+ } as Record<string, unknown>),
587
+ },
588
+ attachments: [],
589
+ });
590
+
591
+ // Mark message as read (blue ticks) immediately on receipt
592
+ if (this.sock && msg.key.remoteJid) {
593
+ try {
594
+ await this.sock.readMessages([msg.key]);
595
+ } catch {
596
+ // Best-effort — don't block message processing
597
+ }
598
+ }
599
+
600
+ this.chat?.processMessage(this, threadId, incoming);
601
+ }
602
+
603
+ private async flushOutgoingQueue(): Promise<void> {
604
+ if (!this.sock || !this.connected || this.flushing) return;
605
+ this.flushing = true;
606
+ try {
607
+ if (this.outgoingQueue.length > 0) {
608
+ logger.info("WhatsApp flushing outbound queue", {
609
+ count: this.outgoingQueue.length,
610
+ });
611
+ }
612
+ while (this.outgoingQueue.length > 0) {
613
+ const item = this.outgoingQueue.shift();
614
+ if (!item) continue;
615
+ await this.sock.sendMessage(item.jid, {
616
+ text: applyRtlDirection(normalizeChatMarkdown(item.text)),
617
+ });
618
+ }
619
+ } finally {
620
+ this.flushing = false;
621
+ }
622
+ }
623
+ }
624
+
625
+ export function createWhatsAppBaileysAdapter(
626
+ options?: WhatsAppAdapterOptions,
627
+ ): WhatsAppBaileysAdapter {
628
+ return new WhatsAppBaileysAdapter(options);
629
+ }