@andreprado/agentkit 0.1.0-alpha.10 → 0.1.0-alpha.12

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.
@@ -27,7 +27,7 @@ AGENTKIT_RUN_ZAPSTER_CHANNEL_TESTS=1 bun test
27
27
 
28
28
  Telegram smoke also requires `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, and `AGENTKIT_TELEGRAM_WEBHOOK_URL`.
29
29
 
30
- Zapster smoke also requires `ZAPSTER_API_KEY`, `AGENTKIT_ZAPSTER_SEND_URL`, and `AGENTKIT_ZAPSTER_TO`.
30
+ Zapster smoke also requires `ZAPSTER_API_KEY`, `ZAPSTER_INSTANCE_ID`, and `AGENTKIT_ZAPSTER_TO`. `AGENTKIT_ZAPSTER_SEND_URL` is optional and defaults to `https://api.zapsterapi.com/v1/wa/messages`.
31
31
 
32
32
  ## Files Created Or Edited
33
33
 
@@ -74,7 +74,7 @@ AGENTKIT_RUN_ZAPSTER_CHANNEL_TESTS=1 bun test
74
74
 
75
75
  Telegram smoke requires `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, and `AGENTKIT_TELEGRAM_WEBHOOK_URL`.
76
76
 
77
- Zapster smoke requires `ZAPSTER_API_KEY`, `AGENTKIT_ZAPSTER_SEND_URL`, and `AGENTKIT_ZAPSTER_TO`.
77
+ Zapster smoke requires `ZAPSTER_API_KEY`, `ZAPSTER_INSTANCE_ID`, and `AGENTKIT_ZAPSTER_TO`. `AGENTKIT_ZAPSTER_SEND_URL` is optional and defaults to `https://api.zapsterapi.com/v1/wa/messages`.
78
78
 
79
79
  ## Smoke Checks
80
80
 
@@ -97,6 +97,8 @@ https://<deploy-host>/channels/chn_<id>/whatsapp/zapster/webhook?token=<ZAPSTER_
97
97
  - Zapster webhook headers are origin validation, not a cryptographic body signature.
98
98
  - Use optional `ZAPSTER_WEBHOOK_TOKEN` in the webhook URL when the endpoint should require an extra secret known only to AgentKit and Zapster.
99
99
  - Unsupported media should be logged as skipped/unsupported without creating an agent run.
100
+ - AgentKit handles Zapster `message.received` envelopes with event id at `id`, message text at `data.content.text`, and contact identity at `data.sender.id`.
101
+ - Outbound replies call `POST https://api.zapsterapi.com/v1/wa/messages` with bearer auth and a JSON body containing `recipient`, `text`, and `instance_id`. Only set `AGENTKIT_CHANNEL_SEND_DRY_RUN=1` in tests when Zapster should not receive a real message.
100
102
 
101
103
  ## Verification
102
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andreprado/agentkit",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ChannelAdapter,
3
3
  ChannelSendInput,
4
+ ChannelSendResult,
4
5
  RawWebhookEvent,
5
6
  WebhookVerificationInput,
6
7
  WebhookVerificationResult,
@@ -71,16 +72,21 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
71
72
  },
72
73
  normalizeInbound(input) {
73
74
  const body = parseJsonObject(input);
74
- const message = readRecord(body, "message");
75
+ const officialMessage = normalizeZapsterOfficialMessage(body);
76
+ const message = officialMessage ?? normalizeZapsterLegacyMessage(body);
75
77
  const instanceId = input.headers.get("x-instance-id") ?? readString(body, "instance_id") ?? "default";
76
- const providerEventId = readString(body, "event_id") ?? input.headers.get("x-message-id") ?? readString(message, "id");
78
+ const providerEventId =
79
+ readString(body, "id") ??
80
+ readString(body, "event_id") ??
81
+ input.headers.get("x-message-id") ??
82
+ message.id;
77
83
 
78
84
  if (!providerEventId) {
79
- throw new AgentKitError("channel_payload_invalid", "Zapster payload is missing event_id, X-Message-ID, or message.id.");
85
+ throw new AgentKitError("channel_payload_invalid", "Zapster payload is missing id, event_id, X-Message-ID, or message.id.");
80
86
  }
81
87
 
82
- const contactId = readString(message, "from") ?? "unknown";
83
- const messageType = readString(message, "type") ?? "unknown";
88
+ const contactId = message.contactId ?? "unknown";
89
+ const messageType = message.type ?? "unknown";
84
90
  const base = {
85
91
  type: "whatsapp" as const,
86
92
  provider: "zapster" as const,
@@ -95,6 +101,7 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
95
101
  providerMetadata: {
96
102
  instanceId,
97
103
  messageType,
104
+ ...(readString(body, "type") ? { eventType: readString(body, "type") } : {}),
98
105
  },
99
106
  };
100
107
 
@@ -108,7 +115,7 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
108
115
  ];
109
116
  }
110
117
 
111
- const text = readString(readRecord(message, "text"), "body");
118
+ const text = message.text;
112
119
 
113
120
  if (!text) {
114
121
  return [
@@ -128,23 +135,13 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
128
135
  role: "user",
129
136
  contentType: "text",
130
137
  content: text,
131
- providerMessageId: readString(message, "id"),
138
+ providerMessageId: message.id,
132
139
  },
133
140
  },
134
141
  ];
135
142
  },
136
143
  async sendMessage(input) {
137
- const request = buildZapsterSendMessageRequest(input);
138
-
139
- return {
140
- ok: true,
141
- status: "sent",
142
- providerRequestId: request.idempotencyKey,
143
- providerMetadata: {
144
- method: request.method,
145
- url: request.url,
146
- },
147
- };
144
+ return sendZapsterMessage(input);
148
145
  },
149
146
  getStatus(input) {
150
147
  const missingSecrets = ["ZAPSTER_API_KEY", "ZAPSTER_INSTANCE_ID", "ZAPSTER_WEBHOOK_ID"].filter(
@@ -167,6 +164,41 @@ export const zapsterWhatsappChannelAdapter: ChannelAdapter = {
167
164
  },
168
165
  };
169
166
 
167
+ type NormalizedZapsterWebhookMessage = {
168
+ id?: string;
169
+ contactId?: string;
170
+ type?: string;
171
+ text?: string;
172
+ };
173
+
174
+ function normalizeZapsterOfficialMessage(body: Record<string, unknown>): NormalizedZapsterWebhookMessage | null {
175
+ if (readString(body, "type") !== "message.received") {
176
+ return null;
177
+ }
178
+
179
+ const data = readRecord(body, "data");
180
+ const content = readRecord(data, "content");
181
+ const sender = readRecord(data, "sender");
182
+
183
+ return {
184
+ id: readString(data, "id"),
185
+ contactId: readString(sender, "id"),
186
+ type: readString(data, "type"),
187
+ text: readString(content, "text"),
188
+ };
189
+ }
190
+
191
+ function normalizeZapsterLegacyMessage(body: Record<string, unknown>): NormalizedZapsterWebhookMessage {
192
+ const message = readRecord(body, "message");
193
+
194
+ return {
195
+ id: readString(message, "id"),
196
+ contactId: readString(message, "from"),
197
+ type: readString(message, "type"),
198
+ text: readString(readRecord(message, "text"), "body"),
199
+ };
200
+ }
201
+
170
202
  function verifyOptionalWebhookToken(input: WebhookVerificationInput): WebhookVerificationResult {
171
203
  const expectsToken = input.channel.secrets.includes("ZAPSTER_WEBHOOK_TOKEN");
172
204
 
@@ -207,16 +239,136 @@ export type ZapsterSendMessageRequest = {
207
239
  method: "POST";
208
240
  url: string;
209
241
  body: {
210
- to: string;
211
- type: "text";
212
- text: {
213
- body: string;
214
- };
242
+ recipient: string;
243
+ text: string;
244
+ instance_id: string;
215
245
  };
216
246
  idempotencyKey: string;
217
247
  };
218
248
 
249
+ type ZapsterFetch = (url: string, init: RequestInit) => Promise<Response>;
250
+
251
+ const ZAPSTER_SEND_MESSAGE_URL = "https://api.zapsterapi.com/v1/wa/messages";
252
+
219
253
  export function buildZapsterSendMessageRequest(input: ChannelSendInput): ZapsterSendMessageRequest {
254
+ return buildZapsterSendMessageRequestInternal(input, "<redacted>");
255
+ }
256
+
257
+ export async function sendZapsterMessage(
258
+ input: ChannelSendInput,
259
+ fetcher: ZapsterFetch = fetch,
260
+ ): Promise<ChannelSendResult> {
261
+ if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
262
+ const request = buildZapsterSendMessageRequest(input);
263
+
264
+ return {
265
+ ok: true,
266
+ status: "sent",
267
+ providerRequestId: request.idempotencyKey,
268
+ providerMetadata: {
269
+ method: request.method,
270
+ url: request.url,
271
+ body: request.body,
272
+ },
273
+ };
274
+ }
275
+
276
+ const apiKey = input.secrets.ZAPSTER_API_KEY;
277
+ const instanceId = input.secrets.ZAPSTER_INSTANCE_ID;
278
+
279
+ if (!apiKey || !instanceId) {
280
+ return {
281
+ ok: false,
282
+ retryable: false,
283
+ code: "channel_secret_missing",
284
+ message: "Secret ZAPSTER_API_KEY or ZAPSTER_INSTANCE_ID is missing.",
285
+ };
286
+ }
287
+
288
+ let request: ZapsterSendMessageRequest;
289
+ let actualRequest: ZapsterSendMessageRequest;
290
+
291
+ try {
292
+ request = buildZapsterSendMessageRequest(input);
293
+ actualRequest = buildZapsterSendMessageRequestInternal(input, instanceId);
294
+ } catch (error) {
295
+ return {
296
+ ok: false,
297
+ retryable: false,
298
+ code: "channel_send_failed",
299
+ message: error instanceof Error ? error.message : String(error),
300
+ };
301
+ }
302
+
303
+ let response: Response;
304
+
305
+ try {
306
+ response = await fetcher(actualRequest.url, {
307
+ method: actualRequest.method,
308
+ headers: {
309
+ Authorization: `Bearer ${apiKey}`,
310
+ "Content-Type": "application/json",
311
+ Accept: "application/json",
312
+ },
313
+ body: JSON.stringify(actualRequest.body),
314
+ });
315
+ } catch (error) {
316
+ const errorMessage = redactString(error instanceof Error ? error.message : String(error), [apiKey, instanceId]);
317
+
318
+ return {
319
+ ok: false,
320
+ retryable: true,
321
+ code: "channel_provider_unavailable",
322
+ message: `Zapster sendMessage request failed before a response: ${errorMessage}`,
323
+ providerRequestId: request.idempotencyKey,
324
+ providerMetadata: {
325
+ method: request.method,
326
+ url: request.url,
327
+ body: request.body,
328
+ },
329
+ };
330
+ }
331
+
332
+ const payload = await readZapsterResponseBody(response);
333
+ const redactedPayload = redactValue(payload, [apiKey, instanceId]);
334
+ const messageId = isRecord(payload)
335
+ ? readString(payload, "message_id") ?? readString(payload, "id")
336
+ : undefined;
337
+
338
+ if (!response.ok) {
339
+ return {
340
+ ok: false,
341
+ retryable: response.status === 429 || response.status >= 500,
342
+ code: "channel_send_failed",
343
+ message: redactString(readZapsterErrorMessage(payload, response.status), [apiKey, instanceId]),
344
+ providerRequestId: request.idempotencyKey,
345
+ providerMetadata: {
346
+ method: request.method,
347
+ url: request.url,
348
+ status: response.status,
349
+ body: redactedPayload,
350
+ },
351
+ };
352
+ }
353
+
354
+ return {
355
+ ok: true,
356
+ status: "sent",
357
+ providerRequestId: messageId ?? request.idempotencyKey,
358
+ ...(messageId ? { providerMessageId: messageId } : {}),
359
+ providerMetadata: {
360
+ method: request.method,
361
+ url: request.url,
362
+ status: response.status,
363
+ body: redactedPayload,
364
+ },
365
+ };
366
+ }
367
+
368
+ function buildZapsterSendMessageRequestInternal(
369
+ input: ChannelSendInput,
370
+ instanceId: string,
371
+ ): ZapsterSendMessageRequest {
220
372
  const to = input.externalIdentity.key.match(/contact:([^:]+)$/)?.[1];
221
373
 
222
374
  if (!to) {
@@ -225,18 +377,83 @@ export function buildZapsterSendMessageRequest(input: ChannelSendInput): Zapster
225
377
 
226
378
  return {
227
379
  method: "POST",
228
- url: "https://api.zapster.example/messages",
380
+ url: ZAPSTER_SEND_MESSAGE_URL,
229
381
  body: {
230
- to,
231
- type: "text",
232
- text: {
233
- body: input.message.content,
234
- },
382
+ recipient: to,
383
+ text: input.message.content,
384
+ instance_id: instanceId,
235
385
  },
236
386
  idempotencyKey: `whatsapp:zapster:${input.channelId}:${input.conversationId}:${redactPhone(to)}`,
237
387
  };
238
388
  }
239
389
 
390
+ async function readZapsterResponseBody(response: Response): Promise<unknown> {
391
+ const text = await response.text().catch(() => "");
392
+
393
+ if (!text) {
394
+ return null;
395
+ }
396
+
397
+ try {
398
+ return JSON.parse(text) as unknown;
399
+ } catch {
400
+ return text;
401
+ }
402
+ }
403
+
404
+ function readZapsterErrorMessage(payload: unknown, status: number): string {
405
+ if (typeof payload === "string" && payload.length > 0) {
406
+ return payload;
407
+ }
408
+
409
+ if (isRecord(payload)) {
410
+ const message = readString(payload, "message") ?? readString(payload, "error") ?? readString(payload, "detail");
411
+
412
+ if (message) {
413
+ return message;
414
+ }
415
+
416
+ const error = readRecord(payload, "error");
417
+ const nestedMessage = readString(error, "message") ?? readString(error, "detail");
418
+
419
+ if (nestedMessage) {
420
+ return nestedMessage;
421
+ }
422
+ }
423
+
424
+ return `Zapster sendMessage returned HTTP ${status}.`;
425
+ }
426
+
427
+ function redactValue(value: unknown, secrets: string[]): unknown {
428
+ if (typeof value === "string") {
429
+ return redactString(value, secrets);
430
+ }
431
+
432
+ if (!isRecord(value) && !Array.isArray(value)) {
433
+ return value;
434
+ }
435
+
436
+ const serialized = redactString(JSON.stringify(value), secrets);
437
+
438
+ try {
439
+ return JSON.parse(serialized) as unknown;
440
+ } catch {
441
+ return serialized;
442
+ }
443
+ }
444
+
445
+ function redactString(value: string, secrets: string[]): string {
446
+ let redacted = value;
447
+
448
+ for (const secret of secrets) {
449
+ if (secret) {
450
+ redacted = redacted.split(secret).join("<redacted>");
451
+ }
452
+ }
453
+
454
+ return redacted;
455
+ }
456
+
240
457
  function parseJsonObject(input: RawWebhookEvent): Record<string, unknown> {
241
458
  try {
242
459
  const parsed = JSON.parse(input.rawBody) as unknown;
@@ -27,6 +27,10 @@ agentkit channels deliveries list support-whatsapp
27
27
 
28
28
  Paste the stable AgentKit webhook URL into Zapster settings. If the channel declares `ZAPSTER_WEBHOOK_TOKEN`, append `?token=<ZAPSTER_WEBHOOK_TOKEN>` to the Zapster webhook URL. Keep phone numbers redacted in logs by default.
29
29
 
30
+ AgentKit handles Zapster `message.received` envelopes with event id at `id`, message text at `data.content.text`, and contact identity at `data.sender.id`. Unsupported media should be logged as skipped/unsupported without creating an agent run.
31
+
32
+ Outbound replies call `POST https://api.zapsterapi.com/v1/wa/messages` with bearer auth and a JSON body containing `recipient`, `text`, and `instance_id`. Only set `AGENTKIT_CHANNEL_SEND_DRY_RUN=1` in tests when Zapster should not receive a real message.
33
+
30
34
  Buffer rapid WhatsApp messages:
31
35
 
32
36
  ```ts