@0xmaxma/claude-gateway 1.8.9 → 1.8.11

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 (121) hide show
  1. package/README.md +46 -6
  2. package/config.template.json +2 -1
  3. package/dist/agent/runner.d.ts +86 -1
  4. package/dist/agent/runner.d.ts.map +1 -1
  5. package/dist/agent/runner.js +396 -9
  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 +1028 -6
  9. package/dist/api/router.js.map +1 -1
  10. package/dist/api/share-router.d.ts.map +1 -1
  11. package/dist/api/share-router.js +24 -0
  12. package/dist/api/share-router.js.map +1 -1
  13. package/dist/api/webhooks-router.d.ts +2 -0
  14. package/dist/api/webhooks-router.d.ts.map +1 -1
  15. package/dist/api/webhooks-router.js +2 -0
  16. package/dist/api/webhooks-router.js.map +1 -1
  17. package/dist/api/whatsapp-access.d.ts +123 -0
  18. package/dist/api/whatsapp-access.d.ts.map +1 -0
  19. package/dist/api/whatsapp-access.js +135 -0
  20. package/dist/api/whatsapp-access.js.map +1 -0
  21. package/dist/api/whatsapp-cloud-access.d.ts +48 -0
  22. package/dist/api/whatsapp-cloud-access.d.ts.map +1 -0
  23. package/dist/api/whatsapp-cloud-access.js +58 -0
  24. package/dist/api/whatsapp-cloud-access.js.map +1 -0
  25. package/dist/api/whatsapp-cloud-client.d.ts +143 -0
  26. package/dist/api/whatsapp-cloud-client.d.ts.map +1 -0
  27. package/dist/api/whatsapp-cloud-client.js +298 -0
  28. package/dist/api/whatsapp-cloud-client.js.map +1 -0
  29. package/dist/api/whatsapp-cloud-webhook-router.d.ts +132 -0
  30. package/dist/api/whatsapp-cloud-webhook-router.d.ts.map +1 -0
  31. package/dist/api/whatsapp-cloud-webhook-router.js +494 -0
  32. package/dist/api/whatsapp-cloud-webhook-router.js.map +1 -0
  33. package/dist/cli/args.d.ts +34 -0
  34. package/dist/cli/args.d.ts.map +1 -1
  35. package/dist/cli/args.js +55 -0
  36. package/dist/cli/args.js.map +1 -1
  37. package/dist/cli/commands/app.d.ts +28 -0
  38. package/dist/cli/commands/app.d.ts.map +1 -0
  39. package/dist/cli/commands/app.js +411 -0
  40. package/dist/cli/commands/app.js.map +1 -0
  41. package/dist/cli/commands/doctor.d.ts.map +1 -1
  42. package/dist/cli/commands/doctor.js +77 -16
  43. package/dist/cli/commands/doctor.js.map +1 -1
  44. package/dist/cli/commands/service.d.ts.map +1 -1
  45. package/dist/cli/commands/service.js +373 -56
  46. package/dist/cli/commands/service.js.map +1 -1
  47. package/dist/cli/commands/update.d.ts.map +1 -1
  48. package/dist/cli/commands/update.js +1 -17
  49. package/dist/cli/commands/update.js.map +1 -1
  50. package/dist/cli/http-client.d.ts +8 -0
  51. package/dist/cli/http-client.d.ts.map +1 -1
  52. package/dist/cli/http-client.js +5 -2
  53. package/dist/cli/http-client.js.map +1 -1
  54. package/dist/cli/index.d.ts.map +1 -1
  55. package/dist/cli/index.js +16 -4
  56. package/dist/cli/index.js.map +1 -1
  57. package/dist/cli/prompt.d.ts +17 -2
  58. package/dist/cli/prompt.d.ts.map +1 -1
  59. package/dist/cli/prompt.js +33 -2
  60. package/dist/cli/prompt.js.map +1 -1
  61. package/dist/config/loader.d.ts +8 -0
  62. package/dist/config/loader.d.ts.map +1 -1
  63. package/dist/config/loader.js +22 -4
  64. package/dist/config/loader.js.map +1 -1
  65. package/dist/config/watcher.d.ts.map +1 -1
  66. package/dist/config/watcher.js +12 -0
  67. package/dist/config/watcher.js.map +1 -1
  68. package/dist/config/whatsapp-accounts.d.ts +77 -0
  69. package/dist/config/whatsapp-accounts.d.ts.map +1 -0
  70. package/dist/config/whatsapp-accounts.js +218 -0
  71. package/dist/config/whatsapp-accounts.js.map +1 -0
  72. package/dist/history/db.d.ts.map +1 -1
  73. package/dist/history/db.js +43 -11
  74. package/dist/history/db.js.map +1 -1
  75. package/dist/history/types.d.ts +18 -1
  76. package/dist/history/types.d.ts.map +1 -1
  77. package/dist/history/types.js +1 -1
  78. package/dist/history/types.js.map +1 -1
  79. package/dist/index.js +26 -0
  80. package/dist/index.js.map +1 -1
  81. package/dist/session/process.d.ts.map +1 -1
  82. package/dist/session/process.js +37 -0
  83. package/dist/session/process.js.map +1 -1
  84. package/dist/session/store.d.ts +3 -2
  85. package/dist/session/store.d.ts.map +1 -1
  86. package/dist/session/store.js +1 -1
  87. package/dist/session/store.js.map +1 -1
  88. package/dist/share/session-video-catalog.d.ts +19 -0
  89. package/dist/share/session-video-catalog.d.ts.map +1 -0
  90. package/dist/share/session-video-catalog.js +119 -0
  91. package/dist/share/session-video-catalog.js.map +1 -0
  92. package/dist/shared/image-optimize.d.ts +32 -0
  93. package/dist/shared/image-optimize.d.ts.map +1 -0
  94. package/dist/shared/image-optimize.js +197 -0
  95. package/dist/shared/image-optimize.js.map +1 -0
  96. package/dist/shared/image-sniff.d.ts +9 -0
  97. package/dist/shared/image-sniff.d.ts.map +1 -1
  98. package/dist/shared/image-sniff.js +13 -0
  99. package/dist/shared/image-sniff.js.map +1 -1
  100. package/dist/shared/text-chunk.d.ts +37 -0
  101. package/dist/shared/text-chunk.d.ts.map +1 -0
  102. package/dist/shared/text-chunk.js +105 -0
  103. package/dist/shared/text-chunk.js.map +1 -0
  104. package/dist/shared/whatsapp-ack.d.ts +13 -0
  105. package/dist/shared/whatsapp-ack.d.ts.map +1 -0
  106. package/dist/shared/whatsapp-ack.js +16 -0
  107. package/dist/shared/whatsapp-ack.js.map +1 -0
  108. package/dist/types.d.ts +183 -0
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/whatsapp/manager.d.ts +173 -0
  111. package/dist/whatsapp/manager.d.ts.map +1 -0
  112. package/dist/whatsapp/manager.js +838 -0
  113. package/dist/whatsapp/manager.js.map +1 -0
  114. package/mcp/server.ts +4 -0
  115. package/mcp/tools/image/module.ts +6 -1
  116. package/mcp/tools/telegram/receiver-server.ts +15 -8
  117. package/mcp/tools/telegram/reply-attachment.ts +81 -0
  118. package/mcp/tools/video/module.ts +6 -1
  119. package/mcp/tools/whatsapp/module.ts +162 -0
  120. package/mcp/tools/whatsapp-cloud/module.ts +536 -0
  121. package/package.json +7 -2
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AgentRunner = exports.CHANNEL_COALESCE_WINDOW_MS = exports.DEFAULT_MODELS = exports.MAX_IMAGE_SIZE_BYTES = void 0;
36
+ exports.AgentRunner = exports.CHANNEL_COALESCE_WINDOW_MS = exports.WHATSAPP_CLOUD_WINDOW_NOTE = exports.DEFAULT_MODELS = exports.MAX_IMAGE_SIZE_BYTES = void 0;
37
37
  exports.toRelMediaFiles = toRelMediaFiles;
38
38
  const crypto_1 = require("crypto");
39
39
  const events_1 = require("events");
@@ -51,6 +51,9 @@ const receiver_1 = require("../telegram/receiver");
51
51
  const receiver_2 = require("../discord/receiver");
52
52
  const line_reply_manager_1 = require("./line-reply-manager");
53
53
  const slack_client_1 = require("../api/slack-client");
54
+ const whatsapp_cloud_client_1 = require("../api/whatsapp-cloud-client");
55
+ const manager_1 = require("../whatsapp/manager");
56
+ const whatsapp_accounts_1 = require("../config/whatsapp-accounts");
54
57
  const markdown_1 = require("../telegram/markdown");
55
58
  const skills_1 = require("../skills");
56
59
  const builtin_commands_1 = require("./builtin-commands");
@@ -125,6 +128,15 @@ const PROTECTED_WORKSPACE_FILES = [
125
128
  'AGENTS.md', 'SOUL.md', 'MEMORY.md', 'CLAUDE.md',
126
129
  'IDENTITY.md', 'USER.md', 'HEARTBEAT.md',
127
130
  ];
131
+ /**
132
+ * One-line reminder injected into every `whatsapp_cloud` turn's <channel>
133
+ * block (see buildChannelXml) — the 24h customer-service window is a
134
+ * WhatsApp-Business-only rule the agent cannot infer from the turn itself.
135
+ *
136
+ * Must stay free of `--` so it remains a well-formed XML comment.
137
+ */
138
+ exports.WHATSAPP_CLOUD_WINDOW_NOTE = '[WhatsApp Business: free-form replies only work within 24h of the user\'s last message. ' +
139
+ 'Outside that window, whatsapp_cloud_reply requires template_name + template_language (see templatesEnabled).]';
128
140
  const MAX_API_IMAGES = 5;
129
141
  /** Extra time an api turn may keep running AFTER its soft timeout already
130
142
  * answered the caller (#75). The soft timeout only abandons the WAIT — the
@@ -236,6 +248,29 @@ class AgentRunner extends events_1.EventEmitter {
236
248
  // it exists only so writeAutoForward's fallback/command-reply path (below) has
237
249
  // somewhere to actually deliver Slack messages instead of silently dropping them.
238
250
  this.slackOutbound = null;
251
+ // WhatsApp Cloud mirrors Slack exactly: webhook-based, real credentials, no
252
+ // reply-token TTL to work around — a plain client so writeAutoForward's
253
+ // fallback/command-reply path has somewhere to deliver messages instead of
254
+ // silently dropping them (see the Slack comment just above).
255
+ this.whatsAppCloudOutbound = null;
256
+ // One live Baileys socket per linked WhatsApp account, keyed by account id
257
+ // (Phase 1 of the WhatsApp feature-parity plan — this used to be a single
258
+ // `whatsapp: WhatsAppManager | null`). Populated in start() (needs
259
+ // this.callbackPort, only resolved once startCallbackServer() runs — same
260
+ // reason DiscordReceiver/TelegramReceiver are built there too) and then
261
+ // DIFFED, never rebuilt wholesale, by syncWhatsAppAccounts(): unlike
262
+ // SlackClient/DiscordReceiver there is no credential to re-read on a config
263
+ // change, since the credential IS the on-disk session (see
264
+ // WhatsAppAccountConfig's doc comment), so tearing a linked socket down on
265
+ // an unrelated config edit would be pure downtime. Each manager decides for
266
+ // itself whether there's anything to do on start (resumeIfLinked() no-ops
267
+ // if that account was never linked).
268
+ this.whatsappAccounts = new Map();
269
+ // chatId → the account id its last inbound message arrived on. Mirrors
270
+ // slackThreadTs above: it lets writeAutoForward (and a whatsapp_reply call
271
+ // that omits account_id) answer on the SAME number the user wrote to
272
+ // instead of guessing 'default' and replying from the wrong line.
273
+ this.whatsappAccountForChat = new Map();
239
274
  this.idleCleanerTimer = null;
240
275
  // Tracks session IDs with an in-flight API request (prevents concurrent turns)
241
276
  this.pendingApiSessions = new Set();
@@ -439,7 +474,11 @@ class AgentRunner extends events_1.EventEmitter {
439
474
  ? 'line'
440
475
  : meta['source'] === 'slack'
441
476
  ? 'slack'
442
- : 'telegram');
477
+ : meta['source'] === 'whatsapp'
478
+ ? 'whatsapp'
479
+ : meta['source'] === 'whatsapp_cloud'
480
+ ? 'whatsapp_cloud'
481
+ : 'telegram');
443
482
  this.channelSourceMap.set(chatId, channelSource);
444
483
  // Slack: remember the current message's thread context so the
445
484
  // auto-forward fallback (writeAutoForward) can stay in-thread. A
@@ -452,6 +491,14 @@ class AgentRunner extends events_1.EventEmitter {
452
491
  else
453
492
  this.slackThreadTs.delete(chatId);
454
493
  }
494
+ // WhatsApp: remember which linked number this chat last came in on,
495
+ // so a reply that doesn't name an account_id (the auto-forward
496
+ // fallback, or a whatsapp_reply call that omitted it) still goes out
497
+ // on the number the user actually wrote to. Same shape as the Slack
498
+ // thread-context stash just above.
499
+ if (channelSource === 'whatsapp' && meta['account_id']) {
500
+ this.whatsappAccountForChat.set(chatId, meta['account_id']);
501
+ }
455
502
  // LINE slow-LLM postback: stash this turn's reply token + arm the
456
503
  // button timer before the token expires. In groups/rooms the shared
457
504
  // button is disabled (armButton:false) — the token is still stashed so
@@ -1027,7 +1074,7 @@ class AgentRunner extends events_1.EventEmitter {
1027
1074
  for (const entry of entries) {
1028
1075
  const meta = entry.meta ?? {};
1029
1076
  const content = entry.content ?? '';
1030
- const userContent = content || (meta['attachment_file_id'] || meta['image_path'] ? '(photo)' : '');
1077
+ const userContent = content || (meta['attachment_file_id'] || meta['image_path'] ? '(photo)' : meta['document_path'] ? '(document)' : meta['sticker_path'] ? '(sticker)' : '');
1031
1078
  const userTs = Date.now();
1032
1079
  await this.sessionStore.appendTelegramMessage(this.agentConfig.id, chatId, sessionId, {
1033
1080
  role: 'user',
@@ -1058,6 +1105,33 @@ class AgentRunner extends events_1.EventEmitter {
1058
1105
  // Non-fatal — leave the original path so host agents still read it
1059
1106
  }
1060
1107
  }
1108
+ // WhatsApp Cloud documents (PDF today — see MediaStore.isAllowedMime):
1109
+ // same MediaStore copy + path-rewrite as image_path above, parallel
1110
+ // key so an inbound document doesn't collide with an inbound image
1111
+ // in the same turn.
1112
+ if (meta['document_path']) {
1113
+ try {
1114
+ const rel = media_store_1.MediaStore.copyToMedia(this.agentsBaseDir, this.agentConfig.id, `${channelSource}-${chatId}`, meta['document_path']);
1115
+ mediaFiles.push(rel);
1116
+ meta['document_path'] = media_store_1.MediaStore.resolvePath(this.agentsBaseDir, this.agentConfig.id, rel);
1117
+ }
1118
+ catch {
1119
+ // Non-fatal — leave the original path so host agents still read it
1120
+ }
1121
+ }
1122
+ // WhatsApp stickers (both channels — image/webp): same MediaStore
1123
+ // copy + path-rewrite as image_path above, on its own key so an
1124
+ // inbound sticker never masquerades as a photo.
1125
+ if (meta['sticker_path']) {
1126
+ try {
1127
+ const rel = media_store_1.MediaStore.copyToMedia(this.agentsBaseDir, this.agentConfig.id, `${channelSource}-${chatId}`, meta['sticker_path']);
1128
+ mediaFiles.push(rel);
1129
+ meta['sticker_path'] = media_store_1.MediaStore.resolvePath(this.agentsBaseDir, this.agentConfig.id, rel);
1130
+ }
1131
+ catch {
1132
+ // Non-fatal — leave the original path so host agents still read it
1133
+ }
1134
+ }
1061
1135
  this.historyDb.insertMessage({
1062
1136
  chatId: `${channelSource}-${chatId}`,
1063
1137
  sessionId,
@@ -1068,6 +1142,14 @@ class AgentRunner extends events_1.EventEmitter {
1068
1142
  senderId: meta['user_id'] ?? meta['chat_id'] ?? undefined,
1069
1143
  platformMessageId: meta['message_id'] ?? undefined,
1070
1144
  mediaFiles: mediaFiles.length > 0 ? mediaFiles : undefined,
1145
+ // Reply context (WhatsApp Phase 2) — the same meta keys buildChannelXml
1146
+ // renders into <replied> for the agent, persisted so the web dashboard
1147
+ // can show "in reply to X" instead of losing it after the turn.
1148
+ // whatsapp_cloud only ever sets replied_message_id (its webhook reports
1149
+ // the quoted id and nothing else), so the other two stay undefined there.
1150
+ repliedToMessageId: meta['replied_message_id'] ?? undefined,
1151
+ repliedToText: meta['replied_text'] ?? undefined,
1152
+ repliedToUser: meta['replied_user'] ?? undefined,
1071
1153
  ts: userTs,
1072
1154
  });
1073
1155
  }
@@ -1219,6 +1301,54 @@ class AgentRunner extends events_1.EventEmitter {
1219
1301
  `Do NOT open or Read the referenced files first — the image model receives the actual files; ` +
1220
1302
  `reading them wastes minutes and can push the request past its timeout. Go straight to generate_image.\n`);
1221
1303
  }
1304
+ /**
1305
+ * Render composer-selected video options as a directive the agent reads and
1306
+ * forwards to the generate_video MCP tool. Mirrors buildImageParamsNote. Returns
1307
+ * '' when no usable options are present. Without this the agent gets no composer
1308
+ * context for video and invents duration/aspect (a phantom 10s cap, an 8+8 scene
1309
+ * split, or a landscape clip when 9:16 was picked).
1310
+ */
1311
+ static buildVideoParamsNote(p) {
1312
+ const attrs = [
1313
+ p.model ? `model="${AgentRunner.escapeXmlAttr(p.model)}"` : '',
1314
+ p.resolution ? `resolution="${AgentRunner.escapeXmlAttr(p.resolution)}"` : '',
1315
+ p.aspect_ratio ? `aspect_ratio="${AgentRunner.escapeXmlAttr(p.aspect_ratio)}"` : '',
1316
+ typeof p.duration === 'number' ? `duration="${p.duration}"` : '',
1317
+ p.image_ref ? `image_ref="${AgentRunner.escapeXmlAttr(p.image_ref)}"` : '',
1318
+ ].filter(Boolean);
1319
+ if (!attrs.length)
1320
+ return '';
1321
+ // The generate_video schema lets the agent pick model/duration itself; when the
1322
+ // composer selection isn't made authoritative the agent falls back to
1323
+ // action="list" and self-selects, or invents a duration cap and splits the clip
1324
+ // into multiple scenes. Nail every field down.
1325
+ const modelNote = p.model
1326
+ ? `The user explicitly SELECTED model="${AgentRunner.escapeXmlAttr(p.model)}" in the composer. ` +
1327
+ `Call generate_video with that exact model — do NOT call action="list" to second-guess an ` +
1328
+ `explicit selection or substitute a different model.\n`
1329
+ : '';
1330
+ const durationNote = typeof p.duration === 'number'
1331
+ ? `duration=${p.duration} is a valid length for the selected model — pass it verbatim as the "duration" ` +
1332
+ `argument. Do NOT invent a maximum, do NOT clamp it to a smaller value, and do NOT split the request ` +
1333
+ `into multiple shorter scenes/clips. Generate exactly ONE clip of this duration.\n`
1334
+ : '';
1335
+ const aspectNote = p.aspect_ratio
1336
+ ? `Pass aspect_ratio="${AgentRunner.escapeXmlAttr(p.aspect_ratio)}" verbatim — do NOT change the orientation.\n`
1337
+ : '';
1338
+ const refNote = p.image_ref
1339
+ ? `The user selected a source image for image-to-video. Pass image_ref="${AgentRunner.escapeXmlAttr(p.image_ref)}" ` +
1340
+ `as the "image" argument of generate_video (the source frame's own aspect then wins). ` +
1341
+ `Do NOT call list_refs to second-guess it, and do NOT open or Read the file first.\n`
1342
+ : '';
1343
+ return (`<video-params ${attrs.join(' ')} />\n` +
1344
+ `The user selected the video-generation options above in the composer. When the request involves ` +
1345
+ `creating a video, call the generate_video tool (action="generate") using these exact values, then ` +
1346
+ `deliver the returned video with your reply tool.\n` +
1347
+ modelNote +
1348
+ durationNote +
1349
+ aspectNote +
1350
+ refNote);
1351
+ }
1222
1352
  /**
1223
1353
  * The durable slice of the composer image options — everything except
1224
1354
  * `image_refs`, which is a per-turn explicit selection (#73) and must never be
@@ -1238,16 +1368,26 @@ class AgentRunner extends events_1.EventEmitter {
1238
1368
  * session path; refs that were never staged (catalog refs, artifact:<id>)
1239
1369
  * pass through untouched. (#74)
1240
1370
  */
1241
- static remapImageParamsRefs(p, stagedPaths, promotedPaths) {
1242
- if (!p || !stagedPaths?.length || !promotedPaths?.length)
1243
- return p;
1371
+ // Build the staging→promoted path map: promoteUiUploads moves a turn's
1372
+ // ui-upload files into per-session storage, so any ref built from a staging
1373
+ // path must follow the file to its new location (#74). Empty map = nothing
1374
+ // moved (or no uploads), so callers leave their refs untouched.
1375
+ static buildRefRemap(stagedPaths, promotedPaths) {
1244
1376
  const map = new Map();
1377
+ if (!stagedPaths?.length || !promotedPaths?.length)
1378
+ return map;
1245
1379
  for (let i = 0; i < Math.min(stagedPaths.length, promotedPaths.length); i++) {
1246
1380
  const from = stagedPaths[i];
1247
1381
  const to = promotedPaths[i];
1248
1382
  if (from !== to)
1249
1383
  map.set(from, to);
1250
1384
  }
1385
+ return map;
1386
+ }
1387
+ static remapImageParamsRefs(p, stagedPaths, promotedPaths) {
1388
+ if (!p)
1389
+ return p;
1390
+ const map = AgentRunner.buildRefRemap(stagedPaths, promotedPaths);
1251
1391
  if (!map.size)
1252
1392
  return p;
1253
1393
  const remap = (r) => map.get(r) ?? r;
@@ -1257,10 +1397,26 @@ class AgentRunner extends events_1.EventEmitter {
1257
1397
  ...(p.image_refs?.length ? { image_refs: p.image_refs.map(remap) } : {}),
1258
1398
  };
1259
1399
  }
1400
+ // Video analogue of remapImageParamsRefs. The composer's image-to-video
1401
+ // source frame (VideoParams.image_ref) is built from a staging path; without
1402
+ // this remap the raw ref dangles after promoteUiUploads moves the file, and
1403
+ // generate_video fails share_ref_not_found. artifact:/catalog refs aren't in
1404
+ // the map, so they pass through untouched.
1405
+ static remapVideoParamsRefs(p, stagedPaths, promotedPaths) {
1406
+ if (!p?.image_ref)
1407
+ return p;
1408
+ const to = AgentRunner.buildRefRemap(stagedPaths, promotedPaths).get(p.image_ref);
1409
+ return to ? { ...p, image_ref: to } : p;
1410
+ }
1260
1411
  static buildChannelXml(params) {
1261
1412
  const meta = params.meta ?? {};
1262
1413
  const optionalAttrs = [
1263
1414
  'image_path',
1415
+ 'document_path', // WhatsApp Cloud: inbound PDF document (see MediaStore.isAllowedMime)
1416
+ 'sticker_path', // WhatsApp (both channels): inbound sticker — kept distinct from image_path so the agent can tell a sticker from a photo
1417
+ 'location_lat', // WhatsApp (both channels): inbound location pin
1418
+ 'location_lng',
1419
+ 'vcard', // WhatsApp: inbound contact card — raw vCard on Baileys, synthesized from Meta's structured payload on Cloud
1264
1420
  'attachment_file_id',
1265
1421
  'attachment_kind',
1266
1422
  'attachment_mime',
@@ -1268,6 +1424,8 @@ class AgentRunner extends events_1.EventEmitter {
1268
1424
  'user_id', // LINE: the userId the session passes back to line_reply
1269
1425
  'reply_token', // LINE: single-use reply token (push is preferred; surfaced for completeness)
1270
1426
  'thread_ts', // Slack: set when the inbound message is inside a thread — pass back as thread_id to slack_reply to reply in-thread
1427
+ 'account_id', // WhatsApp: which linked number this arrived on — pass back to whatsapp_reply to answer on the same one
1428
+ 'interactive_id', // WhatsApp Cloud: the id of the tapped button / picked list row (content already carries its title)
1271
1429
  // NOTE: message_id is NOT listed here — the base <channel> template below
1272
1430
  // already unconditionally emits it; adding it here would duplicate the
1273
1431
  // attribute in the XML whenever meta.message_id is set (any channel).
@@ -1280,6 +1438,10 @@ class AgentRunner extends events_1.EventEmitter {
1280
1438
  if (meta['replied_message_id']) {
1281
1439
  const repliedAttrs = [
1282
1440
  'replied_image_path',
1441
+ 'replied_attachment_file_id',
1442
+ 'replied_attachment_kind',
1443
+ 'replied_attachment_mime',
1444
+ 'replied_attachment_name',
1283
1445
  ]
1284
1446
  .filter(k => meta[k])
1285
1447
  .map(k => ` ${k}="${meta[k].replace(/"/g, '&quot;')}"`)
@@ -1291,9 +1453,18 @@ class AgentRunner extends events_1.EventEmitter {
1291
1453
  `</replied>`;
1292
1454
  }
1293
1455
  const source = meta['source'] ?? 'telegram';
1456
+ // Per-turn channel note (Phase 3). WhatsApp Business enforces a 24h
1457
+ // customer-service window that no other channel here has, and the agent
1458
+ // cannot discover it from the turn itself — it only shows up as an opaque
1459
+ // Meta error at send time. Emitted as an XML COMMENT so it can never be
1460
+ // mistaken for part of the user's message, and kept to ONE short line
1461
+ // because it repeats on every single whatsapp_cloud turn. (A frozen,
1462
+ // system-prompt-level injection would be new infra for one warning; the
1463
+ // per-turn wrapper is the existing seam.)
1464
+ const channelNote = source === 'whatsapp_cloud' ? `<!-- ${exports.WHATSAPP_CLOUD_WINDOW_NOTE} -->` : '';
1294
1465
  return (`<channel source="${source}" chat_id="${meta['chat_id'] ?? ''}" ` +
1295
1466
  `message_id="${meta['message_id'] ?? ''}" user="${AgentRunner.escapeXmlAttr(meta['user'] ?? '')}" ` +
1296
- `ts="${meta['ts'] ?? new Date().toISOString()}"${optionalAttrs}>${repliedBlock}${params.content ?? ''}</channel>`);
1467
+ `ts="${meta['ts'] ?? new Date().toISOString()}"${optionalAttrs}>${channelNote}${repliedBlock}${params.content ?? ''}</channel>`);
1297
1468
  }
1298
1469
  /**
1299
1470
  * Await a SessionProcess's in-flight crash-triggered auto-restart
@@ -1552,7 +1723,11 @@ class AgentRunner extends events_1.EventEmitter {
1552
1723
  ? 'mcp__gateway__line_reply'
1553
1724
  : source === 'slack'
1554
1725
  ? 'mcp__gateway__slack_reply'
1555
- : 'mcp__gateway__telegram_reply';
1726
+ : source === 'whatsapp'
1727
+ ? 'mcp__gateway__whatsapp_reply'
1728
+ : source === 'whatsapp_cloud'
1729
+ ? 'mcp__gateway__whatsapp_cloud_reply'
1730
+ : 'mcp__gateway__telegram_reply';
1556
1731
  proc.on('output', (line) => {
1557
1732
  try {
1558
1733
  const obj = JSON.parse(line);
@@ -1865,10 +2040,15 @@ class AgentRunner extends events_1.EventEmitter {
1865
2040
  }
1866
2041
  else if (channelSrcForResult !== 'discord' &&
1867
2042
  channelSrcForResult !== 'slack' &&
2043
+ channelSrcForResult !== 'whatsapp' &&
2044
+ channelSrcForResult !== 'whatsapp_cloud' &&
1868
2045
  ((0, markdown_1.hasMarkdown)(channelText) || (0, markdown_1.containsTelegramHtml)(channelText))) {
1869
2046
  // Telegram HTML entities — Slack has its own mrkdwn format and
1870
2047
  // would display these tags literally, so Slack skips this and
1871
- // falls through to the plain-text branch below.
2048
+ // falls through to the plain-text branch below. WhatsApp (and
2049
+ // WhatsApp Cloud, same lightweight markup) has
2050
+ // its own lightweight markup (*bold*/_italic_/~strike~, not
2051
+ // HTML), same reasoning.
1872
2052
  this.writeAutoForward(mapKey, (0, markdown_1.toTelegramHtml)(channelText), 'html', replySendFailed);
1873
2053
  }
1874
2054
  else {
@@ -2725,6 +2905,36 @@ class AgentRunner extends events_1.EventEmitter {
2725
2905
  }
2726
2906
  return;
2727
2907
  }
2908
+ // WhatsApp likewise has no .forward consumer, and — unlike Slack/SMS's
2909
+ // outbound REST clients — cannot open a fresh connection per call either
2910
+ // (see WhatsAppManager's doc comment): reach the live socket this
2911
+ // process already holds directly.
2912
+ if (this.channelFor(chatId) === 'whatsapp') {
2913
+ // No account_id to go on here (this is the fallback path, not a tool
2914
+ // call), so whatsAppManagerFor falls back to the account this chat's
2915
+ // last inbound message arrived on — the number the user wrote to.
2916
+ void this.sendWhatsAppMessage(chatId, text).catch((err) => {
2917
+ this.logger.warn('WhatsApp auto-forward failed', {
2918
+ chatId,
2919
+ error: err instanceof Error ? err.message : String(err),
2920
+ });
2921
+ });
2922
+ return;
2923
+ }
2924
+ // WhatsApp Cloud mirrors Slack exactly (no .forward consumer, a real REST
2925
+ // client that can open a fresh connection per call — unlike Baileys'
2926
+ // `whatsapp` above, there's no live socket to reuse or reason to).
2927
+ if (this.channelFor(chatId) === 'whatsapp_cloud') {
2928
+ if (this.whatsAppCloudOutbound) {
2929
+ void this.whatsAppCloudOutbound.sendText(chatId, text).catch((err) => {
2930
+ this.logger.warn('WhatsApp Cloud auto-forward failed', {
2931
+ chatId,
2932
+ error: err instanceof Error ? err.message : String(err),
2933
+ });
2934
+ });
2935
+ }
2936
+ return;
2937
+ }
2728
2938
  const typingDir = this.getTypingDir(chatId);
2729
2939
  const forwardPath = path.join(typingDir, `${chatId}.forward`);
2730
2940
  try {
@@ -2824,6 +3034,15 @@ class AgentRunner extends events_1.EventEmitter {
2824
3034
  // line_reply tool keeps the plain reply-first → push-fallback path).
2825
3035
  this.startLineReply();
2826
3036
  this.startSlackOutbound();
3037
+ this.startWhatsAppCloudOutbound();
3038
+ // Unconditional (unlike every credential-gated channel above) — each
3039
+ // WhatsAppManager checks disk for a previously-linked session itself;
3040
+ // there's no config field to gate on, and an agent with no `whatsapp`
3041
+ // block still gets the implicit 'default' account (see
3042
+ // resolveWhatsAppAccounts). A brand-new agent that's never linked just
3043
+ // no-ops here and stays 'unlinked' until the user starts a QR/pairing
3044
+ // flow via the API.
3045
+ this.syncWhatsAppAccounts();
2827
3046
  this.startIdleCleaner();
2828
3047
  this._startCleanupScheduler();
2829
3048
  this.logger.info('AgentRunner started', { agentId: this.agentConfig.id });
@@ -2877,6 +3096,129 @@ class AgentRunner extends events_1.EventEmitter {
2877
3096
  // it up, same reasoning as LineReplyManager above.
2878
3097
  this.stopSlackOutbound();
2879
3098
  this.startSlackOutbound();
3099
+ // WhatsApp Cloud credentials may have changed (or been cleared) — rebuild
3100
+ // to pick it up, same reasoning as Slack above.
3101
+ this.stopWhatsAppCloudOutbound();
3102
+ this.startWhatsAppCloudOutbound();
3103
+ // WhatsApp has no credential to rebuild on — push the new config
3104
+ // (access-control fields) into every already-running manager, and
3105
+ // add/remove managers only if the set of configured accounts changed.
3106
+ this.syncWhatsAppAccounts();
3107
+ }
3108
+ /**
3109
+ * Reconcile `this.whatsappAccounts` with `agentConfig.whatsapp.accounts`.
3110
+ *
3111
+ * Diffing (rather than teardown+recreate) is load-bearing here: a manager
3112
+ * holds a live, authenticated Baileys socket that can take tens of seconds
3113
+ * to re-establish, so an edit to account "sales" must not drop account
3114
+ * "default"'s connection. Accounts that survive the diff only get the new
3115
+ * AgentConfig pushed into them — their socket is untouched.
3116
+ *
3117
+ * Removal uses stop() (socket teardown), never unlink(): dropping an
3118
+ * account from config must not silently wipe its on-disk session. The
3119
+ * DELETE .../whatsapp/accounts/:id route unlinks explicitly before it
3120
+ * rewrites config.
3121
+ */
3122
+ syncWhatsAppAccounts() {
3123
+ const snapshot = JSON.stringify(this.agentConfig.whatsapp ?? null);
3124
+ const accounts = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(this.agentConfig.whatsapp);
3125
+ // Always push the fresh config into live managers — access-control fields
3126
+ // are read live off it on every inbound message.
3127
+ for (const manager of this.whatsappAccounts.values()) {
3128
+ manager.updateAgentConfig(this.agentConfig);
3129
+ }
3130
+ // Nothing about the WhatsApp block moved ⇒ the account SET can't have
3131
+ // changed either, so skip the diff entirely.
3132
+ if (this.whatsappConfigSnapshot === snapshot && this.whatsappAccounts.size > 0)
3133
+ return;
3134
+ this.whatsappConfigSnapshot = snapshot;
3135
+ const wanted = new Set(accounts.map((a) => a.id));
3136
+ for (const [accountId, manager] of [...this.whatsappAccounts]) {
3137
+ if (wanted.has(accountId))
3138
+ continue;
3139
+ manager.stop();
3140
+ this.whatsappAccounts.delete(accountId);
3141
+ this.logger.info('WhatsApp account removed', { agentId: this.agentConfig.id, accountId });
3142
+ }
3143
+ for (const account of accounts) {
3144
+ if (this.whatsappAccounts.has(account.id))
3145
+ continue;
3146
+ const manager = new manager_1.WhatsAppManager(this.agentConfig, account.id, this.callbackPort, this.gatewayConfig.gateway.logDir);
3147
+ this.whatsappAccounts.set(account.id, manager);
3148
+ void manager.resumeIfLinked();
3149
+ this.logger.info('WhatsApp account started', { agentId: this.agentConfig.id, accountId: account.id });
3150
+ }
3151
+ }
3152
+ /** Live managers, in configured order. */
3153
+ whatsAppManagers() {
3154
+ return (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(this.agentConfig.whatsapp)
3155
+ .map((a) => this.whatsappAccounts.get(a.id))
3156
+ .filter((m) => !!m);
3157
+ }
3158
+ /**
3159
+ * Pick the manager a call should use.
3160
+ *
3161
+ * An EXPLICIT accountId must exist — silently falling back would send a
3162
+ * reply from the wrong number, which on WhatsApp is visible to the
3163
+ * recipient and unrecoverable. Without one, prefer the account the chat's
3164
+ * last inbound message arrived on (so a reply leaves on the number the user
3165
+ * actually wrote to), then 'default', then — for an agent whose only
3166
+ * account is named something else — its single account.
3167
+ */
3168
+ whatsAppManagerFor(chatId, accountId) {
3169
+ if (accountId) {
3170
+ const explicit = this.whatsappAccounts.get(accountId);
3171
+ if (!explicit)
3172
+ throw new Error(`Unknown WhatsApp account '${accountId}'`);
3173
+ return explicit;
3174
+ }
3175
+ const remembered = chatId ? this.whatsappAccountForChat.get(chatId) : undefined;
3176
+ const managers = this.whatsAppManagers();
3177
+ const picked = (remembered ? this.whatsappAccounts.get(remembered) : undefined) ??
3178
+ this.whatsappAccounts.get(whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID) ??
3179
+ managers[0];
3180
+ if (!picked)
3181
+ throw new Error('Agent not started');
3182
+ return picked;
3183
+ }
3184
+ /**
3185
+ * Which account id a send to `chatId` (with an optional explicit
3186
+ * `accountId`) would actually use — the same resolution
3187
+ * `sendWhatsAppMessage` applies internally, exposed read-only so a caller
3188
+ * (the `/whatsapp/send` route) can run access-control/path-confinement
3189
+ * checks against the REAL target account without duplicating or
3190
+ * second-guessing this resolution logic. Throws the same errors
3191
+ * `whatsAppManagerFor` would (unknown account / agent not started).
3192
+ */
3193
+ resolveWhatsAppAccountId(chatId, accountId) {
3194
+ return this.whatsAppManagerFor(chatId, accountId).accountId;
3195
+ }
3196
+ /** Live link status for one account (defaults to 'default'). */
3197
+ getWhatsAppStatus(accountId = whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID) {
3198
+ return this.whatsappAccounts.get(accountId)?.getStatus();
3199
+ }
3200
+ /** Configured account ids that currently have a live manager. */
3201
+ getWhatsAppAccountIds() {
3202
+ return this.whatsAppManagers().map((m) => m.accountId);
3203
+ }
3204
+ async startWhatsAppLinking(accountId) {
3205
+ await this.whatsAppManagerFor(undefined, accountId).startLinking();
3206
+ }
3207
+ async requestWhatsAppPairingCode(phoneNumber, accountId) {
3208
+ return this.whatsAppManagerFor(undefined, accountId).requestPairingCode(phoneNumber);
3209
+ }
3210
+ async unlinkWhatsApp(accountId) {
3211
+ await this.whatsAppManagerFor(undefined, accountId).unlink();
3212
+ }
3213
+ async sendWhatsAppMessage(jid, text, imagePath, accountId,
3214
+ /**
3215
+ * Phase 2 extras, all optional so existing callers (auto-forward, older
3216
+ * MCP builds) are unaffected: quote an inbound message, send the image
3217
+ * uncompressed as a document, and clear the ⏳ ack left on the inbound
3218
+ * message once the send lands.
3219
+ */
3220
+ opts) {
3221
+ await this.whatsAppManagerFor(jid, accountId).sendMessage(jid, text, imagePath, opts);
2880
3222
  }
2881
3223
  startSlackOutbound() {
2882
3224
  if (!this.agentConfig.slack?.botToken || !this.agentConfig.slack?.signingSecret)
@@ -2891,6 +3233,21 @@ class AgentRunner extends events_1.EventEmitter {
2891
3233
  stopSlackOutbound() {
2892
3234
  this.slackOutbound = null;
2893
3235
  }
3236
+ startWhatsAppCloudOutbound() {
3237
+ const cfg = this.agentConfig.whatsapp_cloud;
3238
+ if (!cfg?.accessToken || !cfg?.phoneNumberId || !cfg?.appSecret || !cfg?.verifyToken)
3239
+ return;
3240
+ if (this.whatsAppCloudOutbound)
3241
+ return; // already running
3242
+ this.whatsAppCloudOutbound = new whatsapp_cloud_client_1.WhatsAppCloudClient({
3243
+ accessToken: cfg.accessToken,
3244
+ phoneNumberId: cfg.phoneNumberId,
3245
+ logDir: this.gatewayConfig.gateway.logDir,
3246
+ });
3247
+ }
3248
+ stopWhatsAppCloudOutbound() {
3249
+ this.whatsAppCloudOutbound = null;
3250
+ }
2894
3251
  startLineReply() {
2895
3252
  const lineThreshold = this.agentConfig.line?.slowResponseThreshold ?? 45;
2896
3253
  if (!this.agentConfig.line?.channelSecret || !this.agentConfig.line?.channelAccessToken || lineThreshold <= 0)
@@ -2987,6 +3344,16 @@ class AgentRunner extends events_1.EventEmitter {
2987
3344
  // teardown rather than serially — both are bounded, neither depends on the
2988
3345
  // other, and shutdown latency is user-visible.
2989
3346
  const receiversStopped = [this.receiver?.stop(), this.discordReceiver?.stop()];
3347
+ // stop(), not unlink() — gateway shutdown should NOT wipe a linked
3348
+ // session; resumeIfLinked() picks each account back up on next boot.
3349
+ // Synchronous (no socket-close promise to await), unlike the receivers above.
3350
+ for (const manager of this.whatsappAccounts.values())
3351
+ manager.stop();
3352
+ this.whatsappAccounts.clear();
3353
+ this.whatsappAccountForChat.clear();
3354
+ // Force syncWhatsAppAccounts() to rebuild from scratch if this runner is
3355
+ // started again, rather than short-circuiting on a stale snapshot.
3356
+ this.whatsappConfigSnapshot = undefined;
2990
3357
  this.stopLineReply();
2991
3358
  await Promise.all([
2992
3359
  ...receiversStopped,
@@ -3090,6 +3457,8 @@ class AgentRunner extends events_1.EventEmitter {
3090
3457
  // Refs built from staging paths must follow the files to their promoted
3091
3458
  // location — see remapImageParamsRefs (#74).
3092
3459
  const imageParams = AgentRunner.remapImageParamsRefs(opts.imageParams, opts.mediaFiles, finalMediaFiles);
3460
+ // The image-to-video source frame follows the same staging→promoted move.
3461
+ const videoParams = AgentRunner.remapVideoParamsRefs(opts.videoParams, opts.mediaFiles, finalMediaFiles);
3093
3462
  // Resolve media files to absolute paths for file-path based image passing
3094
3463
  // (same pattern as Telegram — Claude Code reads files via Read tool instead of base64 inline)
3095
3464
  const imagePaths = finalMediaFiles?.length ? this.resolveMediaPaths(finalMediaFiles) : [];
@@ -3145,6 +3514,7 @@ class AgentRunner extends events_1.EventEmitter {
3145
3514
  // Build channel XML with image_path attribute (like Telegram) for first image
3146
3515
  const imageAttr = effectiveImagePaths.length ? ` image_path="${AgentRunner.escapeXmlAttr(effectiveImagePaths[0])}"` : '';
3147
3516
  const imageParamsNote = imageParams ? AgentRunner.buildImageParamsNote(imageParams) : '';
3517
+ const videoParamsNote = videoParams ? AgentRunner.buildVideoParamsNote(videoParams) : '';
3148
3518
  // Persist the composer image options to session meta so the web can restore the
3149
3519
  // selection on reload (SessionMeta.imageConfig). Only when the send carries them
3150
3520
  // (the web sends image_params on first-set/change), so this holds the latest.
@@ -3156,10 +3526,17 @@ class AgentRunner extends events_1.EventEmitter {
3156
3526
  .updateSessionMeta(this.agentConfig.id, chatId, sessionId, { imageConfig: durableImageConfig }, 'api')
3157
3527
  .catch(() => { });
3158
3528
  }
3529
+ // Persist composer video options the same way (SessionMeta.videoConfig).
3530
+ if (videoParams) {
3531
+ this.sessionStore
3532
+ .updateSessionMeta(this.agentConfig.id, chatId, sessionId, { videoConfig: videoParams }, 'api')
3533
+ .catch(() => { });
3534
+ }
3159
3535
  const channelXml = `<channel source="api" chat_id="${chatId}" session_id="${sessionId}" ts="${new Date().toISOString()}"${imageAttr}>\n` +
3160
3536
  `${message}\n\n` +
3161
3537
  `${systemNote}` +
3162
3538
  `${imageParamsNote}` +
3539
+ `${videoParamsNote}` +
3163
3540
  `</channel>` +
3164
3541
  (skillInvocation ? `\n${(0, skills_1.formatSkillContext)(skillInvocation)}` : '');
3165
3542
  return new Promise((resolve, reject) => {
@@ -3362,6 +3739,8 @@ class AgentRunner extends events_1.EventEmitter {
3362
3739
  // Refs built from staging paths must follow the files to their promoted
3363
3740
  // location — see remapImageParamsRefs (#74).
3364
3741
  const imageParamsStream = AgentRunner.remapImageParamsRefs(opts.imageParams, opts.mediaFiles, finalMediaFilesStream);
3742
+ // The image-to-video source frame follows the same staging→promoted move.
3743
+ const videoParamsStream = AgentRunner.remapVideoParamsRefs(opts.videoParams, opts.mediaFiles, finalMediaFilesStream);
3365
3744
  // Resolve media files to absolute paths for file-path based image passing
3366
3745
  const imagePathsStream = finalMediaFilesStream?.length ? this.resolveMediaPaths(finalMediaFilesStream) : [];
3367
3746
  if (!opts.skipUserMessage) {
@@ -3625,6 +4004,7 @@ class AgentRunner extends events_1.EventEmitter {
3625
4004
  // Build channel XML with image_path attribute (like Telegram) for first image
3626
4005
  const imageAttrStream = effectiveImagePathsStream.length ? ` image_path="${AgentRunner.escapeXmlAttr(effectiveImagePathsStream[0])}"` : '';
3627
4006
  const imageParamsNoteStream = imageParamsStream ? AgentRunner.buildImageParamsNote(imageParamsStream) : '';
4007
+ const videoParamsNoteStream = videoParamsStream ? AgentRunner.buildVideoParamsNote(videoParamsStream) : '';
3628
4008
  // Persist composer image config to session meta (SessionMeta.imageConfig) so the
3629
4009
  // web restores the selection on reload. This is the streaming path the web uses.
3630
4010
  // image_refs are per-turn and deliberately excluded (#73).
@@ -3637,10 +4017,17 @@ class AgentRunner extends events_1.EventEmitter {
3637
4017
  .updateSessionMeta(this.agentConfig.id, chatId, sessionId, { imageConfig: durableImageConfigStream }, 'api')
3638
4018
  .catch(() => { });
3639
4019
  }
4020
+ // Persist composer video options the same way (SessionMeta.videoConfig).
4021
+ if (videoParamsStream) {
4022
+ this.sessionStore
4023
+ .updateSessionMeta(this.agentConfig.id, chatId, sessionId, { videoConfig: videoParamsStream }, 'api')
4024
+ .catch(() => { });
4025
+ }
3640
4026
  const channelXml = `<channel source="api" chat_id="${chatId}" session_id="${sessionId}" ts="${new Date().toISOString()}"${imageAttrStream}>\n` +
3641
4027
  `${message}\n\n` +
3642
4028
  systemNote +
3643
4029
  imageParamsNoteStream +
4030
+ videoParamsNoteStream +
3644
4031
  `</channel>` +
3645
4032
  (skillInvocationStream ? `\n${(0, skills_1.formatSkillContext)(skillInvocationStream)}` : '');
3646
4033
  session.setProcessing(true);