@desplega.ai/agent-swarm 1.83.1 → 1.84.0

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 (69) hide show
  1. package/openapi.json +158 -8
  2. package/package.json +1 -1
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/http/webhooks.ts +101 -0
  19. package/src/integrations/kapso/client.ts +198 -0
  20. package/src/integrations/kapso/config.ts +104 -0
  21. package/src/integrations/kapso/inbound.ts +111 -0
  22. package/src/jira/client.ts +3 -5
  23. package/src/jira/oauth.ts +1 -0
  24. package/src/jira/sync.ts +2 -2
  25. package/src/oauth/ensure-token.ts +1 -0
  26. package/src/oauth/wrapper.ts +38 -7
  27. package/src/providers/claude-adapter.ts +7 -2
  28. package/src/providers/claude-managed-adapter.ts +1 -1
  29. package/src/providers/codex-adapter.ts +30 -0
  30. package/src/providers/opencode-adapter.ts +149 -14
  31. package/src/providers/pi-mono-adapter.ts +41 -1
  32. package/src/providers/types.ts +1 -1
  33. package/src/server-user.ts +117 -0
  34. package/src/server.ts +14 -0
  35. package/src/tests/artifact-sdk.test.ts +23 -19
  36. package/src/tests/budget-user-scope.test.ts +376 -0
  37. package/src/tests/claude-managed-adapter.test.ts +6 -0
  38. package/src/tests/codex-adapter.test.ts +192 -0
  39. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  40. package/src/tests/db-queries-oauth.test.ts +43 -0
  41. package/src/tests/ensure-token.test.ts +93 -0
  42. package/src/tests/error-tracker.test.ts +52 -0
  43. package/src/tests/fetch-resolved-env.test.ts +33 -20
  44. package/src/tests/http-users.test.ts +29 -1
  45. package/src/tests/kapso-client.test.ts +94 -0
  46. package/src/tests/kapso-inbound.test.ts +198 -0
  47. package/src/tests/mcp-user-route.test.ts +325 -0
  48. package/src/tests/opencode-adapter.test.ts +75 -0
  49. package/src/tests/pi-mono-adapter.test.ts +21 -1
  50. package/src/tests/rate-limit-event.test.ts +69 -6
  51. package/src/tests/resume-session.test.ts +93 -0
  52. package/src/tests/task-tools-ctx.test.ts +100 -0
  53. package/src/tests/task-tools-ownership.test.ts +167 -0
  54. package/src/tests/tool-annotations.test.ts +3 -2
  55. package/src/tests/user-token-routes.test.ts +221 -0
  56. package/src/tools/cancel-task.ts +137 -83
  57. package/src/tools/get-task-details.ts +73 -59
  58. package/src/tools/get-tasks.ts +134 -126
  59. package/src/tools/register-kapso-number.ts +210 -0
  60. package/src/tools/send-task.ts +312 -312
  61. package/src/tools/task-action.ts +464 -367
  62. package/src/tools/task-tool-ctx.ts +43 -0
  63. package/src/tools/templates.ts +35 -0
  64. package/src/tools/tool-config.ts +6 -0
  65. package/src/tools/whatsapp-message.ts +135 -0
  66. package/src/types.ts +6 -2
  67. package/src/utils/error-tracker.ts +122 -9
  68. package/templates/skills/agentmail-sending/SKILL.md +49 -0
  69. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
package/src/http/poll.ts CHANGED
@@ -42,11 +42,13 @@ import { json, jsonError } from "./utils";
42
42
  * + workflow bus emit. See `src/be/budget-refusal-notify.ts`.
43
43
  */
44
44
  function buildBudgetRefusedTrigger(refusal: {
45
- cause: "agent" | "global";
45
+ cause: "agent" | "global" | "user";
46
46
  agentSpend?: number;
47
47
  agentBudget?: number;
48
48
  globalSpend?: number;
49
49
  globalBudget?: number;
50
+ userSpend?: number;
51
+ userBudget?: number;
50
52
  resetAt: string;
51
53
  }): { type: "budget_refused"; [key: string]: unknown } {
52
54
  const trigger: { type: "budget_refused"; [key: string]: unknown } = {
@@ -58,6 +60,8 @@ function buildBudgetRefusedTrigger(refusal: {
58
60
  if (refusal.agentBudget !== undefined) trigger.agentBudget = refusal.agentBudget;
59
61
  if (refusal.globalSpend !== undefined) trigger.globalSpend = refusal.globalSpend;
60
62
  if (refusal.globalBudget !== undefined) trigger.globalBudget = refusal.globalBudget;
63
+ if (refusal.userSpend !== undefined) trigger.userSpend = refusal.userSpend;
64
+ if (refusal.userBudget !== undefined) trigger.userBudget = refusal.userBudget;
61
65
  return trigger;
62
66
  }
63
67
 
@@ -180,7 +184,7 @@ export async function handlePoll(
180
184
  // the capacity check so capacity AND budget gates share atomicity.
181
185
  // Phase 5 also records the dedup row + captures the side-effect
182
186
  // context here so the after-commit step can notify the lead.
183
- const admission = canClaim(myAgentId, new Date());
187
+ const admission = canClaim(myAgentId, new Date(), pendingTask.requestedByUserId);
184
188
  if (!admission.allowed) {
185
189
  const utcDate = new Date().toISOString().slice(0, 10);
186
190
  const dedup = recordBudgetRefusalNotification({
@@ -192,6 +196,8 @@ export async function handlePoll(
192
196
  agentBudgetUsd: admission.agentBudget,
193
197
  globalSpendUsd: admission.globalSpend,
194
198
  globalBudgetUsd: admission.globalBudget,
199
+ userSpendUsd: admission.userSpend,
200
+ userBudgetUsd: admission.userBudget,
195
201
  });
196
202
  return {
197
203
  trigger: buildBudgetRefusedTrigger(admission),
@@ -200,6 +206,7 @@ export async function handlePoll(
200
206
  task: {
201
207
  id: pendingTask.id,
202
208
  task: pendingTask.task,
209
+ requestedByUserId: pendingTask.requestedByUserId,
203
210
  slackChannelId: pendingTask.slackChannelId,
204
211
  slackThreadTs: pendingTask.slackThreadTs,
205
212
  slackUserId: pendingTask.slackUserId,
@@ -211,6 +218,8 @@ export async function handlePoll(
211
218
  agentBudgetUsd: admission.agentBudget,
212
219
  globalSpendUsd: admission.globalSpend,
213
220
  globalBudgetUsd: admission.globalBudget,
221
+ userSpendUsd: admission.userSpend,
222
+ userBudgetUsd: admission.userBudget,
214
223
  resetAt: admission.resetAt,
215
224
  },
216
225
  inserted: dedup.inserted,
@@ -303,10 +312,10 @@ export async function handlePoll(
303
312
  // refusal, and the dedup is per-(task,date) so subsequent same-day
304
313
  // refusals on the same lead-candidate are suppressed.
305
314
  if (unassignedIds.length > 0) {
306
- const admission = canClaim(myAgentId, new Date());
315
+ const candidateId = unassignedIds[0]!;
316
+ const candidateTask = getTaskById(candidateId);
317
+ const admission = canClaim(myAgentId, new Date(), candidateTask?.requestedByUserId);
307
318
  if (!admission.allowed) {
308
- const candidateId = unassignedIds[0]!;
309
- const candidateTask = getTaskById(candidateId);
310
319
  const utcDate = new Date().toISOString().slice(0, 10);
311
320
  const dedup = recordBudgetRefusalNotification({
312
321
  taskId: candidateId,
@@ -317,6 +326,8 @@ export async function handlePoll(
317
326
  agentBudgetUsd: admission.agentBudget,
318
327
  globalSpendUsd: admission.globalSpend,
319
328
  globalBudgetUsd: admission.globalBudget,
329
+ userSpendUsd: admission.userSpend,
330
+ userBudgetUsd: admission.userBudget,
320
331
  });
321
332
  return {
322
333
  trigger: buildBudgetRefusedTrigger(admission),
@@ -326,6 +337,7 @@ export async function handlePoll(
326
337
  task: {
327
338
  id: candidateTask.id,
328
339
  task: candidateTask.task,
340
+ requestedByUserId: candidateTask.requestedByUserId,
329
341
  slackChannelId: candidateTask.slackChannelId,
330
342
  slackThreadTs: candidateTask.slackThreadTs,
331
343
  slackUserId: candidateTask.slackUserId,
@@ -337,6 +349,8 @@ export async function handlePoll(
337
349
  agentBudgetUsd: admission.agentBudget,
338
350
  globalSpendUsd: admission.globalSpend,
339
351
  globalBudgetUsd: admission.globalBudget,
352
+ userSpendUsd: admission.userSpend,
353
+ userBudgetUsd: admission.userBudget,
340
354
  resetAt: admission.resetAt,
341
355
  },
342
356
  inserted: dedup.inserted,
@@ -466,11 +466,11 @@ export async function handleSchedules(
466
466
  const mergedTimezone =
467
467
  parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
468
468
  if (timing.mergedCron || timing.mergedInterval) {
469
- // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
470
469
  body.nextRunAt = calculateNextRun({
471
470
  cronExpression: timing.mergedCron,
472
471
  intervalMs: timing.mergedInterval,
473
472
  timezone: mergedTimezone,
473
+ // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
474
474
  } as any);
475
475
  }
476
476
  }
package/src/http/users.ts CHANGED
@@ -7,8 +7,7 @@
7
7
  * pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
8
8
  * every identity mutation lands an event row tagged `op:<sha256-16>`.
9
9
  *
10
- * Endpoint set (Core Req #6, minus `POST/DELETE /users/:id/mcp-tokens` which
11
- * are deferred to the MCP plan):
10
+ * Endpoint set:
12
11
  *
13
12
  * GET /api/users
14
13
  * POST /api/users
@@ -16,6 +15,8 @@
16
15
  * POST /api/users/unmapped/:kind/:externalId/resolve
17
16
  * GET /api/users/:id
18
17
  * PATCH /api/users/:id
18
+ * POST /api/users/:id/mcp-tokens
19
+ * DELETE /api/users/:id/mcp-tokens/:tokenId
19
20
  * POST /api/users/:id/merge
20
21
  * GET /api/users/:id/events
21
22
  * POST /api/users/:id/identities
@@ -26,12 +27,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
26
27
  import { z } from "zod";
27
28
  import {
28
29
  createUser,
30
+ deleteBudget,
29
31
  deleteKv,
30
32
  deleteUser,
31
33
  getAllUsers,
32
34
  getUserById,
33
35
  listKv,
34
36
  updateUser,
37
+ upsertBudget,
35
38
  } from "../be/db";
36
39
  import {
37
40
  getUserIdentities,
@@ -39,7 +42,9 @@ import {
39
42
  linkIdentity,
40
43
  listUserEvents,
41
44
  listUserTokens,
45
+ mintToken,
42
46
  recordIdentityEvent,
47
+ revokeToken,
43
48
  unlinkIdentity,
44
49
  } from "../be/users";
45
50
  import { getOperatorActor } from "./operator-actor";
@@ -64,6 +69,15 @@ function composeUser(userId: string, recentEventLimit = 5) {
64
69
  };
65
70
  }
66
71
 
72
+ function syncUserBudgetMirror(userId: string, dailyBudgetUsd: number | null | undefined): void {
73
+ if (dailyBudgetUsd === undefined) return;
74
+ if (dailyBudgetUsd === null) {
75
+ deleteBudget("user", userId);
76
+ return;
77
+ }
78
+ upsertBudget("user", userId, dailyBudgetUsd);
79
+ }
80
+
67
81
  // ─── Route Definitions ───────────────────────────────────────────────────────
68
82
 
69
83
  const listUsers = route({
@@ -203,6 +217,41 @@ const updateUserRoute = route({
203
217
  auth: { apiKey: true },
204
218
  });
205
219
 
220
+ const mintUserMcpTokenRoute = route({
221
+ method: "post",
222
+ path: "/api/users/{id}/mcp-tokens",
223
+ pattern: ["api", "users", null, "mcp-tokens"],
224
+ summary: "Mint a one-time plaintext MCP token for a user",
225
+ description:
226
+ "Returns the plaintext token exactly once. Subsequent reads only expose token summaries.",
227
+ tags: ["Users"],
228
+ params: z.object({ id: z.string() }),
229
+ body: z.object({
230
+ label: z.string().nullable().optional(),
231
+ }),
232
+ responses: {
233
+ 200: { description: "Minted token plaintext, token summary and composed user" },
234
+ 401: { description: "Unauthorized" },
235
+ 404: { description: "User not found" },
236
+ },
237
+ auth: { apiKey: true },
238
+ });
239
+
240
+ const revokeUserMcpTokenRoute = route({
241
+ method: "delete",
242
+ path: "/api/users/{id}/mcp-tokens/{tokenId}",
243
+ pattern: ["api", "users", null, "mcp-tokens", null],
244
+ summary: "Revoke a user's MCP token",
245
+ tags: ["Users"],
246
+ params: z.object({ id: z.string(), tokenId: z.string() }),
247
+ responses: {
248
+ 200: { description: "Composed user after token revocation" },
249
+ 401: { description: "Unauthorized" },
250
+ 404: { description: "User or token not found" },
251
+ },
252
+ auth: { apiKey: true },
253
+ });
254
+
206
255
  const mergeUsersRoute = route({
207
256
  method: "post",
208
257
  path: "/api/users/{id}/merge",
@@ -370,6 +419,7 @@ export async function handleUsers(
370
419
  try {
371
420
  const { identities, ...userFields } = parsed.body;
372
421
  const user = createUser(userFields);
422
+ syncUserBudgetMirror(user.id, userFields.dailyBudgetUsd);
373
423
  for (const ident of identities ?? []) {
374
424
  linkIdentity(user.id, ident.kind, ident.externalId, actor);
375
425
  }
@@ -459,6 +509,60 @@ export async function handleUsers(
459
509
  return true;
460
510
  }
461
511
 
512
+ // ─── POST /api/users/:id/mcp-tokens ───────────────────────────────────────
513
+ if (mintUserMcpTokenRoute.match(req.method, pathSegments)) {
514
+ const parsed = await mintUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
515
+ if (!parsed) return true;
516
+ const actor = getOperatorActor(req, res);
517
+ if (!actor) return true;
518
+ if (!getUserById(parsed.params.id)) {
519
+ jsonError(res, "User not found", 404);
520
+ return true;
521
+ }
522
+
523
+ try {
524
+ const { tokenId, plaintext } = mintToken(parsed.params.id, parsed.body.label ?? null, actor);
525
+ const token = listUserTokens(parsed.params.id).find((t) => t.id === tokenId);
526
+ json(res, { plaintext, token, user: composeUser(parsed.params.id) });
527
+ } catch (err) {
528
+ jsonError(res, err instanceof Error ? err.message : "Failed to mint token", 500);
529
+ }
530
+ return true;
531
+ }
532
+
533
+ // ─── DELETE /api/users/:id/mcp-tokens/:tokenId ────────────────────────────
534
+ if (revokeUserMcpTokenRoute.match(req.method, pathSegments)) {
535
+ const parsed = await revokeUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
536
+ if (!parsed) return true;
537
+ const actor = getOperatorActor(req, res);
538
+ if (!actor) return true;
539
+ if (!getUserById(parsed.params.id)) {
540
+ jsonError(res, "User not found", 404);
541
+ return true;
542
+ }
543
+
544
+ const tokenBelongsToUser = listUserTokens(parsed.params.id).some(
545
+ (token) => token.id === parsed.params.tokenId,
546
+ );
547
+ if (!tokenBelongsToUser) {
548
+ jsonError(res, "Token not found", 404);
549
+ return true;
550
+ }
551
+
552
+ try {
553
+ revokeToken(parsed.params.tokenId, actor);
554
+ json(res, { user: composeUser(parsed.params.id) });
555
+ } catch (err) {
556
+ const message = err instanceof Error ? err.message : "Failed to revoke token";
557
+ if (message.includes("Token not found")) {
558
+ jsonError(res, "Token not found", 404);
559
+ } else {
560
+ jsonError(res, message, 500);
561
+ }
562
+ }
563
+ return true;
564
+ }
565
+
462
566
  // ─── POST /api/users/:id/identities ────────────────────────────────────────
463
567
  if (addIdentityRoute.match(req.method, pathSegments)) {
464
568
  const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
@@ -625,6 +729,7 @@ export async function handleUsers(
625
729
  jsonError(res, "User not found", 404);
626
730
  return true;
627
731
  }
732
+ syncUserBudgetMirror(parsed.params.id, parsed.body.dailyBudgetUsd);
628
733
 
629
734
  // Budget event
630
735
  if (
@@ -41,7 +41,11 @@ import {
41
41
  isGitLabEnabled,
42
42
  verifyGitLabWebhook,
43
43
  } from "../gitlab";
44
+ import { getKapsoConfig } from "../integrations/kapso/config";
45
+ import { routeKapsoInbound } from "../integrations/kapso/inbound";
46
+ import { getExecutorRegistry } from "../workflows";
44
47
  import { workflowEventBus } from "../workflows/event-bus";
48
+ import { handleWebhookTrigger, verifyHmacSignature, WebhookError } from "../workflows/triggers";
45
49
  import { route } from "./route-def";
46
50
 
47
51
  // ─── Route Definitions (documentation only — webhooks handle their own body parsing) ─
@@ -88,6 +92,20 @@ const agentmailWebhook = route({
88
92
  },
89
93
  });
90
94
 
95
+ const kapsoWebhook = route({
96
+ method: "post",
97
+ path: "/api/integrations/kapso/webhook",
98
+ pattern: ["api", "integrations", "kapso", "webhook"],
99
+ summary: "Handle native Kapso/WhatsApp webhook events",
100
+ tags: ["Webhooks"],
101
+ auth: { apiKey: false },
102
+ responses: {
103
+ 200: { description: "Event received" },
104
+ 401: { description: "Invalid signature" },
105
+ 503: { description: "Kapso integration not configured" },
106
+ },
107
+ });
108
+
91
109
  // ─── Handler ─────────────────────────────────────────────────────────────────
92
110
 
93
111
  export async function handleWebhooks(
@@ -437,5 +455,88 @@ export async function handleWebhooks(
437
455
  return true;
438
456
  }
439
457
 
458
+ // Native Kapso/WhatsApp webhook — needs raw body for HMAC verification.
459
+ // Registered numbers route here (register-kapso-number points Kapso's webhook
460
+ // at this URL); the generic workflow-webhook path (/api/webhooks/{id}) is
461
+ // untouched and still serves any number not registered in KV.
462
+ if (kapsoWebhook.match(req.method, pathSegments)) {
463
+ const config = getKapsoConfig();
464
+ if (!config.webhookHmacSecret) {
465
+ res.writeHead(503, { "Content-Type": "application/json" });
466
+ res.end(JSON.stringify({ error: "Kapso integration not configured" }));
467
+ return true;
468
+ }
469
+
470
+ const chunks: Buffer[] = [];
471
+ for await (const chunk of req) {
472
+ chunks.push(chunk as Buffer);
473
+ }
474
+ const rawBody = Buffer.concat(chunks).toString();
475
+
476
+ const signature = req.headers["x-webhook-signature"];
477
+ const signatureValue = Array.isArray(signature) ? signature[0] : signature;
478
+ if (
479
+ !signatureValue ||
480
+ !verifyHmacSignature(config.webhookHmacSecret, rawBody, signatureValue)
481
+ ) {
482
+ console.log("[Kapso] Invalid webhook signature");
483
+ res.writeHead(401, { "Content-Type": "application/json" });
484
+ res.end(JSON.stringify({ error: "Invalid signature" }));
485
+ return true;
486
+ }
487
+
488
+ let payload: Record<string, unknown>;
489
+ try {
490
+ payload = JSON.parse(rawBody);
491
+ } catch {
492
+ res.writeHead(400, { "Content-Type": "application/json" });
493
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
494
+ return true;
495
+ }
496
+
497
+ try {
498
+ const routing = routeKapsoInbound(payload);
499
+ switch (routing.kind) {
500
+ case "workflow":
501
+ // Advanced override — dispatch through the workflow's webhook trigger.
502
+ await handleWebhookTrigger(
503
+ routing.workflowId,
504
+ rawBody,
505
+ req.headers,
506
+ getExecutorRegistry(),
507
+ );
508
+ break;
509
+ case "no_mapping":
510
+ console.warn(
511
+ `[Kapso] No native mapping for phone_number_id "${routing.phoneNumberId}" — ignoring (register it with register-kapso-number)`,
512
+ );
513
+ break;
514
+ case "task":
515
+ console.log(`[Kapso] Dispatched kapso-inbound task ${routing.taskId}`);
516
+ break;
517
+ case "duplicate":
518
+ console.log(`[Kapso] Duplicate delivery for message ${routing.messageId}, skipping`);
519
+ break;
520
+ case "skip":
521
+ console.log(`[Kapso] Skipping event: ${routing.reason}`);
522
+ break;
523
+ }
524
+ res.writeHead(200, { "Content-Type": "application/json" });
525
+ res.end(JSON.stringify({ received: true, routing: routing.kind }));
526
+ } catch (err) {
527
+ // Never fail the delivery on a downstream dispatch error — we already
528
+ // verified + deduped. Log and ack so Kapso doesn't hammer retries.
529
+ const errorMessage = err instanceof Error ? err.message : String(err);
530
+ if (err instanceof WebhookError) {
531
+ console.warn(`[Kapso] Workflow dispatch rejected: ${errorMessage}`);
532
+ } else {
533
+ console.error(`[Kapso] Error handling inbound event: ${errorMessage}`);
534
+ }
535
+ res.writeHead(200, { "Content-Type": "application/json" });
536
+ res.end(JSON.stringify({ received: true, routing: "error" }));
537
+ }
538
+ return true;
539
+ }
540
+
440
541
  return false;
441
542
  }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Thin Kapso REST client for the native integration. Pure `fetch` — no DB access.
3
+ *
4
+ * Two Kapso surfaces are used:
5
+ * - Meta Cloud API proxy: `{base}/meta/whatsapp/v24.0/{phoneNumberId}/messages`
6
+ * - Kapso platform API: `{base}/platform/v1/whatsapp/phone_numbers/{id}/webhooks`
7
+ *
8
+ * Both authenticate with the `X-API-Key` header.
9
+ */
10
+
11
+ /** Result of an outbound text/reply send through the Meta proxy. */
12
+ export interface KapsoSendResult {
13
+ ok: boolean;
14
+ status: number;
15
+ /** Outbound WAMID when the send succeeded. */
16
+ messageId?: string;
17
+ raw: unknown;
18
+ /** True when Kapso/Meta rejected the send for being outside the 24h session window. */
19
+ sessionWindowExpired: boolean;
20
+ errorMessage?: string;
21
+ }
22
+
23
+ /** Meta error codes that mean "outside the 24h customer-service window". */
24
+ const SESSION_WINDOW_ERROR_CODES = new Set([131047, 131051, 470]);
25
+
26
+ function extractMetaError(raw: unknown): { code?: number; message?: string } {
27
+ if (raw && typeof raw === "object" && "error" in raw) {
28
+ const err = (raw as { error?: { code?: number; message?: string } }).error;
29
+ if (err) return { code: err.code, message: err.message };
30
+ }
31
+ return {};
32
+ }
33
+
34
+ function isSessionWindowError(raw: unknown): boolean {
35
+ const { code, message } = extractMetaError(raw);
36
+ if (code !== undefined && SESSION_WINDOW_ERROR_CODES.has(code)) return true;
37
+ const text = (message ?? "").toLowerCase();
38
+ return text.includes("24 hours") || text.includes("re-engagement") || text.includes("outside");
39
+ }
40
+
41
+ async function parseJsonSafe(res: Response): Promise<unknown> {
42
+ const text = await res.text();
43
+ if (!text) return null;
44
+ try {
45
+ return JSON.parse(text);
46
+ } catch {
47
+ return text;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Send a free-form WhatsApp text message via the Meta Cloud API proxy. When
53
+ * `contextMessageId` is set, the message renders as a quote-reply to that WAMID.
54
+ */
55
+ export async function sendKapsoText(params: {
56
+ apiBaseUrl: string;
57
+ apiKey: string;
58
+ phoneNumberId: string;
59
+ to: string;
60
+ body: string;
61
+ previewUrl?: boolean;
62
+ contextMessageId?: string;
63
+ }): Promise<KapsoSendResult> {
64
+ const url = `${params.apiBaseUrl}/meta/whatsapp/v24.0/${params.phoneNumberId}/messages`;
65
+ const payload: Record<string, unknown> = {
66
+ messaging_product: "whatsapp",
67
+ recipient_type: "individual",
68
+ to: params.to,
69
+ type: "text",
70
+ text: { preview_url: params.previewUrl ?? false, body: params.body },
71
+ };
72
+ if (params.contextMessageId) {
73
+ payload.context = { message_id: params.contextMessageId };
74
+ }
75
+
76
+ const res = await fetch(url, {
77
+ method: "POST",
78
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
79
+ body: JSON.stringify(payload),
80
+ });
81
+ const raw = await parseJsonSafe(res);
82
+
83
+ if (!res.ok) {
84
+ const { message } = extractMetaError(raw);
85
+ return {
86
+ ok: false,
87
+ status: res.status,
88
+ raw,
89
+ sessionWindowExpired: isSessionWindowError(raw),
90
+ errorMessage: message ?? `Kapso send failed with status ${res.status}`,
91
+ };
92
+ }
93
+
94
+ const messageId =
95
+ raw && typeof raw === "object" && "messages" in raw
96
+ ? (raw as { messages?: Array<{ id?: string }> }).messages?.[0]?.id
97
+ : undefined;
98
+ return { ok: true, status: res.status, messageId, raw, sessionWindowExpired: false };
99
+ }
100
+
101
+ /** Result of configuring a webhook on a phone number. */
102
+ export interface KapsoWebhookResult {
103
+ ok: boolean;
104
+ status: number;
105
+ raw: unknown;
106
+ errorMessage?: string;
107
+ /** True when an identical webhook already existed and we skipped re-creating it. */
108
+ alreadyRegistered?: boolean;
109
+ }
110
+
111
+ /** Pull a webhook array out of the various shapes Kapso's list endpoint may return. */
112
+ function extractWebhookList(raw: unknown): Array<{ url?: string; kind?: string }> {
113
+ if (Array.isArray(raw)) return raw as Array<{ url?: string; kind?: string }>;
114
+ if (raw && typeof raw === "object") {
115
+ for (const key of ["whatsapp_webhooks", "webhooks", "data"]) {
116
+ const val = (raw as Record<string, unknown>)[key];
117
+ if (Array.isArray(val)) return val as Array<{ url?: string; kind?: string }>;
118
+ }
119
+ }
120
+ return [];
121
+ }
122
+
123
+ /**
124
+ * Return true when a Kapso webhook already points at `webhookUrl` for this
125
+ * phone number — used to avoid creating duplicate webhooks on re-registration.
126
+ * Best-effort: returns false if the list endpoint is unavailable.
127
+ */
128
+ async function kapsoWebhookExists(params: {
129
+ apiBaseUrl: string;
130
+ apiKey: string;
131
+ phoneNumberId: string;
132
+ webhookUrl: string;
133
+ }): Promise<boolean> {
134
+ const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
135
+ try {
136
+ const res = await fetch(url, { headers: { "X-API-Key": params.apiKey } });
137
+ if (!res.ok) return false;
138
+ const raw = await parseJsonSafe(res);
139
+ return extractWebhookList(raw).some((w) => w?.url === params.webhookUrl);
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Register (or re-point) the Kapso webhook for a phone number so inbound events
147
+ * are delivered to `webhookUrl`, signed with `secret` via `X-Webhook-Signature`.
148
+ *
149
+ * First checks whether an identical webhook already exists for the number and
150
+ * skips the create call if so, to avoid piling up duplicate webhooks when a
151
+ * number is registered more than once.
152
+ */
153
+ export async function registerKapsoWebhook(params: {
154
+ apiBaseUrl: string;
155
+ apiKey: string;
156
+ phoneNumberId: string;
157
+ webhookUrl: string;
158
+ secret?: string;
159
+ events?: string[];
160
+ }): Promise<KapsoWebhookResult> {
161
+ const url = `${params.apiBaseUrl}/platform/v1/whatsapp/phone_numbers/${params.phoneNumberId}/webhooks`;
162
+
163
+ if (
164
+ await kapsoWebhookExists({
165
+ apiBaseUrl: params.apiBaseUrl,
166
+ apiKey: params.apiKey,
167
+ phoneNumberId: params.phoneNumberId,
168
+ webhookUrl: params.webhookUrl,
169
+ })
170
+ ) {
171
+ return { ok: true, status: 200, raw: null, alreadyRegistered: true };
172
+ }
173
+
174
+ const whatsapp_webhook: Record<string, unknown> = {
175
+ kind: "kapso",
176
+ url: params.webhookUrl,
177
+ events: params.events ?? ["whatsapp.message.received"],
178
+ };
179
+ if (params.secret) whatsapp_webhook.secret_key = params.secret;
180
+
181
+ const res = await fetch(url, {
182
+ method: "POST",
183
+ headers: { "X-API-Key": params.apiKey, "Content-Type": "application/json" },
184
+ body: JSON.stringify({ whatsapp_webhook }),
185
+ });
186
+ const raw = await parseJsonSafe(res);
187
+
188
+ if (!res.ok) {
189
+ const { message } = extractMetaError(raw);
190
+ return {
191
+ ok: false,
192
+ status: res.status,
193
+ raw,
194
+ errorMessage: message ?? `Kapso webhook registration failed with status ${res.status}`,
195
+ };
196
+ }
197
+ return { ok: true, status: res.status, raw };
198
+ }