@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.
- package/openapi.json +158 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/http/webhooks.ts +101 -0
- package/src/integrations/kapso/client.ts +198 -0
- package/src/integrations/kapso/config.ts +104 -0
- package/src/integrations/kapso/inbound.ts +111 -0
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/server.ts +14 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +198 -0
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/register-kapso-number.ts +210 -0
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/tools/templates.ts +35 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/tools/whatsapp-message.ts +135 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/templates/skills/agentmail-sending/SKILL.md +49 -0
- 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
|
|
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,
|
package/src/http/schedules.ts
CHANGED
|
@@ -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
|
|
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 (
|
package/src/http/webhooks.ts
CHANGED
|
@@ -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
|
+
}
|