@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.
Files changed (122) hide show
  1. package/README.md +18 -1
  2. package/docs/guides/add-channel.md +251 -7
  3. package/docs/guides/add-knowledge.md +10 -0
  4. package/docs/guides/add-managed-composio.md +165 -0
  5. package/docs/guides/add-tool.md +10 -3
  6. package/docs/guides/channel-security.md +162 -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 +61 -1
  10. package/docs/guides/connect-whatsapp-evolution.md +121 -0
  11. package/docs/guides/connect-whatsapp-uazapi.md +139 -0
  12. package/docs/guides/connect-whatsapp-zapster.md +119 -16
  13. package/docs/guides/create-agent.md +31 -4
  14. package/docs/guides/debug-channel.md +159 -0
  15. package/docs/guides/improve-from-production.md +151 -0
  16. package/docs/guides/prepare-deploy.md +32 -14
  17. package/docs/guides/replay-production-traces.md +72 -0
  18. package/docs/guides/run-evals.md +95 -25
  19. package/docs/guides/security-rules.md +9 -5
  20. package/docs/guides/send-feedback.md +135 -0
  21. package/docs/guides/use-jev.md +67 -0
  22. package/docs/guides/use-provider.md +70 -3
  23. package/docs/llms-full.txt +295 -25
  24. package/docs/llms.txt +54 -7
  25. package/package.json +3 -7
  26. package/src/cli/args.ts +23 -2
  27. package/src/cli/cloud-client.ts +121 -9
  28. package/src/cli/commands/channels.ts +856 -36
  29. package/src/cli/commands/feedback.ts +438 -0
  30. package/src/cli/commands/provider.ts +47 -0
  31. package/src/cli/commands/transcribe.ts +171 -0
  32. package/src/cli/deploy-chat-ui.ts +232 -18
  33. package/src/cli/deploy-readiness.ts +227 -14
  34. package/src/cli/help.ts +67 -9
  35. package/src/cli/index.ts +740 -35
  36. package/src/cli/new-command.ts +41 -0
  37. package/src/cloud/client.ts +4 -3
  38. package/src/cloud/contracts.ts +1 -1
  39. package/src/create-project.ts +18 -35
  40. package/src/index.ts +565 -11
  41. package/src/providers/codex-auth.ts +111 -0
  42. package/src/providers/pi.ts +88 -19
  43. package/src/providers/test.ts +36 -0
  44. package/src/providers/types.ts +8 -0
  45. package/src/runtime/channel-test-harness.ts +21 -1
  46. package/src/runtime/channels/discord.ts +904 -0
  47. package/src/runtime/channels/generic-webhook.ts +682 -0
  48. package/src/runtime/channels/net-guard.ts +480 -0
  49. package/src/runtime/channels/provider-fetch.ts +54 -0
  50. package/src/runtime/channels/slack.ts +652 -0
  51. package/src/runtime/channels/telegram.ts +379 -15
  52. package/src/runtime/channels/whatsapp-evolution.ts +1330 -0
  53. package/src/runtime/channels/whatsapp-meta.ts +9 -0
  54. package/src/runtime/channels/whatsapp-uazapi.ts +1192 -0
  55. package/src/runtime/channels/whatsapp-zapster.ts +702 -40
  56. package/src/runtime/channels.ts +83 -3
  57. package/src/runtime/chat.ts +70 -44
  58. package/src/runtime/config.ts +512 -20
  59. package/src/runtime/core/manifest.ts +75 -5
  60. package/src/runtime/core/targets.ts +5 -5
  61. package/src/runtime/deploy-readiness.ts +34 -4
  62. package/src/runtime/dev-server.ts +639 -39
  63. package/src/runtime/env.ts +8 -3
  64. package/src/runtime/evals.ts +445 -74
  65. package/src/runtime/improve.ts +868 -0
  66. package/src/runtime/inspect.ts +173 -4
  67. package/src/runtime/integrations/composio.ts +425 -0
  68. package/src/runtime/knowledge/embeddings.ts +45 -7
  69. package/src/runtime/knowledge/ingest.ts +69 -6
  70. package/src/runtime/knowledge/retrieve.ts +25 -5
  71. package/src/runtime/knowledge/schema.ts +45 -1
  72. package/src/runtime/knowledge/vector.ts +30 -30
  73. package/src/runtime/prompt-context.ts +141 -0
  74. package/src/runtime/runtime-contract.ts +71 -7
  75. package/src/runtime/skills.ts +95 -0
  76. package/src/runtime/targets/cloudflare/build.ts +1010 -208
  77. package/src/runtime/targets/container/server.ts +1 -1
  78. package/src/runtime/targets/vps/deploy.ts +26 -9
  79. package/src/runtime/tool-runner.ts +9 -1
  80. package/src/runtime/tools.ts +26 -2
  81. package/src/runtime/transcription.ts +483 -0
  82. package/src/storage/sqlite.ts +7 -2
  83. package/src/templates/blank.ts +37 -9
  84. package/src/templates/dentista.ts +40 -14
  85. package/src/templates/skills/agentkit-build-agent/SKILL.md +34 -5
  86. package/src/templates/skills/agentkit-build-agent/templates/appointment-intake.instructions.md +2 -1
  87. package/src/templates/skills/agentkit-capsule/SKILL.md +32 -3
  88. package/src/templates/skills/agentkit-capsule/references/docs-router.md +2 -2
  89. package/src/templates/skills/agentkit-channels/SKILL.md +66 -1
  90. package/src/templates/skills/agentkit-channels/references/channel-buffering.md +8 -1
  91. package/src/templates/skills/agentkit-channels/references/channel-debugging.md +28 -3
  92. package/src/templates/skills/agentkit-channels/references/discord.md +93 -0
  93. package/src/templates/skills/agentkit-channels/references/slack.md +56 -0
  94. package/src/templates/skills/agentkit-channels/references/telegram.md +34 -0
  95. package/src/templates/skills/agentkit-channels/references/whatsapp-evolution.md +57 -0
  96. package/src/templates/skills/agentkit-channels/references/whatsapp-uazapi.md +54 -0
  97. package/src/templates/skills/agentkit-channels/references/whatsapp-zapster.md +42 -8
  98. package/src/templates/skills/agentkit-database/SKILL.md +11 -0
  99. package/src/templates/skills/agentkit-deploy/SKILL.md +9 -1
  100. package/src/templates/skills/agentkit-evals/SKILL.md +77 -13
  101. package/src/templates/skills/agentkit-evals/templates/multi-turn.eval.md +13 -6
  102. package/src/templates/skills/agentkit-evals/templates/no-leak.eval.md +8 -4
  103. package/src/templates/skills/agentkit-evals/templates/smoke.eval.md +8 -4
  104. package/src/templates/skills/agentkit-evals/templates/tool-call.eval.md +16 -7
  105. package/src/templates/skills/agentkit-improve/SKILL.md +96 -0
  106. package/src/templates/skills/agentkit-improve/references/replay-side-effects.md +18 -0
  107. package/src/templates/skills/agentkit-improve/references/trace-packets.md +22 -0
  108. package/src/templates/skills/agentkit-improve/templates/regression.eval.md +18 -0
  109. package/src/templates/skills/agentkit-integrations/SKILL.md +98 -0
  110. package/src/templates/skills/agentkit-knowledge/SKILL.md +4 -1
  111. package/src/templates/skills/agentkit-prompts/SKILL.md +3 -1
  112. package/src/templates/skills/agentkit-provider/SKILL.md +29 -4
  113. package/src/templates/skills/agentkit-security/SKILL.md +5 -2
  114. package/src/templates/skills/agentkit-tools/SKILL.md +8 -1
  115. package/src/templates/skills/agentkit-tools/examples/eval-safe-external-action.tool.md +8 -8
  116. package/src/templates/skills/agentkit-tools/examples/jev-service-fit.tool.md +110 -0
  117. package/src/templates/skills/agentkit-troubleshooting/SKILL.md +25 -1
  118. package/src/templates/support.ts +42 -12
  119. package/docs/guides/agentkit-skills-architecture.md +0 -471
  120. package/docs/guides/channels-implementation-map.md +0 -243
  121. package/docs/guides/channels-production-handoff.md +0 -101
  122. package/docs/portable-deploy-release-checklist.md +0 -41
@@ -0,0 +1,1330 @@
1
+ import type {
2
+ ChannelAdapter,
3
+ ChannelMediaDownloadInput,
4
+ ChannelMediaDownloadResult,
5
+ ChannelSendInput,
6
+ ChannelSendResult,
7
+ ChannelStatusInput,
8
+ ChannelStatusResult,
9
+ NormalizedChannelEvent,
10
+ RawWebhookEvent,
11
+ WebhookVerificationInput,
12
+ WebhookVerificationResult,
13
+ } from "../channels";
14
+ import { AgentKitError } from "../errors";
15
+ import {
16
+ CHANNEL_DOWNLOAD_TIMEOUT_MS,
17
+ CHANNEL_SEND_TIMEOUT_MS,
18
+ CHANNEL_STATUS_TIMEOUT_MS,
19
+ classifySendRequestError,
20
+ isRetryableReadRequestError,
21
+ } from "./provider-fetch";
22
+ import { createPublicHttpsFetch, isBlockedOutboundHost } from "./net-guard";
23
+
24
+ type EvolutionFetch = (url: string, init?: RequestInit) => Promise<Response>;
25
+
26
+ const evolutionFetch: EvolutionFetch = createPublicHttpsFetch("EVOLUTION_API_BASE_URL");
27
+
28
+ type EvolutionSendTextRequest = {
29
+ method: "POST";
30
+ url: string;
31
+ body: {
32
+ number: string;
33
+ text: string;
34
+ };
35
+ idempotencyKey: string;
36
+ };
37
+
38
+ type EvolutionMessage = {
39
+ id?: string;
40
+ remoteJid?: string;
41
+ sender?: string;
42
+ senderName?: string;
43
+ fromMe?: boolean;
44
+ messageType?: string;
45
+ timestamp?: number;
46
+ text?: string;
47
+ ptt?: boolean;
48
+ mimeType?: string;
49
+ durationSeconds?: number;
50
+ sizeBytes?: number;
51
+ };
52
+
53
+ const EVOLUTION_MAX_DOWNLOAD_BYTES = 25_000_000;
54
+ const EVOLUTION_MEDIA_DOWNLOAD_BODY_OVERHEAD_BYTES = 64_000;
55
+ const EVOLUTION_MESSAGE_EVENTS = new Set(["messages_upsert", "messages.upsert", "messages", "message"]);
56
+ const EVOLUTION_UPDATE_EVENTS = new Set(["messages_update", "messages.update", "send_message", "send.message"]);
57
+ const EVOLUTION_IGNORED_EVENTS = new Set([
58
+ "application_startup",
59
+ "qrcode_updated",
60
+ "connection_update",
61
+ "contacts_set",
62
+ "contacts_upsert",
63
+ "contacts_update",
64
+ "presence_update",
65
+ "chats_set",
66
+ "chats_upsert",
67
+ "chats_update",
68
+ "chats_delete",
69
+ "groups_upsert",
70
+ "groups_update",
71
+ "group_update",
72
+ "group_participants_update",
73
+ "call",
74
+ "new_jwt_token",
75
+ "new_token",
76
+ "typebot_start",
77
+ "typebot_change_status",
78
+ ]);
79
+
80
+ export const evolutionWhatsappChannelAdapter: ChannelAdapter = {
81
+ type: "whatsapp",
82
+ provider: "evolution",
83
+ requiredSecrets: ["EVOLUTION_API_BASE_URL", "EVOLUTION_API_KEY", "EVOLUTION_INSTANCE_NAME", "EVOLUTION_WEBHOOK_TOKEN"],
84
+ verifyWebhook(input) {
85
+ const tokenResult = verifyWebhookToken(input);
86
+
87
+ if (!tokenResult.ok) {
88
+ return tokenResult;
89
+ }
90
+
91
+ let body: Record<string, unknown>;
92
+
93
+ try {
94
+ body = parseJsonObject(input);
95
+ } catch (error) {
96
+ return {
97
+ ok: false,
98
+ code: "channel_payload_invalid",
99
+ message: error instanceof Error ? error.message : String(error),
100
+ };
101
+ }
102
+
103
+ const instanceResult = verifyEvolutionInstance(input, body);
104
+
105
+ if (!instanceResult.ok) {
106
+ return instanceResult;
107
+ }
108
+
109
+ return { ok: true };
110
+ },
111
+ normalizeInbound(input) {
112
+ const body = parseJsonObject(input);
113
+ const eventType = readEventType(body);
114
+
115
+ if (EVOLUTION_IGNORED_EVENTS.has(eventType)) {
116
+ return [unsupportedEvolutionEvent(input, body, eventType, "unsupported_event_type")];
117
+ }
118
+
119
+ if (EVOLUTION_UPDATE_EVENTS.has(eventType)) {
120
+ return [normalizeEvolutionStatusEvent(input, body, eventType)];
121
+ }
122
+
123
+ if (!EVOLUTION_MESSAGE_EVENTS.has(eventType)) {
124
+ return [unsupportedEvolutionEvent(input, body, eventType, "unsupported_event_type")];
125
+ }
126
+
127
+ const message = normalizeEvolutionMessage(body);
128
+ const providerEventId = providerEventIdFor(body, message);
129
+
130
+ if (!providerEventId) {
131
+ throw new AgentKitError("channel_payload_invalid", "Evolution payload is missing a message key id or event id.");
132
+ }
133
+
134
+ const contactId = stripWhatsappJid(message.sender ?? message.remoteJid ?? "unknown");
135
+ const instanceId = readEvolutionInstanceName(body) ?? "default";
136
+ const messageType = (message.messageType ?? (message.text ? "conversation" : "unknown")).toLowerCase();
137
+ const base = baseEvolutionEvent(input, {
138
+ providerEventId,
139
+ instanceId,
140
+ contactId,
141
+ eventType,
142
+ messageType,
143
+ senderName: message.senderName,
144
+ });
145
+
146
+ if (message.fromMe) {
147
+ return [
148
+ {
149
+ ...base,
150
+ kind: "unsupported",
151
+ unsupportedReason: "ignored_message:from_me",
152
+ },
153
+ ];
154
+ }
155
+
156
+ if (isEvolutionAudioMessage(messageType, message)) {
157
+ return [
158
+ {
159
+ ...base,
160
+ kind: "message",
161
+ message: {
162
+ role: "user",
163
+ contentType: "audio",
164
+ content: "",
165
+ providerMessageId: message.id,
166
+ audio: {
167
+ providerMediaId: message.id ?? providerEventId,
168
+ kind: messageType.includes("ptt") || message.ptt ? "voice" : "audio",
169
+ ...(message.mimeType ? { mimeType: message.mimeType } : {}),
170
+ filename: defaultEvolutionAudioFilename(message),
171
+ ...(message.durationSeconds !== undefined ? { durationSeconds: message.durationSeconds } : {}),
172
+ ...(message.sizeBytes !== undefined ? { sizeBytes: message.sizeBytes } : {}),
173
+ },
174
+ },
175
+ },
176
+ ];
177
+ }
178
+
179
+ if (!isEvolutionTextMessage(messageType)) {
180
+ return [
181
+ {
182
+ ...base,
183
+ kind: "unsupported",
184
+ unsupportedReason: `unsupported_message_type:${messageType}`,
185
+ },
186
+ ];
187
+ }
188
+
189
+ const text = message.text?.trim();
190
+
191
+ if (!text) {
192
+ return [
193
+ {
194
+ ...base,
195
+ kind: "unsupported",
196
+ unsupportedReason: "unsupported_message_type:empty_text",
197
+ },
198
+ ];
199
+ }
200
+
201
+ return [
202
+ {
203
+ ...base,
204
+ kind: "message",
205
+ message: {
206
+ role: "user",
207
+ contentType: "text",
208
+ content: text,
209
+ providerMessageId: message.id,
210
+ },
211
+ },
212
+ ];
213
+ },
214
+ async downloadMedia(input) {
215
+ return downloadEvolutionMedia(input);
216
+ },
217
+ async sendMessage(input) {
218
+ return sendEvolutionMessage(input);
219
+ },
220
+ async getStatus(input) {
221
+ return getEvolutionStatus(input);
222
+ },
223
+ };
224
+
225
+ export function buildEvolutionSendTextRequest(input: ChannelSendInput): EvolutionSendTextRequest {
226
+ return buildEvolutionSendTextRequestInternal(input, "<redacted>", "<redacted>", "<redacted>");
227
+ }
228
+
229
+ export async function sendEvolutionMessage(
230
+ input: ChannelSendInput,
231
+ fetcher: EvolutionFetch = evolutionFetch,
232
+ ): Promise<ChannelSendResult> {
233
+ if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
234
+ const request = buildEvolutionSendTextRequest(input);
235
+
236
+ return {
237
+ ok: true,
238
+ status: "stubbed",
239
+ providerRequestId: request.idempotencyKey,
240
+ providerMetadata: {
241
+ method: request.method,
242
+ url: request.url,
243
+ body: request.body,
244
+ },
245
+ };
246
+ }
247
+
248
+ const baseUrl = input.secrets.EVOLUTION_API_BASE_URL;
249
+ const apiKey = input.secrets.EVOLUTION_API_KEY;
250
+ const instanceName = input.secrets.EVOLUTION_INSTANCE_NAME;
251
+
252
+ if (!baseUrl || !apiKey || !instanceName) {
253
+ return {
254
+ ok: false,
255
+ retryable: false,
256
+ code: "channel_secret_missing",
257
+ message: "Secret EVOLUTION_API_BASE_URL, EVOLUTION_API_KEY, or EVOLUTION_INSTANCE_NAME is missing.",
258
+ };
259
+ }
260
+
261
+ let request: EvolutionSendTextRequest;
262
+ let actualRequest: EvolutionSendTextRequest;
263
+
264
+ try {
265
+ request = buildEvolutionSendTextRequest(input);
266
+ actualRequest = buildEvolutionSendTextRequestInternal(input, baseUrl, instanceName, "actual");
267
+ } catch (error) {
268
+ return {
269
+ ok: false,
270
+ retryable: false,
271
+ code: "channel_send_failed",
272
+ message: error instanceof Error ? error.message : String(error),
273
+ };
274
+ }
275
+
276
+ let response: Response;
277
+
278
+ try {
279
+ response = await fetcher(actualRequest.url, {
280
+ signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
281
+ method: actualRequest.method,
282
+ headers: {
283
+ apikey: apiKey,
284
+ "Content-Type": "application/json",
285
+ Accept: "application/json",
286
+ },
287
+ body: JSON.stringify(actualRequest.body),
288
+ });
289
+ } catch (error) {
290
+ const failure = classifySendRequestError(error);
291
+ return {
292
+ ok: false,
293
+ retryable: failure.retryable,
294
+ code: failure.code,
295
+ message: `Evolution sendText request ${failure.reason}: ${redactString(
296
+ error instanceof Error ? error.message : String(error),
297
+ [apiKey, baseUrl, instanceName],
298
+ )}`,
299
+ providerRequestId: request.idempotencyKey,
300
+ providerMetadata: {
301
+ method: request.method,
302
+ url: request.url,
303
+ body: request.body,
304
+ },
305
+ };
306
+ }
307
+
308
+ const payload = await readEvolutionResponseBody(response).catch(() => null);
309
+ const redactedPayload = redactValue(payload, [apiKey, baseUrl, instanceName]);
310
+ const messageId = readEvolutionProviderMessageId(payload);
311
+
312
+ if (!response.ok) {
313
+ return {
314
+ ok: false,
315
+ retryable: response.status === 429 || response.status >= 500,
316
+ code: "channel_send_failed",
317
+ message: redactString(readEvolutionErrorMessage(payload, response.status), [apiKey, baseUrl, instanceName]),
318
+ providerRequestId: request.idempotencyKey,
319
+ providerMetadata: {
320
+ method: request.method,
321
+ url: request.url,
322
+ status: response.status,
323
+ body: redactedPayload,
324
+ },
325
+ };
326
+ }
327
+
328
+ if (!messageId) {
329
+ return {
330
+ ok: false,
331
+ retryable: false,
332
+ code: "channel_send_failed",
333
+ message: "Evolution sendText did not return a provider message id.",
334
+ providerRequestId: request.idempotencyKey,
335
+ providerMetadata: {
336
+ method: request.method,
337
+ url: request.url,
338
+ status: response.status,
339
+ body: redactedPayload,
340
+ },
341
+ };
342
+ }
343
+
344
+ return {
345
+ ok: true,
346
+ status: "sent",
347
+ providerRequestId: request.idempotencyKey,
348
+ providerMessageId: messageId,
349
+ providerMetadata: {
350
+ method: request.method,
351
+ url: request.url,
352
+ status: response.status,
353
+ body: redactedPayload,
354
+ },
355
+ };
356
+ }
357
+
358
+ export async function downloadEvolutionMedia(
359
+ input: ChannelMediaDownloadInput,
360
+ fetcher: EvolutionFetch = evolutionFetch,
361
+ ): Promise<ChannelMediaDownloadResult> {
362
+ const baseUrl = input.secrets.EVOLUTION_API_BASE_URL;
363
+ const apiKey = input.secrets.EVOLUTION_API_KEY;
364
+ const instanceName = input.secrets.EVOLUTION_INSTANCE_NAME;
365
+
366
+ if (!baseUrl || !apiKey || !instanceName) {
367
+ return {
368
+ ok: false,
369
+ retryable: false,
370
+ code: "channel_secret_missing",
371
+ message: "Secret EVOLUTION_API_BASE_URL, EVOLUTION_API_KEY, or EVOLUTION_INSTANCE_NAME is missing.",
372
+ };
373
+ }
374
+
375
+ const providerMessageId = input.message.providerMessageId ?? input.message.audio?.providerMediaId;
376
+
377
+ if (!providerMessageId) {
378
+ return {
379
+ ok: false,
380
+ retryable: false,
381
+ code: "channel_audio_download_unavailable",
382
+ message: "Evolution audio payload did not include a provider message id.",
383
+ };
384
+ }
385
+
386
+ let url: string;
387
+
388
+ try {
389
+ url = evolutionApiUrl(baseUrl, `/chat/getBase64FromMediaMessage/${encodeURIComponent(instanceName)}`);
390
+ } catch (error) {
391
+ return {
392
+ ok: false,
393
+ retryable: false,
394
+ code: "channel_audio_download_unavailable",
395
+ message: error instanceof Error ? error.message : String(error),
396
+ };
397
+ }
398
+
399
+ let response: Response;
400
+
401
+ try {
402
+ response = await fetcher(url, {
403
+ signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
404
+ method: "POST",
405
+ headers: {
406
+ apikey: apiKey,
407
+ "Content-Type": "application/json",
408
+ Accept: "application/json",
409
+ },
410
+ body: JSON.stringify({
411
+ message: {
412
+ key: {
413
+ id: providerMessageId,
414
+ },
415
+ },
416
+ convertToMp4: false,
417
+ }),
418
+ });
419
+ } catch (error) {
420
+ return {
421
+ ok: false,
422
+ retryable: isRetryableReadRequestError(error),
423
+ code: "channel_provider_unavailable",
424
+ message: `Evolution media download failed before a response: ${redactString(
425
+ error instanceof Error ? error.message : String(error),
426
+ [apiKey, baseUrl, instanceName],
427
+ )}`,
428
+ };
429
+ }
430
+
431
+ const maxBytes = input.maxBytes ?? EVOLUTION_MAX_DOWNLOAD_BYTES;
432
+ let payload: unknown;
433
+
434
+ try {
435
+ payload = await readEvolutionResponseBody(response, {
436
+ maxBytes: maxEvolutionMediaResponseBytes(maxBytes),
437
+ });
438
+ } catch (error) {
439
+ if (error instanceof EvolutionResponseBodyTooLargeError) {
440
+ return {
441
+ ok: false,
442
+ retryable: false,
443
+ code: "channel_audio_too_large",
444
+ message: `Evolution media download response exceeds the configured ${maxBytes} byte audio limit before decode.`,
445
+ providerMetadata: {
446
+ status: response.status,
447
+ },
448
+ };
449
+ }
450
+
451
+ return {
452
+ ok: false,
453
+ retryable: true,
454
+ code: "channel_provider_unavailable",
455
+ message: `Evolution media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
456
+ providerMetadata: {
457
+ status: response.status,
458
+ },
459
+ };
460
+ }
461
+
462
+ if (!response.ok) {
463
+ return {
464
+ ok: false,
465
+ retryable: response.status === 429 || response.status >= 500,
466
+ code: response.status === 429 || response.status >= 500
467
+ ? "channel_provider_unavailable"
468
+ : "channel_media_download_failed",
469
+ message: `Evolution media download returned HTTP ${response.status}.`,
470
+ providerMetadata: {
471
+ status: response.status,
472
+ body: redactValue(payload, [apiKey, baseUrl, instanceName]),
473
+ },
474
+ };
475
+ }
476
+
477
+ const base64Data = readEvolutionBase64Data(payload);
478
+
479
+ if (!base64Data) {
480
+ return {
481
+ ok: false,
482
+ retryable: false,
483
+ code: "channel_audio_download_unavailable",
484
+ message: "Evolution media download did not return base64 data.",
485
+ providerMetadata: {
486
+ status: response.status,
487
+ body: redactValue(payload, [apiKey, baseUrl, instanceName]),
488
+ },
489
+ };
490
+ }
491
+
492
+ const normalizedBase64 = normalizeBase64(base64Data);
493
+ const estimatedBytes = estimateBase64DecodedBytes(normalizedBase64);
494
+
495
+ if (estimatedBytes === undefined) {
496
+ return {
497
+ ok: false,
498
+ retryable: false,
499
+ code: "channel_media_download_failed",
500
+ message: "Evolution media download returned invalid base64 data.",
501
+ providerMetadata: {
502
+ status: response.status,
503
+ },
504
+ };
505
+ }
506
+
507
+ if (estimatedBytes > maxBytes) {
508
+ return {
509
+ ok: false,
510
+ retryable: false,
511
+ code: "channel_audio_too_large",
512
+ message: `Audio response contains approximately ${estimatedBytes} bytes, which exceeds the configured ${maxBytes} byte limit.`,
513
+ providerMetadata: {
514
+ status: response.status,
515
+ sizeBytes: estimatedBytes,
516
+ },
517
+ };
518
+ }
519
+
520
+ let audio: Uint8Array;
521
+
522
+ try {
523
+ audio = decodeBase64(normalizedBase64);
524
+ } catch {
525
+ return {
526
+ ok: false,
527
+ retryable: false,
528
+ code: "channel_media_download_failed",
529
+ message: "Evolution media download returned invalid base64 data.",
530
+ providerMetadata: {
531
+ status: response.status,
532
+ },
533
+ };
534
+ }
535
+
536
+ if (audio.byteLength > maxBytes) {
537
+ return {
538
+ ok: false,
539
+ retryable: false,
540
+ code: "channel_audio_too_large",
541
+ message: `Audio response contains ${audio.byteLength} bytes, which exceeds the configured ${maxBytes} byte limit.`,
542
+ providerMetadata: {
543
+ status: response.status,
544
+ sizeBytes: audio.byteLength,
545
+ },
546
+ };
547
+ }
548
+
549
+ return {
550
+ ok: true,
551
+ audio,
552
+ filename: input.message.audio?.filename ?? `${providerMessageId}.ogg`,
553
+ ...(readEvolutionMimeType(payload) ? { mimeType: readEvolutionMimeType(payload) } : input.message.audio?.mimeType ? { mimeType: input.message.audio.mimeType } : {}),
554
+ ...(input.message.audio?.durationSeconds !== undefined ? { durationSeconds: input.message.audio.durationSeconds } : {}),
555
+ sizeBytes: audio.byteLength,
556
+ providerMetadata: {
557
+ status: response.status,
558
+ },
559
+ };
560
+ }
561
+
562
+ export async function getEvolutionStatus(
563
+ input: ChannelStatusInput,
564
+ fetcher: EvolutionFetch = evolutionFetch,
565
+ ): Promise<ChannelStatusResult> {
566
+ const missingSecrets = ["EVOLUTION_API_BASE_URL", "EVOLUTION_API_KEY", "EVOLUTION_INSTANCE_NAME"].filter(
567
+ (secret) => !input.secrets[secret],
568
+ );
569
+
570
+ if (missingSecrets.length > 0) {
571
+ return {
572
+ status: "needs_setup",
573
+ checkedAt: new Date().toISOString(),
574
+ message: `Evolution channel secrets are missing: ${missingSecrets.join(", ")}.`,
575
+ };
576
+ }
577
+
578
+ let url: string;
579
+
580
+ try {
581
+ url = evolutionApiUrl(
582
+ input.secrets.EVOLUTION_API_BASE_URL,
583
+ `/instance/connectionState/${encodeURIComponent(input.secrets.EVOLUTION_INSTANCE_NAME)}`,
584
+ );
585
+ } catch (error) {
586
+ return {
587
+ status: "failed",
588
+ checkedAt: new Date().toISOString(),
589
+ message: error instanceof Error ? error.message : String(error),
590
+ };
591
+ }
592
+
593
+ let response: Response;
594
+
595
+ try {
596
+ response = await fetcher(url, {
597
+ signal: AbortSignal.timeout(CHANNEL_STATUS_TIMEOUT_MS),
598
+ method: "GET",
599
+ headers: {
600
+ apikey: input.secrets.EVOLUTION_API_KEY,
601
+ Accept: "application/json",
602
+ },
603
+ });
604
+ } catch (error) {
605
+ return {
606
+ status: "failed",
607
+ checkedAt: new Date().toISOString(),
608
+ message: `Evolution connection state failed before a response: ${redactString(
609
+ error instanceof Error ? error.message : String(error),
610
+ [input.secrets.EVOLUTION_API_KEY, input.secrets.EVOLUTION_API_BASE_URL, input.secrets.EVOLUTION_INSTANCE_NAME],
611
+ )}`,
612
+ };
613
+ }
614
+
615
+ const payload = await readEvolutionResponseBody(response).catch(() => null);
616
+
617
+ if (!response.ok) {
618
+ return {
619
+ status: "failed",
620
+ checkedAt: new Date().toISOString(),
621
+ message: redactString(
622
+ readEvolutionErrorMessage(payload, response.status),
623
+ [input.secrets.EVOLUTION_API_KEY, input.secrets.EVOLUTION_API_BASE_URL, input.secrets.EVOLUTION_INSTANCE_NAME],
624
+ ),
625
+ providerMetadata: {
626
+ status: response.status,
627
+ body: redactValue(payload, [
628
+ input.secrets.EVOLUTION_API_KEY,
629
+ input.secrets.EVOLUTION_API_BASE_URL,
630
+ input.secrets.EVOLUTION_INSTANCE_NAME,
631
+ ]),
632
+ },
633
+ };
634
+ }
635
+
636
+ const instance = isRecord(payload) ? readRecord(payload, "instance") : {};
637
+ const state = readString(instance, "state") ?? readString(payload, "state") ?? "unknown";
638
+
639
+ if (state === "open") {
640
+ return {
641
+ status: "connected",
642
+ checkedAt: new Date().toISOString(),
643
+ message: "Evolution instance is connected.",
644
+ providerMetadata: {
645
+ instanceState: state,
646
+ },
647
+ };
648
+ }
649
+
650
+ return {
651
+ status: "needs_setup",
652
+ checkedAt: new Date().toISOString(),
653
+ message: `Evolution instance is ${state}. Connect it with QR code or pairing code in Evolution API.`,
654
+ providerMetadata: {
655
+ instanceState: state,
656
+ },
657
+ };
658
+ }
659
+
660
+ export function buildEvolutionWebhookSetupPayload(webhookUrl: string): Record<string, unknown> {
661
+ return {
662
+ enabled: true,
663
+ url: webhookUrl,
664
+ webhookByEvents: false,
665
+ webhookBase64: false,
666
+ events: ["MESSAGES_UPSERT", "MESSAGES_UPDATE", "CONNECTION_UPDATE"],
667
+ };
668
+ }
669
+
670
+ export function evolutionApiUrl(baseUrl: string, path: string): string {
671
+ const base = validateEvolutionBaseUrl(baseUrl);
672
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
673
+ return new URL(normalizedPath, `${base}/`).href;
674
+ }
675
+
676
+ function verifyWebhookToken(input: WebhookVerificationInput): WebhookVerificationResult {
677
+ const expectedToken = input.secrets.EVOLUTION_WEBHOOK_TOKEN;
678
+
679
+ if (!expectedToken) {
680
+ return {
681
+ ok: false,
682
+ code: "channel_secret_missing",
683
+ message: "Secret EVOLUTION_WEBHOOK_TOKEN is not set for this channel.",
684
+ };
685
+ }
686
+
687
+ let actualToken: string | null = null;
688
+
689
+ try {
690
+ actualToken = new URL(input.url).searchParams.get("token");
691
+ } catch {
692
+ actualToken = null;
693
+ }
694
+
695
+ if (actualToken !== expectedToken) {
696
+ return {
697
+ ok: false,
698
+ code: "channel_signature_invalid",
699
+ message: "Evolution webhook token did not match.",
700
+ };
701
+ }
702
+
703
+ return { ok: true };
704
+ }
705
+
706
+ function verifyEvolutionInstance(
707
+ input: WebhookVerificationInput,
708
+ body: Record<string, unknown>,
709
+ ): WebhookVerificationResult {
710
+ const expectedInstance = input.secrets.EVOLUTION_INSTANCE_NAME;
711
+
712
+ if (!expectedInstance) {
713
+ return {
714
+ ok: false,
715
+ code: "channel_secret_missing",
716
+ message: "Secret EVOLUTION_INSTANCE_NAME is not set for this channel.",
717
+ };
718
+ }
719
+
720
+ const actualInstance = readEvolutionInstanceName(body);
721
+
722
+ if (!actualInstance) {
723
+ return {
724
+ ok: false,
725
+ code: "channel_payload_invalid",
726
+ message: "Evolution webhook payload is missing an instance name.",
727
+ };
728
+ }
729
+
730
+ if (actualInstance !== expectedInstance) {
731
+ return {
732
+ ok: false,
733
+ code: "channel_signature_invalid",
734
+ message: "Evolution webhook instance did not match.",
735
+ };
736
+ }
737
+
738
+ return { ok: true };
739
+ }
740
+
741
+ function normalizeEvolutionStatusEvent(
742
+ input: RawWebhookEvent,
743
+ body: Record<string, unknown>,
744
+ eventType: string,
745
+ ): NormalizedChannelEvent {
746
+ const message = normalizeEvolutionMessage(body);
747
+ const providerEventId = providerEventIdFor(body, message) ?? `status:${Date.now()}`;
748
+ const data = readRecord(body, "data");
749
+ const status =
750
+ readString(data, "status") ??
751
+ readString(data, "messageStatus") ??
752
+ readString(body, "status") ??
753
+ message.messageType ??
754
+ "sent";
755
+ const normalizedStatus = status.toLowerCase();
756
+ const deliveryStatus = normalizedStatus.includes("read")
757
+ ? "delivered"
758
+ : normalizedStatus.includes("deliver")
759
+ ? "delivered"
760
+ : normalizedStatus.includes("fail")
761
+ ? "failed"
762
+ : "sent";
763
+
764
+ return {
765
+ ...baseEvolutionEvent(input, {
766
+ providerEventId,
767
+ instanceId: readEvolutionInstanceName(body) ?? "default",
768
+ contactId: stripWhatsappJid(message.sender ?? message.remoteJid ?? "unknown"),
769
+ eventType,
770
+ messageType: normalizedStatus,
771
+ senderName: message.senderName,
772
+ }),
773
+ kind: "delivery_status",
774
+ deliveryStatus,
775
+ };
776
+ }
777
+
778
+ function unsupportedEvolutionEvent(
779
+ input: RawWebhookEvent,
780
+ body: Record<string, unknown>,
781
+ eventType: string,
782
+ reason: string,
783
+ ): NormalizedChannelEvent {
784
+ const message = normalizeEvolutionMessage(body);
785
+ const providerEventId = providerEventIdFor(body, message) ?? `${eventType}:${Date.now()}`;
786
+
787
+ return {
788
+ ...baseEvolutionEvent(input, {
789
+ providerEventId,
790
+ instanceId: readEvolutionInstanceName(body) ?? "default",
791
+ contactId: stripWhatsappJid(message.sender ?? message.remoteJid ?? "unknown"),
792
+ eventType,
793
+ messageType: message.messageType ?? "unknown",
794
+ senderName: message.senderName,
795
+ }),
796
+ kind: "unsupported",
797
+ unsupportedReason: `${reason}:${eventType}`,
798
+ };
799
+ }
800
+
801
+ function baseEvolutionEvent(
802
+ input: RawWebhookEvent,
803
+ values: {
804
+ providerEventId: string;
805
+ instanceId: string;
806
+ contactId: string;
807
+ eventType: string;
808
+ messageType: string;
809
+ senderName?: string;
810
+ },
811
+ ): Omit<NormalizedChannelEvent, "kind"> {
812
+ return {
813
+ type: "whatsapp",
814
+ provider: "evolution",
815
+ providerEventId: values.providerEventId,
816
+ dedupeKey: `whatsapp:evolution:${input.channelId}:${values.providerEventId}`,
817
+ externalIdentity: {
818
+ key: `whatsapp:evolution:contact:${values.contactId}`,
819
+ display: values.senderName ?? values.contactId,
820
+ redactedDisplay: values.senderName
821
+ ? `${values.senderName} (${redactPhone(values.contactId)})`
822
+ : `whatsapp:evolution:contact:${redactPhone(values.contactId)}`,
823
+ },
824
+ receivedAt: input.receivedAt,
825
+ providerMetadata: {
826
+ eventType: values.eventType,
827
+ messageType: values.messageType,
828
+ },
829
+ };
830
+ }
831
+
832
+ function normalizeEvolutionMessage(body: Record<string, unknown>): EvolutionMessage {
833
+ const data = readRecord(body, "data");
834
+ const payload = Object.keys(data).length > 0 ? data : body;
835
+ const key = readRecord(payload, "key");
836
+ const message = readRecord(payload, "message");
837
+ const extendedText = readRecord(message, "extendedTextMessage");
838
+ const image = readRecord(message, "imageMessage");
839
+ const video = readRecord(message, "videoMessage");
840
+ const audio = readRecord(message, "audioMessage");
841
+ const document = readRecord(message, "documentMessage");
842
+ const messageType =
843
+ readString(payload, "messageType") ??
844
+ readString(payload, "type") ??
845
+ firstPresentMessageType(message);
846
+
847
+ return {
848
+ id: readString(key, "id") ?? readString(payload, "id") ?? readString(body, "id"),
849
+ remoteJid: readString(key, "remoteJid") ?? readString(payload, "remoteJid") ?? readString(payload, "chatId"),
850
+ sender:
851
+ readString(key, "participant") ??
852
+ readString(payload, "sender") ??
853
+ readString(payload, "remoteJid") ??
854
+ readString(key, "remoteJid"),
855
+ senderName: readString(payload, "pushName") ?? readString(payload, "senderName") ?? readString(payload, "name"),
856
+ fromMe: readBoolean(key, "fromMe") ?? readBoolean(payload, "fromMe"),
857
+ messageType,
858
+ timestamp: readNumber(payload, "messageTimestamp") ?? readNumber(payload, "timestamp") ?? readNumber(body, "date_time"),
859
+ text:
860
+ readString(message, "conversation") ??
861
+ readString(extendedText, "text") ??
862
+ readString(payload, "text") ??
863
+ readString(payload, "body"),
864
+ ptt: readBoolean(audio, "ptt"),
865
+ mimeType:
866
+ readString(audio, "mimetype") ??
867
+ readString(image, "mimetype") ??
868
+ readString(video, "mimetype") ??
869
+ readString(document, "mimetype") ??
870
+ readString(payload, "mimetype"),
871
+ durationSeconds: readNumber(audio, "seconds") ?? readNumber(audio, "duration"),
872
+ sizeBytes:
873
+ readNumber(audio, "fileLength") ??
874
+ readNumber(image, "fileLength") ??
875
+ readNumber(video, "fileLength") ??
876
+ readNumber(document, "fileLength"),
877
+ };
878
+ }
879
+
880
+ function providerEventIdFor(body: Record<string, unknown>, message: EvolutionMessage): string | undefined {
881
+ return (
882
+ message.id ??
883
+ readString(body, "event_id") ??
884
+ readString(body, "eventId") ??
885
+ readString(body, "id") ??
886
+ fallbackEventId(message)
887
+ );
888
+ }
889
+
890
+ function fallbackEventId(message: EvolutionMessage): string | undefined {
891
+ if (!message.remoteJid || message.timestamp === undefined) {
892
+ return undefined;
893
+ }
894
+
895
+ return `fallback:${stripWhatsappJid(message.remoteJid)}:${message.timestamp}`;
896
+ }
897
+
898
+ function readEventType(body: Record<string, unknown>): string {
899
+ const value =
900
+ readString(body, "EventType") ??
901
+ readString(body, "eventType") ??
902
+ readString(body, "event") ??
903
+ readString(body, "type") ??
904
+ "messages_upsert";
905
+ return value.toLowerCase().replace(/\./g, "_");
906
+ }
907
+
908
+ function readEvolutionInstanceName(body: Record<string, unknown>): string | undefined {
909
+ const data = readRecord(body, "data");
910
+
911
+ return (
912
+ readString(body, "instance") ??
913
+ readString(body, "instanceName") ??
914
+ readString(body, "instance_name") ??
915
+ readString(data, "instance") ??
916
+ readString(data, "instanceName") ??
917
+ readString(data, "instance_name")
918
+ );
919
+ }
920
+
921
+ function firstPresentMessageType(message: Record<string, unknown>): string | undefined {
922
+ for (const key of [
923
+ "conversation",
924
+ "extendedTextMessage",
925
+ "audioMessage",
926
+ "imageMessage",
927
+ "videoMessage",
928
+ "documentMessage",
929
+ "stickerMessage",
930
+ ]) {
931
+ if (message[key] !== undefined) {
932
+ return key;
933
+ }
934
+ }
935
+
936
+ return undefined;
937
+ }
938
+
939
+ function isEvolutionTextMessage(messageType: string): boolean {
940
+ return messageType === "conversation" || messageType === "extendedtextmessage" || messageType === "text";
941
+ }
942
+
943
+ function isEvolutionAudioMessage(messageType: string, message: EvolutionMessage): boolean {
944
+ return messageType === "audiomessage" || messageType === "audio" || Boolean(message.mimeType?.toLowerCase().startsWith("audio/"));
945
+ }
946
+
947
+ function defaultEvolutionAudioFilename(message: EvolutionMessage): string {
948
+ const id = message.id ?? "evolution-audio";
949
+ const extension = message.mimeType?.includes("mpeg") ? "mp3" : "ogg";
950
+ return `${id}.${extension}`;
951
+ }
952
+
953
+ function buildEvolutionSendTextRequestInternal(
954
+ input: ChannelSendInput,
955
+ baseUrl: string,
956
+ instanceName: string,
957
+ mode: "actual" | "<redacted>",
958
+ ): EvolutionSendTextRequest {
959
+ if (input.message.contentType !== "text") {
960
+ throw new Error("Evolution sendText supports only text channel messages.");
961
+ }
962
+
963
+ const to = contactFromExternalIdentity(input.externalIdentity.key);
964
+ const redactedTo = redactPhone(to);
965
+
966
+ return {
967
+ method: "POST",
968
+ url: mode === "actual"
969
+ ? evolutionApiUrl(baseUrl, `/message/sendText/${encodeURIComponent(instanceName)}`)
970
+ : "https://<redacted>/message/sendText/<redacted>",
971
+ body: {
972
+ number: mode === "actual" ? to : redactedTo,
973
+ text: input.message.content,
974
+ },
975
+ idempotencyKey: `whatsapp:evolution:${input.channelId}:${input.conversationId}:${redactedTo}`,
976
+ };
977
+ }
978
+
979
+ function contactFromExternalIdentity(identityKey: string): string {
980
+ const prefix = "whatsapp:evolution:contact:";
981
+
982
+ if (!identityKey.startsWith(prefix)) {
983
+ throw new Error("Evolution WhatsApp external identity must start with whatsapp:evolution:contact:.");
984
+ }
985
+
986
+ const contact = identityKey.slice(prefix.length);
987
+
988
+ if (!contact) {
989
+ throw new Error("Evolution WhatsApp external identity is missing a contact id.");
990
+ }
991
+
992
+ return contact;
993
+ }
994
+
995
+ function readEvolutionProviderMessageId(payload: unknown): string | undefined {
996
+ if (!isRecord(payload)) {
997
+ return undefined;
998
+ }
999
+
1000
+ const key = readRecord(payload, "key");
1001
+ const response = readRecord(payload, "response");
1002
+ const responseKey = readRecord(response, "key");
1003
+
1004
+ return (
1005
+ readString(key, "id") ??
1006
+ readString(payload, "messageId") ??
1007
+ readString(payload, "messageid") ??
1008
+ readString(payload, "id") ??
1009
+ readString(responseKey, "id") ??
1010
+ readString(response, "messageId") ??
1011
+ readString(response, "id")
1012
+ );
1013
+ }
1014
+
1015
+ class EvolutionResponseBodyTooLargeError extends Error {
1016
+ constructor() {
1017
+ super("Evolution response body exceeded the configured limit.");
1018
+ }
1019
+ }
1020
+
1021
+ async function readEvolutionResponseBody(response: Response, options: { maxBytes?: number } = {}): Promise<unknown> {
1022
+ const text = await readEvolutionResponseText(response, options.maxBytes);
1023
+
1024
+ if (!text) {
1025
+ return null;
1026
+ }
1027
+
1028
+ try {
1029
+ return JSON.parse(text);
1030
+ } catch {
1031
+ return text;
1032
+ }
1033
+ }
1034
+
1035
+ async function readEvolutionResponseText(response: Response, maxBytes?: number): Promise<string> {
1036
+ if (maxBytes !== undefined) {
1037
+ const contentLength = readContentLength(response.headers);
1038
+
1039
+ if (contentLength !== undefined && contentLength > maxBytes) {
1040
+ throw new EvolutionResponseBodyTooLargeError();
1041
+ }
1042
+ }
1043
+
1044
+ if (maxBytes !== undefined && response.body) {
1045
+ const reader = response.body.getReader();
1046
+ const decoder = new TextDecoder();
1047
+ let text = "";
1048
+ let bytesRead = 0;
1049
+
1050
+ try {
1051
+ while (true) {
1052
+ const chunk = await reader.read();
1053
+
1054
+ if (chunk.done) {
1055
+ break;
1056
+ }
1057
+
1058
+ bytesRead += chunk.value.byteLength;
1059
+
1060
+ if (bytesRead > maxBytes) {
1061
+ throw new EvolutionResponseBodyTooLargeError();
1062
+ }
1063
+
1064
+ text += decoder.decode(chunk.value, { stream: true });
1065
+ }
1066
+
1067
+ text += decoder.decode();
1068
+ return text;
1069
+ } finally {
1070
+ reader.releaseLock();
1071
+ }
1072
+ }
1073
+
1074
+ const text = await response.text();
1075
+
1076
+ if (maxBytes !== undefined && utf8ByteLength(text) > maxBytes) {
1077
+ throw new EvolutionResponseBodyTooLargeError();
1078
+ }
1079
+
1080
+ return text;
1081
+ }
1082
+
1083
+ function readContentLength(headers: Headers): number | undefined {
1084
+ const value = headers.get("content-length");
1085
+
1086
+ if (!value) {
1087
+ return undefined;
1088
+ }
1089
+
1090
+ const parsed = Number(value);
1091
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
1092
+ }
1093
+
1094
+ function maxEvolutionMediaResponseBytes(maxAudioBytes: number): number {
1095
+ return Math.ceil(maxAudioBytes / 3) * 4 + EVOLUTION_MEDIA_DOWNLOAD_BODY_OVERHEAD_BYTES;
1096
+ }
1097
+
1098
+ function readEvolutionBase64Data(payload: unknown): string | undefined {
1099
+ if (typeof payload === "string") {
1100
+ return payload;
1101
+ }
1102
+
1103
+ if (!isRecord(payload)) {
1104
+ return undefined;
1105
+ }
1106
+
1107
+ const media = readRecord(payload, "media");
1108
+
1109
+ return (
1110
+ readString(payload, "base64") ??
1111
+ readString(payload, "base64Data") ??
1112
+ readString(payload, "data") ??
1113
+ readString(media, "base64") ??
1114
+ readString(media, "base64Data")
1115
+ );
1116
+ }
1117
+
1118
+ function readEvolutionMimeType(payload: unknown): string | undefined {
1119
+ if (!isRecord(payload)) {
1120
+ return undefined;
1121
+ }
1122
+
1123
+ const media = readRecord(payload, "media");
1124
+
1125
+ return (
1126
+ readString(payload, "mimetype") ??
1127
+ readString(payload, "mimeType") ??
1128
+ readString(media, "mimetype") ??
1129
+ readString(media, "mimeType")
1130
+ );
1131
+ }
1132
+
1133
+ function readEvolutionErrorMessage(payload: unknown, status: number): string {
1134
+ if (isRecord(payload)) {
1135
+ return (
1136
+ readString(payload, "message") ??
1137
+ readString(payload, "error") ??
1138
+ readString(payload, "provider_message") ??
1139
+ `Evolution request returned HTTP ${status}.`
1140
+ );
1141
+ }
1142
+
1143
+ return typeof payload === "string" && payload ? payload : `Evolution request returned HTTP ${status}.`;
1144
+ }
1145
+
1146
+ function parseJsonObject(input: RawWebhookEvent): Record<string, unknown> {
1147
+ let parsed: unknown;
1148
+
1149
+ try {
1150
+ parsed = JSON.parse(input.rawBody);
1151
+ } catch {
1152
+ throw new AgentKitError("channel_payload_invalid", "Evolution webhook payload must be valid JSON.");
1153
+ }
1154
+
1155
+ if (!isRecord(parsed)) {
1156
+ throw new AgentKitError("channel_payload_invalid", "Evolution webhook payload must be a JSON object.");
1157
+ }
1158
+
1159
+ return parsed;
1160
+ }
1161
+
1162
+ function validateEvolutionBaseUrl(baseUrl: string): string {
1163
+ let parsed: URL;
1164
+
1165
+ try {
1166
+ parsed = new URL(baseUrl);
1167
+ } catch {
1168
+ throw new Error("EVOLUTION_API_BASE_URL must be a valid HTTPS URL.");
1169
+ }
1170
+
1171
+ if (parsed.protocol !== "https:") {
1172
+ throw new Error("EVOLUTION_API_BASE_URL must use HTTPS.");
1173
+ }
1174
+
1175
+ if (parsed.username || parsed.password) {
1176
+ throw new Error("EVOLUTION_API_BASE_URL must not include embedded credentials.");
1177
+ }
1178
+
1179
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
1180
+ throw new Error("EVOLUTION_API_BASE_URL must be an origin URL without a path, query, or hash.");
1181
+ }
1182
+
1183
+ const host = parsed.hostname.toLowerCase();
1184
+
1185
+ if (isBlockedOutboundHost(host)) {
1186
+ throw new Error("EVOLUTION_API_BASE_URL must be a public HTTPS origin.");
1187
+ }
1188
+
1189
+ return parsed.origin;
1190
+ }
1191
+
1192
+
1193
+
1194
+
1195
+ function readRecord(record: Record<string, unknown>, key: string): Record<string, unknown> {
1196
+ const value = record[key];
1197
+ return isRecord(value) ? value : {};
1198
+ }
1199
+
1200
+ function readString(record: unknown, key: string): string | undefined {
1201
+ if (!isRecord(record)) {
1202
+ return undefined;
1203
+ }
1204
+
1205
+ const value = record[key];
1206
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1207
+ }
1208
+
1209
+ function readBoolean(record: unknown, key: string): boolean | undefined {
1210
+ if (!isRecord(record)) {
1211
+ return undefined;
1212
+ }
1213
+
1214
+ const value = record[key];
1215
+ return typeof value === "boolean" ? value : undefined;
1216
+ }
1217
+
1218
+ function readNumber(record: unknown, key: string): number | undefined {
1219
+ if (!isRecord(record)) {
1220
+ return undefined;
1221
+ }
1222
+
1223
+ const value = record[key];
1224
+
1225
+ if (typeof value === "number" && Number.isFinite(value)) {
1226
+ return value;
1227
+ }
1228
+
1229
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
1230
+ return Number(value);
1231
+ }
1232
+
1233
+ return undefined;
1234
+ }
1235
+
1236
+ function isRecord(value: unknown): value is Record<string, unknown> {
1237
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1238
+ }
1239
+
1240
+ function normalizeBase64(value: string): string {
1241
+ const commaIndex = value.indexOf(",");
1242
+ const raw = commaIndex >= 0 ? value.slice(commaIndex + 1) : value;
1243
+ return raw.replace(/\s+/g, "");
1244
+ }
1245
+
1246
+ function estimateBase64DecodedBytes(value: string): number | undefined {
1247
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) {
1248
+ return undefined;
1249
+ }
1250
+
1251
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
1252
+ return Math.floor((value.length * 3) / 4) - padding;
1253
+ }
1254
+
1255
+ function decodeBase64(value: string): Uint8Array {
1256
+ if (typeof Buffer !== "undefined") {
1257
+ return new Uint8Array(Buffer.from(value, "base64"));
1258
+ }
1259
+
1260
+ const binary = atob(value);
1261
+ const bytes = new Uint8Array(binary.length);
1262
+
1263
+ for (let index = 0; index < binary.length; index += 1) {
1264
+ bytes[index] = binary.charCodeAt(index);
1265
+ }
1266
+
1267
+ return bytes;
1268
+ }
1269
+
1270
+ function utf8ByteLength(value: string): number {
1271
+ return new TextEncoder().encode(value).byteLength;
1272
+ }
1273
+
1274
+ function stripWhatsappJid(value: string): string {
1275
+ return value
1276
+ .replace(/^whatsapp:/, "")
1277
+ .replace(/@s\.whatsapp\.net$/i, "")
1278
+ .replace(/@c\.us$/i, "")
1279
+ .replace(/@g\.us$/i, "")
1280
+ .replace(/[^0-9+]/g, "");
1281
+ }
1282
+
1283
+ function redactPhone(value: string): string {
1284
+ const digits = value.replace(/\D/g, "");
1285
+
1286
+ if (digits.length <= 8) {
1287
+ return digits ? `${digits.slice(0, 2)}******` : "unknown";
1288
+ }
1289
+
1290
+ return `${digits.slice(0, 4)}******${digits.slice(-4)}`;
1291
+ }
1292
+
1293
+ function redactString(value: string, secrets: Array<string | undefined>): string {
1294
+ let redacted = value;
1295
+
1296
+ for (const secret of secrets) {
1297
+ if (secret) {
1298
+ redacted = redacted.split(secret).join("<redacted>");
1299
+ }
1300
+ }
1301
+
1302
+ return redacted.replace(/\b\+?\d[\d\s().-]{8,}\d\b/g, (match) => redactPhone(match));
1303
+ }
1304
+
1305
+ function redactValue(value: unknown, secrets: Array<string | undefined>): unknown {
1306
+ if (typeof value === "string") {
1307
+ return redactString(value, secrets);
1308
+ }
1309
+
1310
+ if (Array.isArray(value)) {
1311
+ return value.map((item) => redactValue(item, secrets));
1312
+ }
1313
+
1314
+ if (isRecord(value)) {
1315
+ const redacted: Record<string, unknown> = {};
1316
+
1317
+ for (const [key, item] of Object.entries(value)) {
1318
+ if (/secret|token|apikey|api_key|authorization|password/i.test(key)) {
1319
+ redacted[key] = "<redacted>";
1320
+ continue;
1321
+ }
1322
+
1323
+ redacted[key] = redactValue(item, secrets);
1324
+ }
1325
+
1326
+ return redacted;
1327
+ }
1328
+
1329
+ return value;
1330
+ }