@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.
- package/README.md +46 -6
- package/config.template.json +2 -1
- package/dist/agent/runner.d.ts +86 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +396 -9
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +1028 -6
- package/dist/api/router.js.map +1 -1
- package/dist/api/share-router.d.ts.map +1 -1
- package/dist/api/share-router.js +24 -0
- package/dist/api/share-router.js.map +1 -1
- package/dist/api/webhooks-router.d.ts +2 -0
- package/dist/api/webhooks-router.d.ts.map +1 -1
- package/dist/api/webhooks-router.js +2 -0
- package/dist/api/webhooks-router.js.map +1 -1
- package/dist/api/whatsapp-access.d.ts +123 -0
- package/dist/api/whatsapp-access.d.ts.map +1 -0
- package/dist/api/whatsapp-access.js +135 -0
- package/dist/api/whatsapp-access.js.map +1 -0
- package/dist/api/whatsapp-cloud-access.d.ts +48 -0
- package/dist/api/whatsapp-cloud-access.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-access.js +58 -0
- package/dist/api/whatsapp-cloud-access.js.map +1 -0
- package/dist/api/whatsapp-cloud-client.d.ts +143 -0
- package/dist/api/whatsapp-cloud-client.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-client.js +298 -0
- package/dist/api/whatsapp-cloud-client.js.map +1 -0
- package/dist/api/whatsapp-cloud-webhook-router.d.ts +132 -0
- package/dist/api/whatsapp-cloud-webhook-router.d.ts.map +1 -0
- package/dist/api/whatsapp-cloud-webhook-router.js +494 -0
- package/dist/api/whatsapp-cloud-webhook-router.js.map +1 -0
- package/dist/cli/args.d.ts +34 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +55 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/commands/app.d.ts +28 -0
- package/dist/cli/commands/app.d.ts.map +1 -0
- package/dist/cli/commands/app.js +411 -0
- package/dist/cli/commands/app.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +77 -16
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/service.d.ts.map +1 -1
- package/dist/cli/commands/service.js +373 -56
- package/dist/cli/commands/service.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +1 -17
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/http-client.d.ts +8 -0
- package/dist/cli/http-client.d.ts.map +1 -1
- package/dist/cli/http-client.js +5 -2
- package/dist/cli/http-client.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +16 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/prompt.d.ts +17 -2
- package/dist/cli/prompt.d.ts.map +1 -1
- package/dist/cli/prompt.js +33 -2
- package/dist/cli/prompt.js.map +1 -1
- package/dist/config/loader.d.ts +8 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +22 -4
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +12 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/config/whatsapp-accounts.d.ts +77 -0
- package/dist/config/whatsapp-accounts.d.ts.map +1 -0
- package/dist/config/whatsapp-accounts.js +218 -0
- package/dist/config/whatsapp-accounts.js.map +1 -0
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +43 -11
- package/dist/history/db.js.map +1 -1
- package/dist/history/types.d.ts +18 -1
- package/dist/history/types.d.ts.map +1 -1
- package/dist/history/types.js +1 -1
- package/dist/history/types.js.map +1 -1
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +37 -0
- package/dist/session/process.js.map +1 -1
- package/dist/session/store.d.ts +3 -2
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +1 -1
- package/dist/session/store.js.map +1 -1
- package/dist/share/session-video-catalog.d.ts +19 -0
- package/dist/share/session-video-catalog.d.ts.map +1 -0
- package/dist/share/session-video-catalog.js +119 -0
- package/dist/share/session-video-catalog.js.map +1 -0
- package/dist/shared/image-optimize.d.ts +32 -0
- package/dist/shared/image-optimize.d.ts.map +1 -0
- package/dist/shared/image-optimize.js +197 -0
- package/dist/shared/image-optimize.js.map +1 -0
- package/dist/shared/image-sniff.d.ts +9 -0
- package/dist/shared/image-sniff.d.ts.map +1 -1
- package/dist/shared/image-sniff.js +13 -0
- package/dist/shared/image-sniff.js.map +1 -1
- package/dist/shared/text-chunk.d.ts +37 -0
- package/dist/shared/text-chunk.d.ts.map +1 -0
- package/dist/shared/text-chunk.js +105 -0
- package/dist/shared/text-chunk.js.map +1 -0
- package/dist/shared/whatsapp-ack.d.ts +13 -0
- package/dist/shared/whatsapp-ack.d.ts.map +1 -0
- package/dist/shared/whatsapp-ack.js +16 -0
- package/dist/shared/whatsapp-ack.js.map +1 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/whatsapp/manager.d.ts +173 -0
- package/dist/whatsapp/manager.d.ts.map +1 -0
- package/dist/whatsapp/manager.js +838 -0
- package/dist/whatsapp/manager.js.map +1 -0
- package/mcp/server.ts +4 -0
- package/mcp/tools/image/module.ts +6 -1
- package/mcp/tools/telegram/receiver-server.ts +15 -8
- package/mcp/tools/telegram/reply-attachment.ts +81 -0
- package/mcp/tools/video/module.ts +6 -1
- package/mcp/tools/whatsapp/module.ts +162 -0
- package/mcp/tools/whatsapp-cloud/module.ts +536 -0
- package/package.json +7 -2
package/dist/api/router.js
CHANGED
|
@@ -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
|