@desplega.ai/agent-swarm 1.83.2 → 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 +20 -1
- package/package.json +1 -1
- 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/server.ts +14 -0
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +198 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tools/register-kapso-number.ts +210 -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/templates/skills/agentmail-sending/SKILL.md +49 -0
- package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.84.0",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -11394,6 +11394,25 @@
|
|
|
11394
11394
|
}
|
|
11395
11395
|
}
|
|
11396
11396
|
},
|
|
11397
|
+
"/api/integrations/kapso/webhook": {
|
|
11398
|
+
"post": {
|
|
11399
|
+
"summary": "Handle native Kapso/WhatsApp webhook events",
|
|
11400
|
+
"tags": [
|
|
11401
|
+
"Webhooks"
|
|
11402
|
+
],
|
|
11403
|
+
"responses": {
|
|
11404
|
+
"200": {
|
|
11405
|
+
"description": "Event received"
|
|
11406
|
+
},
|
|
11407
|
+
"401": {
|
|
11408
|
+
"description": "Invalid signature"
|
|
11409
|
+
},
|
|
11410
|
+
"503": {
|
|
11411
|
+
"description": "Kapso integration not configured"
|
|
11412
|
+
}
|
|
11413
|
+
}
|
|
11414
|
+
}
|
|
11415
|
+
},
|
|
11397
11416
|
"/api/workflow-runs/{runId}/events": {
|
|
11398
11417
|
"post": {
|
|
11399
11418
|
"summary": "Fire a run-scoped event signal",
|
package/package.json
CHANGED
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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { deleteKv, getKv, getSwarmConfigs, upsertKv } from "@/be/db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native Kapso/WhatsApp integration — shared server-side config + mapping store.
|
|
5
|
+
*
|
|
6
|
+
* The mapping (phone-number-id → routing target) is backed by the swarm KV store
|
|
7
|
+
* under a pinned namespace, NOT a dedicated table. The inbound webhook handler and
|
|
8
|
+
* the `register-kapso-number` MCP tool are the only readers/writers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Pinned KV namespace for phone-number → routing mappings. No TTL. */
|
|
12
|
+
export const KAPSO_NUMBERS_NAMESPACE = "integrations:kapso:numbers";
|
|
13
|
+
|
|
14
|
+
/** Pinned KV namespace for inbound message-id dedupe markers (24h TTL). */
|
|
15
|
+
export const KAPSO_DEDUPE_NAMESPACE = "integrations:kapso:dedupe";
|
|
16
|
+
|
|
17
|
+
/** How long a dedupe marker lives — long enough to cover Kapso's webhook retries. */
|
|
18
|
+
export const KAPSO_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** Default Kapso API host when `KAPSO_API_BASE_URL` is unset (host only, no path). */
|
|
21
|
+
export const DEFAULT_KAPSO_API_BASE_URL = "https://api.kapso.ai";
|
|
22
|
+
|
|
23
|
+
/** A registered phone number and where its inbound messages should route. */
|
|
24
|
+
export interface KapsoNumberMapping {
|
|
25
|
+
phoneNumberId: string;
|
|
26
|
+
/** Route inbound to this agent as a task. */
|
|
27
|
+
agentId?: string;
|
|
28
|
+
/** Advanced override: dispatch via this workflow's webhook trigger instead of a task. */
|
|
29
|
+
workflowId?: string;
|
|
30
|
+
/** Human-friendly display name for the number. */
|
|
31
|
+
name?: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KapsoConfig {
|
|
36
|
+
apiKey: string | undefined;
|
|
37
|
+
apiBaseUrl: string;
|
|
38
|
+
webhookHmacSecret: string | undefined;
|
|
39
|
+
phoneNumberId: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read a swarm-config value (global scope) by key, falling back to the process
|
|
44
|
+
* env. Decryption happens inside `getSwarmConfigs`.
|
|
45
|
+
*/
|
|
46
|
+
function readConfigValue(key: string): string | undefined {
|
|
47
|
+
const found = getSwarmConfigs({ scope: "global", key }).find(
|
|
48
|
+
(c) => typeof c.value === "string" && c.value.length > 0,
|
|
49
|
+
);
|
|
50
|
+
if (found) return found.value;
|
|
51
|
+
const env = process.env[key];
|
|
52
|
+
return env && env.length > 0 ? env : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the Kapso integration config from swarm config (env fallback). */
|
|
56
|
+
export function getKapsoConfig(): KapsoConfig {
|
|
57
|
+
const base = readConfigValue("KAPSO_API_BASE_URL") ?? DEFAULT_KAPSO_API_BASE_URL;
|
|
58
|
+
return {
|
|
59
|
+
apiKey: readConfigValue("KAPSO_API_KEY"),
|
|
60
|
+
apiBaseUrl: base.replace(/\/+$/, ""),
|
|
61
|
+
webhookHmacSecret: readConfigValue("KAPSO_WEBHOOK_HMAC_SECRET"),
|
|
62
|
+
phoneNumberId: readConfigValue("KAPSO_PHONE_NUMBER_ID"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Look up the routing mapping for a phone-number-id, or null if unregistered. */
|
|
67
|
+
export function getKapsoNumberMapping(phoneNumberId: string): KapsoNumberMapping | null {
|
|
68
|
+
const row = getKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
|
|
69
|
+
return row ? (row.value as KapsoNumberMapping) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Upsert a routing mapping (no TTL). */
|
|
73
|
+
export function putKapsoNumberMapping(mapping: KapsoNumberMapping): KapsoNumberMapping {
|
|
74
|
+
upsertKv({
|
|
75
|
+
namespace: KAPSO_NUMBERS_NAMESPACE,
|
|
76
|
+
key: mapping.phoneNumberId,
|
|
77
|
+
value: mapping,
|
|
78
|
+
valueType: "json",
|
|
79
|
+
expiresAt: null,
|
|
80
|
+
});
|
|
81
|
+
return mapping;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete a routing mapping. Returns true if a row was removed. */
|
|
85
|
+
export function deleteKapsoNumberMapping(phoneNumberId: string): boolean {
|
|
86
|
+
return deleteKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record a message-id as processed. Returns true the FIRST time a given id is
|
|
91
|
+
* seen and false on every subsequent delivery within the TTL window — so the
|
|
92
|
+
* caller drops duplicates (Kapso retries deliveries).
|
|
93
|
+
*/
|
|
94
|
+
export function markKapsoMessageSeen(messageId: string): boolean {
|
|
95
|
+
if (getKv(KAPSO_DEDUPE_NAMESPACE, messageId)) return false;
|
|
96
|
+
upsertKv({
|
|
97
|
+
namespace: KAPSO_DEDUPE_NAMESPACE,
|
|
98
|
+
key: messageId,
|
|
99
|
+
value: 1,
|
|
100
|
+
valueType: "integer",
|
|
101
|
+
expiresAt: Date.now() + KAPSO_DEDUPE_TTL_MS,
|
|
102
|
+
});
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { resolveTemplate } from "@/prompts/resolver";
|
|
2
|
+
import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
|
|
3
|
+
import { workflowEventBus } from "@/workflows/event-bus";
|
|
4
|
+
import "@/tools/templates";
|
|
5
|
+
import { getKapsoNumberMapping, markKapsoMessageSeen } from "./config";
|
|
6
|
+
|
|
7
|
+
/** Minimal shape of the Kapso v2 inbound webhook payload (see the kapso-whatsapp skill). */
|
|
8
|
+
export interface KapsoWebhookPayload {
|
|
9
|
+
message?: {
|
|
10
|
+
id?: string;
|
|
11
|
+
from?: string;
|
|
12
|
+
type?: string;
|
|
13
|
+
text?: { body?: string };
|
|
14
|
+
kapso?: { direction?: string; content?: string; has_media?: boolean };
|
|
15
|
+
};
|
|
16
|
+
conversation?: {
|
|
17
|
+
id?: string;
|
|
18
|
+
phone_number?: string;
|
|
19
|
+
contact_name?: string;
|
|
20
|
+
};
|
|
21
|
+
phone_number_id?: string;
|
|
22
|
+
test?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Outcome of routing one inbound webhook delivery. */
|
|
26
|
+
export type KapsoRouting =
|
|
27
|
+
| { kind: "skip"; reason: string }
|
|
28
|
+
| { kind: "duplicate"; messageId: string }
|
|
29
|
+
| { kind: "workflow"; workflowId: string }
|
|
30
|
+
| { kind: "task"; taskId: string }
|
|
31
|
+
| { kind: "no_mapping"; phoneNumberId: string };
|
|
32
|
+
|
|
33
|
+
function extractText(message: NonNullable<KapsoWebhookPayload["message"]>): string {
|
|
34
|
+
if (message.text?.body) return message.text.body;
|
|
35
|
+
if (message.kapso?.content) return message.kapso.content;
|
|
36
|
+
return `(non-text message — type: ${message.type ?? "unknown"})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildTaskDescription(payload: KapsoWebhookPayload): string {
|
|
40
|
+
const message = payload.message ?? {};
|
|
41
|
+
const conversation = payload.conversation ?? {};
|
|
42
|
+
return resolveTemplate("kapso.message.received", {
|
|
43
|
+
conversation_id: conversation.id ?? "unknown",
|
|
44
|
+
inbound_wamid: message.id ?? "unknown",
|
|
45
|
+
sender_phone: message.from ?? conversation.phone_number ?? "unknown",
|
|
46
|
+
contact_name: conversation.contact_name ?? "unknown",
|
|
47
|
+
phone_number_id: payload.phone_number_id ?? "unknown",
|
|
48
|
+
test_note: payload.test ? "\n- test: true (do NOT send a real WhatsApp reply)" : "",
|
|
49
|
+
message_text: extractText(message),
|
|
50
|
+
}).text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Route one inbound Kapso webhook delivery. Pure of HTTP concerns — the caller
|
|
55
|
+
* handles HMAC verification and the workflow-trigger dispatch (which needs the
|
|
56
|
+
* raw body + executor registry). This:
|
|
57
|
+
* 1. drops non-inbound events and deliveries missing a message id,
|
|
58
|
+
* 2. dedupes by message id (KV, 24h TTL),
|
|
59
|
+
* 3. emits the `kapso.message.received` workflow event (additive),
|
|
60
|
+
* 4. looks up the phone-number mapping and either signals a workflow dispatch
|
|
61
|
+
* or creates a native `kapso-inbound` task,
|
|
62
|
+
* 5. returns `no_mapping` when the number isn't registered (caller logs a warning).
|
|
63
|
+
*/
|
|
64
|
+
export function routeKapsoInbound(payload: KapsoWebhookPayload): KapsoRouting {
|
|
65
|
+
const message = payload.message;
|
|
66
|
+
const direction = message?.kapso?.direction;
|
|
67
|
+
if (direction !== "inbound") {
|
|
68
|
+
return { kind: "skip", reason: `non_inbound (direction=${direction ?? "none"})` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const messageId = message?.id;
|
|
72
|
+
if (!messageId) {
|
|
73
|
+
return { kind: "skip", reason: "missing_message_id" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!markKapsoMessageSeen(messageId)) {
|
|
77
|
+
return { kind: "duplicate", messageId };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const phoneNumberId = payload.phone_number_id ?? "";
|
|
81
|
+
|
|
82
|
+
// Additive: let event-subscribed workflows observe inbound regardless of mapping.
|
|
83
|
+
workflowEventBus.emit("kapso.message.received", {
|
|
84
|
+
phoneNumberId,
|
|
85
|
+
conversationId: payload.conversation?.id,
|
|
86
|
+
messageId,
|
|
87
|
+
from: message?.from,
|
|
88
|
+
type: message?.type,
|
|
89
|
+
text: extractText(message ?? {}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const mapping = phoneNumberId ? getKapsoNumberMapping(phoneNumberId) : null;
|
|
93
|
+
if (!mapping) {
|
|
94
|
+
return { kind: "no_mapping", phoneNumberId };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (mapping.workflowId) {
|
|
98
|
+
return { kind: "workflow", workflowId: mapping.workflowId };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const task = createTaskWithSiblingAwareness(buildTaskDescription(payload), {
|
|
102
|
+
agentId: mapping.agentId ?? null,
|
|
103
|
+
source: "system",
|
|
104
|
+
taskType: "kapso-inbound",
|
|
105
|
+
tags: ["kapso-whatsapp", "inbound"],
|
|
106
|
+
priority: 70,
|
|
107
|
+
contextKey: `kapso:conversation:${payload.conversation?.id ?? messageId}`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { kind: "task", taskId: task.id };
|
|
111
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -55,6 +55,10 @@ import {
|
|
|
55
55
|
} from "./tools/prompt-templates";
|
|
56
56
|
import { registerReadMessagesTool } from "./tools/read-messages";
|
|
57
57
|
import { registerRegisterAgentmailInboxTool } from "./tools/register-agentmail-inbox";
|
|
58
|
+
import {
|
|
59
|
+
registerRegisterKapsoNumberTool,
|
|
60
|
+
registerUnregisterKapsoNumberTool,
|
|
61
|
+
} from "./tools/register-kapso-number";
|
|
58
62
|
// Services capability
|
|
59
63
|
import { registerRegisterServiceTool } from "./tools/register-service";
|
|
60
64
|
// Repo management tools
|
|
@@ -118,6 +122,10 @@ import { registerUnregisterServiceTool } from "./tools/unregister-service";
|
|
|
118
122
|
// Profiles capability
|
|
119
123
|
import { registerUpdateProfileTool } from "./tools/update-profile";
|
|
120
124
|
import { registerUpdateServiceStatusTool } from "./tools/update-service-status";
|
|
125
|
+
import {
|
|
126
|
+
registerReplyWhatsappMessageTool,
|
|
127
|
+
registerSendWhatsappMessageTool,
|
|
128
|
+
} from "./tools/whatsapp-message";
|
|
121
129
|
// Workflows capability
|
|
122
130
|
import {
|
|
123
131
|
registerCancelWorkflowRunTool,
|
|
@@ -228,6 +236,12 @@ export function createServer() {
|
|
|
228
236
|
// AgentMail integration tool (always registered, self-service inbox mapping)
|
|
229
237
|
registerRegisterAgentmailInboxTool(server);
|
|
230
238
|
|
|
239
|
+
// Kapso/WhatsApp integration tools (native inbound provisioning + outbound)
|
|
240
|
+
registerRegisterKapsoNumberTool(server);
|
|
241
|
+
registerUnregisterKapsoNumberTool(server);
|
|
242
|
+
registerSendWhatsappMessageTool(server);
|
|
243
|
+
registerReplyWhatsappMessageTool(server);
|
|
244
|
+
|
|
231
245
|
// Task pool capability - task pool operations (create unassigned, claim, release, accept, reject)
|
|
232
246
|
if (hasCapability("task-pool")) {
|
|
233
247
|
registerTaskActionTool(server);
|