@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.
- package/docs/guides/channel-security.md +1 -1
- package/docs/guides/channels-production-handoff.md +1 -1
- package/docs/guides/connect-whatsapp-zapster.md +2 -0
- package/package.json +1 -1
- package/src/runtime/channels/whatsapp-zapster.ts +246 -29
- package/src/templates/skills/agentkit-channels/references/whatsapp-zapster.md +4 -0
|
@@ -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`, `
|
|
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`, `
|
|
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,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
|
|
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 =
|
|
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 =
|
|
83
|
-
const messageType =
|
|
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 =
|
|
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:
|
|
138
|
+
providerMessageId: message.id,
|
|
132
139
|
},
|
|
133
140
|
},
|
|
134
141
|
];
|
|
135
142
|
},
|
|
136
143
|
async sendMessage(input) {
|
|
137
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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:
|
|
380
|
+
url: ZAPSTER_SEND_MESSAGE_URL,
|
|
229
381
|
body: {
|
|
230
|
-
to,
|
|
231
|
-
|
|
232
|
-
|
|
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
|