@andreprado/agentkit 0.1.0-alpha.2 → 0.1.0-alpha.21

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