@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
@@ -58,6 +58,8 @@ const pending_senders_1 = require("./pending-senders");
58
58
  const create_agent_prompts_1 = require("../agent/create-agent-prompts");
59
59
  const model_catalog_1 = require("../agent/model-catalog");
60
60
  const runner_2 = require("../agent/runner");
61
+ const whatsapp_accounts_1 = require("../config/whatsapp-accounts");
62
+ const whatsapp_access_1 = require("./whatsapp-access");
61
63
  const MAX_MESSAGE_LENGTH = 10000;
62
64
  const DEFAULT_TIMEOUT_MS = 60000;
63
65
  /**
@@ -84,6 +86,26 @@ async function verifySlackBotToken(botToken) {
84
86
  return { ok: false, error: err instanceof Error ? err.message : 'network error' };
85
87
  }
86
88
  }
89
+ /**
90
+ * Standalone Graph API credential check for the WhatsApp Cloud connect
91
+ * flow's Save-time validation — mirrors verifySlackBotToken's placement and
92
+ * style directly above. Not WhatsAppCloudClient (src/api/whatsapp-cloud-client.ts)
93
+ * for the same reason: that class needs a logDir-backed logger this router
94
+ * has no other reason to plumb through for one validation call.
95
+ */
96
+ async function verifyWhatsAppCloudCredentials(accessToken, phoneNumberId) {
97
+ try {
98
+ const res = await fetch(`https://graph.facebook.com/v20.0/${encodeURIComponent(phoneNumberId)}?access_token=${encodeURIComponent(accessToken)}`);
99
+ const json = (await res.json());
100
+ if (json.error) {
101
+ return { ok: false, error: json.error.message ?? 'unknown error' };
102
+ }
103
+ return { ok: true };
104
+ }
105
+ catch (err) {
106
+ return { ok: false, error: err instanceof Error ? err.message : 'network error' };
107
+ }
108
+ }
87
109
  const AGENT_ID_RE = /^[a-z][a-z0-9_-]{1,31}$/;
88
110
  const SAFE_FILENAME_RE = /^[a-zA-Z0-9._\-() ]+$/;
89
111
  // session_id becomes a filesystem key (sessions/<id>.jsonl, .sessions/<id>/) so it
@@ -249,6 +271,158 @@ function maskToken(token) {
249
271
  return '•'.repeat(token.length);
250
272
  return token.slice(0, 8) + '•••••' + token.slice(-4);
251
273
  }
274
+ /**
275
+ * Live WhatsApp status for an agent, tolerant of a runner that doesn't
276
+ * implement `getWhatsAppStatus` — unlike every other channel's fields
277
+ * (config-derived, always safe to read), this one reaches into a live
278
+ * object. Test fixtures across this file stub `AgentRunner` as a plain
279
+ * object with only the methods each test needs; `?.()` (not just `?.`)
280
+ * keeps those fixtures passing instead of throwing "not a function".
281
+ */
282
+ function getWhatsAppStatus(agentRunners, id, accountId = whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID) {
283
+ return agentRunners.get(id)?.getWhatsAppStatus?.(accountId);
284
+ }
285
+ /**
286
+ * The per-account WhatsApp block of an agent's API response — the source of
287
+ * truth for the multi-account UI (Phase 1 of the WhatsApp feature-parity
288
+ * plan). Config fields come from config.json; connected/status/number are
289
+ * LIVE runtime state read from that account's manager, exactly as the old
290
+ * flat `whatsapp_connected`/`whatsapp_status`/`whatsapp_number` were.
291
+ *
292
+ * An agent with no `whatsapp` block still reports a single 'default' entry
293
+ * (see resolveWhatsAppAccounts) — the same "always linkable" posture the
294
+ * single-account implementation had, just expressed as a one-element array.
295
+ */
296
+ function whatsAppAccountsResponse(agentRunners, cfg, id) {
297
+ return (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp).map((account) => {
298
+ const live = getWhatsAppStatus(agentRunners, id, account.id);
299
+ return {
300
+ id: account.id,
301
+ label: account.label ?? null,
302
+ connected: live?.status === 'linked',
303
+ status: live?.status ?? 'unlinked',
304
+ number: live?.phoneNumber ?? null,
305
+ dm_policy: account.dmPolicy ?? null,
306
+ dm_allowlist: account.dmAllowlist ?? [],
307
+ group_policy: account.groupPolicy ?? null,
308
+ group_allowlist: account.groupAllowlist ?? [],
309
+ require_mention: account.requireMention ?? null,
310
+ pairing: account.pairing ?? true,
311
+ };
312
+ });
313
+ }
314
+ /**
315
+ * Every WhatsApp field on an agent response: the `whatsapp_accounts` array
316
+ * (source of truth) plus the pre-multi-account flat `whatsapp_*` fields,
317
+ * mirrored from `accounts[0]`.
318
+ *
319
+ * The flat fields are kept deliberately: the Settings UI in the getpod-ai
320
+ * repo still reads them, and its multi-account rewrite is a separate,
321
+ * parallel PR. A single-account agent — which is every agent that hasn't
322
+ * opted into a second number — sees byte-identical values to before, so the
323
+ * old UI keeps working untouched until it switches to the array.
324
+ */
325
+ function whatsAppResponseFields(agentRunners, cfg, id) {
326
+ const accounts = whatsAppAccountsResponse(agentRunners, cfg, id);
327
+ const first = accounts[0];
328
+ return {
329
+ whatsapp_accounts: accounts,
330
+ whatsapp_connected: first.connected,
331
+ whatsapp_status: first.status,
332
+ whatsapp_number: first.number,
333
+ whatsapp_dm_policy: first.dm_policy,
334
+ whatsapp_dm_allowlist: first.dm_allowlist,
335
+ whatsapp_group_policy: first.group_policy,
336
+ whatsapp_group_allowlist: first.group_allowlist,
337
+ whatsapp_require_mention: first.require_mention,
338
+ whatsapp_pairing: first.pairing,
339
+ };
340
+ }
341
+ /** Access-control fields a PATCH may set on one WhatsApp account. */
342
+ const WHATSAPP_ACCESS_FIELDS = [
343
+ 'dmPolicy',
344
+ 'dmAllowlist',
345
+ 'groupPolicy',
346
+ 'groupAllowlist',
347
+ 'requireMention',
348
+ 'pairing',
349
+ ];
350
+ /**
351
+ * Merge access-control fields into ONE account inside a container's `whatsapp`
352
+ * block, creating the block, the `accounts` array, and the account entry if
353
+ * they don't exist yet.
354
+ *
355
+ * Used for both halves of PATCH /v1/agents/:agentId — the on-disk agent entry
356
+ * (a plain parsed-JSON object) and the in-memory AgentConfig — so the two can
357
+ * never drift. `undefined` leaves a field alone, `null` clears it, anything
358
+ * else sets it, matching the flat single-account behavior this replaced.
359
+ *
360
+ * Creating the block on first touch is deliberate and unchanged from before:
361
+ * unlike every other channel, WhatsApp access control is meaningful to
362
+ * configure before a device has ever been linked, so there's no credential
363
+ * block whose absence should make the patch a no-op.
364
+ */
365
+ function applyWhatsAppAccountPatch(container, accountId, patch) {
366
+ const target = container;
367
+ const rawBlock = target.whatsapp;
368
+ const block = rawBlock && typeof rawBlock === 'object' && !Array.isArray(rawBlock)
369
+ ? rawBlock
370
+ : {};
371
+ const accounts = Array.isArray(block.accounts)
372
+ ? block.accounts
373
+ : [];
374
+ if (accounts.length === 0)
375
+ accounts.push({ id: whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID });
376
+ let account = accounts.find((a) => a && typeof a === 'object' && a.id === accountId);
377
+ if (!account) {
378
+ account = { id: accountId };
379
+ accounts.push(account);
380
+ }
381
+ for (const field of WHATSAPP_ACCESS_FIELDS) {
382
+ const value = patch[field];
383
+ if (value === undefined)
384
+ continue;
385
+ if (value === null)
386
+ delete account[field];
387
+ else
388
+ account[field] = value;
389
+ }
390
+ block.accounts = accounts;
391
+ target.whatsapp = block;
392
+ }
393
+ /**
394
+ * Which account a WhatsApp request targets: an explicitly requested id, else
395
+ * the agent's first account. The fallback is what makes every pre-multi-account
396
+ * caller keep working unchanged — on a single-account agent the first account
397
+ * IS the only account, so omitting the id resolves to exactly what the flat
398
+ * single-account routes and PATCH fields always acted on.
399
+ */
400
+ function whatsAppTargetAccountId(cfg, requested) {
401
+ if (typeof requested === 'string' && requested.trim())
402
+ return requested.trim();
403
+ if (!cfg)
404
+ return whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID;
405
+ return (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp)[0].id;
406
+ }
407
+ /**
408
+ * Resolve the account a WhatsApp route targets, rejecting an explicitly
409
+ * requested id the agent doesn't have. Returns null after sending 404, so
410
+ * callers just `if (accountId === null) return;`.
411
+ *
412
+ * An OMITTED id is never rejected: it resolves to the first account, which is
413
+ * how the routes stayed backward compatible for callers that predate
414
+ * multi-account.
415
+ */
416
+ function resolveWhatsAppRouteAccount(agentConfigs, agentId, requested, res) {
417
+ const cfg = agentConfigs.get(agentId);
418
+ const accountId = whatsAppTargetAccountId(cfg, requested);
419
+ if (typeof requested === 'string' && requested.trim() &&
420
+ !(0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg?.whatsapp).some((a) => a.id === accountId)) {
421
+ res.status(404).json({ error: `WhatsApp account '${accountId}' not found` });
422
+ return null;
423
+ }
424
+ return accountId;
425
+ }
252
426
  /** Detect MIME type from file magic bytes (first 12 bytes). */
253
427
  function detectMimeFromMagic(header) {
254
428
  if (header[0] === 0xFF && header[1] === 0xD8 && header[2] === 0xFF)
@@ -429,7 +603,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
429
603
  return;
430
604
  }
431
605
  const body = req.body;
432
- const { message, chat_id, session_id, stream, timeout_ms, media_files, model: requestModel, store_user_message, image_params } = body;
606
+ const { message, chat_id, session_id, stream, timeout_ms, media_files, model: requestModel, store_user_message, image_params, video_params } = body;
433
607
  if (message !== undefined && typeof message !== 'string') {
434
608
  res.status(400).json({ error: 'message must be a string if provided' });
435
609
  return;
@@ -526,6 +700,39 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
526
700
  if (Object.keys(out).length)
527
701
  validatedImageParams = out;
528
702
  }
703
+ // Validate optional video_params — mirrors image_params. Surfaced to the agent
704
+ // so it calls generate_video with the composer-selected model/duration/
705
+ // resolution/aspect_ratio instead of inventing them.
706
+ let validatedVideoParams;
707
+ if (video_params !== undefined) {
708
+ if (typeof video_params !== 'object' || video_params === null || Array.isArray(video_params)) {
709
+ res.status(400).json({ error: 'video_params must be an object if provided' });
710
+ return;
711
+ }
712
+ const vp = video_params;
713
+ const vStrFields = ['model', 'resolution', 'aspect_ratio', 'image_ref'];
714
+ const vOut = {};
715
+ for (const f of vStrFields) {
716
+ const v = vp[f];
717
+ if (v !== undefined) {
718
+ if (typeof v !== 'string') {
719
+ res.status(400).json({ error: `video_params.${f} must be a string` });
720
+ return;
721
+ }
722
+ if (v.trim())
723
+ vOut[f] = v.trim();
724
+ }
725
+ }
726
+ if (vp.duration !== undefined) {
727
+ if (typeof vp.duration !== 'number' || !Number.isFinite(vp.duration) || vp.duration < 1) {
728
+ res.status(400).json({ error: 'video_params.duration must be a positive number' });
729
+ return;
730
+ }
731
+ vOut.duration = Math.floor(vp.duration);
732
+ }
733
+ if (Object.keys(vOut).length)
734
+ validatedVideoParams = vOut;
735
+ }
529
736
  // Allow message OR media_files. Image-only sends pass an empty text
530
737
  // alongside the image_path attribute on channelXml so Claude can Read the file.
531
738
  const trimmedMessage = typeof message === 'string' ? message.trim() : '';
@@ -602,7 +809,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
602
809
  openSseStream(res);
603
810
  const agentCfg = agentConfigs.get(agentId);
604
811
  const allowTools = agentCfg.allow_tools ?? !!apiKey.allow_tools;
605
- onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage, imageParams: validatedImageParams, requestId });
812
+ onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage, imageParams: validatedImageParams, videoParams: validatedVideoParams, requestId });
606
813
  // Client disconnect — detaches this connection's sink. The turn keeps
607
814
  // running and keeps buffering, so the client can resume it on a new
608
815
  // connection via GET …/sessions/:sessionId/stream?after_seq= (#421).
@@ -646,6 +853,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
646
853
  model: modelStr,
647
854
  skipUserMessage,
648
855
  imageParams: validatedImageParams,
856
+ videoParams: validatedVideoParams,
649
857
  }));
650
858
  }
651
859
  const syncResult = {
@@ -764,6 +972,28 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
764
972
  slack_group_allowlist: cfg.slack?.signingSecret ? (cfg.slack?.groupAllowlist ?? []) : null,
765
973
  slack_require_mention: cfg.slack?.signingSecret ? (cfg.slack?.requireMention ?? null) : null,
766
974
  slack_pairing: cfg.slack?.signingSecret ? (cfg.slack?.pairing ?? true) : null,
975
+ // WhatsApp — no credential field to gate on (see
976
+ // WhatsAppAccountConfig's doc comment): connected/status/number are
977
+ // LIVE runtime state read from each account's manager, not derived
978
+ // from config.json. Access-control fields are always surfaced (not
979
+ // gated behind "connected") since they're meaningful to configure
980
+ // even before the first link. `whatsapp_accounts` is the source of
981
+ // truth; the flat `whatsapp_*` fields mirror accounts[0] for the
982
+ // not-yet-updated UI (see whatsAppResponseFields).
983
+ ...whatsAppResponseFields(agentRunners, cfg, id),
984
+ // WhatsApp Business Cloud API — webhook-based with real credentials
985
+ // (mirrors Slack's shape/semantics field-for-field), gated on
986
+ // appSecret the way Slack gates on signingSecret. DM-only: no
987
+ // group_policy/group_allowlist/require_mention fields exist for
988
+ // this channel (see AgentConfig.whatsapp_cloud's doc comment).
989
+ whatsapp_cloud_connected: !!cfg.whatsapp_cloud?.appSecret,
990
+ whatsapp_cloud_access_token_preview: cfg.whatsapp_cloud?.accessToken ? maskToken(cfg.whatsapp_cloud.accessToken) : null,
991
+ whatsapp_cloud_phone_number_id: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.phoneNumberId ?? null) : null,
992
+ whatsapp_cloud_webhook_path: cfg.whatsapp_cloud?.appSecret ? `/webhooks/whatsapp_cloud/${id}` : null,
993
+ whatsapp_cloud_dm_policy: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmPolicy ?? null) : null,
994
+ whatsapp_cloud_dm_allowlist: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmAllowlist ?? []) : null,
995
+ whatsapp_cloud_pairing: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.pairing ?? true) : null,
996
+ whatsapp_cloud_templates_enabled: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.templatesEnabled ?? false) : null,
767
997
  }));
768
998
  res.json({ agents });
769
999
  });
@@ -790,7 +1020,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
790
1020
  description: cfg?.description ?? '',
791
1021
  sessions: sessions.map((s) => {
792
1022
  const meta = metaMap.get(s.sessionId);
793
- return { ...s, sessionName: meta?.name ?? null, imageConfig: meta?.imageConfig ?? null, model: meta?.model ?? null };
1023
+ return { ...s, sessionName: meta?.name ?? null, imageConfig: meta?.imageConfig ?? null, videoConfig: meta?.videoConfig ?? null, model: meta?.model ?? null };
794
1024
  }),
795
1025
  };
796
1026
  }));
@@ -1449,7 +1679,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1449
1679
  return;
1450
1680
  }
1451
1681
  const body = req.body;
1452
- const { name, description, model, allow_tools, telegram_bot_token, discord_bot_token, line_channel_access_token, line_channel_secret, line_dm_policy, line_dm_allowlist, line_group_policy, line_group_allowlist, line_require_mention, line_pairing, slack_bot_token, slack_signing_secret, slack_dm_policy, slack_dm_allowlist, slack_group_policy, slack_group_allowlist, slack_require_mention, slack_pairing, connectors } = body;
1682
+ const { name, description, model, allow_tools, telegram_bot_token, discord_bot_token, line_channel_access_token, line_channel_secret, line_dm_policy, line_dm_allowlist, line_group_policy, line_group_allowlist, line_require_mention, line_pairing, slack_bot_token, slack_signing_secret, slack_dm_policy, slack_dm_allowlist, slack_group_policy, slack_group_allowlist, slack_require_mention, slack_pairing, connectors, whatsapp_account_id, whatsapp_dm_policy, whatsapp_dm_allowlist, whatsapp_group_policy, whatsapp_group_allowlist, whatsapp_require_mention, whatsapp_pairing, whatsapp_cloud_access_token, whatsapp_cloud_phone_number_id, whatsapp_cloud_app_secret, whatsapp_cloud_verify_token, whatsapp_cloud_dm_policy, whatsapp_cloud_dm_allowlist, whatsapp_cloud_pairing, whatsapp_cloud_templates_enabled } = body;
1453
1683
  if (name !== undefined && name !== null && typeof name !== 'string') {
1454
1684
  res.status(400).json({ error: 'name must be a string or null' });
1455
1685
  return;
@@ -1646,6 +1876,131 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1646
1876
  connectorPatch[id] = { enabled };
1647
1877
  }
1648
1878
  }
1879
+ // WhatsApp — access-control fields only, same validation shape as Slack.
1880
+ // No credential fields to validate here at all (see AgentConfig.whatsapp's
1881
+ // doc comment): the "credential" is the linked device session on disk,
1882
+ // never in this PATCH body.
1883
+ if (whatsapp_dm_policy !== undefined && whatsapp_dm_policy !== null &&
1884
+ !(typeof whatsapp_dm_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_dm_policy))) {
1885
+ res.status(400).json({ error: "whatsapp_dm_policy must be 'open', 'allowlist', 'disabled', or null" });
1886
+ return;
1887
+ }
1888
+ if (whatsapp_dm_allowlist !== undefined && whatsapp_dm_allowlist !== null &&
1889
+ !(Array.isArray(whatsapp_dm_allowlist) && whatsapp_dm_allowlist.every((u) => typeof u === 'string'))) {
1890
+ res.status(400).json({ error: 'whatsapp_dm_allowlist must be an array of strings or null' });
1891
+ return;
1892
+ }
1893
+ if (whatsapp_group_policy !== undefined && whatsapp_group_policy !== null &&
1894
+ !(typeof whatsapp_group_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_group_policy))) {
1895
+ res.status(400).json({ error: "whatsapp_group_policy must be 'open', 'allowlist', 'disabled', or null" });
1896
+ return;
1897
+ }
1898
+ if (whatsapp_group_allowlist !== undefined && whatsapp_group_allowlist !== null &&
1899
+ !(Array.isArray(whatsapp_group_allowlist) && whatsapp_group_allowlist.every((u) => typeof u === 'string'))) {
1900
+ res.status(400).json({ error: 'whatsapp_group_allowlist must be an array of strings or null' });
1901
+ return;
1902
+ }
1903
+ if (whatsapp_require_mention !== undefined && whatsapp_require_mention !== null &&
1904
+ typeof whatsapp_require_mention !== 'boolean') {
1905
+ res.status(400).json({ error: 'whatsapp_require_mention must be a boolean or null' });
1906
+ return;
1907
+ }
1908
+ if (whatsapp_pairing !== undefined && whatsapp_pairing !== null &&
1909
+ typeof whatsapp_pairing !== 'boolean') {
1910
+ res.status(400).json({ error: 'whatsapp_pairing must be a boolean or null' });
1911
+ return;
1912
+ }
1913
+ if (whatsapp_account_id !== undefined && typeof whatsapp_account_id !== 'string') {
1914
+ res.status(400).json({ error: 'whatsapp_account_id must be a string' });
1915
+ return;
1916
+ }
1917
+ const whatsappAccessTouched = whatsapp_dm_policy !== undefined || whatsapp_dm_allowlist !== undefined ||
1918
+ whatsapp_group_policy !== undefined || whatsapp_group_allowlist !== undefined ||
1919
+ whatsapp_require_mention !== undefined || whatsapp_pairing !== undefined;
1920
+ // Which account the access fields above apply to. Omitting
1921
+ // whatsapp_account_id targets the first account, which keeps the flat
1922
+ // single-account PATCH contract byte-identical for existing clients.
1923
+ const whatsappTargetAccountId = whatsAppTargetAccountId(agentConfigs.get(agentId), whatsapp_account_id);
1924
+ if (whatsappAccessTouched && typeof whatsapp_account_id === 'string' &&
1925
+ !(0, whatsapp_accounts_1.resolveWhatsAppAccounts)(agentConfigs.get(agentId)?.whatsapp).some((a) => a.id === whatsappTargetAccountId)) {
1926
+ // Patching an account that doesn't exist is a client bug, not an implicit
1927
+ // create — accounts are created through POST /whatsapp/accounts, which is
1928
+ // where id validation and duplicate rejection live.
1929
+ res.status(404).json({ error: `WhatsApp account '${whatsappTargetAccountId}' not found` });
1930
+ return;
1931
+ }
1932
+ // WhatsApp Business Cloud API — same validation shape as Slack above
1933
+ // (webhook-based, real credentials), but FOUR fields must be provided
1934
+ // together or all cleared together, not two.
1935
+ if (whatsapp_cloud_access_token !== undefined && whatsapp_cloud_access_token !== null && typeof whatsapp_cloud_access_token !== 'string') {
1936
+ res.status(400).json({ error: 'whatsapp_cloud_access_token must be a string or null' });
1937
+ return;
1938
+ }
1939
+ if (whatsapp_cloud_phone_number_id !== undefined && whatsapp_cloud_phone_number_id !== null && typeof whatsapp_cloud_phone_number_id !== 'string') {
1940
+ res.status(400).json({ error: 'whatsapp_cloud_phone_number_id must be a string or null' });
1941
+ return;
1942
+ }
1943
+ if (whatsapp_cloud_app_secret !== undefined && whatsapp_cloud_app_secret !== null && typeof whatsapp_cloud_app_secret !== 'string') {
1944
+ res.status(400).json({ error: 'whatsapp_cloud_app_secret must be a string or null' });
1945
+ return;
1946
+ }
1947
+ if (whatsapp_cloud_verify_token !== undefined && whatsapp_cloud_verify_token !== null && typeof whatsapp_cloud_verify_token !== 'string') {
1948
+ res.status(400).json({ error: 'whatsapp_cloud_verify_token must be a string or null' });
1949
+ return;
1950
+ }
1951
+ const whatsappCloudTouched = whatsapp_cloud_access_token !== undefined || whatsapp_cloud_phone_number_id !== undefined ||
1952
+ whatsapp_cloud_app_secret !== undefined || whatsapp_cloud_verify_token !== undefined;
1953
+ if (whatsappCloudTouched) {
1954
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
1955
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
1956
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
1957
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
1958
+ const allSet = at !== '' && pid !== '' && sec !== '' && vt !== '';
1959
+ const allClear = at === '' && pid === '' && sec === '' && vt === '';
1960
+ if (!allSet && !allClear) {
1961
+ res.status(400).json({
1962
+ error: 'whatsapp_cloud_access_token, whatsapp_cloud_phone_number_id, whatsapp_cloud_app_secret, and whatsapp_cloud_verify_token must be provided together',
1963
+ });
1964
+ return;
1965
+ }
1966
+ // Reject bad/expired credentials at Save time instead of persisting them
1967
+ // silently, same reasoning as verifySlackBotToken's Save-time check above.
1968
+ if (allSet) {
1969
+ const verify = await verifyWhatsAppCloudCredentials(at, pid);
1970
+ if (!verify.ok) {
1971
+ res.status(400).json({
1972
+ error: `Invalid WhatsApp Cloud credentials — Graph API check failed: ${verify.error ?? 'unknown error'}`,
1973
+ });
1974
+ return;
1975
+ }
1976
+ }
1977
+ }
1978
+ if (whatsapp_cloud_dm_policy !== undefined && whatsapp_cloud_dm_policy !== null &&
1979
+ !(typeof whatsapp_cloud_dm_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_cloud_dm_policy))) {
1980
+ res.status(400).json({ error: "whatsapp_cloud_dm_policy must be 'open', 'allowlist', 'disabled', or null" });
1981
+ return;
1982
+ }
1983
+ if (whatsapp_cloud_dm_allowlist !== undefined && whatsapp_cloud_dm_allowlist !== null &&
1984
+ !(Array.isArray(whatsapp_cloud_dm_allowlist) && whatsapp_cloud_dm_allowlist.every((u) => typeof u === 'string'))) {
1985
+ res.status(400).json({ error: 'whatsapp_cloud_dm_allowlist must be an array of strings or null' });
1986
+ return;
1987
+ }
1988
+ if (whatsapp_cloud_pairing !== undefined && whatsapp_cloud_pairing !== null &&
1989
+ typeof whatsapp_cloud_pairing !== 'boolean') {
1990
+ res.status(400).json({ error: 'whatsapp_cloud_pairing must be a boolean or null' });
1991
+ return;
1992
+ }
1993
+ // Template opt-in (Phase 3). Not an ACCESS field, but validated and
1994
+ // persisted through the identical "merge into the existing whatsapp_cloud
1995
+ // block" path as whatsapp_cloud_pairing above, so it rides the same flag
1996
+ // rather than growing a second one-field branch.
1997
+ if (whatsapp_cloud_templates_enabled !== undefined && whatsapp_cloud_templates_enabled !== null &&
1998
+ typeof whatsapp_cloud_templates_enabled !== 'boolean') {
1999
+ res.status(400).json({ error: 'whatsapp_cloud_templates_enabled must be a boolean or null' });
2000
+ return;
2001
+ }
2002
+ const whatsappCloudAccessTouched = whatsapp_cloud_dm_policy !== undefined || whatsapp_cloud_dm_allowlist !== undefined ||
2003
+ whatsapp_cloud_pairing !== undefined || whatsapp_cloud_templates_enabled !== undefined;
1649
2004
  try {
1650
2005
  await writeAgentsToConfig(configPath, (agents) => {
1651
2006
  const agent = agents.find((a) => a.id === agentId);
@@ -1799,6 +2154,71 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1799
2154
  const existing = agent.connectors ?? {};
1800
2155
  agent.connectors = { ...existing, ...connectorPatch };
1801
2156
  }
2157
+ // WhatsApp access fields — merged into ONE account of the multi-account
2158
+ // block (see applyWhatsAppAccountPatch, which also explains why the
2159
+ // block is created on first touch instead of skipped when absent).
2160
+ if (whatsappAccessTouched) {
2161
+ applyWhatsAppAccountPatch(agent, whatsappTargetAccountId, {
2162
+ dmPolicy: whatsapp_dm_policy,
2163
+ dmAllowlist: whatsapp_dm_allowlist,
2164
+ groupPolicy: whatsapp_group_policy,
2165
+ groupAllowlist: whatsapp_group_allowlist,
2166
+ requireMention: whatsapp_require_mention,
2167
+ pairing: whatsapp_pairing,
2168
+ });
2169
+ }
2170
+ // WhatsApp Cloud — credential block follows the SLACK pattern (delete
2171
+ // the whole block if all 4 cleared, else merge), the OPPOSITE of the
2172
+ // Baileys `whatsapp` block just above: Cloud API credentials are real,
2173
+ // Meta-issued secrets, not an on-disk device-link session, so there's
2174
+ // no "meaningful before ever linking" case to unconditionally create for.
2175
+ if (whatsappCloudTouched) {
2176
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
2177
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
2178
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
2179
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
2180
+ if (at === '' && pid === '' && sec === '' && vt === '') {
2181
+ delete agent.whatsapp_cloud;
2182
+ }
2183
+ else {
2184
+ const existing = agent.whatsapp_cloud;
2185
+ agent.whatsapp_cloud = { ...(existing ?? {}), accessToken: at, phoneNumberId: pid, appSecret: sec, verifyToken: vt };
2186
+ }
2187
+ }
2188
+ // Access fields — merge into the existing whatsapp_cloud block (re-read
2189
+ // after the credential block above, which may have just created or
2190
+ // deleted it). Skip silently when no whatsapp_cloud channel exists;
2191
+ // policy without credentials is meaningless (same comment Slack's
2192
+ // access block uses).
2193
+ if (whatsappCloudAccessTouched) {
2194
+ const existing = agent.whatsapp_cloud;
2195
+ if (existing) {
2196
+ if (whatsapp_cloud_dm_policy !== undefined) {
2197
+ if (whatsapp_cloud_dm_policy === null)
2198
+ delete existing.dmPolicy;
2199
+ else
2200
+ existing.dmPolicy = whatsapp_cloud_dm_policy;
2201
+ }
2202
+ if (whatsapp_cloud_dm_allowlist !== undefined) {
2203
+ if (whatsapp_cloud_dm_allowlist === null)
2204
+ delete existing.dmAllowlist;
2205
+ else
2206
+ existing.dmAllowlist = whatsapp_cloud_dm_allowlist;
2207
+ }
2208
+ if (whatsapp_cloud_pairing !== undefined) {
2209
+ if (whatsapp_cloud_pairing === null)
2210
+ delete existing.pairing;
2211
+ else
2212
+ existing.pairing = whatsapp_cloud_pairing;
2213
+ }
2214
+ if (whatsapp_cloud_templates_enabled !== undefined) {
2215
+ if (whatsapp_cloud_templates_enabled === null)
2216
+ delete existing.templatesEnabled;
2217
+ else
2218
+ existing.templatesEnabled = whatsapp_cloud_templates_enabled;
2219
+ }
2220
+ }
2221
+ }
1802
2222
  });
1803
2223
  }
1804
2224
  catch (err) {
@@ -2032,6 +2452,83 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2032
2452
  }
2033
2453
  }
2034
2454
  }
2455
+ if (whatsappAccessTouched) {
2456
+ applyWhatsAppAccountPatch(cfg, whatsappTargetAccountId, {
2457
+ dmPolicy: whatsapp_dm_policy,
2458
+ dmAllowlist: whatsapp_dm_allowlist,
2459
+ groupPolicy: whatsapp_group_policy,
2460
+ groupAllowlist: whatsapp_group_allowlist,
2461
+ requireMention: whatsapp_require_mention,
2462
+ pairing: whatsapp_pairing,
2463
+ });
2464
+ // Access-only edits never add or remove an account, so this re-sync is
2465
+ // just handing the managers their new policy — syncWhatsAppAccounts sees
2466
+ // the changed snapshot but keeps every existing manager (and its linked
2467
+ // session) alive because the id set is unchanged.
2468
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2469
+ // Pending senders are namespaced per WhatsApp account (see
2470
+ // pending-senders.ts's `channel` dimension) — the same JID can
2471
+ // legitimately knock on two different numbers, so clearing must target
2472
+ // only the account this PATCH actually touched.
2473
+ const whatsappPendingChannel = `whatsapp:${whatsappTargetAccountId}`;
2474
+ if (Array.isArray(whatsapp_dm_allowlist)) {
2475
+ for (const jid of whatsapp_dm_allowlist)
2476
+ (0, pending_senders_1.clearPendingSender)(whatsappPendingChannel, agentId, jid);
2477
+ }
2478
+ if (Array.isArray(whatsapp_group_allowlist)) {
2479
+ for (const jid of whatsapp_group_allowlist)
2480
+ (0, pending_senders_1.clearPendingSender)(whatsappPendingChannel, agentId, jid);
2481
+ }
2482
+ }
2483
+ if (whatsappCloudTouched) {
2484
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
2485
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
2486
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
2487
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
2488
+ if (at && pid && sec && vt) {
2489
+ cfg.whatsapp_cloud = { ...(cfg.whatsapp_cloud ?? {}), accessToken: at, phoneNumberId: pid, appSecret: sec, verifyToken: vt };
2490
+ }
2491
+ else {
2492
+ delete cfg.whatsapp_cloud;
2493
+ }
2494
+ // WhatsApp Cloud is webhook-based — no receiver to start/stop. The
2495
+ // webhook router reads config live via runner.getAgentConfig(); just
2496
+ // keep the runner's copy in sync (same as Slack/LINE above).
2497
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2498
+ }
2499
+ if (whatsappCloudAccessTouched && cfg.whatsapp_cloud) {
2500
+ if (whatsapp_cloud_dm_policy !== undefined) {
2501
+ if (whatsapp_cloud_dm_policy === null)
2502
+ delete cfg.whatsapp_cloud.dmPolicy;
2503
+ else
2504
+ cfg.whatsapp_cloud.dmPolicy = whatsapp_cloud_dm_policy;
2505
+ }
2506
+ if (whatsapp_cloud_dm_allowlist !== undefined) {
2507
+ if (whatsapp_cloud_dm_allowlist === null)
2508
+ delete cfg.whatsapp_cloud.dmAllowlist;
2509
+ else
2510
+ cfg.whatsapp_cloud.dmAllowlist = whatsapp_cloud_dm_allowlist;
2511
+ }
2512
+ if (whatsapp_cloud_pairing !== undefined) {
2513
+ if (whatsapp_cloud_pairing === null)
2514
+ delete cfg.whatsapp_cloud.pairing;
2515
+ else
2516
+ cfg.whatsapp_cloud.pairing = whatsapp_cloud_pairing;
2517
+ }
2518
+ if (whatsapp_cloud_templates_enabled !== undefined) {
2519
+ if (whatsapp_cloud_templates_enabled === null)
2520
+ delete cfg.whatsapp_cloud.templatesEnabled;
2521
+ else
2522
+ cfg.whatsapp_cloud.templatesEnabled = whatsapp_cloud_templates_enabled;
2523
+ }
2524
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2525
+ // Anyone just added to the allowlist is now allowed — drop them from
2526
+ // the in-memory knock list so the discovery UI stops surfacing them.
2527
+ if (Array.isArray(whatsapp_cloud_dm_allowlist)) {
2528
+ for (const id of whatsapp_cloud_dm_allowlist)
2529
+ (0, pending_senders_1.clearPendingSender)('whatsapp_cloud', agentId, id);
2530
+ }
2531
+ }
2035
2532
  res.json({
2036
2533
  agent: {
2037
2534
  id: agentId,
@@ -2075,6 +2572,17 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2075
2572
  slack_group_allowlist: cfg.slack?.signingSecret ? (cfg.slack?.groupAllowlist ?? []) : null,
2076
2573
  slack_require_mention: cfg.slack?.signingSecret ? (cfg.slack?.requireMention ?? null) : null,
2077
2574
  slack_pairing: cfg.slack?.signingSecret ? (cfg.slack?.pairing ?? true) : null,
2575
+ ...whatsAppResponseFields(agentRunners, cfg, agentId),
2576
+ // WhatsApp Cloud — same shape/semantics as Slack above, mirrors the
2577
+ // GET /agents list response exactly.
2578
+ whatsapp_cloud_connected: !!cfg.whatsapp_cloud?.appSecret,
2579
+ whatsapp_cloud_access_token_preview: cfg.whatsapp_cloud?.accessToken ? maskToken(cfg.whatsapp_cloud.accessToken) : null,
2580
+ whatsapp_cloud_phone_number_id: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.phoneNumberId ?? null) : null,
2581
+ whatsapp_cloud_webhook_path: cfg.whatsapp_cloud?.appSecret ? `/webhooks/whatsapp_cloud/${agentId}` : null,
2582
+ whatsapp_cloud_dm_policy: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmPolicy ?? null) : null,
2583
+ whatsapp_cloud_dm_allowlist: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmAllowlist ?? []) : null,
2584
+ whatsapp_cloud_pairing: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.pairing ?? true) : null,
2585
+ whatsapp_cloud_templates_enabled: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.templatesEnabled ?? false) : null,
2078
2586
  },
2079
2587
  });
2080
2588
  });
@@ -2351,6 +2859,513 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2351
2859
  (0, pending_senders_1.clearPendingSender)('slack', agentId, senderId);
2352
2860
  res.json({ ok: true });
2353
2861
  });
2862
+ /**
2863
+ * GET /api/v1/agents/:agentId/whatsapp/pending?account_id=...
2864
+ * Recently denied WhatsApp senders/groups (Tier 1/3 discovery aid) for ONE
2865
+ * linked number. Admin only. Namespaced per-account (`whatsapp:${accountId}`
2866
+ * in the shared pending-senders store) — the same JID can legitimately knock
2867
+ * on two different numbers on the same agent, so this must not merge them.
2868
+ * `account_id` defaults to the pre-multi-account 'default' id, so an old
2869
+ * caller that never learned about accounts still gets that number's list.
2870
+ */
2871
+ router.get('/v1/agents/:agentId/whatsapp/pending', auth, (req, res) => {
2872
+ const { agentId } = req.params;
2873
+ const accountId = req.query.account_id || whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID;
2874
+ const apiKey = req.apiKey;
2875
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2876
+ res.status(403).json({ error: 'Admin key required' });
2877
+ return;
2878
+ }
2879
+ if (!agentConfigs.has(agentId)) {
2880
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2881
+ return;
2882
+ }
2883
+ res.json({ senders: (0, pending_senders_1.getPendingSenders)(`whatsapp:${accountId}`, agentId) });
2884
+ });
2885
+ /**
2886
+ * DELETE /api/v1/agents/:agentId/whatsapp/pending/:senderId?account_id=...
2887
+ * Dismiss one knock from one number's pending list (admin only). The id is
2888
+ * a WhatsApp JID (DM sender or group). Same per-account namespacing and
2889
+ * default as the GET route above.
2890
+ */
2891
+ router.delete('/v1/agents/:agentId/whatsapp/pending/:senderId', auth, (req, res) => {
2892
+ const apiKey = req.apiKey;
2893
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2894
+ res.status(403).json({ error: 'Admin key required' });
2895
+ return;
2896
+ }
2897
+ const { agentId, senderId } = req.params;
2898
+ const accountId = req.query.account_id || whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID;
2899
+ if (!agentConfigs.has(agentId)) {
2900
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2901
+ return;
2902
+ }
2903
+ (0, pending_senders_1.clearPendingSender)(`whatsapp:${accountId}`, agentId, senderId);
2904
+ res.json({ ok: true });
2905
+ });
2906
+ /**
2907
+ * GET /api/v1/agents/:agentId/whatsapp_cloud/pending
2908
+ * Recently denied WhatsApp Cloud senders (Tier 1 allowlist discovery aid).
2909
+ * Admin only. Mirrors GET .../slack/pending exactly, keyed under the
2910
+ * 'whatsapp_cloud' channel namespace in the shared pending-senders store.
2911
+ */
2912
+ router.get('/v1/agents/:agentId/whatsapp_cloud/pending', auth, (req, res) => {
2913
+ const { agentId } = req.params;
2914
+ const apiKey = req.apiKey;
2915
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2916
+ res.status(403).json({ error: 'Admin key required' });
2917
+ return;
2918
+ }
2919
+ if (!agentConfigs.has(agentId)) {
2920
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2921
+ return;
2922
+ }
2923
+ res.json({ senders: (0, pending_senders_1.getPendingSenders)('whatsapp_cloud', agentId) });
2924
+ });
2925
+ /**
2926
+ * DELETE /api/v1/agents/:agentId/whatsapp_cloud/pending/:senderId
2927
+ * Dismiss one knock from the in-memory pending list (admin only). Mirrors
2928
+ * DELETE .../slack/pending/:senderId exactly. The id is a bare WhatsApp
2929
+ * Cloud phone-number string (no groups on this channel).
2930
+ */
2931
+ router.delete('/v1/agents/:agentId/whatsapp_cloud/pending/:senderId', auth, (req, res) => {
2932
+ const apiKey = req.apiKey;
2933
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2934
+ res.status(403).json({ error: 'Admin key required' });
2935
+ return;
2936
+ }
2937
+ const { agentId, senderId } = req.params;
2938
+ if (!agentConfigs.has(agentId)) {
2939
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2940
+ return;
2941
+ }
2942
+ (0, pending_senders_1.clearPendingSender)('whatsapp_cloud', agentId, senderId);
2943
+ res.json({ ok: true });
2944
+ });
2945
+ /**
2946
+ * GET /api/v1/agents/:agentId/whatsapp/status
2947
+ * Live link status — status/qr/pairingCode/phoneNumber straight from the
2948
+ * in-process WhatsAppManager, never config-derived (there is no config
2949
+ * field to derive it from). Polled by the web UI during linking and while
2950
+ * showing the connected card. Requires write access (same as every other
2951
+ * channel's connect surface), not just read.
2952
+ *
2953
+ * `?account_id=` selects one linked number; omitting it targets the agent's
2954
+ * first account, which is the single account for anyone who hasn't added a
2955
+ * second one. Same convention on link/pairing-code/unlink/send below.
2956
+ */
2957
+ router.get('/v1/agents/:agentId/whatsapp/status', auth, (req, res) => {
2958
+ const { agentId } = req.params;
2959
+ const apiKey = req.apiKey;
2960
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
2961
+ res.status(403).json({ error: 'Write permission required' });
2962
+ return;
2963
+ }
2964
+ const runner = agentRunners.get(agentId);
2965
+ if (!runner) {
2966
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2967
+ return;
2968
+ }
2969
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.query.account_id, res);
2970
+ if (accountId === null)
2971
+ return;
2972
+ res.json({ account_id: accountId, ...(runner.getWhatsAppStatus?.(accountId) ?? { status: 'unlinked' }) });
2973
+ });
2974
+ /**
2975
+ * POST /api/v1/agents/:agentId/whatsapp/link
2976
+ * Start (or restart) a QR-code linking flow. Returns immediately — poll
2977
+ * GET .../status for the QR image once it's available.
2978
+ */
2979
+ router.post('/v1/agents/:agentId/whatsapp/link', auth, async (req, res) => {
2980
+ const { agentId } = req.params;
2981
+ const apiKey = req.apiKey;
2982
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
2983
+ res.status(403).json({ error: 'Write permission required' });
2984
+ return;
2985
+ }
2986
+ const runner = agentRunners.get(agentId);
2987
+ if (!runner) {
2988
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2989
+ return;
2990
+ }
2991
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.body?.account_id, res);
2992
+ if (accountId === null)
2993
+ return;
2994
+ try {
2995
+ await runner.startWhatsAppLinking(accountId);
2996
+ res.json({ ok: true, account_id: accountId });
2997
+ }
2998
+ catch (err) {
2999
+ res.status(500).json({ error: err.message });
3000
+ }
3001
+ });
3002
+ /**
3003
+ * POST /api/v1/agents/:agentId/whatsapp/pairing-code
3004
+ * Start linking via a text pairing code instead of QR. Body: {phoneNumber}
3005
+ * (E.164, e.g. "+15551234567"). Returns the code once Baileys issues it.
3006
+ */
3007
+ router.post('/v1/agents/:agentId/whatsapp/pairing-code', auth, async (req, res) => {
3008
+ const { agentId } = req.params;
3009
+ const apiKey = req.apiKey;
3010
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3011
+ res.status(403).json({ error: 'Write permission required' });
3012
+ return;
3013
+ }
3014
+ const runner = agentRunners.get(agentId);
3015
+ if (!runner) {
3016
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3017
+ return;
3018
+ }
3019
+ const { phoneNumber, account_id } = req.body;
3020
+ if (typeof phoneNumber !== 'string' || !phoneNumber.trim()) {
3021
+ res.status(400).json({ error: 'phoneNumber is required' });
3022
+ return;
3023
+ }
3024
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, account_id, res);
3025
+ if (accountId === null)
3026
+ return;
3027
+ try {
3028
+ const code = await runner.requestWhatsAppPairingCode(phoneNumber.trim(), accountId);
3029
+ res.json({ pairingCode: code, account_id: accountId });
3030
+ }
3031
+ catch (err) {
3032
+ res.status(500).json({ error: err.message });
3033
+ }
3034
+ });
3035
+ /**
3036
+ * POST /api/v1/agents/:agentId/whatsapp/unlink
3037
+ * Logout and wipe the linked session. The user must scan/pair fresh afterward.
3038
+ */
3039
+ router.post('/v1/agents/:agentId/whatsapp/unlink', auth, async (req, res) => {
3040
+ const { agentId } = req.params;
3041
+ const apiKey = req.apiKey;
3042
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3043
+ res.status(403).json({ error: 'Write permission required' });
3044
+ return;
3045
+ }
3046
+ const runner = agentRunners.get(agentId);
3047
+ if (!runner) {
3048
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3049
+ return;
3050
+ }
3051
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.body?.account_id, res);
3052
+ if (accountId === null)
3053
+ return;
3054
+ await runner.unlinkWhatsApp(accountId);
3055
+ res.json({ ok: true, account_id: accountId });
3056
+ });
3057
+ /**
3058
+ * POST /api/v1/agents/:agentId/whatsapp/send
3059
+ * INTERNAL — called by the `whatsapp_reply` MCP tool (via GATEWAY_API_URL/
3060
+ * GATEWAY_API_KEY, same as every other MCP subprocess reaches the gateway),
3061
+ * never by the web UI. Same auth as the rest of /api — no separate secret.
3062
+ * Exists because Baileys has no stateless per-call send path (see
3063
+ * WhatsAppManager's doc comment): the MCP tool cannot open its own
3064
+ * connection, it must reach the live socket this process already holds.
3065
+ */
3066
+ router.post('/v1/agents/:agentId/whatsapp/send', auth, async (req, res) => {
3067
+ const { agentId } = req.params;
3068
+ const apiKey = req.apiKey;
3069
+ // write-scoped, like every other whatsapp/* mutation route: this route
3070
+ // sends real outbound messages, not a read.
3071
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3072
+ res.status(403).json({ error: 'Write permission required' });
3073
+ return;
3074
+ }
3075
+ const runner = agentRunners.get(agentId);
3076
+ if (!runner) {
3077
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3078
+ return;
3079
+ }
3080
+ const cfg = agentConfigs.get(agentId);
3081
+ const { jid, text, image_path, account_id, reply_to_message_id, as_document, message_id } = req.body;
3082
+ if (typeof jid !== 'string' || !jid) {
3083
+ res.status(400).json({ error: 'jid is required' });
3084
+ return;
3085
+ }
3086
+ if (typeof text !== 'string' && typeof image_path !== 'string') {
3087
+ res.status(400).json({ error: 'text or image_path is required' });
3088
+ return;
3089
+ }
3090
+ // account_id comes from the `<channel account_id="…">` tag the agent was
3091
+ // handed, so an agent replying in a thread answers from the SAME number the
3092
+ // message arrived on. Omitted (older MCP builds, or an unprompted send) the
3093
+ // runner falls back to the inbound account it remembers for this chat.
3094
+ if (account_id !== undefined && typeof account_id !== 'string') {
3095
+ res.status(400).json({ error: 'account_id must be a string' });
3096
+ return;
3097
+ }
3098
+ // Resolve which account this send will ACTUALLY use — same fallback
3099
+ // logic sendWhatsAppMessage applies internally (last-inbound-account,
3100
+ // then 'default', then the agent's only account) — so the checks below
3101
+ // gate the real target without second-guessing or changing that
3102
+ // resolution when account_id is omitted.
3103
+ let resolvedAccountId;
3104
+ try {
3105
+ resolvedAccountId = runner.resolveWhatsAppAccountId(jid, account_id);
3106
+ }
3107
+ catch (err) {
3108
+ res.status(400).json({ error: err.message });
3109
+ return;
3110
+ }
3111
+ // jid allowlist: a prompt-injected turn must not be able to message an
3112
+ // arbitrary WhatsApp JID through this "internal" bridge route. Confine
3113
+ // sends to whatever this account is already configured to receive FROM
3114
+ // — the same dmPolicy/dmAllowlist (or groupPolicy/groupAllowlist) gate
3115
+ // inbound messages pass through. A normal reply's jid is exactly the
3116
+ // chat the inbound message arrived on, which by construction already
3117
+ // cleared this same gate, so the golden path is unaffected.
3118
+ const account = (0, whatsapp_accounts_1.findWhatsAppAccount)(cfg?.whatsapp, resolvedAccountId);
3119
+ const isGroupJid = jid.endsWith('@g.us');
3120
+ const isDmJid = jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid');
3121
+ const jidAllowed = isGroupJid
3122
+ ? (0, whatsapp_access_1.isWhatsAppSenderAllowed)(account?.groupPolicy, account?.groupAllowlist, jid)
3123
+ : isDmJid && (0, whatsapp_access_1.isWhatsAppSenderAllowed)(account?.dmPolicy, account?.dmAllowlist, jid);
3124
+ if (!jidAllowed) {
3125
+ res.status(403).json({ error: `jid '${jid}' is not allowed to receive messages from this account` });
3126
+ return;
3127
+ }
3128
+ // image_path confinement: refuse to send a file living inside this
3129
+ // account's own state directory (creds.json et al) — the concrete
3130
+ // exfiltration path a prompt-injected turn would use. Fail-open on an
3131
+ // unresolvable path (missing file, etc.) — the send call below produces
3132
+ // the real error for that case.
3133
+ if (typeof image_path === 'string' && image_path && cfg) {
3134
+ try {
3135
+ const real = fs.realpathSync(image_path);
3136
+ const stateReal = fs.realpathSync((0, whatsapp_accounts_1.whatsAppStateDir)(cfg.workspace, resolvedAccountId));
3137
+ if (real === stateReal || real.startsWith(stateReal + path.sep)) {
3138
+ res.status(400).json({ error: `refusing to send channel state: ${image_path}` });
3139
+ return;
3140
+ }
3141
+ }
3142
+ catch {
3143
+ /* unresolvable path — let sendWhatsAppMessage report the real error */
3144
+ }
3145
+ }
3146
+ // Phase 2 extras — each is only carried through when actually present and
3147
+ // well-typed, so an older MCP build's three-field body produces an empty
3148
+ // options object and the exact pre-Phase-2 send behaviour.
3149
+ const sendOpts = {};
3150
+ if (typeof reply_to_message_id === 'string' && reply_to_message_id) {
3151
+ sendOpts.quotedMessageId = reply_to_message_id;
3152
+ }
3153
+ if (as_document === true)
3154
+ sendOpts.asDocument = true;
3155
+ // The inbound message whose ⏳ ack should be cleared once this send lands.
3156
+ // Baileys' MCP tool can't clear it itself (it has no socket of its own) —
3157
+ // see WhatsAppManager.clearAckReaction.
3158
+ if (typeof message_id === 'string' && message_id)
3159
+ sendOpts.ackMessageId = message_id;
3160
+ try {
3161
+ await runner.sendWhatsAppMessage(jid, typeof text === 'string' ? text : '', typeof image_path === 'string' ? image_path : undefined, account_id, sendOpts);
3162
+ res.json({ ok: true });
3163
+ }
3164
+ catch (err) {
3165
+ res.status(502).json({ error: err.message });
3166
+ }
3167
+ });
3168
+ /**
3169
+ * GET /api/v1/agents/:agentId/whatsapp/accounts
3170
+ * Every linked-number slot on this agent — config plus live link state, the
3171
+ * same objects the agent response's `whatsapp_accounts` field carries.
3172
+ */
3173
+ router.get('/v1/agents/:agentId/whatsapp/accounts', auth, (req, res) => {
3174
+ const { agentId } = req.params;
3175
+ const apiKey = req.apiKey;
3176
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3177
+ res.status(403).json({ error: 'Write permission required' });
3178
+ return;
3179
+ }
3180
+ const cfg = agentConfigs.get(agentId);
3181
+ if (!cfg) {
3182
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3183
+ return;
3184
+ }
3185
+ res.json({ accounts: whatsAppAccountsResponse(agentRunners, cfg, agentId) });
3186
+ });
3187
+ /**
3188
+ * POST /api/v1/agents/:agentId/whatsapp/accounts
3189
+ * Body: {id, label?}. Adds an EMPTY account slot and starts its manager; the
3190
+ * number itself is linked afterward through the normal
3191
+ * POST .../whatsapp/link or .../pairing-code with this `account_id`.
3192
+ *
3193
+ * The id becomes a directory name under `.whatsapp-state/`, so it's held to
3194
+ * WHATSAPP_ACCOUNT_ID_RE's conservative slug. 'default' can never be created
3195
+ * here: it always already exists (implicitly when the config has no accounts
3196
+ * array at all), which is what keeps a pre-multi-account linked session
3197
+ * addressable — see src/config/whatsapp-accounts.ts.
3198
+ */
3199
+ router.post('/v1/agents/:agentId/whatsapp/accounts', auth, async (req, res) => {
3200
+ const { agentId } = req.params;
3201
+ const apiKey = req.apiKey;
3202
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3203
+ res.status(403).json({ error: 'Write permission required' });
3204
+ return;
3205
+ }
3206
+ if (!configPath) {
3207
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
3208
+ return;
3209
+ }
3210
+ const cfg = agentConfigs.get(agentId);
3211
+ if (!cfg) {
3212
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3213
+ return;
3214
+ }
3215
+ const { id, label } = req.body;
3216
+ if (typeof id !== 'string' || !whatsapp_accounts_1.WHATSAPP_ACCOUNT_ID_RE.test(id)) {
3217
+ res.status(400).json({ error: 'id must be 1-32 chars of lowercase letters, digits, "-" or "_", starting with a letter or digit' });
3218
+ return;
3219
+ }
3220
+ if (label !== undefined && label !== null && typeof label !== 'string') {
3221
+ res.status(400).json({ error: 'label must be a string or null' });
3222
+ return;
3223
+ }
3224
+ // Fast-fail check outside the lock, for a snappy 409 in the common case —
3225
+ // the authoritative check happens again inside the lock below.
3226
+ if ((0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp).some((a) => a.id === id)) {
3227
+ res.status(409).json({ error: `WhatsApp account '${id}' already exists` });
3228
+ return;
3229
+ }
3230
+ const entry = { id, ...(typeof label === 'string' && label.trim() ? { label: label.trim() } : {}) };
3231
+ try {
3232
+ // The existing-accounts READ, the disk WRITE, and the in-memory
3233
+ // `cfg.whatsapp` ASSIGNMENT all happen inside one lock-protected
3234
+ // section — otherwise two concurrent add/remove requests for the same
3235
+ // agent can both read the same stale `existing`, and the loser's
3236
+ // in-memory assignment (built from that stale snapshot) clobbers the
3237
+ // winner's, even though the file writes themselves are already
3238
+ // serialized. updateAgentConfig() propagates whatever ends up in
3239
+ // `cfg.whatsapp` straight to the live WhatsAppManager set, so a stale
3240
+ // clobber here tears down a just-added account's manager.
3241
+ await (0, config_write_lock_1.withConfigWriteLock)(configPath, async () => {
3242
+ const existing = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3243
+ if (existing.some((a) => a.id === id)) {
3244
+ throw Object.assign(new Error(`WhatsApp account '${id}' already exists`), { code: 'DUPLICATE_ACCOUNT' });
3245
+ }
3246
+ await writeAgentsToConfigImpl(configPath, (agents) => {
3247
+ const agent = agents.find((a) => a.id === agentId);
3248
+ if (!agent)
3249
+ return;
3250
+ // Reuses the PATCH merge helper purely for its create-the-block-and-
3251
+ // materialize-the-implicit-default behavior; the patch itself is empty.
3252
+ applyWhatsAppAccountPatch(agent, id, {});
3253
+ if (!entry.label)
3254
+ return;
3255
+ const block = agent.whatsapp;
3256
+ const written = block.accounts.find((a) => a.id === id);
3257
+ if (written)
3258
+ written.label = entry.label;
3259
+ });
3260
+ // Materializes the implicit 'default' too, so the in-memory list
3261
+ // matches what was just written to disk.
3262
+ cfg.whatsapp = { accounts: [...existing, entry] };
3263
+ });
3264
+ }
3265
+ catch (err) {
3266
+ const code = err.code;
3267
+ res.status(code === 'DUPLICATE_ACCOUNT' ? 409 : 500).json({ error: err.message });
3268
+ return;
3269
+ }
3270
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
3271
+ const accounts = whatsAppAccountsResponse(agentRunners, cfg, agentId);
3272
+ res.status(201).json({ account: accounts.find((a) => a.id === id), accounts });
3273
+ });
3274
+ /**
3275
+ * DELETE /api/v1/agents/:agentId/whatsapp/accounts/:accountId
3276
+ * Unlinks the number (logout + wipe that account's session directory), tears
3277
+ * down its manager, and drops it from config.
3278
+ *
3279
+ * The last remaining account is deliberately NOT deletable: with no accounts
3280
+ * left the config resolves back to an implicit 'default' (an agent always has
3281
+ * one linkable slot), so the delete would silently degrade into an unlink.
3282
+ * Callers wanting that should call POST .../whatsapp/unlink, which says so.
3283
+ */
3284
+ router.delete('/v1/agents/:agentId/whatsapp/accounts/:accountId', auth, async (req, res) => {
3285
+ const { agentId, accountId } = req.params;
3286
+ const apiKey = req.apiKey;
3287
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3288
+ res.status(403).json({ error: 'Write permission required' });
3289
+ return;
3290
+ }
3291
+ if (!configPath) {
3292
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
3293
+ return;
3294
+ }
3295
+ const cfg = agentConfigs.get(agentId);
3296
+ if (!cfg) {
3297
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3298
+ return;
3299
+ }
3300
+ const existing = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3301
+ // Fast-fail check outside the lock, for a snappy 404/409 in the common
3302
+ // case — the authoritative check happens again inside the lock below,
3303
+ // same pattern as the POST handler above.
3304
+ if (!existing.some((a) => a.id === accountId)) {
3305
+ res.status(404).json({ error: `WhatsApp account '${accountId}' not found` });
3306
+ return;
3307
+ }
3308
+ if (existing.length === 1) {
3309
+ res.status(409).json({ error: 'Cannot remove the last WhatsApp account — use POST /whatsapp/unlink instead' });
3310
+ return;
3311
+ }
3312
+ // Unlink FIRST: once the account is out of config its manager is gone, and
3313
+ // the session directory would be left behind still logged in on the phone.
3314
+ try {
3315
+ await agentRunners.get(agentId)?.unlinkWhatsApp(accountId);
3316
+ }
3317
+ catch (err) {
3318
+ res.status(500).json({ error: `Failed to unlink account: ${err.message}` });
3319
+ return;
3320
+ }
3321
+ try {
3322
+ // Disk write and in-memory `cfg.whatsapp` update in one lock-protected
3323
+ // section — same race as the POST handler above: a concurrent
3324
+ // add/remove for this agent must not be able to interleave between the
3325
+ // read and the assignment. `unlinkWhatsApp` stays OUTSIDE the lock
3326
+ // (above) since it's a slow, network-bound Baileys call and holding
3327
+ // the global config lock across it would stall unrelated writers.
3328
+ await (0, config_write_lock_1.withConfigWriteLock)(configPath, async () => {
3329
+ // Authoritative re-check against the CURRENT cfg.whatsapp — a
3330
+ // concurrent DELETE for a different account may have already run its
3331
+ // own lock-protected section and shrunk the list since the fast-fail
3332
+ // check above read its (now stale) snapshot. Without this, two
3333
+ // concurrent deletes of two different accounts, with exactly two
3334
+ // configured, could both pass the outer check and leave `accounts: []`
3335
+ // on disk, silently violating the "always at least one" invariant.
3336
+ const fresh = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3337
+ if (!fresh.some((a) => a.id === accountId)) {
3338
+ throw Object.assign(new Error(`WhatsApp account '${accountId}' not found`), { code: 'NOT_FOUND' });
3339
+ }
3340
+ if (fresh.length === 1) {
3341
+ throw Object.assign(new Error('Cannot remove the last WhatsApp account — use POST /whatsapp/unlink instead'), { code: 'LAST_ACCOUNT' });
3342
+ }
3343
+ await writeAgentsToConfigImpl(configPath, (agents) => {
3344
+ const agent = agents.find((a) => a.id === agentId);
3345
+ const block = agent?.whatsapp;
3346
+ if (!block || !Array.isArray(block.accounts))
3347
+ return;
3348
+ block.accounts = block.accounts.filter((a) => a.id !== accountId);
3349
+ });
3350
+ cfg.whatsapp = { accounts: fresh.filter((a) => a.id !== accountId) };
3351
+ });
3352
+ }
3353
+ catch (err) {
3354
+ const code = err.code;
3355
+ if (code === 'NOT_FOUND') {
3356
+ res.status(404).json({ error: err.message });
3357
+ return;
3358
+ }
3359
+ if (code === 'LAST_ACCOUNT') {
3360
+ res.status(409).json({ error: err.message });
3361
+ return;
3362
+ }
3363
+ res.status(500).json({ error: `Failed to write config: ${err.message}` });
3364
+ return;
3365
+ }
3366
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
3367
+ res.json({ ok: true, accounts: whatsAppAccountsResponse(agentRunners, cfg, agentId) });
3368
+ });
2354
3369
  /**
2355
3370
  * DELETE /api/v1/agents/:agentId/telegram/allow/:userId
2356
3371
  * Remove a user from the allowFrom list. Admin only.
@@ -2791,7 +3806,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2791
3806
  }
2792
3807
  const { source, rawChatId } = parseHistoryChatId(chatId);
2793
3808
  if (!(0, types_1.isChatChannel)(source)) {
2794
- res.status(400).json({ error: 'Sessions endpoint only supports telegram/discord/line/slack chats' });
3809
+ res.status(400).json({ error: 'Sessions endpoint only supports telegram/discord/line/slack/whatsapp/whatsapp_cloud chats' });
2795
3810
  return;
2796
3811
  }
2797
3812
  try {
@@ -2977,7 +3992,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2977
3992
  }
2978
3993
  const { source, rawChatId } = parseHistoryChatId(chatId);
2979
3994
  if (!(0, types_1.isChatChannel)(source)) {
2980
- res.status(400).json({ error: 'Cross-channel messaging only supported for telegram/discord/line/slack chats' });
3995
+ res.status(400).json({ error: 'Cross-channel messaging only supported for telegram/discord/line/slack/whatsapp/whatsapp_cloud chats' });
2981
3996
  return;
2982
3997
  }
2983
3998
  const body = req.body;
@@ -3861,6 +4876,13 @@ function parseHistoryChatId(fullChatId) {
3861
4876
  return { source: 'line', rawChatId: fullChatId.slice(5) };
3862
4877
  if (fullChatId.startsWith('slack-'))
3863
4878
  return { source: 'slack', rawChatId: fullChatId.slice(6) };
4879
+ // Checked BEFORE the plain 'whatsapp-' branch below: 'whatsapp_cloud-' does
4880
+ // NOT collide with it (the char after "whatsapp" differs, '_' vs '-'), but
4881
+ // ordering this first keeps the two visually adjacent and unambiguous.
4882
+ if (fullChatId.startsWith('whatsapp_cloud-'))
4883
+ return { source: 'whatsapp_cloud', rawChatId: fullChatId.slice(15) };
4884
+ if (fullChatId.startsWith('whatsapp-'))
4885
+ return { source: 'whatsapp', rawChatId: fullChatId.slice(9) };
3864
4886
  return { source: 'api', rawChatId: fullChatId };
3865
4887
  }
3866
4888
  //# sourceMappingURL=router.js.map