@andreprado/agentkit 0.1.0-alpha.9 → 0.1.1
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 +18 -1
- package/docs/guides/add-channel.md +251 -7
- package/docs/guides/add-knowledge.md +10 -0
- package/docs/guides/add-managed-composio.md +165 -0
- package/docs/guides/add-tool.md +10 -3
- package/docs/guides/channel-security.md +162 -32
- package/docs/guides/connect-discord.md +178 -0
- package/docs/guides/connect-slack.md +126 -0
- package/docs/guides/connect-telegram.md +61 -1
- package/docs/guides/connect-whatsapp-evolution.md +121 -0
- package/docs/guides/connect-whatsapp-uazapi.md +139 -0
- package/docs/guides/connect-whatsapp-zapster.md +119 -16
- package/docs/guides/create-agent.md +31 -4
- package/docs/guides/debug-channel.md +159 -0
- package/docs/guides/improve-from-production.md +151 -0
- package/docs/guides/prepare-deploy.md +32 -14
- package/docs/guides/replay-production-traces.md +72 -0
- package/docs/guides/run-evals.md +95 -25
- package/docs/guides/security-rules.md +9 -5
- package/docs/guides/send-feedback.md +135 -0
- package/docs/guides/use-jev.md +67 -0
- package/docs/guides/use-provider.md +70 -3
- package/docs/llms-full.txt +295 -25
- package/docs/llms.txt +54 -7
- package/package.json +3 -7
- package/src/cli/args.ts +23 -2
- package/src/cli/cloud-client.ts +121 -9
- package/src/cli/commands/channels.ts +856 -36
- package/src/cli/commands/feedback.ts +438 -0
- package/src/cli/commands/provider.ts +47 -0
- package/src/cli/commands/transcribe.ts +171 -0
- package/src/cli/deploy-chat-ui.ts +232 -18
- package/src/cli/deploy-readiness.ts +227 -14
- package/src/cli/help.ts +67 -9
- package/src/cli/index.ts +740 -35
- package/src/cli/new-command.ts +41 -0
- package/src/cloud/client.ts +4 -3
- package/src/cloud/contracts.ts +1 -1
- package/src/create-project.ts +18 -35
- package/src/index.ts +565 -11
- package/src/providers/codex-auth.ts +111 -0
- package/src/providers/pi.ts +88 -19
- package/src/providers/test.ts +36 -0
- package/src/providers/types.ts +8 -0
- package/src/runtime/channel-test-harness.ts +21 -1
- package/src/runtime/channels/discord.ts +904 -0
- package/src/runtime/channels/generic-webhook.ts +682 -0
- package/src/runtime/channels/net-guard.ts +480 -0
- package/src/runtime/channels/provider-fetch.ts +54 -0
- package/src/runtime/channels/slack.ts +652 -0
- package/src/runtime/channels/telegram.ts +379 -15
- package/src/runtime/channels/whatsapp-evolution.ts +1330 -0
- package/src/runtime/channels/whatsapp-meta.ts +9 -0
- package/src/runtime/channels/whatsapp-uazapi.ts +1192 -0
- package/src/runtime/channels/whatsapp-zapster.ts +702 -40
- package/src/runtime/channels.ts +83 -3
- package/src/runtime/chat.ts +70 -44
- package/src/runtime/config.ts +512 -20
- package/src/runtime/core/manifest.ts +75 -5
- package/src/runtime/core/targets.ts +5 -5
- package/src/runtime/deploy-readiness.ts +34 -4
- package/src/runtime/dev-server.ts +639 -39
- package/src/runtime/env.ts +8 -3
- package/src/runtime/evals.ts +445 -74
- package/src/runtime/improve.ts +868 -0
- package/src/runtime/inspect.ts +173 -4
- package/src/runtime/integrations/composio.ts +425 -0
- package/src/runtime/knowledge/embeddings.ts +45 -7
- package/src/runtime/knowledge/ingest.ts +69 -6
- package/src/runtime/knowledge/retrieve.ts +25 -5
- package/src/runtime/knowledge/schema.ts +45 -1
- package/src/runtime/knowledge/vector.ts +30 -30
- package/src/runtime/prompt-context.ts +141 -0
- package/src/runtime/runtime-contract.ts +71 -7
- package/src/runtime/skills.ts +95 -0
- package/src/runtime/targets/cloudflare/build.ts +1010 -208
- package/src/runtime/targets/container/server.ts +1 -1
- package/src/runtime/targets/vps/deploy.ts +26 -9
- package/src/runtime/tool-runner.ts +9 -1
- package/src/runtime/tools.ts +26 -2
- package/src/runtime/transcription.ts +483 -0
- package/src/storage/sqlite.ts +7 -2
- package/src/templates/blank.ts +37 -9
- package/src/templates/dentista.ts +40 -14
- package/src/templates/skills/agentkit-build-agent/SKILL.md +34 -5
- package/src/templates/skills/agentkit-build-agent/templates/appointment-intake.instructions.md +2 -1
- package/src/templates/skills/agentkit-capsule/SKILL.md +32 -3
- package/src/templates/skills/agentkit-capsule/references/docs-router.md +2 -2
- package/src/templates/skills/agentkit-channels/SKILL.md +66 -1
- package/src/templates/skills/agentkit-channels/references/channel-buffering.md +8 -1
- package/src/templates/skills/agentkit-channels/references/channel-debugging.md +28 -3
- package/src/templates/skills/agentkit-channels/references/discord.md +93 -0
- package/src/templates/skills/agentkit-channels/references/slack.md +56 -0
- package/src/templates/skills/agentkit-channels/references/telegram.md +34 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-evolution.md +57 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-uazapi.md +54 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-zapster.md +42 -8
- package/src/templates/skills/agentkit-database/SKILL.md +11 -0
- package/src/templates/skills/agentkit-deploy/SKILL.md +9 -1
- package/src/templates/skills/agentkit-evals/SKILL.md +77 -13
- package/src/templates/skills/agentkit-evals/templates/multi-turn.eval.md +13 -6
- package/src/templates/skills/agentkit-evals/templates/no-leak.eval.md +8 -4
- package/src/templates/skills/agentkit-evals/templates/smoke.eval.md +8 -4
- package/src/templates/skills/agentkit-evals/templates/tool-call.eval.md +16 -7
- package/src/templates/skills/agentkit-improve/SKILL.md +96 -0
- package/src/templates/skills/agentkit-improve/references/replay-side-effects.md +18 -0
- package/src/templates/skills/agentkit-improve/references/trace-packets.md +22 -0
- package/src/templates/skills/agentkit-improve/templates/regression.eval.md +18 -0
- package/src/templates/skills/agentkit-integrations/SKILL.md +98 -0
- package/src/templates/skills/agentkit-knowledge/SKILL.md +4 -1
- package/src/templates/skills/agentkit-prompts/SKILL.md +3 -1
- package/src/templates/skills/agentkit-provider/SKILL.md +29 -4
- package/src/templates/skills/agentkit-security/SKILL.md +5 -2
- package/src/templates/skills/agentkit-tools/SKILL.md +8 -1
- package/src/templates/skills/agentkit-tools/examples/eval-safe-external-action.tool.md +8 -8
- package/src/templates/skills/agentkit-tools/examples/jev-service-fit.tool.md +110 -0
- package/src/templates/skills/agentkit-troubleshooting/SKILL.md +25 -1
- package/src/templates/support.ts +42 -12
- package/docs/guides/agentkit-skills-architecture.md +0 -471
- package/docs/guides/channels-implementation-map.md +0 -243
- package/docs/guides/channels-production-handoff.md +0 -101
- package/docs/portable-deploy-release-checklist.md +0 -41
|
@@ -1,28 +1,78 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelMediaDownloadInput,
|
|
4
|
+
ChannelMediaDownloadResult,
|
|
5
|
+
ChannelSendInput,
|
|
6
|
+
ChannelSendResult,
|
|
7
|
+
RawWebhookEvent,
|
|
8
|
+
WebhookVerificationInput,
|
|
9
|
+
WebhookVerificationResult,
|
|
10
|
+
} from "../channels";
|
|
2
11
|
import { AgentKitError } from "../errors";
|
|
12
|
+
import {
|
|
13
|
+
CHANNEL_DOWNLOAD_TIMEOUT_MS,
|
|
14
|
+
CHANNEL_SEND_TIMEOUT_MS,
|
|
15
|
+
classifySendRequestError,
|
|
16
|
+
isRetryableReadRequestError,
|
|
17
|
+
} from "./provider-fetch";
|
|
3
18
|
|
|
4
19
|
export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
5
20
|
type: "whatsapp",
|
|
6
21
|
provider: "zapster",
|
|
7
|
-
requiredSecrets: ["ZAPSTER_API_KEY", "
|
|
22
|
+
requiredSecrets: ["ZAPSTER_API_KEY", "ZAPSTER_INSTANCE_ID", "ZAPSTER_WEBHOOK_ID", "ZAPSTER_WEBHOOK_TOKEN"],
|
|
8
23
|
verifyWebhook(input) {
|
|
9
|
-
const
|
|
24
|
+
const expectedInstanceId = input.secrets.ZAPSTER_INSTANCE_ID;
|
|
25
|
+
const expectedWebhookId = input.secrets.ZAPSTER_WEBHOOK_ID;
|
|
10
26
|
|
|
11
|
-
if (!
|
|
27
|
+
if (!expectedInstanceId) {
|
|
12
28
|
return {
|
|
13
29
|
ok: false,
|
|
14
30
|
code: "channel_secret_missing",
|
|
15
|
-
message: "Secret
|
|
31
|
+
message: "Secret ZAPSTER_INSTANCE_ID is not set for this channel.",
|
|
16
32
|
};
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
|
|
35
|
+
if (!expectedWebhookId) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
code: "channel_secret_missing",
|
|
39
|
+
message: "Secret ZAPSTER_WEBHOOK_ID is not set for this channel.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tokenResult = verifyWebhookToken(input);
|
|
44
|
+
|
|
45
|
+
if (!tokenResult.ok) {
|
|
46
|
+
return tokenResult;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const instanceId = input.headers.get("x-instance-id");
|
|
50
|
+
const webhookId = input.headers.get("x-webhook-id");
|
|
51
|
+
const messageId = input.headers.get("x-message-id");
|
|
52
|
+
const attemptCount = input.headers.get("x-attempt-count");
|
|
53
|
+
const userAgent = input.headers.get("user-agent");
|
|
20
54
|
|
|
21
|
-
if (
|
|
55
|
+
if (instanceId !== expectedInstanceId) {
|
|
22
56
|
return {
|
|
23
57
|
ok: false,
|
|
24
58
|
code: "channel_signature_invalid",
|
|
25
|
-
message: "Zapster
|
|
59
|
+
message: "Zapster instance id did not match.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (webhookId !== expectedWebhookId) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
code: "channel_signature_invalid",
|
|
67
|
+
message: "Zapster webhook id did not match.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!messageId || !isPositiveIntegerHeader(attemptCount) || !userAgent?.toLowerCase().startsWith("zapsterapi/")) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
code: "channel_payload_invalid",
|
|
75
|
+
message: "Missing Zapster webhook headers.",
|
|
26
76
|
};
|
|
27
77
|
}
|
|
28
78
|
|
|
@@ -30,16 +80,21 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
|
30
80
|
},
|
|
31
81
|
normalizeInbound(input) {
|
|
32
82
|
const body = parseJsonObject(input);
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
83
|
+
const officialMessage = normalizeZapsterOfficialMessage(body);
|
|
84
|
+
const message = officialMessage ?? normalizeZapsterLegacyMessage(body);
|
|
85
|
+
const instanceId = input.headers.get("x-instance-id") ?? readString(body, "instance_id") ?? "default";
|
|
86
|
+
const providerEventId =
|
|
87
|
+
readString(body, "id") ??
|
|
88
|
+
readString(body, "event_id") ??
|
|
89
|
+
input.headers.get("x-message-id") ??
|
|
90
|
+
message.id;
|
|
36
91
|
|
|
37
92
|
if (!providerEventId) {
|
|
38
|
-
throw new AgentKitError("channel_payload_invalid", "Zapster payload is missing event_id or message.id.");
|
|
93
|
+
throw new AgentKitError("channel_payload_invalid", "Zapster payload is missing id, event_id, X-Message-ID, or message.id.");
|
|
39
94
|
}
|
|
40
95
|
|
|
41
|
-
const contactId =
|
|
42
|
-
const messageType =
|
|
96
|
+
const contactId = message.contactId ?? "unknown";
|
|
97
|
+
const messageType = message.type ?? "unknown";
|
|
43
98
|
const base = {
|
|
44
99
|
type: "whatsapp" as const,
|
|
45
100
|
provider: "zapster" as const,
|
|
@@ -54,9 +109,26 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
|
54
109
|
providerMetadata: {
|
|
55
110
|
instanceId,
|
|
56
111
|
messageType,
|
|
112
|
+
...(readString(body, "type") ? { eventType: readString(body, "type") } : {}),
|
|
57
113
|
},
|
|
58
114
|
};
|
|
59
115
|
|
|
116
|
+
if (messageType === "audio" && message.audio) {
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
...base,
|
|
120
|
+
kind: "message",
|
|
121
|
+
message: {
|
|
122
|
+
role: "user",
|
|
123
|
+
contentType: "audio",
|
|
124
|
+
content: "",
|
|
125
|
+
providerMessageId: message.id,
|
|
126
|
+
audio: message.audio,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
60
132
|
if (messageType !== "text") {
|
|
61
133
|
return [
|
|
62
134
|
{
|
|
@@ -67,7 +139,7 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
|
67
139
|
];
|
|
68
140
|
}
|
|
69
141
|
|
|
70
|
-
const text =
|
|
142
|
+
const text = message.text;
|
|
71
143
|
|
|
72
144
|
if (!text) {
|
|
73
145
|
return [
|
|
@@ -87,30 +159,30 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
|
87
159
|
role: "user",
|
|
88
160
|
contentType: "text",
|
|
89
161
|
content: text,
|
|
90
|
-
providerMessageId:
|
|
162
|
+
providerMessageId: message.id,
|
|
91
163
|
},
|
|
92
164
|
},
|
|
93
165
|
];
|
|
94
166
|
},
|
|
167
|
+
async downloadMedia(input) {
|
|
168
|
+
return downloadZapsterMedia(input);
|
|
169
|
+
},
|
|
95
170
|
async sendMessage(input) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
ok: true,
|
|
100
|
-
status: "sent",
|
|
101
|
-
providerRequestId: request.idempotencyKey,
|
|
102
|
-
providerMetadata: {
|
|
103
|
-
method: request.method,
|
|
104
|
-
url: request.url,
|
|
105
|
-
},
|
|
106
|
-
};
|
|
171
|
+
return sendZapsterMessage(input);
|
|
107
172
|
},
|
|
108
173
|
getStatus(input) {
|
|
109
|
-
|
|
174
|
+
const missingSecrets = [
|
|
175
|
+
"ZAPSTER_API_KEY",
|
|
176
|
+
"ZAPSTER_INSTANCE_ID",
|
|
177
|
+
"ZAPSTER_WEBHOOK_ID",
|
|
178
|
+
"ZAPSTER_WEBHOOK_TOKEN",
|
|
179
|
+
].filter((secret) => !input.secrets[secret]);
|
|
180
|
+
|
|
181
|
+
if (missingSecrets.length > 0) {
|
|
110
182
|
return {
|
|
111
183
|
status: "needs_setup",
|
|
112
184
|
checkedAt: new Date().toISOString(),
|
|
113
|
-
message:
|
|
185
|
+
message: `Zapster channel secrets are missing: ${missingSecrets.join(", ")}.`,
|
|
114
186
|
};
|
|
115
187
|
}
|
|
116
188
|
|
|
@@ -122,20 +194,518 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
|
|
|
122
194
|
},
|
|
123
195
|
};
|
|
124
196
|
|
|
197
|
+
type NormalizedZapsterWebhookMessage = {
|
|
198
|
+
id?: string;
|
|
199
|
+
contactId?: string;
|
|
200
|
+
type?: string;
|
|
201
|
+
text?: string;
|
|
202
|
+
audio?: {
|
|
203
|
+
providerMediaId: string;
|
|
204
|
+
kind: "voice" | "audio";
|
|
205
|
+
mimeType?: string;
|
|
206
|
+
filename?: string;
|
|
207
|
+
durationSeconds?: number;
|
|
208
|
+
sizeBytes?: number;
|
|
209
|
+
downloadUrl?: string;
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function normalizeZapsterOfficialMessage(body: Record<string, unknown>): NormalizedZapsterWebhookMessage | null {
|
|
214
|
+
if (readString(body, "type") !== "message.received") {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const data = readRecord(body, "data");
|
|
219
|
+
const content = readRecord(data, "content");
|
|
220
|
+
const sender = readRecord(data, "sender");
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
id: readString(data, "id"),
|
|
224
|
+
contactId: readString(sender, "id"),
|
|
225
|
+
type: readString(data, "type"),
|
|
226
|
+
text: readString(content, "text"),
|
|
227
|
+
audio: normalizeZapsterAudio(content),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeZapsterLegacyMessage(body: Record<string, unknown>): NormalizedZapsterWebhookMessage {
|
|
232
|
+
const message = readRecord(body, "message");
|
|
233
|
+
const audio = readRecord(message, "audio");
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
id: readString(message, "id"),
|
|
237
|
+
contactId: readString(message, "from"),
|
|
238
|
+
type: readString(message, "type"),
|
|
239
|
+
text: readString(readRecord(message, "text"), "body"),
|
|
240
|
+
audio: normalizeZapsterAudio(audio),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeZapsterAudio(value: Record<string, unknown>): NormalizedZapsterWebhookMessage["audio"] {
|
|
245
|
+
const mediaId = readString(value, "id") ?? readString(value, "media_id") ?? readString(value, "mediaId");
|
|
246
|
+
const downloadUrl =
|
|
247
|
+
readString(value, "url") ??
|
|
248
|
+
readString(value, "download_url") ??
|
|
249
|
+
readString(value, "downloadUrl") ??
|
|
250
|
+
readString(value, "media_url") ??
|
|
251
|
+
readString(value, "mediaUrl");
|
|
252
|
+
|
|
253
|
+
if (!mediaId && !downloadUrl) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
providerMediaId: mediaId ?? downloadUrl ?? "unknown",
|
|
259
|
+
kind: readString(value, "ptt") === "true" ? "voice" : "audio",
|
|
260
|
+
...(readString(value, "mime_type") ?? readString(value, "mimeType")
|
|
261
|
+
? { mimeType: readString(value, "mime_type") ?? readString(value, "mimeType") }
|
|
262
|
+
: {}),
|
|
263
|
+
filename: readString(value, "filename") ?? readString(value, "file_name") ?? defaultZapsterAudioFilename(value),
|
|
264
|
+
...(readNumber(value, "duration") !== undefined ? { durationSeconds: readNumber(value, "duration") } : {}),
|
|
265
|
+
...(readNumber(value, "file_size") ?? readNumber(value, "size")
|
|
266
|
+
? { sizeBytes: readNumber(value, "file_size") ?? readNumber(value, "size") }
|
|
267
|
+
: {}),
|
|
268
|
+
...(downloadUrl ? { downloadUrl } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function verifyWebhookToken(input: WebhookVerificationInput): WebhookVerificationResult {
|
|
273
|
+
const expectedToken = input.secrets.ZAPSTER_WEBHOOK_TOKEN;
|
|
274
|
+
|
|
275
|
+
if (!expectedToken) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code: "channel_secret_missing",
|
|
279
|
+
message: "Secret ZAPSTER_WEBHOOK_TOKEN is not set for this channel.",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let actualToken: string | null = null;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
actualToken = new URL(input.url).searchParams.get("token");
|
|
287
|
+
} catch {
|
|
288
|
+
actualToken = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (actualToken !== expectedToken) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
code: "channel_signature_invalid",
|
|
295
|
+
message: "Zapster webhook token did not match.",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { ok: true };
|
|
300
|
+
}
|
|
301
|
+
|
|
125
302
|
export type ZapsterSendMessageRequest = {
|
|
126
303
|
method: "POST";
|
|
127
304
|
url: string;
|
|
128
305
|
body: {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
body: string;
|
|
133
|
-
};
|
|
306
|
+
recipient: string;
|
|
307
|
+
text: string;
|
|
308
|
+
instance_id: string;
|
|
134
309
|
};
|
|
135
310
|
idempotencyKey: string;
|
|
136
311
|
};
|
|
137
312
|
|
|
313
|
+
type ZapsterFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
314
|
+
|
|
315
|
+
const ZAPSTER_SEND_MESSAGE_URL = "https://api.zapsterapi.com/v1/wa/messages";
|
|
316
|
+
const ZAPSTER_DEFAULT_MEDIA_HOSTS = ["api.zapsterapi.com"];
|
|
317
|
+
const ZAPSTER_MAX_DOWNLOAD_BYTES = 25_000_000;
|
|
318
|
+
|
|
138
319
|
export function buildZapsterSendMessageRequest(input: ChannelSendInput): ZapsterSendMessageRequest {
|
|
320
|
+
return buildZapsterSendMessageRequestInternal(input, "<redacted>");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function sendZapsterMessage(
|
|
324
|
+
input: ChannelSendInput,
|
|
325
|
+
fetcher: ZapsterFetch = fetch,
|
|
326
|
+
): Promise<ChannelSendResult> {
|
|
327
|
+
if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
|
|
328
|
+
const request = buildZapsterSendMessageRequest(input);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
status: "stubbed",
|
|
333
|
+
providerRequestId: request.idempotencyKey,
|
|
334
|
+
providerMetadata: {
|
|
335
|
+
method: request.method,
|
|
336
|
+
url: request.url,
|
|
337
|
+
body: request.body,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const apiKey = input.secrets.ZAPSTER_API_KEY;
|
|
343
|
+
const instanceId = input.secrets.ZAPSTER_INSTANCE_ID;
|
|
344
|
+
|
|
345
|
+
if (!apiKey || !instanceId) {
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
retryable: false,
|
|
349
|
+
code: "channel_secret_missing",
|
|
350
|
+
message: "Secret ZAPSTER_API_KEY or ZAPSTER_INSTANCE_ID is missing.",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let request: ZapsterSendMessageRequest;
|
|
355
|
+
let actualRequest: ZapsterSendMessageRequest;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
request = buildZapsterSendMessageRequest(input);
|
|
359
|
+
actualRequest = buildZapsterSendMessageRequestInternal(input, instanceId);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
retryable: false,
|
|
364
|
+
code: "channel_send_failed",
|
|
365
|
+
message: error instanceof Error ? error.message : String(error),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let response: Response;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
response = await fetcher(actualRequest.url, {
|
|
373
|
+
signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
|
|
374
|
+
method: actualRequest.method,
|
|
375
|
+
headers: {
|
|
376
|
+
Authorization: `Bearer ${apiKey}`,
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
Accept: "application/json",
|
|
379
|
+
},
|
|
380
|
+
body: JSON.stringify(actualRequest.body),
|
|
381
|
+
});
|
|
382
|
+
} catch (error) {
|
|
383
|
+
const failure = classifySendRequestError(error);
|
|
384
|
+
const errorMessage = redactString(error instanceof Error ? error.message : String(error), [apiKey, instanceId]);
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
retryable: failure.retryable,
|
|
389
|
+
code: failure.code,
|
|
390
|
+
message: `Zapster sendMessage request ${failure.reason}: ${errorMessage}`,
|
|
391
|
+
providerRequestId: request.idempotencyKey,
|
|
392
|
+
providerMetadata: {
|
|
393
|
+
method: request.method,
|
|
394
|
+
url: request.url,
|
|
395
|
+
body: request.body,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const payload = await readZapsterResponseBody(response);
|
|
401
|
+
const redactedPayload = redactValue(payload, [apiKey, instanceId]);
|
|
402
|
+
const messageId = isRecord(payload)
|
|
403
|
+
? readString(payload, "message_id") ?? readString(payload, "id")
|
|
404
|
+
: undefined;
|
|
405
|
+
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
410
|
+
code: "channel_send_failed",
|
|
411
|
+
message: redactString(readZapsterErrorMessage(payload, response.status), [apiKey, instanceId]),
|
|
412
|
+
providerRequestId: request.idempotencyKey,
|
|
413
|
+
providerMetadata: {
|
|
414
|
+
method: request.method,
|
|
415
|
+
url: request.url,
|
|
416
|
+
status: response.status,
|
|
417
|
+
body: redactedPayload,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (!messageId) {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
retryable: false,
|
|
426
|
+
code: "channel_send_failed",
|
|
427
|
+
message: "Zapster sendMessage did not return message_id or id.",
|
|
428
|
+
providerRequestId: request.idempotencyKey,
|
|
429
|
+
providerMetadata: {
|
|
430
|
+
method: request.method,
|
|
431
|
+
url: request.url,
|
|
432
|
+
status: response.status,
|
|
433
|
+
body: redactedPayload,
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
status: "sent",
|
|
441
|
+
providerRequestId: request.idempotencyKey,
|
|
442
|
+
providerMessageId: messageId,
|
|
443
|
+
providerMetadata: {
|
|
444
|
+
method: request.method,
|
|
445
|
+
url: request.url,
|
|
446
|
+
status: response.status,
|
|
447
|
+
body: redactedPayload,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function downloadZapsterMedia(
|
|
453
|
+
input: ChannelMediaDownloadInput,
|
|
454
|
+
fetcher: ZapsterFetch = fetch,
|
|
455
|
+
): Promise<ChannelMediaDownloadResult> {
|
|
456
|
+
const apiKey = input.secrets.ZAPSTER_API_KEY;
|
|
457
|
+
const audio = input.message.audio;
|
|
458
|
+
|
|
459
|
+
if (!apiKey) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
retryable: false,
|
|
463
|
+
code: "channel_secret_missing",
|
|
464
|
+
message: "Secret ZAPSTER_API_KEY is missing.",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!audio?.downloadUrl) {
|
|
469
|
+
return {
|
|
470
|
+
ok: false,
|
|
471
|
+
retryable: false,
|
|
472
|
+
code: "channel_audio_download_unavailable",
|
|
473
|
+
message: "Zapster audio payload did not include a media download URL.",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const mediaUrl = validateZapsterMediaDownloadUrl(audio.downloadUrl);
|
|
478
|
+
|
|
479
|
+
if (!mediaUrl.ok) {
|
|
480
|
+
return mediaUrl;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let response: Response;
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
response = await fetcher(mediaUrl.url, {
|
|
487
|
+
signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
|
|
488
|
+
method: "GET",
|
|
489
|
+
headers: {
|
|
490
|
+
Authorization: `Bearer ${apiKey}`,
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
retryable: isRetryableReadRequestError(error),
|
|
497
|
+
code: "channel_provider_unavailable",
|
|
498
|
+
message: `Zapster media download failed before a response: ${redactString(
|
|
499
|
+
error instanceof Error ? error.message : String(error),
|
|
500
|
+
[apiKey, mediaUrl.url],
|
|
501
|
+
)}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!response.ok) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
509
|
+
code: response.status === 429 || response.status >= 500
|
|
510
|
+
? "channel_provider_unavailable"
|
|
511
|
+
: "channel_media_download_failed",
|
|
512
|
+
message: `Zapster media download returned HTTP ${response.status}.`,
|
|
513
|
+
providerMetadata: {
|
|
514
|
+
status: response.status,
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const maxDownloadBytes = input.maxBytes ?? ZAPSTER_MAX_DOWNLOAD_BYTES;
|
|
520
|
+
const bytes = await readResponseBytesWithLimit(response, maxDownloadBytes);
|
|
521
|
+
|
|
522
|
+
if (!bytes.ok) {
|
|
523
|
+
return bytes;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
audio: bytes.audio,
|
|
529
|
+
filename: audio.filename ?? `${audio.providerMediaId}.ogg`,
|
|
530
|
+
...(audio.mimeType ? { mimeType: audio.mimeType } : {}),
|
|
531
|
+
...(audio.durationSeconds !== undefined ? { durationSeconds: audio.durationSeconds } : {}),
|
|
532
|
+
sizeBytes: bytes.audio.byteLength,
|
|
533
|
+
providerMetadata: {
|
|
534
|
+
status: response.status,
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function validateZapsterMediaDownloadUrl(rawUrl: string):
|
|
540
|
+
| {
|
|
541
|
+
ok: true;
|
|
542
|
+
url: string;
|
|
543
|
+
}
|
|
544
|
+
| {
|
|
545
|
+
ok: false;
|
|
546
|
+
retryable: false;
|
|
547
|
+
code: "channel_audio_download_unavailable";
|
|
548
|
+
message: string;
|
|
549
|
+
} {
|
|
550
|
+
let url: URL;
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
url = new URL(rawUrl);
|
|
554
|
+
} catch {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
retryable: false,
|
|
558
|
+
code: "channel_audio_download_unavailable",
|
|
559
|
+
message: "Zapster audio download URL is not a valid URL.",
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (url.protocol !== "https:") {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
retryable: false,
|
|
567
|
+
code: "channel_audio_download_unavailable",
|
|
568
|
+
message: "Zapster audio download URL must use HTTPS.",
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const hostname = url.hostname.toLowerCase();
|
|
573
|
+
const allowed = ZAPSTER_DEFAULT_MEDIA_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`));
|
|
574
|
+
|
|
575
|
+
if (!allowed) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
retryable: false,
|
|
579
|
+
code: "channel_audio_download_unavailable",
|
|
580
|
+
message: "Zapster audio download URL host is not allowed.",
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
url.username = "";
|
|
585
|
+
url.password = "";
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
url: url.href,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function readResponseBytesWithLimit(
|
|
594
|
+
response: Response,
|
|
595
|
+
maxBytes: number,
|
|
596
|
+
): Promise<
|
|
597
|
+
| {
|
|
598
|
+
ok: true;
|
|
599
|
+
audio: Uint8Array;
|
|
600
|
+
}
|
|
601
|
+
| {
|
|
602
|
+
ok: false;
|
|
603
|
+
retryable: boolean;
|
|
604
|
+
code: "channel_audio_too_large" | "channel_provider_unavailable";
|
|
605
|
+
message: string;
|
|
606
|
+
providerMetadata?: Record<string, unknown>;
|
|
607
|
+
}
|
|
608
|
+
> {
|
|
609
|
+
const contentLength = response.headers.get("content-length");
|
|
610
|
+
const declaredBytes = contentLength ? Number(contentLength) : undefined;
|
|
611
|
+
|
|
612
|
+
if (declaredBytes !== undefined && Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
retryable: false,
|
|
616
|
+
code: "channel_audio_too_large",
|
|
617
|
+
message: `Audio response declares ${declaredBytes} bytes, which exceeds the configured ${maxBytes} byte limit.`,
|
|
618
|
+
providerMetadata: {
|
|
619
|
+
sizeBytes: declaredBytes,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (!response.body) {
|
|
625
|
+
let audio: Uint8Array;
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
audio = new Uint8Array(await response.arrayBuffer());
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
retryable: true,
|
|
633
|
+
code: "channel_provider_unavailable",
|
|
634
|
+
message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (audio.byteLength > maxBytes) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
retryable: false,
|
|
642
|
+
code: "channel_audio_too_large",
|
|
643
|
+
message: `Audio response is ${audio.byteLength} bytes, which exceeds the configured ${maxBytes} byte limit.`,
|
|
644
|
+
providerMetadata: {
|
|
645
|
+
sizeBytes: audio.byteLength,
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return { ok: true, audio };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const reader = response.body.getReader();
|
|
654
|
+
const chunks: Uint8Array[] = [];
|
|
655
|
+
let totalBytes = 0;
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
while (true) {
|
|
659
|
+
const chunk = await reader.read();
|
|
660
|
+
|
|
661
|
+
if (chunk.done) {
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
totalBytes += chunk.value.byteLength;
|
|
666
|
+
|
|
667
|
+
if (totalBytes > maxBytes) {
|
|
668
|
+
await reader.cancel().catch(() => undefined);
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
retryable: false,
|
|
672
|
+
code: "channel_audio_too_large",
|
|
673
|
+
message: `Audio response exceeded the configured ${maxBytes} byte limit while downloading.`,
|
|
674
|
+
providerMetadata: {
|
|
675
|
+
sizeBytes: totalBytes,
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
chunks.push(chunk.value);
|
|
681
|
+
}
|
|
682
|
+
} catch (error) {
|
|
683
|
+
await reader.cancel().catch(() => undefined);
|
|
684
|
+
return {
|
|
685
|
+
ok: false,
|
|
686
|
+
retryable: true,
|
|
687
|
+
code: "channel_provider_unavailable",
|
|
688
|
+
message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
689
|
+
};
|
|
690
|
+
} finally {
|
|
691
|
+
reader.releaseLock();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const audio = new Uint8Array(totalBytes);
|
|
695
|
+
let offset = 0;
|
|
696
|
+
|
|
697
|
+
for (const chunk of chunks) {
|
|
698
|
+
audio.set(chunk, offset);
|
|
699
|
+
offset += chunk.byteLength;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return { ok: true, audio };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function buildZapsterSendMessageRequestInternal(
|
|
706
|
+
input: ChannelSendInput,
|
|
707
|
+
instanceId: string,
|
|
708
|
+
): ZapsterSendMessageRequest {
|
|
139
709
|
const to = input.externalIdentity.key.match(/contact:([^:]+)$/)?.[1];
|
|
140
710
|
|
|
141
711
|
if (!to) {
|
|
@@ -144,18 +714,83 @@ export function buildZapsterSendMessageRequest(input: ChannelSendInput): Zapster
|
|
|
144
714
|
|
|
145
715
|
return {
|
|
146
716
|
method: "POST",
|
|
147
|
-
url:
|
|
717
|
+
url: ZAPSTER_SEND_MESSAGE_URL,
|
|
148
718
|
body: {
|
|
149
|
-
to,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
body: input.message.content,
|
|
153
|
-
},
|
|
719
|
+
recipient: to,
|
|
720
|
+
text: input.message.content,
|
|
721
|
+
instance_id: instanceId,
|
|
154
722
|
},
|
|
155
723
|
idempotencyKey: `whatsapp:zapster:${input.channelId}:${input.conversationId}:${redactPhone(to)}`,
|
|
156
724
|
};
|
|
157
725
|
}
|
|
158
726
|
|
|
727
|
+
async function readZapsterResponseBody(response: Response): Promise<unknown> {
|
|
728
|
+
const text = await response.text().catch(() => "");
|
|
729
|
+
|
|
730
|
+
if (!text) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
return JSON.parse(text) as unknown;
|
|
736
|
+
} catch {
|
|
737
|
+
return text;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function readZapsterErrorMessage(payload: unknown, status: number): string {
|
|
742
|
+
if (typeof payload === "string" && payload.length > 0) {
|
|
743
|
+
return payload;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (isRecord(payload)) {
|
|
747
|
+
const message = readString(payload, "message") ?? readString(payload, "error") ?? readString(payload, "detail");
|
|
748
|
+
|
|
749
|
+
if (message) {
|
|
750
|
+
return message;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const error = readRecord(payload, "error");
|
|
754
|
+
const nestedMessage = readString(error, "message") ?? readString(error, "detail");
|
|
755
|
+
|
|
756
|
+
if (nestedMessage) {
|
|
757
|
+
return nestedMessage;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return `Zapster sendMessage returned HTTP ${status}.`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function redactValue(value: unknown, secrets: string[]): unknown {
|
|
765
|
+
if (typeof value === "string") {
|
|
766
|
+
return redactString(value, secrets);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (!isRecord(value) && !Array.isArray(value)) {
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const serialized = redactString(JSON.stringify(value), secrets);
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
return JSON.parse(serialized) as unknown;
|
|
777
|
+
} catch {
|
|
778
|
+
return serialized;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function redactString(value: string, secrets: string[]): string {
|
|
783
|
+
let redacted = value;
|
|
784
|
+
|
|
785
|
+
for (const secret of secrets) {
|
|
786
|
+
if (secret) {
|
|
787
|
+
redacted = redacted.split(secret).join("<redacted>");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return redacted;
|
|
792
|
+
}
|
|
793
|
+
|
|
159
794
|
function parseJsonObject(input: RawWebhookEvent): Record<string, unknown> {
|
|
160
795
|
try {
|
|
161
796
|
const parsed = JSON.parse(input.rawBody) as unknown;
|
|
@@ -180,6 +815,33 @@ function readString(value: Record<string, unknown>, key: string): string | undef
|
|
|
180
815
|
return typeof field === "string" && field.length > 0 ? field : undefined;
|
|
181
816
|
}
|
|
182
817
|
|
|
818
|
+
function readNumber(value: Record<string, unknown>, key: string): number | undefined {
|
|
819
|
+
const field = value[key];
|
|
820
|
+
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function defaultZapsterAudioFilename(value: Record<string, unknown>): string {
|
|
824
|
+
const mimeType = readString(value, "mime_type") ?? readString(value, "mimeType");
|
|
825
|
+
|
|
826
|
+
if (mimeType?.includes("ogg")) {
|
|
827
|
+
return "audio.ogg";
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (mimeType?.includes("mpeg") || mimeType?.includes("mp3")) {
|
|
831
|
+
return "audio.mp3";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (mimeType?.includes("wav")) {
|
|
835
|
+
return "audio.wav";
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return "audio.ogg";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function isPositiveIntegerHeader(value: string | null): value is string {
|
|
842
|
+
return typeof value === "string" && /^[1-9]\d*$/.test(value);
|
|
843
|
+
}
|
|
844
|
+
|
|
183
845
|
function redactPhone(value: string): string {
|
|
184
846
|
if (value.length <= 6) {
|
|
185
847
|
return "*".repeat(value.length);
|