@0xmaxma/claude-gateway 1.8.10 → 1.8.12

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 (74) hide show
  1. package/README.md +14 -1
  2. package/config.template.json +2 -1
  3. package/dist/agent/runner.d.ts +72 -0
  4. package/dist/agent/runner.d.ts.map +1 -1
  5. package/dist/agent/runner.js +300 -6
  6. package/dist/agent/runner.js.map +1 -1
  7. package/dist/api/router.d.ts.map +1 -1
  8. package/dist/api/router.js +1001 -4
  9. package/dist/api/router.js.map +1 -1
  10. package/dist/api/webhooks-router.d.ts +2 -0
  11. package/dist/api/webhooks-router.d.ts.map +1 -1
  12. package/dist/api/webhooks-router.js +2 -0
  13. package/dist/api/webhooks-router.js.map +1 -1
  14. package/dist/api/whatsapp-access.d.ts +123 -0
  15. package/dist/api/whatsapp-access.d.ts.map +1 -0
  16. package/dist/api/whatsapp-access.js +135 -0
  17. package/dist/api/whatsapp-access.js.map +1 -0
  18. package/dist/api/whatsapp-cloud-access.d.ts +48 -0
  19. package/dist/api/whatsapp-cloud-access.d.ts.map +1 -0
  20. package/dist/api/whatsapp-cloud-access.js +58 -0
  21. package/dist/api/whatsapp-cloud-access.js.map +1 -0
  22. package/dist/api/whatsapp-cloud-client.d.ts +143 -0
  23. package/dist/api/whatsapp-cloud-client.d.ts.map +1 -0
  24. package/dist/api/whatsapp-cloud-client.js +298 -0
  25. package/dist/api/whatsapp-cloud-client.js.map +1 -0
  26. package/dist/api/whatsapp-cloud-webhook-router.d.ts +132 -0
  27. package/dist/api/whatsapp-cloud-webhook-router.d.ts.map +1 -0
  28. package/dist/api/whatsapp-cloud-webhook-router.js +494 -0
  29. package/dist/api/whatsapp-cloud-webhook-router.js.map +1 -0
  30. package/dist/config/loader.d.ts.map +1 -1
  31. package/dist/config/loader.js +7 -0
  32. package/dist/config/loader.js.map +1 -1
  33. package/dist/config/whatsapp-accounts.d.ts +77 -0
  34. package/dist/config/whatsapp-accounts.d.ts.map +1 -0
  35. package/dist/config/whatsapp-accounts.js +218 -0
  36. package/dist/config/whatsapp-accounts.js.map +1 -0
  37. package/dist/history/db.d.ts.map +1 -1
  38. package/dist/history/db.js +43 -11
  39. package/dist/history/db.js.map +1 -1
  40. package/dist/history/types.d.ts +18 -1
  41. package/dist/history/types.d.ts.map +1 -1
  42. package/dist/history/types.js +1 -1
  43. package/dist/history/types.js.map +1 -1
  44. package/dist/index.js +18 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/session/process.d.ts.map +1 -1
  47. package/dist/session/process.js +37 -0
  48. package/dist/session/process.js.map +1 -1
  49. package/dist/shared/image-optimize.d.ts +32 -0
  50. package/dist/shared/image-optimize.d.ts.map +1 -0
  51. package/dist/shared/image-optimize.js +197 -0
  52. package/dist/shared/image-optimize.js.map +1 -0
  53. package/dist/shared/image-sniff.d.ts +9 -0
  54. package/dist/shared/image-sniff.d.ts.map +1 -1
  55. package/dist/shared/image-sniff.js +13 -0
  56. package/dist/shared/image-sniff.js.map +1 -1
  57. package/dist/shared/text-chunk.d.ts +37 -0
  58. package/dist/shared/text-chunk.d.ts.map +1 -0
  59. package/dist/shared/text-chunk.js +105 -0
  60. package/dist/shared/text-chunk.js.map +1 -0
  61. package/dist/shared/whatsapp-ack.d.ts +13 -0
  62. package/dist/shared/whatsapp-ack.d.ts.map +1 -0
  63. package/dist/shared/whatsapp-ack.js +16 -0
  64. package/dist/shared/whatsapp-ack.js.map +1 -0
  65. package/dist/types.d.ts +158 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/whatsapp/manager.d.ts +173 -0
  68. package/dist/whatsapp/manager.d.ts.map +1 -0
  69. package/dist/whatsapp/manager.js +838 -0
  70. package/dist/whatsapp/manager.js.map +1 -0
  71. package/mcp/server.ts +4 -0
  72. package/mcp/tools/whatsapp/module.ts +162 -0
  73. package/mcp/tools/whatsapp-cloud/module.ts +536 -0
  74. package/package.json +9 -4
@@ -0,0 +1,838 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.WhatsAppManager = void 0;
40
+ /**
41
+ * WhatsAppManager — per-agent WhatsApp Web bridge via Baileys, run IN-PROCESS
42
+ * in the main gateway (not a spawned child, unlike Telegram/Discord's
43
+ * receivers). See the design note in AgentConfig.whatsapp's doc comment
44
+ * (src/types.ts) and the plan this was built from for why: Baileys
45
+ * multiplexes ALL send/receive through one live multi-device WebSocket tied
46
+ * to the linked session — there is no stateless per-call REST path the way
47
+ * Discord/Telegram/Slack/SMS have, so the socket must be a long-lived
48
+ * resource the gateway process holds and reaches into directly (via
49
+ * src/api/router.ts's internal /whatsapp/send route), not something an MCP
50
+ * subprocess can independently reconnect for every reply.
51
+ *
52
+ * One WhatsAppManager per LINKED ACCOUNT (Phase 1 of the WhatsApp
53
+ * feature-parity plan made this multi-account; it used to be one per agent).
54
+ * AgentRunner keeps a Map<accountId, WhatsAppManager> and only adds/removes
55
+ * entries as `agentConfig.whatsapp.accounts` changes — an existing account's
56
+ * live socket is never torn down just because a sibling account's config
57
+ * moved, since the credential IS the on-disk session, not a config.json field.
58
+ *
59
+ * The account's id selects its session directory: 'default' keeps the
60
+ * historical bare `<workspace>/.whatsapp-state/`, anything else nests under
61
+ * it (see whatsAppStateDir in src/config/whatsapp-accounts.ts).
62
+ */
63
+ const fs = __importStar(require("fs"));
64
+ const fsp = __importStar(require("fs/promises"));
65
+ const path = __importStar(require("path"));
66
+ const os = __importStar(require("os"));
67
+ async function loadBaileys() {
68
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
69
+ return (await Promise.resolve().then(() => __importStar(require('@whiskeysockets/baileys'))));
70
+ }
71
+ const QRCode = __importStar(require("qrcode"));
72
+ const pino_1 = __importDefault(require("pino"));
73
+ const whatsapp_accounts_1 = require("../config/whatsapp-accounts");
74
+ const logger_1 = require("../logger");
75
+ const media_store_1 = require("../history/media-store");
76
+ const image_sniff_1 = require("../shared/image-sniff");
77
+ const whatsapp_access_1 = require("../api/whatsapp-access");
78
+ const text_chunk_1 = require("../shared/text-chunk");
79
+ const image_optimize_1 = require("../shared/image-optimize");
80
+ const whatsapp_ack_1 = require("../shared/whatsapp-ack");
81
+ const pending_senders_1 = require("../api/pending-senders");
82
+ const AUTO_RESTART_DELAY_MS = 5000;
83
+ const MAX_RESTARTS = 3;
84
+ const SLOW_RESTART_DELAY_MS = 5 * 60000;
85
+ const MAX_IMAGE_BYTES = media_store_1.MediaStore.maxUploadBytes;
86
+ /**
87
+ * Per-message character budget for outbound text (Phase 2). WhatsApp's own
88
+ * ceiling is ~65k, but long single bubbles render badly and Baileys can be
89
+ * flaky well below the protocol limit — 4000 is the conservative value the
90
+ * Cloud channel's hard 4096 limit already forces us to design for, so both
91
+ * WhatsApp channels behave the same way.
92
+ */
93
+ const MAX_TEXT_CHARS = 4000;
94
+ /**
95
+ * How many recent inbound messages to keep addressable. A quote-reply needs
96
+ * the WHOLE original message (Baileys' `quoted` option takes a WAMessage, not
97
+ * an id) and a reaction needs its full key (participant included, or a
98
+ * reaction in a group lands on nothing), but the MCP tool only ever hands
99
+ * back a message_id string. Bounded FIFO: this only has to outlive the gap
100
+ * between an inbound message and the agent's reply to it.
101
+ */
102
+ const RECENT_MESSAGE_CACHE_SIZE = 200;
103
+ /**
104
+ * Extension-based mime guess for an outbound `forceDocument` send. These are
105
+ * files the AGENT produced (a generated image, a rendered chart), not
106
+ * attacker bytes, so the extension is trustworthy enough here — the inbound
107
+ * side sniffs real magic bytes instead (sniffImageExt).
108
+ */
109
+ function guessOutboundMimeType(filePath) {
110
+ switch (path.extname(filePath).toLowerCase()) {
111
+ case '.png': return 'image/png';
112
+ case '.jpg':
113
+ case '.jpeg': return 'image/jpeg';
114
+ case '.gif': return 'image/gif';
115
+ case '.webp': return 'image/webp';
116
+ case '.pdf': return 'application/pdf';
117
+ default: return 'application/octet-stream';
118
+ }
119
+ }
120
+ /**
121
+ * Pull the quote context off whichever message variant carries it. A plain
122
+ * text message that quotes something is upgraded to `extendedTextMessage` by
123
+ * WhatsApp, but a quoted IMAGE/sticker/document/video reply keeps its own
124
+ * type and hangs `contextInfo` off that instead — so all of them are checked.
125
+ */
126
+ function extractQuotedContext(message) {
127
+ if (!message)
128
+ return undefined;
129
+ for (const key of [
130
+ 'extendedTextMessage',
131
+ 'imageMessage',
132
+ 'stickerMessage',
133
+ 'documentMessage',
134
+ 'videoMessage',
135
+ 'audioMessage',
136
+ ]) {
137
+ const ctx = message[key]?.contextInfo;
138
+ if (ctx?.stanzaId)
139
+ return ctx;
140
+ }
141
+ return undefined;
142
+ }
143
+ /** Best-effort body text of a quoted message, across the variants that have one. */
144
+ function quotedMessageText(quoted) {
145
+ if (!quoted)
146
+ return '';
147
+ const conversation = quoted.conversation;
148
+ if (typeof conversation === 'string')
149
+ return conversation;
150
+ const extended = quoted.extendedTextMessage?.text;
151
+ if (typeof extended === 'string')
152
+ return extended;
153
+ for (const key of ['imageMessage', 'videoMessage', 'documentMessage']) {
154
+ const caption = quoted[key]?.caption;
155
+ if (typeof caption === 'string')
156
+ return caption;
157
+ }
158
+ return '';
159
+ }
160
+ /**
161
+ * One-time pairing-code message — same visual-match-code contract as every
162
+ * other channel's pairing flow (the sender reports the code to the admin,
163
+ * who matches it in the UI before adding them to the allowlist).
164
+ */
165
+ function pairingMessage(code, isGroup) {
166
+ const thWhere = isGroup ? 'ในกลุ่มนี้' : '';
167
+ const enWhere = isGroup ? ' in this group' : '';
168
+ return (`รหัสจับคู่ (pairing code) ของคุณคือ: ${code}\n` +
169
+ `กรุณาแจ้งรหัสนี้ให้แอดมินเพื่อขอเปิดใช้งานบอท${thWhere} (ไม่ต้องพิมพ์รหัสตอบกลับ)\n\n` +
170
+ `Your pairing code: ${code}\n` +
171
+ `Share this code with the admin to get access${enWhere}. (No need to reply with it.)`);
172
+ }
173
+ class WhatsAppManager {
174
+ constructor(agentConfig,
175
+ /**
176
+ * Which of the agent's WhatsApp accounts this instance owns. Selects the
177
+ * on-disk session directory, tags every event forwarded to the runner,
178
+ * and picks the access-control block read on each inbound message.
179
+ */
180
+ accountId, callbackPort, logDir) {
181
+ this.agentConfig = agentConfig;
182
+ this.accountId = accountId;
183
+ this.callbackPort = callbackPort;
184
+ this.logDir = logDir;
185
+ this.sock = null;
186
+ this.status = 'unlinked';
187
+ this.loggedOut = false;
188
+ this.stopping = false;
189
+ this.restartCount = 0;
190
+ /**
191
+ * Bumped at the start of every connect() call. A socket's event handlers
192
+ * capture the generation they were created under and become no-ops once a
193
+ * newer connect() supersedes them — see connect()'s doc comment.
194
+ */
195
+ this.connectGeneration = 0;
196
+ this.restartTimer = null;
197
+ this.pinoLogger = (0, pino_1.default)({ level: 'silent' });
198
+ this.baileys = null;
199
+ /** Recent inbound messages by id — see RECENT_MESSAGE_CACHE_SIZE. */
200
+ this.recentMessages = new Map();
201
+ this.stateDir = (0, whatsapp_accounts_1.whatsAppStateDir)(agentConfig.workspace, accountId);
202
+ this.logger = (0, logger_1.createLogger)(accountId === whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID
203
+ ? `${agentConfig.id}:whatsapp`
204
+ : `${agentConfig.id}:whatsapp:${accountId}`, logDir);
205
+ }
206
+ updateAgentConfig(newConfig) {
207
+ this.agentConfig = newConfig;
208
+ // No credential to react to (see class doc comment) — access-control
209
+ // fields (dmPolicy etc.) are read live off this.accountConfig() on every
210
+ // inbound message, so nothing else needs to happen here.
211
+ }
212
+ /**
213
+ * This account's access-control block. Undefined when the account isn't in
214
+ * config at all (an unconfigured agent's implicit 'default'), which the
215
+ * gate treats exactly like an empty block — closed by default.
216
+ */
217
+ accountConfig() {
218
+ return (0, whatsapp_accounts_1.findWhatsAppAccount)(this.agentConfig.whatsapp, this.accountId);
219
+ }
220
+ /**
221
+ * Wipe this account's linked session.
222
+ *
223
+ * The 'default' account shares the bare `.whatsapp-state/` directory with
224
+ * BOTH the channel's message-turn state and every other account's
225
+ * subdirectory (see whatsAppStateDir), so a blind `rm -rf` of it would
226
+ * unlink sibling accounts as collateral. Delete only the files at the top
227
+ * level there — Baileys' `creds.json` and key files — and leave
228
+ * subdirectories alone. Non-default accounts own their directory outright,
229
+ * so those are removed wholesale.
230
+ */
231
+ async wipeStateDir() {
232
+ if (this.accountId !== whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID) {
233
+ await fsp.rm(this.stateDir, { recursive: true, force: true }).catch(() => { });
234
+ return;
235
+ }
236
+ const entries = await fsp.readdir(this.stateDir, { withFileTypes: true }).catch(() => []);
237
+ await Promise.all(entries
238
+ .filter((e) => e.isFile())
239
+ .map((e) => fsp.rm(path.join(this.stateDir, e.name), { force: true }).catch(() => { })));
240
+ }
241
+ getStatus() {
242
+ return {
243
+ status: this.status,
244
+ qr: this.qrDataUri,
245
+ pairingCode: this.pairingCode,
246
+ phoneNumber: this.phoneNumber,
247
+ loggedOut: this.loggedOut,
248
+ };
249
+ }
250
+ /** Resume a previously-linked session on gateway boot — no-op if never linked. */
251
+ async resumeIfLinked() {
252
+ if (!fs.existsSync(path.join(this.stateDir, 'creds.json')))
253
+ return;
254
+ this.logger.info('Resuming previously-linked WhatsApp session', {
255
+ agentId: this.agentConfig.id,
256
+ accountId: this.accountId,
257
+ });
258
+ await this.connect();
259
+ }
260
+ /** Start a fresh QR-code linking flow. */
261
+ async startLinking() {
262
+ this.pairingCode = undefined;
263
+ await this.connect();
264
+ }
265
+ /**
266
+ * Request a pairing code instead of QR. Per Baileys' contract this must be
267
+ * called once the socket is up but before it's registered — connect()
268
+ * always opens the socket first; if the caller wants a pairing code, pass
269
+ * the phone number and it's requested right after the socket is ready.
270
+ */
271
+ async requestPairingCode(phoneNumber) {
272
+ this.qrDataUri = undefined;
273
+ await this.connect(phoneNumber);
274
+ if (!this.pairingCode)
275
+ throw new Error('Failed to obtain a pairing code');
276
+ return this.pairingCode;
277
+ }
278
+ /**
279
+ * A concurrent second call (double-click "Link", a pairing-code retry, or
280
+ * .../link then .../pairing-code back to back — none of this is guarded at
281
+ * the route layer) must not leave two live sockets both wired to
282
+ * this.sock/this.status/this.phoneNumber. Every call bumps
283
+ * connectGeneration and captures it as `myGeneration`; any socket from an
284
+ * earlier generation is torn down (here, or by the generation check inside
285
+ * its own event handlers below) rather than left running alongside the new
286
+ * one.
287
+ */
288
+ async connect(pairingPhoneNumber) {
289
+ this.stopping = false;
290
+ const myGeneration = ++this.connectGeneration;
291
+ if (this.sock) {
292
+ try {
293
+ this.sock.end(undefined);
294
+ }
295
+ catch { /* already closing */ }
296
+ this.sock = null;
297
+ }
298
+ const baileys = this.baileys ?? (this.baileys = await loadBaileys());
299
+ // 0o700: this directory holds creds.json — a live linked-session
300
+ // credential equivalent to a password. Matches the 0o700 convention used
301
+ // for every other secret-bearing directory in this codebase (see
302
+ // config/bootstrap.ts, connectors/token-env.ts, session/process.ts).
303
+ fs.mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
304
+ const { state, saveCreds } = await baileys.useMultiFileAuthState(this.stateDir);
305
+ const sock = baileys.default({
306
+ auth: state,
307
+ browser: baileys.Browsers.ubuntu('Claude Gateway'),
308
+ logger: this.pinoLogger,
309
+ });
310
+ if (myGeneration !== this.connectGeneration) {
311
+ // Superseded again while awaiting the auth-state load above.
312
+ try {
313
+ sock.end(undefined);
314
+ }
315
+ catch { /* already closing */ }
316
+ return;
317
+ }
318
+ this.sock = sock;
319
+ this.status = this.status === 'linked' ? 'reconnecting' : 'pending_scan';
320
+ sock.ev.on('creds.update', saveCreds);
321
+ sock.ev.on('connection.update', (update) => {
322
+ if (myGeneration !== this.connectGeneration)
323
+ return;
324
+ void this.handleConnectionUpdate(update);
325
+ });
326
+ sock.ev.on('messages.upsert', (payload) => {
327
+ if (myGeneration !== this.connectGeneration)
328
+ return;
329
+ void this.handleMessagesUpsert(payload);
330
+ });
331
+ if (pairingPhoneNumber && !state.creds.registered) {
332
+ try {
333
+ // sock.ws only finishes its handshake asynchronously after
334
+ // baileys.default() returns — requestPairingCode sends over that raw
335
+ // socket immediately, so calling it before ws.isOpen throws
336
+ // "Connection Closed" nearly every time (the comment above used to
337
+ // assume "the socket is up" meant "the connection is open"; it
338
+ // doesn't — those are two different moments).
339
+ await this.waitForSocketOpen(sock);
340
+ if (myGeneration !== this.connectGeneration) {
341
+ throw new Error('WhatsApp connection superseded by a newer connect() call');
342
+ }
343
+ this.pairingCode = await sock.requestPairingCode(pairingPhoneNumber);
344
+ }
345
+ catch (err) {
346
+ this.logger.error('requestPairingCode failed', { error: err.message });
347
+ // The socket never finished opening (or got superseded mid-wait) —
348
+ // drop it rather than leaving an abandoned, never-linked socket
349
+ // wired to this.sock (waitForSocketOpen's timeout has no Baileys
350
+ // 'close' event to clean it up on its own).
351
+ if (myGeneration === this.connectGeneration) {
352
+ try {
353
+ this.sock?.end(undefined);
354
+ }
355
+ catch { /* already closing */ }
356
+ this.sock = null;
357
+ }
358
+ throw err;
359
+ }
360
+ }
361
+ }
362
+ /**
363
+ * Poll `sock.ws.isOpen` until the underlying WebSocket handshake completes
364
+ * (or `timeoutMs` elapses). There's no Baileys-emitted event for "ws is
365
+ * open but not yet authenticated" to await instead — `connection.update`
366
+ * only fires once the higher-level handshake is further along, which is
367
+ * later than requestPairingCode needs.
368
+ */
369
+ async waitForSocketOpen(sock, timeoutMs = 10000) {
370
+ const start = Date.now();
371
+ while (!sock.ws?.isOpen) {
372
+ if (this.stopping)
373
+ throw new Error('WhatsApp connection stopped before it opened');
374
+ if (Date.now() - start > timeoutMs) {
375
+ throw new Error('WhatsApp socket did not open in time for the pairing-code request');
376
+ }
377
+ await new Promise((resolve) => setTimeout(resolve, 100));
378
+ }
379
+ }
380
+ async handleConnectionUpdate(update) {
381
+ if (update.qr && !this.pairingCode) {
382
+ try {
383
+ this.qrDataUri = await QRCode.toDataURL(update.qr);
384
+ }
385
+ catch (err) {
386
+ this.logger.error('Failed to render QR', { error: err.message });
387
+ }
388
+ }
389
+ if (update.connection === 'open') {
390
+ this.status = 'linked';
391
+ this.qrDataUri = undefined;
392
+ this.pairingCode = undefined;
393
+ this.loggedOut = false;
394
+ this.restartCount = 0;
395
+ this.phoneNumber = this.sock?.user?.id?.split(':')[0]?.split('@')[0];
396
+ this.logger.info('WhatsApp linked', { agentId: this.agentConfig.id, phoneNumber: this.phoneNumber });
397
+ }
398
+ if (update.connection === 'close') {
399
+ const statusCode = update.lastDisconnect?.error?.output?.statusCode;
400
+ this.sock = null;
401
+ if (statusCode === this.baileys.DisconnectReason.loggedOut) {
402
+ this.loggedOut = true;
403
+ this.status = 'unlinked';
404
+ this.logger.warn('WhatsApp session logged out — re-linking required', {
405
+ agentId: this.agentConfig.id,
406
+ });
407
+ // The old session is dead; wipe it so a fresh link doesn't try to
408
+ // resume invalid creds.
409
+ await this.wipeStateDir();
410
+ return;
411
+ }
412
+ this.status = 'reconnecting';
413
+ if (!this.stopping)
414
+ this.scheduleReconnect();
415
+ }
416
+ }
417
+ scheduleReconnect() {
418
+ const slowPhase = this.restartCount >= MAX_RESTARTS;
419
+ const delay = slowPhase ? SLOW_RESTART_DELAY_MS : AUTO_RESTART_DELAY_MS;
420
+ if (!slowPhase)
421
+ this.restartCount++;
422
+ this.logger.warn(`Reconnecting WhatsApp in ${delay}ms`, {
423
+ agentId: this.agentConfig.id,
424
+ attempt: this.restartCount,
425
+ slowPhase,
426
+ });
427
+ this.restartTimer = setTimeout(() => {
428
+ this.restartTimer = null;
429
+ if (!this.stopping)
430
+ void this.connect();
431
+ }, delay);
432
+ }
433
+ async handleMessagesUpsert(payload) {
434
+ // Only live/new messages, not history-sync backfill on (re)connect.
435
+ if (payload.type !== 'notify')
436
+ return;
437
+ for (const msg of payload.messages) {
438
+ if (msg.key?.fromMe)
439
+ continue; // bot-loop protection
440
+ const resolved = (0, whatsapp_access_1.resolveWhatsAppSource)(msg);
441
+ if (resolved.kind === 'other' || !resolved.conversationId)
442
+ continue;
443
+ const cfg = this.accountConfig();
444
+ const deniedAgentId = this.agentConfig.id;
445
+ if (!(0, whatsapp_access_1.isResolvedSourceAllowed)(cfg, resolved)) {
446
+ this.logger.debug('WhatsApp message denied', {
447
+ agentId: deniedAgentId,
448
+ accountId: this.accountId,
449
+ kind: resolved.kind,
450
+ conversationId: resolved.conversationId,
451
+ });
452
+ const knockId = resolved.kind === 'user' ? resolved.senderId : resolved.conversationId;
453
+ const isGroup = resolved.kind === 'group';
454
+ const sourcePolicy = isGroup ? cfg?.groupPolicy : cfg?.dmPolicy;
455
+ const isPairing = cfg?.pairing !== false && sourcePolicy !== 'open' && sourcePolicy !== 'disabled';
456
+ // Namespaced per account, not just 'whatsapp' — the same JID can
457
+ // legitimately knock on two different numbers linked to this agent,
458
+ // and without the account dimension a knock on one number would
459
+ // show up (and be approvable) against the other's allowlist too.
460
+ const pendingChannel = `whatsapp:${this.accountId}`;
461
+ const prev = (0, pending_senders_1.getPendingSender)(pendingChannel, deniedAgentId, knockId);
462
+ const code = prev?.code ?? (isPairing ? (0, pending_senders_1.generatePairingCode)() : undefined);
463
+ const wasNew = isGroup
464
+ ? (0, pending_senders_1.recordDeniedConversation)(pendingChannel, deniedAgentId, knockId, 'group', undefined, Date.now(), code)
465
+ : (0, pending_senders_1.recordDeniedSender)(pendingChannel, deniedAgentId, knockId, undefined, Date.now(), code);
466
+ if (isPairing && wasNew && code) {
467
+ void this.sock?.sendMessage(resolved.conversationId, { text: pairingMessage(code, isGroup) })
468
+ .catch((err) => this.logger.debug('WhatsApp pairing code send failed', { error: err.message }));
469
+ }
470
+ continue;
471
+ }
472
+ // Group mention gate — mirrors Slack/LINE's requireMention (default true, no effect on DMs).
473
+ if (resolved.kind === 'group' && cfg?.requireMention !== false) {
474
+ const botJid = this.sock?.user?.id;
475
+ // `sock.user.lid` is the bot's own @lid identity under WhatsApp's Linked
476
+ // ID privacy system — groups can report the mention using either form.
477
+ const botLid = this.sock?.user?.lid;
478
+ if (!(0, whatsapp_access_1.wasBotMentioned)(resolved.mentionedJids, botJid, botLid))
479
+ continue;
480
+ }
481
+ // Phase 2 — remember the raw message before anything else touches it: a
482
+ // later quote-reply needs the whole WAMessage and a reaction needs its
483
+ // full key, but the MCP tool only ever hands back the id string.
484
+ if (msg.key?.id)
485
+ this.rememberMessage(msg.key.id, msg);
486
+ // Receipt signals, both best-effort and both config-gated. Fired right
487
+ // after the access gate (same call site as Slack's ack reaction in
488
+ // slack-webhook-router.ts), never awaited, never able to fail the turn.
489
+ // The ack reaction is cleared once the reply actually sends — see
490
+ // sendMessage's clearAckReaction call.
491
+ if (msg.key) {
492
+ if (cfg?.sendReadReceipts !== false) {
493
+ void this.markAsRead(resolved.conversationId, msg.key).catch(() => { });
494
+ }
495
+ if ((cfg?.reactionLevel ?? 'ack') === 'ack') {
496
+ void this.sendReaction(resolved.conversationId, msg.key, whatsapp_ack_1.WHATSAPP_ACK_EMOJI).catch(() => { });
497
+ }
498
+ }
499
+ let content = msg.message?.conversation ?? msg.message?.extendedTextMessage?.text ?? '';
500
+ const meta = {
501
+ source: 'whatsapp',
502
+ chat_id: resolved.conversationId,
503
+ user_id: resolved.senderId,
504
+ user: resolved.senderId,
505
+ message_id: msg.key?.id ?? '',
506
+ whatsapp_chat_type: resolved.kind,
507
+ // Which linked number this arrived on. Round-trips through the
508
+ // <channel> tag (AgentRunner.buildChannelXml's optionalAttrs) so the
509
+ // session can hand it straight back to whatsapp_reply, and is also
510
+ // stashed runner-side per chat so an auto-forward reply without an
511
+ // explicit account_id still leaves on the same number.
512
+ account_id: this.accountId,
513
+ };
514
+ // Reply context (Phase 2). Unlike the Cloud API — which only reports the
515
+ // quoted message's ID and nothing else — Baileys inlines the whole
516
+ // quoted message, so all three `replied_*` keys buildChannelXml reads
517
+ // can be populated here.
518
+ const quotedCtx = extractQuotedContext(msg.message);
519
+ if (quotedCtx?.stanzaId) {
520
+ meta.replied_message_id = quotedCtx.stanzaId;
521
+ if (quotedCtx.participant)
522
+ meta.replied_user = quotedCtx.participant;
523
+ const repliedText = quotedMessageText(quotedCtx.quotedMessage);
524
+ if (repliedText)
525
+ meta.replied_text = repliedText;
526
+ }
527
+ // Location — same meta keys the Cloud channel writes (location_lat /
528
+ // location_lng), so runner.ts needs no channel-specific attribute names.
529
+ const locationMsg = msg.message?.locationMessage;
530
+ if (locationMsg) {
531
+ if (typeof locationMsg.degreesLatitude === 'number') {
532
+ meta.location_lat = String(locationMsg.degreesLatitude);
533
+ }
534
+ if (typeof locationMsg.degreesLongitude === 'number') {
535
+ meta.location_lng = String(locationMsg.degreesLongitude);
536
+ }
537
+ if (!content) {
538
+ content = [locationMsg.name, locationMsg.address].filter(Boolean).join(', ') || '[Location shared]';
539
+ }
540
+ }
541
+ // Contact card. Baileys hands over the RAW vCard string the sender's
542
+ // phone produced, so it's used verbatim — the Cloud channel has to
543
+ // synthesize one instead (its payload is structured JSON, not a vCard).
544
+ const vcard = msg.message?.contactMessage?.vcard ??
545
+ msg.message?.contactsArrayMessage?.contacts?.find((c) => c.vcard)?.vcard;
546
+ if (vcard)
547
+ meta.vcard = vcard;
548
+ // Sticker — downloaded like an image but onto its own meta key, so the
549
+ // agent can tell a sticker apart from a photo.
550
+ if (msg.message?.stickerMessage) {
551
+ try {
552
+ const buf = (await this.baileys.downloadMediaMessage(msg, 'buffer', {}));
553
+ if (buf.length > 0 && buf.length <= MAX_IMAGE_BYTES) {
554
+ const dest = path.join(os.tmpdir(), `whatsapp-sticker-${(0, image_sniff_1.sanitizeFilenameId)(msg.key?.id)}.${(0, image_sniff_1.sniffImageExt)(buf)}`);
555
+ fs.writeFileSync(dest, buf, { mode: 0o600 });
556
+ meta.sticker_path = dest;
557
+ }
558
+ else if (buf.length > MAX_IMAGE_BYTES) {
559
+ this.logger.warn('Inbound WhatsApp sticker exceeds cap, dropping media', {
560
+ agentId: deniedAgentId,
561
+ bytes: buf.length,
562
+ });
563
+ }
564
+ }
565
+ catch (err) {
566
+ this.logger.warn('Inbound WhatsApp sticker download failed', {
567
+ agentId: deniedAgentId,
568
+ error: err.message,
569
+ });
570
+ }
571
+ }
572
+ // Inbound image — best-effort, same posture as Slack's downloadSlackImage:
573
+ // a failed download still forwards the text turn.
574
+ if (msg.message?.imageMessage) {
575
+ try {
576
+ const buf = (await this.baileys.downloadMediaMessage(msg, 'buffer', {}));
577
+ if (buf.length > 0 && buf.length <= MAX_IMAGE_BYTES) {
578
+ const dest = path.join(os.tmpdir(), `whatsapp-img-${(0, image_sniff_1.sanitizeFilenameId)(msg.key?.id)}.${(0, image_sniff_1.sniffImageExt)(buf)}`);
579
+ fs.writeFileSync(dest, buf, { mode: 0o600 });
580
+ meta.image_path = dest;
581
+ }
582
+ else if (buf.length > MAX_IMAGE_BYTES) {
583
+ this.logger.warn('Inbound WhatsApp image exceeds cap, dropping media', {
584
+ agentId: deniedAgentId,
585
+ bytes: buf.length,
586
+ });
587
+ }
588
+ }
589
+ catch (err) {
590
+ this.logger.warn('Inbound WhatsApp image download failed', {
591
+ agentId: deniedAgentId,
592
+ error: err.message,
593
+ });
594
+ }
595
+ }
596
+ try {
597
+ await fetch(`http://127.0.0.1:${this.callbackPort}/channel`, {
598
+ method: 'POST',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify({ content, meta }),
601
+ });
602
+ }
603
+ catch (err) {
604
+ this.logger.error('WhatsApp: failed to forward to callback', {
605
+ agentId: deniedAgentId,
606
+ error: err.message,
607
+ });
608
+ }
609
+ }
610
+ }
611
+ /** Keep the last RECENT_MESSAGE_CACHE_SIZE inbound messages addressable by id (FIFO). */
612
+ rememberMessage(id, msg) {
613
+ this.recentMessages.delete(id); // re-insert so this id becomes the newest
614
+ this.recentMessages.set(id, msg);
615
+ while (this.recentMessages.size > RECENT_MESSAGE_CACHE_SIZE) {
616
+ const oldest = this.recentMessages.keys().next().value;
617
+ if (oldest === undefined)
618
+ break;
619
+ this.recentMessages.delete(oldest);
620
+ }
621
+ }
622
+ /**
623
+ * Mark an inbound message as read (blue double-ticks). Best-effort: logged,
624
+ * never thrown — same posture as the Cloud client's markAsRead and Slack's
625
+ * ack reaction. `jid` is only used for log context; Baileys addresses the
626
+ * read by key alone.
627
+ */
628
+ async markAsRead(jid, key) {
629
+ if (!this.sock || !key)
630
+ return;
631
+ try {
632
+ await this.sock.readMessages([key]);
633
+ }
634
+ catch (err) {
635
+ this.logger.debug('WhatsApp markAsRead failed', { jid, error: err.message });
636
+ }
637
+ }
638
+ /**
639
+ * React to a message — an EMPTY `emoji` clears an existing reaction, which
640
+ * is how the ack is removed (same convention as the Cloud API). Best-effort,
641
+ * same as markAsRead.
642
+ */
643
+ async sendReaction(jid, key, emoji) {
644
+ if (!this.sock || !key)
645
+ return;
646
+ try {
647
+ await this.sock.sendMessage(jid, { react: { text: emoji, key } });
648
+ }
649
+ catch (err) {
650
+ this.logger.debug('WhatsApp sendReaction failed', { jid, error: err.message });
651
+ }
652
+ }
653
+ /**
654
+ * Clear the ⏳ ack left on an inbound message at receipt.
655
+ *
656
+ * Unlike Slack (whose MCP tool holds a real API client and calls
657
+ * `removeReaction` itself) the `whatsapp_reply` tool has no socket of its
658
+ * own — every send is proxied through the gateway (see this class's doc
659
+ * comment). So the tool passes the inbound `message_id` along with the
660
+ * reply and the clear happens HERE, right after the send succeeds; the
661
+ * call site is the same logical one, just on the other side of the bridge.
662
+ */
663
+ async clearAckReaction(jid, messageId) {
664
+ if ((this.accountConfig()?.reactionLevel ?? 'ack') !== 'ack')
665
+ return;
666
+ const key = this.recentMessages.get(messageId)?.key;
667
+ if (!key)
668
+ return;
669
+ await this.sendReaction(jid, key, '');
670
+ }
671
+ /**
672
+ * Native @mentions for an outbound GROUP reply.
673
+ *
674
+ * WhatsApp only renders "@66812345678" as a real, notifying mention when the
675
+ * matching JID is also listed in the message's `mentions` array — the text
676
+ * alone is inert. So: scan the reply for `@<digits>` tokens, look up the
677
+ * group's CURRENT participants, and return the JIDs that match. Matching
678
+ * reuses the same normalization the inbound mention gate uses
679
+ * (`whatsAppJidUser`, which strips the `:<device>` suffix) rather than a
680
+ * second, subtly-different comparison. Participants are also matched on
681
+ * their phone-number field, since a LID-privacy group reports `id` as
682
+ * `<opaque>@lid` while the human still types the phone number.
683
+ *
684
+ * Best-effort: a failed groupMetadata lookup just sends the reply without
685
+ * mentions rather than failing it.
686
+ */
687
+ async resolveMentions(jid, text) {
688
+ if (!jid.endsWith('@g.us') || !text)
689
+ return undefined;
690
+ const wanted = new Set([...text.matchAll(/@(\d{5,})/g)].map((m) => m[1]));
691
+ if (wanted.size === 0)
692
+ return undefined;
693
+ try {
694
+ const metadata = await this.sock?.groupMetadata(jid);
695
+ const mentions = [];
696
+ for (const participant of metadata?.participants ?? []) {
697
+ const p = participant;
698
+ if (!p.id)
699
+ continue;
700
+ const identities = [p.id, p.jid, p.phoneNumber].filter((v) => !!v);
701
+ if (identities.some((i) => wanted.has((0, whatsapp_access_1.whatsAppJidUser)(i))))
702
+ mentions.push(p.id);
703
+ }
704
+ return mentions.length > 0 ? mentions : undefined;
705
+ }
706
+ catch (err) {
707
+ this.logger.debug('WhatsApp groupMetadata lookup for mentions failed', {
708
+ jid,
709
+ error: err.message,
710
+ });
711
+ return undefined;
712
+ }
713
+ }
714
+ /**
715
+ * Send a text (+ optional image) reply on the live socket. Throws if not
716
+ * currently linked.
717
+ *
718
+ * Text over MAX_TEXT_CHARS is split into several messages (Phase 2); with
719
+ * an image attached the first chunk rides as the caption and the rest
720
+ * follow as plain messages. A quote (`quotedMessageId`) and the ack-clear
721
+ * (`ackMessageId`) both resolve through the recent-message cache, so a
722
+ * message older than that cache simply sends unquoted rather than failing.
723
+ */
724
+ async sendMessage(jid, text, imagePath, opts = {}) {
725
+ if (!this.sock || this.status !== 'linked') {
726
+ throw new Error('WhatsApp is not linked');
727
+ }
728
+ const sock = this.sock;
729
+ const quoted = opts.quotedMessageId ? this.recentMessages.get(opts.quotedMessageId) : undefined;
730
+ // Only the FIRST message of a multi-chunk reply quotes the original —
731
+ // repeating the quote block on every chunk is visual noise.
732
+ const firstOpts = quoted ? { quoted } : undefined;
733
+ const mentions = await this.resolveMentions(jid, text);
734
+ const mentionField = mentions ? { mentions } : {};
735
+ // Baileys' third argument is optional — pass it ONLY when there is
736
+ // actually something to say, so an ordinary unquoted send keeps the exact
737
+ // two-argument call shape it had before quoting existed.
738
+ const send = (content, options) => options
739
+ ? sock.sendMessage(jid, content, options)
740
+ : sock.sendMessage(jid, content);
741
+ // An empty text with no image keeps the historical single-send behaviour
742
+ // (chunkText returns [] for '') rather than silently sending nothing.
743
+ const chunks = (0, text_chunk_1.chunkText)(text, MAX_TEXT_CHARS);
744
+ const parts = chunks.length > 0 ? chunks : imagePath ? [] : [text];
745
+ if (imagePath) {
746
+ const stat = await fsp.stat(imagePath).catch(() => null);
747
+ if (!stat)
748
+ throw new Error(`image not found: ${imagePath}`);
749
+ // Auto-optimize an over-cap PHOTO instead of refusing the whole reply.
750
+ // Deliberately skipped when asDocument is set: that mode exists to
751
+ // deliver the exact bytes (see the forceDocument note below), so
752
+ // recompressing there would defeat the only reason to choose it — an
753
+ // over-cap document still throws, exactly as before.
754
+ //
755
+ // The throw below is kept as a backstop rather than removed: optimizeImage
756
+ // is best-effort and can hand back something still over the cap (a
757
+ // non-image file, sharp unavailable), and silently handing WhatsApp bytes
758
+ // it will reject is worse than a readable error the agent can act on.
759
+ let sendPath = imagePath;
760
+ let sendBytes = stat.size;
761
+ if (sendBytes > MAX_IMAGE_BYTES && !opts.asDocument) {
762
+ sendPath = await (0, image_optimize_1.optimizeImageFile)(imagePath, MAX_IMAGE_BYTES).catch(() => imagePath);
763
+ if (sendPath !== imagePath) {
764
+ sendBytes = (await fsp.stat(sendPath).catch(() => null))?.size ?? sendBytes;
765
+ this.logger.info('Outbound WhatsApp image optimized to fit the size cap', {
766
+ agentId: this.agentConfig.id,
767
+ originalBytes: stat.size,
768
+ optimizedBytes: sendBytes,
769
+ });
770
+ }
771
+ }
772
+ if (sendBytes > MAX_IMAGE_BYTES) {
773
+ throw new Error(`image exceeds ${MAX_IMAGE_BYTES} byte cap`);
774
+ }
775
+ const caption = parts[0] || undefined;
776
+ // forceDocument: WhatsApp re-compresses anything sent as `image`, which
777
+ // destroys fine detail in screenshots/diagrams the agent generated. Sent
778
+ // as a `document` the exact bytes arrive intact.
779
+ const content = opts.asDocument
780
+ ? {
781
+ document: { url: imagePath },
782
+ mimetype: guessOutboundMimeType(imagePath),
783
+ fileName: path.basename(imagePath),
784
+ caption,
785
+ ...mentionField,
786
+ }
787
+ : { image: { url: sendPath }, caption, ...mentionField };
788
+ await send(content, firstOpts);
789
+ for (const chunk of parts.slice(1)) {
790
+ await send({ text: chunk, ...mentionField });
791
+ }
792
+ }
793
+ else {
794
+ for (let i = 0; i < parts.length; i++) {
795
+ await send({ text: parts[i], ...mentionField }, i === 0 ? firstOpts : undefined);
796
+ }
797
+ }
798
+ if (opts.ackMessageId) {
799
+ await this.clearAckReaction(jid, opts.ackMessageId).catch(() => { });
800
+ }
801
+ }
802
+ isLinked() {
803
+ return this.status === 'linked';
804
+ }
805
+ /** Logout and wipe the linked session — the user must scan/pair fresh afterward. */
806
+ async unlink() {
807
+ this.stopping = true;
808
+ if (this.restartTimer) {
809
+ clearTimeout(this.restartTimer);
810
+ this.restartTimer = null;
811
+ }
812
+ try {
813
+ await this.sock?.logout();
814
+ }
815
+ catch {
816
+ // best-effort — proceed to wipe state regardless
817
+ }
818
+ this.sock = null;
819
+ this.status = 'unlinked';
820
+ this.qrDataUri = undefined;
821
+ this.pairingCode = undefined;
822
+ this.phoneNumber = undefined;
823
+ this.loggedOut = false;
824
+ await this.wipeStateDir();
825
+ }
826
+ /** Tear down without wiping state (gateway shutdown — a reconnect on next boot should resume). */
827
+ stop() {
828
+ this.stopping = true;
829
+ if (this.restartTimer) {
830
+ clearTimeout(this.restartTimer);
831
+ this.restartTimer = null;
832
+ }
833
+ this.sock?.end(undefined);
834
+ this.sock = null;
835
+ }
836
+ }
837
+ exports.WhatsAppManager = WhatsAppManager;
838
+ //# sourceMappingURL=manager.js.map