@0xmaxma/claude-gateway 1.8.10 → 1.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +14 -1
  2. package/config.template.json +2 -1
  3. package/dist/agent/runner.d.ts +72 -0
  4. package/dist/agent/runner.d.ts.map +1 -1
  5. package/dist/agent/runner.js +300 -6
  6. package/dist/agent/runner.js.map +1 -1
  7. package/dist/api/router.d.ts.map +1 -1
  8. package/dist/api/router.js +1001 -4
  9. package/dist/api/router.js.map +1 -1
  10. package/dist/api/webhooks-router.d.ts +2 -0
  11. package/dist/api/webhooks-router.d.ts.map +1 -1
  12. package/dist/api/webhooks-router.js +2 -0
  13. package/dist/api/webhooks-router.js.map +1 -1
  14. package/dist/api/whatsapp-access.d.ts +123 -0
  15. package/dist/api/whatsapp-access.d.ts.map +1 -0
  16. package/dist/api/whatsapp-access.js +135 -0
  17. package/dist/api/whatsapp-access.js.map +1 -0
  18. package/dist/api/whatsapp-cloud-access.d.ts +48 -0
  19. package/dist/api/whatsapp-cloud-access.d.ts.map +1 -0
  20. package/dist/api/whatsapp-cloud-access.js +58 -0
  21. package/dist/api/whatsapp-cloud-access.js.map +1 -0
  22. package/dist/api/whatsapp-cloud-client.d.ts +143 -0
  23. package/dist/api/whatsapp-cloud-client.d.ts.map +1 -0
  24. package/dist/api/whatsapp-cloud-client.js +298 -0
  25. package/dist/api/whatsapp-cloud-client.js.map +1 -0
  26. package/dist/api/whatsapp-cloud-webhook-router.d.ts +132 -0
  27. package/dist/api/whatsapp-cloud-webhook-router.d.ts.map +1 -0
  28. package/dist/api/whatsapp-cloud-webhook-router.js +494 -0
  29. package/dist/api/whatsapp-cloud-webhook-router.js.map +1 -0
  30. package/dist/config/loader.d.ts.map +1 -1
  31. package/dist/config/loader.js +7 -0
  32. package/dist/config/loader.js.map +1 -1
  33. package/dist/config/whatsapp-accounts.d.ts +77 -0
  34. package/dist/config/whatsapp-accounts.d.ts.map +1 -0
  35. package/dist/config/whatsapp-accounts.js +218 -0
  36. package/dist/config/whatsapp-accounts.js.map +1 -0
  37. package/dist/history/db.d.ts.map +1 -1
  38. package/dist/history/db.js +43 -11
  39. package/dist/history/db.js.map +1 -1
  40. package/dist/history/types.d.ts +18 -1
  41. package/dist/history/types.d.ts.map +1 -1
  42. package/dist/history/types.js +1 -1
  43. package/dist/history/types.js.map +1 -1
  44. package/dist/index.js +18 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/session/process.d.ts.map +1 -1
  47. package/dist/session/process.js +37 -0
  48. package/dist/session/process.js.map +1 -1
  49. package/dist/shared/image-optimize.d.ts +32 -0
  50. package/dist/shared/image-optimize.d.ts.map +1 -0
  51. package/dist/shared/image-optimize.js +197 -0
  52. package/dist/shared/image-optimize.js.map +1 -0
  53. package/dist/shared/image-sniff.d.ts +9 -0
  54. package/dist/shared/image-sniff.d.ts.map +1 -1
  55. package/dist/shared/image-sniff.js +13 -0
  56. package/dist/shared/image-sniff.js.map +1 -1
  57. package/dist/shared/text-chunk.d.ts +37 -0
  58. package/dist/shared/text-chunk.d.ts.map +1 -0
  59. package/dist/shared/text-chunk.js +105 -0
  60. package/dist/shared/text-chunk.js.map +1 -0
  61. package/dist/shared/whatsapp-ack.d.ts +13 -0
  62. package/dist/shared/whatsapp-ack.d.ts.map +1 -0
  63. package/dist/shared/whatsapp-ack.js +16 -0
  64. package/dist/shared/whatsapp-ack.js.map +1 -0
  65. package/dist/types.d.ts +158 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/whatsapp/manager.d.ts +173 -0
  68. package/dist/whatsapp/manager.d.ts.map +1 -0
  69. package/dist/whatsapp/manager.js +838 -0
  70. package/dist/whatsapp/manager.js.map +1 -0
  71. package/mcp/server.ts +4 -0
  72. package/mcp/tools/whatsapp/module.ts +162 -0
  73. package/mcp/tools/whatsapp-cloud/module.ts +536 -0
  74. package/package.json +9 -4
@@ -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)
@@ -798,6 +972,28 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
798
972
  slack_group_allowlist: cfg.slack?.signingSecret ? (cfg.slack?.groupAllowlist ?? []) : null,
799
973
  slack_require_mention: cfg.slack?.signingSecret ? (cfg.slack?.requireMention ?? null) : null,
800
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,
801
997
  }));
802
998
  res.json({ agents });
803
999
  });
@@ -1101,10 +1297,17 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1101
1297
  res.status(409).json({ error: `Agent '${id}' already exists` });
1102
1298
  return;
1103
1299
  }
1104
- if (wizard_state_1.wizardStore.findByAgentId(id)) {
1300
+ const existingWizard = wizard_state_1.wizardStore.findByAgentId(id);
1301
+ if (existingWizard && existingWizard.step !== 'pending') {
1302
+ // 'confirmed'/'complete': files were already written — a real conflict.
1105
1303
  res.status(409).json({ error: `Wizard for agent '${id}' is already in progress` });
1106
1304
  return;
1107
1305
  }
1306
+ // A prior 'pending' draft for this id never advanced past generation — the
1307
+ // user hit Back/Cancel then restarted with the same id (#2493). It owns no
1308
+ // on-disk agent, so it's safe to replace — but don't delete it until the
1309
+ // replacement is guaranteed below, so a 429/500 doesn't destroy it for nothing.
1310
+ // WIZARD_MAX_CONCURRENT still caps generation load.
1108
1311
  if (wizardStartsInFlight >= WIZARD_MAX_CONCURRENT) {
1109
1312
  res.status(429).json({ error: 'Too many wizard starts in progress, please retry later' });
1110
1313
  return;
@@ -1147,6 +1350,8 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1147
1350
  parsedFiles.set('AGENTS.md', `# Agent: ${id}\n\n${prompt.trim().slice(0, 400)}\n`);
1148
1351
  }
1149
1352
  const files = Object.fromEntries(parsedFiles);
1353
+ if (existingWizard)
1354
+ wizard_state_1.wizardStore.delete(existingWizard.wizardId);
1150
1355
  const state = wizard_state_1.wizardStore.create(id, prompt.trim(), files);
1151
1356
  if (signatureEmoji)
1152
1357
  wizard_state_1.wizardStore.update(state.wizardId, { signatureEmoji });
@@ -1483,7 +1688,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1483
1688
  return;
1484
1689
  }
1485
1690
  const body = req.body;
1486
- 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;
1691
+ 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;
1487
1692
  if (name !== undefined && name !== null && typeof name !== 'string') {
1488
1693
  res.status(400).json({ error: 'name must be a string or null' });
1489
1694
  return;
@@ -1680,6 +1885,131 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1680
1885
  connectorPatch[id] = { enabled };
1681
1886
  }
1682
1887
  }
1888
+ // WhatsApp — access-control fields only, same validation shape as Slack.
1889
+ // No credential fields to validate here at all (see AgentConfig.whatsapp's
1890
+ // doc comment): the "credential" is the linked device session on disk,
1891
+ // never in this PATCH body.
1892
+ if (whatsapp_dm_policy !== undefined && whatsapp_dm_policy !== null &&
1893
+ !(typeof whatsapp_dm_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_dm_policy))) {
1894
+ res.status(400).json({ error: "whatsapp_dm_policy must be 'open', 'allowlist', 'disabled', or null" });
1895
+ return;
1896
+ }
1897
+ if (whatsapp_dm_allowlist !== undefined && whatsapp_dm_allowlist !== null &&
1898
+ !(Array.isArray(whatsapp_dm_allowlist) && whatsapp_dm_allowlist.every((u) => typeof u === 'string'))) {
1899
+ res.status(400).json({ error: 'whatsapp_dm_allowlist must be an array of strings or null' });
1900
+ return;
1901
+ }
1902
+ if (whatsapp_group_policy !== undefined && whatsapp_group_policy !== null &&
1903
+ !(typeof whatsapp_group_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_group_policy))) {
1904
+ res.status(400).json({ error: "whatsapp_group_policy must be 'open', 'allowlist', 'disabled', or null" });
1905
+ return;
1906
+ }
1907
+ if (whatsapp_group_allowlist !== undefined && whatsapp_group_allowlist !== null &&
1908
+ !(Array.isArray(whatsapp_group_allowlist) && whatsapp_group_allowlist.every((u) => typeof u === 'string'))) {
1909
+ res.status(400).json({ error: 'whatsapp_group_allowlist must be an array of strings or null' });
1910
+ return;
1911
+ }
1912
+ if (whatsapp_require_mention !== undefined && whatsapp_require_mention !== null &&
1913
+ typeof whatsapp_require_mention !== 'boolean') {
1914
+ res.status(400).json({ error: 'whatsapp_require_mention must be a boolean or null' });
1915
+ return;
1916
+ }
1917
+ if (whatsapp_pairing !== undefined && whatsapp_pairing !== null &&
1918
+ typeof whatsapp_pairing !== 'boolean') {
1919
+ res.status(400).json({ error: 'whatsapp_pairing must be a boolean or null' });
1920
+ return;
1921
+ }
1922
+ if (whatsapp_account_id !== undefined && typeof whatsapp_account_id !== 'string') {
1923
+ res.status(400).json({ error: 'whatsapp_account_id must be a string' });
1924
+ return;
1925
+ }
1926
+ const whatsappAccessTouched = whatsapp_dm_policy !== undefined || whatsapp_dm_allowlist !== undefined ||
1927
+ whatsapp_group_policy !== undefined || whatsapp_group_allowlist !== undefined ||
1928
+ whatsapp_require_mention !== undefined || whatsapp_pairing !== undefined;
1929
+ // Which account the access fields above apply to. Omitting
1930
+ // whatsapp_account_id targets the first account, which keeps the flat
1931
+ // single-account PATCH contract byte-identical for existing clients.
1932
+ const whatsappTargetAccountId = whatsAppTargetAccountId(agentConfigs.get(agentId), whatsapp_account_id);
1933
+ if (whatsappAccessTouched && typeof whatsapp_account_id === 'string' &&
1934
+ !(0, whatsapp_accounts_1.resolveWhatsAppAccounts)(agentConfigs.get(agentId)?.whatsapp).some((a) => a.id === whatsappTargetAccountId)) {
1935
+ // Patching an account that doesn't exist is a client bug, not an implicit
1936
+ // create — accounts are created through POST /whatsapp/accounts, which is
1937
+ // where id validation and duplicate rejection live.
1938
+ res.status(404).json({ error: `WhatsApp account '${whatsappTargetAccountId}' not found` });
1939
+ return;
1940
+ }
1941
+ // WhatsApp Business Cloud API — same validation shape as Slack above
1942
+ // (webhook-based, real credentials), but FOUR fields must be provided
1943
+ // together or all cleared together, not two.
1944
+ if (whatsapp_cloud_access_token !== undefined && whatsapp_cloud_access_token !== null && typeof whatsapp_cloud_access_token !== 'string') {
1945
+ res.status(400).json({ error: 'whatsapp_cloud_access_token must be a string or null' });
1946
+ return;
1947
+ }
1948
+ if (whatsapp_cloud_phone_number_id !== undefined && whatsapp_cloud_phone_number_id !== null && typeof whatsapp_cloud_phone_number_id !== 'string') {
1949
+ res.status(400).json({ error: 'whatsapp_cloud_phone_number_id must be a string or null' });
1950
+ return;
1951
+ }
1952
+ if (whatsapp_cloud_app_secret !== undefined && whatsapp_cloud_app_secret !== null && typeof whatsapp_cloud_app_secret !== 'string') {
1953
+ res.status(400).json({ error: 'whatsapp_cloud_app_secret must be a string or null' });
1954
+ return;
1955
+ }
1956
+ if (whatsapp_cloud_verify_token !== undefined && whatsapp_cloud_verify_token !== null && typeof whatsapp_cloud_verify_token !== 'string') {
1957
+ res.status(400).json({ error: 'whatsapp_cloud_verify_token must be a string or null' });
1958
+ return;
1959
+ }
1960
+ const whatsappCloudTouched = whatsapp_cloud_access_token !== undefined || whatsapp_cloud_phone_number_id !== undefined ||
1961
+ whatsapp_cloud_app_secret !== undefined || whatsapp_cloud_verify_token !== undefined;
1962
+ if (whatsappCloudTouched) {
1963
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
1964
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
1965
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
1966
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
1967
+ const allSet = at !== '' && pid !== '' && sec !== '' && vt !== '';
1968
+ const allClear = at === '' && pid === '' && sec === '' && vt === '';
1969
+ if (!allSet && !allClear) {
1970
+ res.status(400).json({
1971
+ error: 'whatsapp_cloud_access_token, whatsapp_cloud_phone_number_id, whatsapp_cloud_app_secret, and whatsapp_cloud_verify_token must be provided together',
1972
+ });
1973
+ return;
1974
+ }
1975
+ // Reject bad/expired credentials at Save time instead of persisting them
1976
+ // silently, same reasoning as verifySlackBotToken's Save-time check above.
1977
+ if (allSet) {
1978
+ const verify = await verifyWhatsAppCloudCredentials(at, pid);
1979
+ if (!verify.ok) {
1980
+ res.status(400).json({
1981
+ error: `Invalid WhatsApp Cloud credentials — Graph API check failed: ${verify.error ?? 'unknown error'}`,
1982
+ });
1983
+ return;
1984
+ }
1985
+ }
1986
+ }
1987
+ if (whatsapp_cloud_dm_policy !== undefined && whatsapp_cloud_dm_policy !== null &&
1988
+ !(typeof whatsapp_cloud_dm_policy === 'string' && ['open', 'allowlist', 'disabled'].includes(whatsapp_cloud_dm_policy))) {
1989
+ res.status(400).json({ error: "whatsapp_cloud_dm_policy must be 'open', 'allowlist', 'disabled', or null" });
1990
+ return;
1991
+ }
1992
+ if (whatsapp_cloud_dm_allowlist !== undefined && whatsapp_cloud_dm_allowlist !== null &&
1993
+ !(Array.isArray(whatsapp_cloud_dm_allowlist) && whatsapp_cloud_dm_allowlist.every((u) => typeof u === 'string'))) {
1994
+ res.status(400).json({ error: 'whatsapp_cloud_dm_allowlist must be an array of strings or null' });
1995
+ return;
1996
+ }
1997
+ if (whatsapp_cloud_pairing !== undefined && whatsapp_cloud_pairing !== null &&
1998
+ typeof whatsapp_cloud_pairing !== 'boolean') {
1999
+ res.status(400).json({ error: 'whatsapp_cloud_pairing must be a boolean or null' });
2000
+ return;
2001
+ }
2002
+ // Template opt-in (Phase 3). Not an ACCESS field, but validated and
2003
+ // persisted through the identical "merge into the existing whatsapp_cloud
2004
+ // block" path as whatsapp_cloud_pairing above, so it rides the same flag
2005
+ // rather than growing a second one-field branch.
2006
+ if (whatsapp_cloud_templates_enabled !== undefined && whatsapp_cloud_templates_enabled !== null &&
2007
+ typeof whatsapp_cloud_templates_enabled !== 'boolean') {
2008
+ res.status(400).json({ error: 'whatsapp_cloud_templates_enabled must be a boolean or null' });
2009
+ return;
2010
+ }
2011
+ const whatsappCloudAccessTouched = whatsapp_cloud_dm_policy !== undefined || whatsapp_cloud_dm_allowlist !== undefined ||
2012
+ whatsapp_cloud_pairing !== undefined || whatsapp_cloud_templates_enabled !== undefined;
1683
2013
  try {
1684
2014
  await writeAgentsToConfig(configPath, (agents) => {
1685
2015
  const agent = agents.find((a) => a.id === agentId);
@@ -1833,6 +2163,71 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
1833
2163
  const existing = agent.connectors ?? {};
1834
2164
  agent.connectors = { ...existing, ...connectorPatch };
1835
2165
  }
2166
+ // WhatsApp access fields — merged into ONE account of the multi-account
2167
+ // block (see applyWhatsAppAccountPatch, which also explains why the
2168
+ // block is created on first touch instead of skipped when absent).
2169
+ if (whatsappAccessTouched) {
2170
+ applyWhatsAppAccountPatch(agent, whatsappTargetAccountId, {
2171
+ dmPolicy: whatsapp_dm_policy,
2172
+ dmAllowlist: whatsapp_dm_allowlist,
2173
+ groupPolicy: whatsapp_group_policy,
2174
+ groupAllowlist: whatsapp_group_allowlist,
2175
+ requireMention: whatsapp_require_mention,
2176
+ pairing: whatsapp_pairing,
2177
+ });
2178
+ }
2179
+ // WhatsApp Cloud — credential block follows the SLACK pattern (delete
2180
+ // the whole block if all 4 cleared, else merge), the OPPOSITE of the
2181
+ // Baileys `whatsapp` block just above: Cloud API credentials are real,
2182
+ // Meta-issued secrets, not an on-disk device-link session, so there's
2183
+ // no "meaningful before ever linking" case to unconditionally create for.
2184
+ if (whatsappCloudTouched) {
2185
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
2186
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
2187
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
2188
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
2189
+ if (at === '' && pid === '' && sec === '' && vt === '') {
2190
+ delete agent.whatsapp_cloud;
2191
+ }
2192
+ else {
2193
+ const existing = agent.whatsapp_cloud;
2194
+ agent.whatsapp_cloud = { ...(existing ?? {}), accessToken: at, phoneNumberId: pid, appSecret: sec, verifyToken: vt };
2195
+ }
2196
+ }
2197
+ // Access fields — merge into the existing whatsapp_cloud block (re-read
2198
+ // after the credential block above, which may have just created or
2199
+ // deleted it). Skip silently when no whatsapp_cloud channel exists;
2200
+ // policy without credentials is meaningless (same comment Slack's
2201
+ // access block uses).
2202
+ if (whatsappCloudAccessTouched) {
2203
+ const existing = agent.whatsapp_cloud;
2204
+ if (existing) {
2205
+ if (whatsapp_cloud_dm_policy !== undefined) {
2206
+ if (whatsapp_cloud_dm_policy === null)
2207
+ delete existing.dmPolicy;
2208
+ else
2209
+ existing.dmPolicy = whatsapp_cloud_dm_policy;
2210
+ }
2211
+ if (whatsapp_cloud_dm_allowlist !== undefined) {
2212
+ if (whatsapp_cloud_dm_allowlist === null)
2213
+ delete existing.dmAllowlist;
2214
+ else
2215
+ existing.dmAllowlist = whatsapp_cloud_dm_allowlist;
2216
+ }
2217
+ if (whatsapp_cloud_pairing !== undefined) {
2218
+ if (whatsapp_cloud_pairing === null)
2219
+ delete existing.pairing;
2220
+ else
2221
+ existing.pairing = whatsapp_cloud_pairing;
2222
+ }
2223
+ if (whatsapp_cloud_templates_enabled !== undefined) {
2224
+ if (whatsapp_cloud_templates_enabled === null)
2225
+ delete existing.templatesEnabled;
2226
+ else
2227
+ existing.templatesEnabled = whatsapp_cloud_templates_enabled;
2228
+ }
2229
+ }
2230
+ }
1836
2231
  });
1837
2232
  }
1838
2233
  catch (err) {
@@ -2066,6 +2461,83 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2066
2461
  }
2067
2462
  }
2068
2463
  }
2464
+ if (whatsappAccessTouched) {
2465
+ applyWhatsAppAccountPatch(cfg, whatsappTargetAccountId, {
2466
+ dmPolicy: whatsapp_dm_policy,
2467
+ dmAllowlist: whatsapp_dm_allowlist,
2468
+ groupPolicy: whatsapp_group_policy,
2469
+ groupAllowlist: whatsapp_group_allowlist,
2470
+ requireMention: whatsapp_require_mention,
2471
+ pairing: whatsapp_pairing,
2472
+ });
2473
+ // Access-only edits never add or remove an account, so this re-sync is
2474
+ // just handing the managers their new policy — syncWhatsAppAccounts sees
2475
+ // the changed snapshot but keeps every existing manager (and its linked
2476
+ // session) alive because the id set is unchanged.
2477
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2478
+ // Pending senders are namespaced per WhatsApp account (see
2479
+ // pending-senders.ts's `channel` dimension) — the same JID can
2480
+ // legitimately knock on two different numbers, so clearing must target
2481
+ // only the account this PATCH actually touched.
2482
+ const whatsappPendingChannel = `whatsapp:${whatsappTargetAccountId}`;
2483
+ if (Array.isArray(whatsapp_dm_allowlist)) {
2484
+ for (const jid of whatsapp_dm_allowlist)
2485
+ (0, pending_senders_1.clearPendingSender)(whatsappPendingChannel, agentId, jid);
2486
+ }
2487
+ if (Array.isArray(whatsapp_group_allowlist)) {
2488
+ for (const jid of whatsapp_group_allowlist)
2489
+ (0, pending_senders_1.clearPendingSender)(whatsappPendingChannel, agentId, jid);
2490
+ }
2491
+ }
2492
+ if (whatsappCloudTouched) {
2493
+ const at = typeof whatsapp_cloud_access_token === 'string' ? whatsapp_cloud_access_token.trim() : '';
2494
+ const pid = typeof whatsapp_cloud_phone_number_id === 'string' ? whatsapp_cloud_phone_number_id.trim() : '';
2495
+ const sec = typeof whatsapp_cloud_app_secret === 'string' ? whatsapp_cloud_app_secret.trim() : '';
2496
+ const vt = typeof whatsapp_cloud_verify_token === 'string' ? whatsapp_cloud_verify_token.trim() : '';
2497
+ if (at && pid && sec && vt) {
2498
+ cfg.whatsapp_cloud = { ...(cfg.whatsapp_cloud ?? {}), accessToken: at, phoneNumberId: pid, appSecret: sec, verifyToken: vt };
2499
+ }
2500
+ else {
2501
+ delete cfg.whatsapp_cloud;
2502
+ }
2503
+ // WhatsApp Cloud is webhook-based — no receiver to start/stop. The
2504
+ // webhook router reads config live via runner.getAgentConfig(); just
2505
+ // keep the runner's copy in sync (same as Slack/LINE above).
2506
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2507
+ }
2508
+ if (whatsappCloudAccessTouched && cfg.whatsapp_cloud) {
2509
+ if (whatsapp_cloud_dm_policy !== undefined) {
2510
+ if (whatsapp_cloud_dm_policy === null)
2511
+ delete cfg.whatsapp_cloud.dmPolicy;
2512
+ else
2513
+ cfg.whatsapp_cloud.dmPolicy = whatsapp_cloud_dm_policy;
2514
+ }
2515
+ if (whatsapp_cloud_dm_allowlist !== undefined) {
2516
+ if (whatsapp_cloud_dm_allowlist === null)
2517
+ delete cfg.whatsapp_cloud.dmAllowlist;
2518
+ else
2519
+ cfg.whatsapp_cloud.dmAllowlist = whatsapp_cloud_dm_allowlist;
2520
+ }
2521
+ if (whatsapp_cloud_pairing !== undefined) {
2522
+ if (whatsapp_cloud_pairing === null)
2523
+ delete cfg.whatsapp_cloud.pairing;
2524
+ else
2525
+ cfg.whatsapp_cloud.pairing = whatsapp_cloud_pairing;
2526
+ }
2527
+ if (whatsapp_cloud_templates_enabled !== undefined) {
2528
+ if (whatsapp_cloud_templates_enabled === null)
2529
+ delete cfg.whatsapp_cloud.templatesEnabled;
2530
+ else
2531
+ cfg.whatsapp_cloud.templatesEnabled = whatsapp_cloud_templates_enabled;
2532
+ }
2533
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
2534
+ // Anyone just added to the allowlist is now allowed — drop them from
2535
+ // the in-memory knock list so the discovery UI stops surfacing them.
2536
+ if (Array.isArray(whatsapp_cloud_dm_allowlist)) {
2537
+ for (const id of whatsapp_cloud_dm_allowlist)
2538
+ (0, pending_senders_1.clearPendingSender)('whatsapp_cloud', agentId, id);
2539
+ }
2540
+ }
2069
2541
  res.json({
2070
2542
  agent: {
2071
2543
  id: agentId,
@@ -2109,6 +2581,17 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2109
2581
  slack_group_allowlist: cfg.slack?.signingSecret ? (cfg.slack?.groupAllowlist ?? []) : null,
2110
2582
  slack_require_mention: cfg.slack?.signingSecret ? (cfg.slack?.requireMention ?? null) : null,
2111
2583
  slack_pairing: cfg.slack?.signingSecret ? (cfg.slack?.pairing ?? true) : null,
2584
+ ...whatsAppResponseFields(agentRunners, cfg, agentId),
2585
+ // WhatsApp Cloud — same shape/semantics as Slack above, mirrors the
2586
+ // GET /agents list response exactly.
2587
+ whatsapp_cloud_connected: !!cfg.whatsapp_cloud?.appSecret,
2588
+ whatsapp_cloud_access_token_preview: cfg.whatsapp_cloud?.accessToken ? maskToken(cfg.whatsapp_cloud.accessToken) : null,
2589
+ whatsapp_cloud_phone_number_id: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.phoneNumberId ?? null) : null,
2590
+ whatsapp_cloud_webhook_path: cfg.whatsapp_cloud?.appSecret ? `/webhooks/whatsapp_cloud/${agentId}` : null,
2591
+ whatsapp_cloud_dm_policy: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmPolicy ?? null) : null,
2592
+ whatsapp_cloud_dm_allowlist: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.dmAllowlist ?? []) : null,
2593
+ whatsapp_cloud_pairing: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.pairing ?? true) : null,
2594
+ whatsapp_cloud_templates_enabled: cfg.whatsapp_cloud?.appSecret ? (cfg.whatsapp_cloud?.templatesEnabled ?? false) : null,
2112
2595
  },
2113
2596
  });
2114
2597
  });
@@ -2385,6 +2868,513 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2385
2868
  (0, pending_senders_1.clearPendingSender)('slack', agentId, senderId);
2386
2869
  res.json({ ok: true });
2387
2870
  });
2871
+ /**
2872
+ * GET /api/v1/agents/:agentId/whatsapp/pending?account_id=...
2873
+ * Recently denied WhatsApp senders/groups (Tier 1/3 discovery aid) for ONE
2874
+ * linked number. Admin only. Namespaced per-account (`whatsapp:${accountId}`
2875
+ * in the shared pending-senders store) — the same JID can legitimately knock
2876
+ * on two different numbers on the same agent, so this must not merge them.
2877
+ * `account_id` defaults to the pre-multi-account 'default' id, so an old
2878
+ * caller that never learned about accounts still gets that number's list.
2879
+ */
2880
+ router.get('/v1/agents/:agentId/whatsapp/pending', auth, (req, res) => {
2881
+ const { agentId } = req.params;
2882
+ const accountId = req.query.account_id || whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID;
2883
+ const apiKey = req.apiKey;
2884
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2885
+ res.status(403).json({ error: 'Admin key required' });
2886
+ return;
2887
+ }
2888
+ if (!agentConfigs.has(agentId)) {
2889
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2890
+ return;
2891
+ }
2892
+ res.json({ senders: (0, pending_senders_1.getPendingSenders)(`whatsapp:${accountId}`, agentId) });
2893
+ });
2894
+ /**
2895
+ * DELETE /api/v1/agents/:agentId/whatsapp/pending/:senderId?account_id=...
2896
+ * Dismiss one knock from one number's pending list (admin only). The id is
2897
+ * a WhatsApp JID (DM sender or group). Same per-account namespacing and
2898
+ * default as the GET route above.
2899
+ */
2900
+ router.delete('/v1/agents/:agentId/whatsapp/pending/:senderId', auth, (req, res) => {
2901
+ const apiKey = req.apiKey;
2902
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2903
+ res.status(403).json({ error: 'Admin key required' });
2904
+ return;
2905
+ }
2906
+ const { agentId, senderId } = req.params;
2907
+ const accountId = req.query.account_id || whatsapp_accounts_1.DEFAULT_WHATSAPP_ACCOUNT_ID;
2908
+ if (!agentConfigs.has(agentId)) {
2909
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2910
+ return;
2911
+ }
2912
+ (0, pending_senders_1.clearPendingSender)(`whatsapp:${accountId}`, agentId, senderId);
2913
+ res.json({ ok: true });
2914
+ });
2915
+ /**
2916
+ * GET /api/v1/agents/:agentId/whatsapp_cloud/pending
2917
+ * Recently denied WhatsApp Cloud senders (Tier 1 allowlist discovery aid).
2918
+ * Admin only. Mirrors GET .../slack/pending exactly, keyed under the
2919
+ * 'whatsapp_cloud' channel namespace in the shared pending-senders store.
2920
+ */
2921
+ router.get('/v1/agents/:agentId/whatsapp_cloud/pending', auth, (req, res) => {
2922
+ const { agentId } = req.params;
2923
+ const apiKey = req.apiKey;
2924
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2925
+ res.status(403).json({ error: 'Admin key required' });
2926
+ return;
2927
+ }
2928
+ if (!agentConfigs.has(agentId)) {
2929
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2930
+ return;
2931
+ }
2932
+ res.json({ senders: (0, pending_senders_1.getPendingSenders)('whatsapp_cloud', agentId) });
2933
+ });
2934
+ /**
2935
+ * DELETE /api/v1/agents/:agentId/whatsapp_cloud/pending/:senderId
2936
+ * Dismiss one knock from the in-memory pending list (admin only). Mirrors
2937
+ * DELETE .../slack/pending/:senderId exactly. The id is a bare WhatsApp
2938
+ * Cloud phone-number string (no groups on this channel).
2939
+ */
2940
+ router.delete('/v1/agents/:agentId/whatsapp_cloud/pending/:senderId', auth, (req, res) => {
2941
+ const apiKey = req.apiKey;
2942
+ if (!(0, auth_1.isAdmin)(apiKey)) {
2943
+ res.status(403).json({ error: 'Admin key required' });
2944
+ return;
2945
+ }
2946
+ const { agentId, senderId } = req.params;
2947
+ if (!agentConfigs.has(agentId)) {
2948
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2949
+ return;
2950
+ }
2951
+ (0, pending_senders_1.clearPendingSender)('whatsapp_cloud', agentId, senderId);
2952
+ res.json({ ok: true });
2953
+ });
2954
+ /**
2955
+ * GET /api/v1/agents/:agentId/whatsapp/status
2956
+ * Live link status — status/qr/pairingCode/phoneNumber straight from the
2957
+ * in-process WhatsAppManager, never config-derived (there is no config
2958
+ * field to derive it from). Polled by the web UI during linking and while
2959
+ * showing the connected card. Requires write access (same as every other
2960
+ * channel's connect surface), not just read.
2961
+ *
2962
+ * `?account_id=` selects one linked number; omitting it targets the agent's
2963
+ * first account, which is the single account for anyone who hasn't added a
2964
+ * second one. Same convention on link/pairing-code/unlink/send below.
2965
+ */
2966
+ router.get('/v1/agents/:agentId/whatsapp/status', auth, (req, res) => {
2967
+ const { agentId } = req.params;
2968
+ const apiKey = req.apiKey;
2969
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
2970
+ res.status(403).json({ error: 'Write permission required' });
2971
+ return;
2972
+ }
2973
+ const runner = agentRunners.get(agentId);
2974
+ if (!runner) {
2975
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2976
+ return;
2977
+ }
2978
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.query.account_id, res);
2979
+ if (accountId === null)
2980
+ return;
2981
+ res.json({ account_id: accountId, ...(runner.getWhatsAppStatus?.(accountId) ?? { status: 'unlinked' }) });
2982
+ });
2983
+ /**
2984
+ * POST /api/v1/agents/:agentId/whatsapp/link
2985
+ * Start (or restart) a QR-code linking flow. Returns immediately — poll
2986
+ * GET .../status for the QR image once it's available.
2987
+ */
2988
+ router.post('/v1/agents/:agentId/whatsapp/link', auth, async (req, res) => {
2989
+ const { agentId } = req.params;
2990
+ const apiKey = req.apiKey;
2991
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
2992
+ res.status(403).json({ error: 'Write permission required' });
2993
+ return;
2994
+ }
2995
+ const runner = agentRunners.get(agentId);
2996
+ if (!runner) {
2997
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2998
+ return;
2999
+ }
3000
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.body?.account_id, res);
3001
+ if (accountId === null)
3002
+ return;
3003
+ try {
3004
+ await runner.startWhatsAppLinking(accountId);
3005
+ res.json({ ok: true, account_id: accountId });
3006
+ }
3007
+ catch (err) {
3008
+ res.status(500).json({ error: err.message });
3009
+ }
3010
+ });
3011
+ /**
3012
+ * POST /api/v1/agents/:agentId/whatsapp/pairing-code
3013
+ * Start linking via a text pairing code instead of QR. Body: {phoneNumber}
3014
+ * (E.164, e.g. "+15551234567"). Returns the code once Baileys issues it.
3015
+ */
3016
+ router.post('/v1/agents/:agentId/whatsapp/pairing-code', auth, async (req, res) => {
3017
+ const { agentId } = req.params;
3018
+ const apiKey = req.apiKey;
3019
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3020
+ res.status(403).json({ error: 'Write permission required' });
3021
+ return;
3022
+ }
3023
+ const runner = agentRunners.get(agentId);
3024
+ if (!runner) {
3025
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3026
+ return;
3027
+ }
3028
+ const { phoneNumber, account_id } = req.body;
3029
+ if (typeof phoneNumber !== 'string' || !phoneNumber.trim()) {
3030
+ res.status(400).json({ error: 'phoneNumber is required' });
3031
+ return;
3032
+ }
3033
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, account_id, res);
3034
+ if (accountId === null)
3035
+ return;
3036
+ try {
3037
+ const code = await runner.requestWhatsAppPairingCode(phoneNumber.trim(), accountId);
3038
+ res.json({ pairingCode: code, account_id: accountId });
3039
+ }
3040
+ catch (err) {
3041
+ res.status(500).json({ error: err.message });
3042
+ }
3043
+ });
3044
+ /**
3045
+ * POST /api/v1/agents/:agentId/whatsapp/unlink
3046
+ * Logout and wipe the linked session. The user must scan/pair fresh afterward.
3047
+ */
3048
+ router.post('/v1/agents/:agentId/whatsapp/unlink', auth, async (req, res) => {
3049
+ const { agentId } = req.params;
3050
+ const apiKey = req.apiKey;
3051
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3052
+ res.status(403).json({ error: 'Write permission required' });
3053
+ return;
3054
+ }
3055
+ const runner = agentRunners.get(agentId);
3056
+ if (!runner) {
3057
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3058
+ return;
3059
+ }
3060
+ const accountId = resolveWhatsAppRouteAccount(agentConfigs, agentId, req.body?.account_id, res);
3061
+ if (accountId === null)
3062
+ return;
3063
+ await runner.unlinkWhatsApp(accountId);
3064
+ res.json({ ok: true, account_id: accountId });
3065
+ });
3066
+ /**
3067
+ * POST /api/v1/agents/:agentId/whatsapp/send
3068
+ * INTERNAL — called by the `whatsapp_reply` MCP tool (via GATEWAY_API_URL/
3069
+ * GATEWAY_API_KEY, same as every other MCP subprocess reaches the gateway),
3070
+ * never by the web UI. Same auth as the rest of /api — no separate secret.
3071
+ * Exists because Baileys has no stateless per-call send path (see
3072
+ * WhatsAppManager's doc comment): the MCP tool cannot open its own
3073
+ * connection, it must reach the live socket this process already holds.
3074
+ */
3075
+ router.post('/v1/agents/:agentId/whatsapp/send', auth, async (req, res) => {
3076
+ const { agentId } = req.params;
3077
+ const apiKey = req.apiKey;
3078
+ // write-scoped, like every other whatsapp/* mutation route: this route
3079
+ // sends real outbound messages, not a read.
3080
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3081
+ res.status(403).json({ error: 'Write permission required' });
3082
+ return;
3083
+ }
3084
+ const runner = agentRunners.get(agentId);
3085
+ if (!runner) {
3086
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3087
+ return;
3088
+ }
3089
+ const cfg = agentConfigs.get(agentId);
3090
+ const { jid, text, image_path, account_id, reply_to_message_id, as_document, message_id } = req.body;
3091
+ if (typeof jid !== 'string' || !jid) {
3092
+ res.status(400).json({ error: 'jid is required' });
3093
+ return;
3094
+ }
3095
+ if (typeof text !== 'string' && typeof image_path !== 'string') {
3096
+ res.status(400).json({ error: 'text or image_path is required' });
3097
+ return;
3098
+ }
3099
+ // account_id comes from the `<channel account_id="…">` tag the agent was
3100
+ // handed, so an agent replying in a thread answers from the SAME number the
3101
+ // message arrived on. Omitted (older MCP builds, or an unprompted send) the
3102
+ // runner falls back to the inbound account it remembers for this chat.
3103
+ if (account_id !== undefined && typeof account_id !== 'string') {
3104
+ res.status(400).json({ error: 'account_id must be a string' });
3105
+ return;
3106
+ }
3107
+ // Resolve which account this send will ACTUALLY use — same fallback
3108
+ // logic sendWhatsAppMessage applies internally (last-inbound-account,
3109
+ // then 'default', then the agent's only account) — so the checks below
3110
+ // gate the real target without second-guessing or changing that
3111
+ // resolution when account_id is omitted.
3112
+ let resolvedAccountId;
3113
+ try {
3114
+ resolvedAccountId = runner.resolveWhatsAppAccountId(jid, account_id);
3115
+ }
3116
+ catch (err) {
3117
+ res.status(400).json({ error: err.message });
3118
+ return;
3119
+ }
3120
+ // jid allowlist: a prompt-injected turn must not be able to message an
3121
+ // arbitrary WhatsApp JID through this "internal" bridge route. Confine
3122
+ // sends to whatever this account is already configured to receive FROM
3123
+ // — the same dmPolicy/dmAllowlist (or groupPolicy/groupAllowlist) gate
3124
+ // inbound messages pass through. A normal reply's jid is exactly the
3125
+ // chat the inbound message arrived on, which by construction already
3126
+ // cleared this same gate, so the golden path is unaffected.
3127
+ const account = (0, whatsapp_accounts_1.findWhatsAppAccount)(cfg?.whatsapp, resolvedAccountId);
3128
+ const isGroupJid = jid.endsWith('@g.us');
3129
+ const isDmJid = jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid');
3130
+ const jidAllowed = isGroupJid
3131
+ ? (0, whatsapp_access_1.isWhatsAppSenderAllowed)(account?.groupPolicy, account?.groupAllowlist, jid)
3132
+ : isDmJid && (0, whatsapp_access_1.isWhatsAppSenderAllowed)(account?.dmPolicy, account?.dmAllowlist, jid);
3133
+ if (!jidAllowed) {
3134
+ res.status(403).json({ error: `jid '${jid}' is not allowed to receive messages from this account` });
3135
+ return;
3136
+ }
3137
+ // image_path confinement: refuse to send a file living inside this
3138
+ // account's own state directory (creds.json et al) — the concrete
3139
+ // exfiltration path a prompt-injected turn would use. Fail-open on an
3140
+ // unresolvable path (missing file, etc.) — the send call below produces
3141
+ // the real error for that case.
3142
+ if (typeof image_path === 'string' && image_path && cfg) {
3143
+ try {
3144
+ const real = fs.realpathSync(image_path);
3145
+ const stateReal = fs.realpathSync((0, whatsapp_accounts_1.whatsAppStateDir)(cfg.workspace, resolvedAccountId));
3146
+ if (real === stateReal || real.startsWith(stateReal + path.sep)) {
3147
+ res.status(400).json({ error: `refusing to send channel state: ${image_path}` });
3148
+ return;
3149
+ }
3150
+ }
3151
+ catch {
3152
+ /* unresolvable path — let sendWhatsAppMessage report the real error */
3153
+ }
3154
+ }
3155
+ // Phase 2 extras — each is only carried through when actually present and
3156
+ // well-typed, so an older MCP build's three-field body produces an empty
3157
+ // options object and the exact pre-Phase-2 send behaviour.
3158
+ const sendOpts = {};
3159
+ if (typeof reply_to_message_id === 'string' && reply_to_message_id) {
3160
+ sendOpts.quotedMessageId = reply_to_message_id;
3161
+ }
3162
+ if (as_document === true)
3163
+ sendOpts.asDocument = true;
3164
+ // The inbound message whose ⏳ ack should be cleared once this send lands.
3165
+ // Baileys' MCP tool can't clear it itself (it has no socket of its own) —
3166
+ // see WhatsAppManager.clearAckReaction.
3167
+ if (typeof message_id === 'string' && message_id)
3168
+ sendOpts.ackMessageId = message_id;
3169
+ try {
3170
+ await runner.sendWhatsAppMessage(jid, typeof text === 'string' ? text : '', typeof image_path === 'string' ? image_path : undefined, account_id, sendOpts);
3171
+ res.json({ ok: true });
3172
+ }
3173
+ catch (err) {
3174
+ res.status(502).json({ error: err.message });
3175
+ }
3176
+ });
3177
+ /**
3178
+ * GET /api/v1/agents/:agentId/whatsapp/accounts
3179
+ * Every linked-number slot on this agent — config plus live link state, the
3180
+ * same objects the agent response's `whatsapp_accounts` field carries.
3181
+ */
3182
+ router.get('/v1/agents/:agentId/whatsapp/accounts', auth, (req, res) => {
3183
+ const { agentId } = req.params;
3184
+ const apiKey = req.apiKey;
3185
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3186
+ res.status(403).json({ error: 'Write permission required' });
3187
+ return;
3188
+ }
3189
+ const cfg = agentConfigs.get(agentId);
3190
+ if (!cfg) {
3191
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3192
+ return;
3193
+ }
3194
+ res.json({ accounts: whatsAppAccountsResponse(agentRunners, cfg, agentId) });
3195
+ });
3196
+ /**
3197
+ * POST /api/v1/agents/:agentId/whatsapp/accounts
3198
+ * Body: {id, label?}. Adds an EMPTY account slot and starts its manager; the
3199
+ * number itself is linked afterward through the normal
3200
+ * POST .../whatsapp/link or .../pairing-code with this `account_id`.
3201
+ *
3202
+ * The id becomes a directory name under `.whatsapp-state/`, so it's held to
3203
+ * WHATSAPP_ACCOUNT_ID_RE's conservative slug. 'default' can never be created
3204
+ * here: it always already exists (implicitly when the config has no accounts
3205
+ * array at all), which is what keeps a pre-multi-account linked session
3206
+ * addressable — see src/config/whatsapp-accounts.ts.
3207
+ */
3208
+ router.post('/v1/agents/:agentId/whatsapp/accounts', auth, async (req, res) => {
3209
+ const { agentId } = req.params;
3210
+ const apiKey = req.apiKey;
3211
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3212
+ res.status(403).json({ error: 'Write permission required' });
3213
+ return;
3214
+ }
3215
+ if (!configPath) {
3216
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
3217
+ return;
3218
+ }
3219
+ const cfg = agentConfigs.get(agentId);
3220
+ if (!cfg) {
3221
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3222
+ return;
3223
+ }
3224
+ const { id, label } = req.body;
3225
+ if (typeof id !== 'string' || !whatsapp_accounts_1.WHATSAPP_ACCOUNT_ID_RE.test(id)) {
3226
+ res.status(400).json({ error: 'id must be 1-32 chars of lowercase letters, digits, "-" or "_", starting with a letter or digit' });
3227
+ return;
3228
+ }
3229
+ if (label !== undefined && label !== null && typeof label !== 'string') {
3230
+ res.status(400).json({ error: 'label must be a string or null' });
3231
+ return;
3232
+ }
3233
+ // Fast-fail check outside the lock, for a snappy 409 in the common case —
3234
+ // the authoritative check happens again inside the lock below.
3235
+ if ((0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp).some((a) => a.id === id)) {
3236
+ res.status(409).json({ error: `WhatsApp account '${id}' already exists` });
3237
+ return;
3238
+ }
3239
+ const entry = { id, ...(typeof label === 'string' && label.trim() ? { label: label.trim() } : {}) };
3240
+ try {
3241
+ // The existing-accounts READ, the disk WRITE, and the in-memory
3242
+ // `cfg.whatsapp` ASSIGNMENT all happen inside one lock-protected
3243
+ // section — otherwise two concurrent add/remove requests for the same
3244
+ // agent can both read the same stale `existing`, and the loser's
3245
+ // in-memory assignment (built from that stale snapshot) clobbers the
3246
+ // winner's, even though the file writes themselves are already
3247
+ // serialized. updateAgentConfig() propagates whatever ends up in
3248
+ // `cfg.whatsapp` straight to the live WhatsAppManager set, so a stale
3249
+ // clobber here tears down a just-added account's manager.
3250
+ await (0, config_write_lock_1.withConfigWriteLock)(configPath, async () => {
3251
+ const existing = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3252
+ if (existing.some((a) => a.id === id)) {
3253
+ throw Object.assign(new Error(`WhatsApp account '${id}' already exists`), { code: 'DUPLICATE_ACCOUNT' });
3254
+ }
3255
+ await writeAgentsToConfigImpl(configPath, (agents) => {
3256
+ const agent = agents.find((a) => a.id === agentId);
3257
+ if (!agent)
3258
+ return;
3259
+ // Reuses the PATCH merge helper purely for its create-the-block-and-
3260
+ // materialize-the-implicit-default behavior; the patch itself is empty.
3261
+ applyWhatsAppAccountPatch(agent, id, {});
3262
+ if (!entry.label)
3263
+ return;
3264
+ const block = agent.whatsapp;
3265
+ const written = block.accounts.find((a) => a.id === id);
3266
+ if (written)
3267
+ written.label = entry.label;
3268
+ });
3269
+ // Materializes the implicit 'default' too, so the in-memory list
3270
+ // matches what was just written to disk.
3271
+ cfg.whatsapp = { accounts: [...existing, entry] };
3272
+ });
3273
+ }
3274
+ catch (err) {
3275
+ const code = err.code;
3276
+ res.status(code === 'DUPLICATE_ACCOUNT' ? 409 : 500).json({ error: err.message });
3277
+ return;
3278
+ }
3279
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
3280
+ const accounts = whatsAppAccountsResponse(agentRunners, cfg, agentId);
3281
+ res.status(201).json({ account: accounts.find((a) => a.id === id), accounts });
3282
+ });
3283
+ /**
3284
+ * DELETE /api/v1/agents/:agentId/whatsapp/accounts/:accountId
3285
+ * Unlinks the number (logout + wipe that account's session directory), tears
3286
+ * down its manager, and drops it from config.
3287
+ *
3288
+ * The last remaining account is deliberately NOT deletable: with no accounts
3289
+ * left the config resolves back to an implicit 'default' (an agent always has
3290
+ * one linkable slot), so the delete would silently degrade into an unlink.
3291
+ * Callers wanting that should call POST .../whatsapp/unlink, which says so.
3292
+ */
3293
+ router.delete('/v1/agents/:agentId/whatsapp/accounts/:accountId', auth, async (req, res) => {
3294
+ const { agentId, accountId } = req.params;
3295
+ const apiKey = req.apiKey;
3296
+ if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
3297
+ res.status(403).json({ error: 'Write permission required' });
3298
+ return;
3299
+ }
3300
+ if (!configPath) {
3301
+ res.status(501).json({ error: 'Agent management not available (no configPath)' });
3302
+ return;
3303
+ }
3304
+ const cfg = agentConfigs.get(agentId);
3305
+ if (!cfg) {
3306
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
3307
+ return;
3308
+ }
3309
+ const existing = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3310
+ // Fast-fail check outside the lock, for a snappy 404/409 in the common
3311
+ // case — the authoritative check happens again inside the lock below,
3312
+ // same pattern as the POST handler above.
3313
+ if (!existing.some((a) => a.id === accountId)) {
3314
+ res.status(404).json({ error: `WhatsApp account '${accountId}' not found` });
3315
+ return;
3316
+ }
3317
+ if (existing.length === 1) {
3318
+ res.status(409).json({ error: 'Cannot remove the last WhatsApp account — use POST /whatsapp/unlink instead' });
3319
+ return;
3320
+ }
3321
+ // Unlink FIRST: once the account is out of config its manager is gone, and
3322
+ // the session directory would be left behind still logged in on the phone.
3323
+ try {
3324
+ await agentRunners.get(agentId)?.unlinkWhatsApp(accountId);
3325
+ }
3326
+ catch (err) {
3327
+ res.status(500).json({ error: `Failed to unlink account: ${err.message}` });
3328
+ return;
3329
+ }
3330
+ try {
3331
+ // Disk write and in-memory `cfg.whatsapp` update in one lock-protected
3332
+ // section — same race as the POST handler above: a concurrent
3333
+ // add/remove for this agent must not be able to interleave between the
3334
+ // read and the assignment. `unlinkWhatsApp` stays OUTSIDE the lock
3335
+ // (above) since it's a slow, network-bound Baileys call and holding
3336
+ // the global config lock across it would stall unrelated writers.
3337
+ await (0, config_write_lock_1.withConfigWriteLock)(configPath, async () => {
3338
+ // Authoritative re-check against the CURRENT cfg.whatsapp — a
3339
+ // concurrent DELETE for a different account may have already run its
3340
+ // own lock-protected section and shrunk the list since the fast-fail
3341
+ // check above read its (now stale) snapshot. Without this, two
3342
+ // concurrent deletes of two different accounts, with exactly two
3343
+ // configured, could both pass the outer check and leave `accounts: []`
3344
+ // on disk, silently violating the "always at least one" invariant.
3345
+ const fresh = (0, whatsapp_accounts_1.resolveWhatsAppAccounts)(cfg.whatsapp);
3346
+ if (!fresh.some((a) => a.id === accountId)) {
3347
+ throw Object.assign(new Error(`WhatsApp account '${accountId}' not found`), { code: 'NOT_FOUND' });
3348
+ }
3349
+ if (fresh.length === 1) {
3350
+ throw Object.assign(new Error('Cannot remove the last WhatsApp account — use POST /whatsapp/unlink instead'), { code: 'LAST_ACCOUNT' });
3351
+ }
3352
+ await writeAgentsToConfigImpl(configPath, (agents) => {
3353
+ const agent = agents.find((a) => a.id === agentId);
3354
+ const block = agent?.whatsapp;
3355
+ if (!block || !Array.isArray(block.accounts))
3356
+ return;
3357
+ block.accounts = block.accounts.filter((a) => a.id !== accountId);
3358
+ });
3359
+ cfg.whatsapp = { accounts: fresh.filter((a) => a.id !== accountId) };
3360
+ });
3361
+ }
3362
+ catch (err) {
3363
+ const code = err.code;
3364
+ if (code === 'NOT_FOUND') {
3365
+ res.status(404).json({ error: err.message });
3366
+ return;
3367
+ }
3368
+ if (code === 'LAST_ACCOUNT') {
3369
+ res.status(409).json({ error: err.message });
3370
+ return;
3371
+ }
3372
+ res.status(500).json({ error: `Failed to write config: ${err.message}` });
3373
+ return;
3374
+ }
3375
+ agentRunners.get(agentId)?.updateAgentConfig(cfg);
3376
+ res.json({ ok: true, accounts: whatsAppAccountsResponse(agentRunners, cfg, agentId) });
3377
+ });
2388
3378
  /**
2389
3379
  * DELETE /api/v1/agents/:agentId/telegram/allow/:userId
2390
3380
  * Remove a user from the allowFrom list. Admin only.
@@ -2825,7 +3815,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2825
3815
  }
2826
3816
  const { source, rawChatId } = parseHistoryChatId(chatId);
2827
3817
  if (!(0, types_1.isChatChannel)(source)) {
2828
- res.status(400).json({ error: 'Sessions endpoint only supports telegram/discord/line/slack chats' });
3818
+ res.status(400).json({ error: 'Sessions endpoint only supports telegram/discord/line/slack/whatsapp/whatsapp_cloud chats' });
2829
3819
  return;
2830
3820
  }
2831
3821
  try {
@@ -3011,7 +4001,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
3011
4001
  }
3012
4002
  const { source, rawChatId } = parseHistoryChatId(chatId);
3013
4003
  if (!(0, types_1.isChatChannel)(source)) {
3014
- res.status(400).json({ error: 'Cross-channel messaging only supported for telegram/discord/line/slack chats' });
4004
+ res.status(400).json({ error: 'Cross-channel messaging only supported for telegram/discord/line/slack/whatsapp/whatsapp_cloud chats' });
3015
4005
  return;
3016
4006
  }
3017
4007
  const body = req.body;
@@ -3895,6 +4885,13 @@ function parseHistoryChatId(fullChatId) {
3895
4885
  return { source: 'line', rawChatId: fullChatId.slice(5) };
3896
4886
  if (fullChatId.startsWith('slack-'))
3897
4887
  return { source: 'slack', rawChatId: fullChatId.slice(6) };
4888
+ // Checked BEFORE the plain 'whatsapp-' branch below: 'whatsapp_cloud-' does
4889
+ // NOT collide with it (the char after "whatsapp" differs, '_' vs '-'), but
4890
+ // ordering this first keeps the two visually adjacent and unambiguous.
4891
+ if (fullChatId.startsWith('whatsapp_cloud-'))
4892
+ return { source: 'whatsapp_cloud', rawChatId: fullChatId.slice(15) };
4893
+ if (fullChatId.startsWith('whatsapp-'))
4894
+ return { source: 'whatsapp', rawChatId: fullChatId.slice(9) };
3898
4895
  return { source: 'api', rawChatId: fullChatId };
3899
4896
  }
3900
4897
  //# sourceMappingURL=router.js.map