@andreprado/agentkit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/runtime/channels/discord.ts +14 -6
- package/src/runtime/channels/generic-webhook.ts +21 -313
- package/src/runtime/channels/net-guard.ts +480 -0
- package/src/runtime/channels/provider-fetch.ts +54 -0
- package/src/runtime/channels/slack.ts +9 -3
- package/src/runtime/channels/telegram.ts +43 -10
- package/src/runtime/channels/whatsapp-evolution.ts +33 -60
- package/src/runtime/channels/whatsapp-uazapi.ts +33 -164
- package/src/runtime/channels/whatsapp-zapster.ts +35 -7
- package/src/runtime/config.ts +23 -0
- package/src/runtime/knowledge/embeddings.ts +45 -7
- package/src/runtime/knowledge/ingest.ts +69 -6
- package/src/runtime/knowledge/vector.ts +30 -30
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import type { LookupFunction } from "node:net";
|
|
5
|
+
import { pipeline, Readable } from "node:stream";
|
|
6
|
+
import { createBrotliDecompress, createUnzip } from "node:zlib";
|
|
7
|
+
|
|
8
|
+
import { AgentKitError } from "../errors";
|
|
9
|
+
|
|
10
|
+
// Outbound requests to operator-configured URLs (webhook outputs, self-hosted
|
|
11
|
+
// WhatsApp gateways) must not reach private networks. Checking the hostname
|
|
12
|
+
// text is not enough: a public name can resolve to a private address, and a
|
|
13
|
+
// public server can redirect to one. So every guarded request resolves DNS,
|
|
14
|
+
// rejects blocked addresses, pins the connection to the checked address, and
|
|
15
|
+
// never follows redirects.
|
|
16
|
+
|
|
17
|
+
export type OutboundResolver = (hostname: string) => Promise<string[]>;
|
|
18
|
+
|
|
19
|
+
export type ResolvedOutboundUrl = {
|
|
20
|
+
href: string;
|
|
21
|
+
address: string;
|
|
22
|
+
family: 4 | 6;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PinnedRequestInit = {
|
|
26
|
+
method: string;
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
body?: string;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PublicHttpsFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
// Test seams only. Production callers never pass these, so private addresses
|
|
35
|
+
// stay blocked and TLS is verified against the system CA store.
|
|
36
|
+
export type PinnedTransportOptions = {
|
|
37
|
+
isBlockedAddress?: (address: string) => boolean;
|
|
38
|
+
ca?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createPublicHttpsFetch(
|
|
42
|
+
label: string,
|
|
43
|
+
resolver: OutboundResolver = resolveOutboundAddresses,
|
|
44
|
+
transport: PinnedTransportOptions = {},
|
|
45
|
+
): PublicHttpsFetch {
|
|
46
|
+
return async (url, init = {}) => {
|
|
47
|
+
const parsed = new URL(url);
|
|
48
|
+
|
|
49
|
+
if (parsed.protocol !== "https:") {
|
|
50
|
+
throw new AgentKitError("channel_payload_invalid", `${label} must use HTTPS.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isBlockedOutboundHost(parsed.hostname)) {
|
|
54
|
+
throw new AgentKitError("channel_payload_invalid", `${label} host is not allowed.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (init.body !== undefined && init.body !== null && typeof init.body !== "string") {
|
|
58
|
+
throw new AgentKitError("channel_payload_invalid", `${label} requests only support string bodies.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const signal = init.signal ?? undefined;
|
|
62
|
+
const resolved = await resolvePublicOutboundUrl(parsed.href, label, resolver, signal, transport.isBlockedAddress);
|
|
63
|
+
const headers: Record<string, string> = {};
|
|
64
|
+
|
|
65
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
66
|
+
headers[key] = value;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return pinnedHttpsFetch(
|
|
70
|
+
resolved,
|
|
71
|
+
{
|
|
72
|
+
method: init.method ?? "GET",
|
|
73
|
+
headers,
|
|
74
|
+
body: init.body ?? undefined,
|
|
75
|
+
signal,
|
|
76
|
+
},
|
|
77
|
+
label,
|
|
78
|
+
transport,
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function resolvePublicOutboundUrl(
|
|
84
|
+
href: string,
|
|
85
|
+
label: string,
|
|
86
|
+
resolver: OutboundResolver = resolveOutboundAddresses,
|
|
87
|
+
signal?: AbortSignal,
|
|
88
|
+
isBlockedAddress: (address: string) => boolean = isBlockedOutboundHost,
|
|
89
|
+
): Promise<ResolvedOutboundUrl> {
|
|
90
|
+
const url = new URL(href);
|
|
91
|
+
let addresses: string[];
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
addresses = await resolveWithinDeadline(resolver, url.hostname, signal, label);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof AgentKitError) {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new AgentKitError(
|
|
101
|
+
"channel_provider_unavailable",
|
|
102
|
+
`${label} host could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (addresses.length === 0) {
|
|
107
|
+
throw new AgentKitError("channel_provider_unavailable", `${label} host did not resolve.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const address of addresses) {
|
|
111
|
+
if (isBlockedAddress(address)) {
|
|
112
|
+
throw new AgentKitError("channel_payload_invalid", `${label} host resolved to a blocked address.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const address = normalizeOutboundHost(addresses[0]);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
href,
|
|
120
|
+
address,
|
|
121
|
+
family: parseIpv4Octets(address) ? 4 : 6,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Node's https.request does not follow redirects, so a 3xx response is
|
|
126
|
+
// returned to the caller as-is rather than being chased to another host.
|
|
127
|
+
export function pinnedHttpsFetch(
|
|
128
|
+
resolved: ResolvedOutboundUrl,
|
|
129
|
+
init: PinnedRequestInit,
|
|
130
|
+
label: string,
|
|
131
|
+
transport: PinnedTransportOptions = {},
|
|
132
|
+
): Promise<Response> {
|
|
133
|
+
const url = new URL(resolved.href);
|
|
134
|
+
const isBlockedAddress = transport.isBlockedAddress ?? isBlockedOutboundHost;
|
|
135
|
+
// Node calls lookup with `all: true` (for happy-eyeballs family selection),
|
|
136
|
+
// which expects an array of records instead of a single address.
|
|
137
|
+
const lookup = ((_hostname: string, options: { all?: boolean }, callback: (...args: unknown[]) => void) => {
|
|
138
|
+
if (options?.all) {
|
|
139
|
+
callback(null, [{ address: resolved.address, family: resolved.family }]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
callback(null, resolved.address, resolved.family);
|
|
144
|
+
}) as unknown as LookupFunction;
|
|
145
|
+
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const request = httpsRequest(
|
|
148
|
+
url,
|
|
149
|
+
{
|
|
150
|
+
method: init.method,
|
|
151
|
+
headers: init.headers,
|
|
152
|
+
lookup,
|
|
153
|
+
signal: init.signal,
|
|
154
|
+
...(transport.ca ? { ca: transport.ca } : {}),
|
|
155
|
+
},
|
|
156
|
+
(response) => {
|
|
157
|
+
// Everything here runs outside the promise executor, so any throw must
|
|
158
|
+
// become a rejection or it would crash the process.
|
|
159
|
+
try {
|
|
160
|
+
assertAllowedRemoteAddress(response.socket.remoteAddress, label, isBlockedAddress);
|
|
161
|
+
const status = response.statusCode ?? 502;
|
|
162
|
+
const bodyless = init.method.toUpperCase() === "HEAD" || NULL_BODY_STATUSES.has(status);
|
|
163
|
+
|
|
164
|
+
if (bodyless) {
|
|
165
|
+
response.resume();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
resolve(
|
|
169
|
+
new Response(bodyless ? null : (Readable.toWeb(decodedResponseBody(response)) as unknown as ReadableStream<Uint8Array>), {
|
|
170
|
+
status,
|
|
171
|
+
headers: headersFromIncomingMessage(response.headers),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
176
|
+
reject(error);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
request.on("socket", (socket) => {
|
|
182
|
+
const validateSocket = () => {
|
|
183
|
+
try {
|
|
184
|
+
assertAllowedRemoteAddress(socket.remoteAddress, label, isBlockedAddress);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
request.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
socket.once("connect", validateSocket);
|
|
191
|
+
socket.once("secureConnect", validateSocket);
|
|
192
|
+
});
|
|
193
|
+
request.once("error", reject);
|
|
194
|
+
request.end(init.body);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function decodedResponseBody(response: IncomingMessage): Readable {
|
|
199
|
+
const encodings = response.headers["content-encoding"]?.toLowerCase().split(",").map((value) => value.trim()) ?? [];
|
|
200
|
+
|
|
201
|
+
if (encodings.length === 0 || !encodings.every((value) => ["gzip", "x-gzip", "deflate", "x-deflate", "br"].includes(value))) {
|
|
202
|
+
return response;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const decoders = encodings.reverse().map((encoding) => encoding === "br" ? createBrotliDecompress() : createUnzip());
|
|
206
|
+
// Keep decoding streamed so callers enforce limits on decoded bytes. Pipeline
|
|
207
|
+
// propagates source errors and destroys the socket when the reader cancels.
|
|
208
|
+
pipeline([response, ...decoders], () => {});
|
|
209
|
+
return decoders[decoders.length - 1];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function resolveOutboundAddresses(hostname: string): Promise<string[]> {
|
|
213
|
+
const host = normalizeOutboundHost(hostname);
|
|
214
|
+
|
|
215
|
+
if (parseIpv4Octets(host) || parseIpv6Segments(host)) {
|
|
216
|
+
return [host];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const records = await dnsLookup(host, {
|
|
220
|
+
all: true,
|
|
221
|
+
verbatim: true,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return records.map((record) => record.address);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function isBlockedOutboundHost(hostname: string): boolean {
|
|
228
|
+
const host = normalizeOutboundHost(hostname);
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
host === "localhost" ||
|
|
232
|
+
host.endsWith(".localhost") ||
|
|
233
|
+
host === "metadata.google.internal"
|
|
234
|
+
) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const ipv4 = parseIpv4Octets(host);
|
|
239
|
+
|
|
240
|
+
if (ipv4) {
|
|
241
|
+
return isBlockedIpv4Octets(ipv4);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const ipv6 = parseIpv6Segments(host);
|
|
245
|
+
|
|
246
|
+
if (ipv6) {
|
|
247
|
+
return isBlockedIpv6Segments(ipv6);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function normalizeOutboundHost(hostname: string): string {
|
|
254
|
+
return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The Fetch Response constructor rejects a body for these statuses.
|
|
258
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
259
|
+
|
|
260
|
+
// Resolution happens before the request exists, so the caller's deadline has to
|
|
261
|
+
// cover it here; otherwise a stalled resolver outlives the request timeout.
|
|
262
|
+
// Nothing has been sent yet, so this failure is always safe to retry.
|
|
263
|
+
async function resolveWithinDeadline(
|
|
264
|
+
resolver: OutboundResolver,
|
|
265
|
+
hostname: string,
|
|
266
|
+
signal: AbortSignal | undefined,
|
|
267
|
+
label: string,
|
|
268
|
+
): Promise<string[]> {
|
|
269
|
+
const deadlineError = () =>
|
|
270
|
+
new AgentKitError("channel_provider_unavailable", `${label} host resolution did not finish before the request deadline.`);
|
|
271
|
+
|
|
272
|
+
if (!signal) {
|
|
273
|
+
return resolver(hostname);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (signal.aborted) {
|
|
277
|
+
throw deadlineError();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
const onAbort = () => reject(deadlineError());
|
|
282
|
+
|
|
283
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
284
|
+
resolver(hostname).then(
|
|
285
|
+
(addresses) => {
|
|
286
|
+
signal.removeEventListener("abort", onAbort);
|
|
287
|
+
resolve(addresses);
|
|
288
|
+
},
|
|
289
|
+
(error) => {
|
|
290
|
+
signal.removeEventListener("abort", onAbort);
|
|
291
|
+
reject(error);
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function assertAllowedRemoteAddress(
|
|
298
|
+
address: string | undefined,
|
|
299
|
+
label: string,
|
|
300
|
+
isBlockedAddress: (address: string) => boolean,
|
|
301
|
+
): void {
|
|
302
|
+
if (!address || isBlockedAddress(address)) {
|
|
303
|
+
throw new AgentKitError("channel_payload_invalid", `${label} connected to a blocked address.`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function headersFromIncomingMessage(headers: Record<string, string | string[] | number | undefined>): Headers {
|
|
308
|
+
const result = new Headers();
|
|
309
|
+
|
|
310
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
for (const item of value) {
|
|
313
|
+
result.append(key, item);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (value !== undefined) {
|
|
319
|
+
result.set(key, String(value));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseIpv4Octets(host: string): number[] | undefined {
|
|
327
|
+
const parts = host.split(".");
|
|
328
|
+
|
|
329
|
+
if (parts.length !== 4) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const octets = parts.map((part) => Number(part));
|
|
334
|
+
|
|
335
|
+
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return octets;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isBlockedIpv4Octets(parts: number[]): boolean {
|
|
343
|
+
const [a, b] = parts;
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
a === 0 ||
|
|
347
|
+
a === 10 ||
|
|
348
|
+
a === 127 ||
|
|
349
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
350
|
+
(a === 169 && b === 254) ||
|
|
351
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
352
|
+
(a === 192 && b === 168) ||
|
|
353
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
354
|
+
a >= 224
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function parseIpv6Segments(host: string): number[] | undefined {
|
|
359
|
+
if (!host.includes(":")) {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (host === "::") {
|
|
364
|
+
return new Array(8).fill(0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const [leftRaw, rightRaw, extra] = host.split("::");
|
|
368
|
+
|
|
369
|
+
if (extra !== undefined) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const left = parseIpv6SegmentSide(leftRaw ?? "");
|
|
374
|
+
const right = parseIpv6SegmentSide(rightRaw ?? "");
|
|
375
|
+
|
|
376
|
+
if (!left || !right) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (rightRaw === undefined) {
|
|
381
|
+
return left.length === 8 ? left : undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const missing = 8 - left.length - right.length;
|
|
385
|
+
|
|
386
|
+
if (missing < 1) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return [...left, ...new Array(missing).fill(0), ...right];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseIpv6SegmentSide(value: string): number[] | undefined {
|
|
394
|
+
if (!value) {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const segments: number[] = [];
|
|
399
|
+
|
|
400
|
+
const parts = value.split(":");
|
|
401
|
+
|
|
402
|
+
for (const [index, part] of parts.entries()) {
|
|
403
|
+
if (part.includes(".")) {
|
|
404
|
+
if (index !== parts.length - 1) {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const octets = parseIpv4Octets(part);
|
|
409
|
+
|
|
410
|
+
if (!octets) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
segments.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!/^[0-9a-f]{1,4}$/i.test(part)) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
segments.push(parseInt(part, 16));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return segments;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function isBlockedIpv6Segments(parts: number[]): boolean {
|
|
429
|
+
if (parts.length !== 8) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const embeddedIpv4 = ipv4OctetsFromIpv6Segments(parts);
|
|
434
|
+
|
|
435
|
+
if (embeddedIpv4) {
|
|
436
|
+
return isBlockedIpv4Octets(embeddedIpv4);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const isAllZero = parts.every((part) => part === 0);
|
|
440
|
+
|
|
441
|
+
if (isAllZero || (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1)) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const first = parts[0];
|
|
446
|
+
|
|
447
|
+
return (
|
|
448
|
+
(first & 0xfe00) === 0xfc00 ||
|
|
449
|
+
(first & 0xffc0) === 0xfe80 ||
|
|
450
|
+
(first & 0xff00) === 0xff00
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function ipv4OctetsFromIpv6Segments(parts: number[]): number[] | undefined {
|
|
455
|
+
const trailingIpv4 = [
|
|
456
|
+
(parts[6] >> 8) & 0xff,
|
|
457
|
+
parts[6] & 0xff,
|
|
458
|
+
(parts[7] >> 8) & 0xff,
|
|
459
|
+
parts[7] & 0xff,
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
if (parts.slice(0, 5).every((part) => part === 0) && (parts[5] === 0xffff || parts[5] === 0)) {
|
|
463
|
+
return trailingIpv4;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (parts[0] === 0x64 && parts[1] === 0xff9b && parts.slice(2, 6).every((part) => part === 0)) {
|
|
467
|
+
return trailingIpv4;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (parts[0] === 0x2002) {
|
|
471
|
+
return [
|
|
472
|
+
(parts[1] >> 8) & 0xff,
|
|
473
|
+
parts[1] & 0xff,
|
|
474
|
+
(parts[2] >> 8) & 0xff,
|
|
475
|
+
parts[2] & 0xff,
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isAgentKitError } from "../errors";
|
|
2
|
+
|
|
3
|
+
// Deadlines for outbound provider calls. Without them a hung provider stalls
|
|
4
|
+
// a delivery job until the runtime's own socket defaults give up.
|
|
5
|
+
export const CHANNEL_SEND_TIMEOUT_MS = 15_000;
|
|
6
|
+
export const CHANNEL_STATUS_TIMEOUT_MS = 15_000;
|
|
7
|
+
export const CHANNEL_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
export type SendRequestFailure = {
|
|
10
|
+
retryable: boolean;
|
|
11
|
+
code: "channel_provider_unavailable" | "channel_send_failed";
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Classifies a failure thrown by fetch() for a non-idempotent send. A deadline
|
|
16
|
+
// can fire after the provider has already accepted the message, and none of
|
|
17
|
+
// the chat providers deduplicate on our idempotency key, so retrying a timed
|
|
18
|
+
// out send risks delivering the reply twice.
|
|
19
|
+
export function classifySendRequestError(error: unknown): SendRequestFailure {
|
|
20
|
+
if (isOutboundGuardRejection(error)) {
|
|
21
|
+
return {
|
|
22
|
+
retryable: false,
|
|
23
|
+
code: "channel_send_failed",
|
|
24
|
+
reason: "was blocked by the outbound network guard",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isDeadlineError(error)) {
|
|
29
|
+
return {
|
|
30
|
+
retryable: false,
|
|
31
|
+
code: "channel_send_failed",
|
|
32
|
+
reason: "timed out before a response; the provider may already have accepted the message, so it will not be retried",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
retryable: true,
|
|
38
|
+
code: "channel_provider_unavailable",
|
|
39
|
+
reason: "failed before a response",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Downloads and status checks are safe to repeat; only a guard rejection is final.
|
|
44
|
+
export function isRetryableReadRequestError(error: unknown): boolean {
|
|
45
|
+
return !isOutboundGuardRejection(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isDeadlineError(error: unknown): boolean {
|
|
49
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isOutboundGuardRejection(error: unknown): boolean {
|
|
53
|
+
return isAgentKitError(error) && error.code === "channel_payload_invalid";
|
|
54
|
+
}
|
|
@@ -7,6 +7,10 @@ import type {
|
|
|
7
7
|
WebhookVerificationResult,
|
|
8
8
|
} from "../channels";
|
|
9
9
|
import { AgentKitError } from "../errors";
|
|
10
|
+
import {
|
|
11
|
+
CHANNEL_SEND_TIMEOUT_MS,
|
|
12
|
+
classifySendRequestError,
|
|
13
|
+
} from "./provider-fetch";
|
|
10
14
|
|
|
11
15
|
const SLACK_API_BASE_URL = "https://slack.com/api";
|
|
12
16
|
const SLACK_SIGNATURE_VERSION = "v0";
|
|
@@ -285,16 +289,18 @@ export async function sendSlackMessage(
|
|
|
285
289
|
|
|
286
290
|
try {
|
|
287
291
|
response = await fetcher(actualRequest.url, {
|
|
292
|
+
signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
|
|
288
293
|
method: actualRequest.method,
|
|
289
294
|
headers: actualRequest.headers,
|
|
290
295
|
body: JSON.stringify(actualRequest.body),
|
|
291
296
|
});
|
|
292
297
|
} catch (error) {
|
|
298
|
+
const failure = classifySendRequestError(error);
|
|
293
299
|
return {
|
|
294
300
|
ok: false,
|
|
295
|
-
retryable:
|
|
296
|
-
code:
|
|
297
|
-
message: `Slack chat.postMessage request
|
|
301
|
+
retryable: failure.retryable,
|
|
302
|
+
code: failure.code,
|
|
303
|
+
message: `Slack chat.postMessage request ${failure.reason}: ${redactSlackToken(
|
|
298
304
|
error instanceof Error ? error.message : String(error),
|
|
299
305
|
input.secrets.SLACK_BOT_TOKEN,
|
|
300
306
|
)}`,
|
|
@@ -8,6 +8,12 @@ import type {
|
|
|
8
8
|
WebhookVerificationResult,
|
|
9
9
|
} from "../channels";
|
|
10
10
|
import { AgentKitError } from "../errors";
|
|
11
|
+
import {
|
|
12
|
+
CHANNEL_DOWNLOAD_TIMEOUT_MS,
|
|
13
|
+
CHANNEL_SEND_TIMEOUT_MS,
|
|
14
|
+
classifySendRequestError,
|
|
15
|
+
isRetryableReadRequestError,
|
|
16
|
+
} from "./provider-fetch";
|
|
11
17
|
|
|
12
18
|
export const telegramChannelAdapter: ChannelAdapter = {
|
|
13
19
|
type: "telegram",
|
|
@@ -221,6 +227,7 @@ export async function sendTelegramMessage(
|
|
|
221
227
|
|
|
222
228
|
try {
|
|
223
229
|
response = await fetcher(actualRequest.url, {
|
|
230
|
+
signal: AbortSignal.timeout(CHANNEL_SEND_TIMEOUT_MS),
|
|
224
231
|
method: actualRequest.method,
|
|
225
232
|
headers: {
|
|
226
233
|
"Content-Type": "application/json",
|
|
@@ -228,13 +235,14 @@ export async function sendTelegramMessage(
|
|
|
228
235
|
body: JSON.stringify(actualRequest.body),
|
|
229
236
|
});
|
|
230
237
|
} catch (error) {
|
|
238
|
+
const failure = classifySendRequestError(error);
|
|
231
239
|
const errorMessage = (error instanceof Error ? error.message : String(error)).replace(token, "<redacted>");
|
|
232
240
|
|
|
233
241
|
return {
|
|
234
242
|
ok: false,
|
|
235
|
-
retryable:
|
|
236
|
-
code:
|
|
237
|
-
message: `Telegram sendMessage request
|
|
243
|
+
retryable: failure.retryable,
|
|
244
|
+
code: failure.code,
|
|
245
|
+
message: `Telegram sendMessage request ${failure.reason}: ${errorMessage}`,
|
|
238
246
|
providerRequestId: request.idempotencyKey,
|
|
239
247
|
providerMetadata: {
|
|
240
248
|
method: request.method,
|
|
@@ -331,11 +339,14 @@ export async function downloadTelegramMedia(
|
|
|
331
339
|
try {
|
|
332
340
|
const url = new URL(`/bot${token}/getFile`, telegramApiBaseUrl(input.secrets));
|
|
333
341
|
url.searchParams.set("file_id", audio.providerMediaId);
|
|
334
|
-
fileResponse = await fetcher(url.href, {
|
|
342
|
+
fileResponse = await fetcher(url.href, {
|
|
343
|
+
method: "GET",
|
|
344
|
+
signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
|
|
345
|
+
});
|
|
335
346
|
} catch (error) {
|
|
336
347
|
return {
|
|
337
348
|
ok: false,
|
|
338
|
-
retryable:
|
|
349
|
+
retryable: isRetryableReadRequestError(error),
|
|
339
350
|
code: "channel_provider_unavailable",
|
|
340
351
|
message: `Telegram getFile request failed before a response: ${redactToken(
|
|
341
352
|
error instanceof Error ? error.message : String(error),
|
|
@@ -397,11 +408,14 @@ export async function downloadTelegramMedia(
|
|
|
397
408
|
let mediaResponse: Response;
|
|
398
409
|
|
|
399
410
|
try {
|
|
400
|
-
mediaResponse = await fetcher(`${telegramApiBaseUrl(input.secrets)}/file/bot${token}/${filePath}`, {
|
|
411
|
+
mediaResponse = await fetcher(`${telegramApiBaseUrl(input.secrets)}/file/bot${token}/${filePath}`, {
|
|
412
|
+
method: "GET",
|
|
413
|
+
signal: AbortSignal.timeout(CHANNEL_DOWNLOAD_TIMEOUT_MS),
|
|
414
|
+
});
|
|
401
415
|
} catch (error) {
|
|
402
416
|
return {
|
|
403
417
|
ok: false,
|
|
404
|
-
retryable:
|
|
418
|
+
retryable: isRetryableReadRequestError(error),
|
|
405
419
|
code: "channel_provider_unavailable",
|
|
406
420
|
message: `Telegram file download failed before a response: ${redactToken(
|
|
407
421
|
error instanceof Error ? error.message : String(error),
|
|
@@ -591,8 +605,8 @@ async function readResponseBytesWithLimit(
|
|
|
591
605
|
}
|
|
592
606
|
| {
|
|
593
607
|
ok: false;
|
|
594
|
-
retryable:
|
|
595
|
-
code: "channel_audio_too_large";
|
|
608
|
+
retryable: boolean;
|
|
609
|
+
code: "channel_audio_too_large" | "channel_provider_unavailable";
|
|
596
610
|
message: string;
|
|
597
611
|
providerMetadata?: Record<string, unknown>;
|
|
598
612
|
}
|
|
@@ -613,7 +627,18 @@ async function readResponseBytesWithLimit(
|
|
|
613
627
|
}
|
|
614
628
|
|
|
615
629
|
if (!response.body) {
|
|
616
|
-
|
|
630
|
+
let audio: Uint8Array;
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
audio = new Uint8Array(await response.arrayBuffer());
|
|
634
|
+
} catch (error) {
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
retryable: true,
|
|
638
|
+
code: "channel_provider_unavailable",
|
|
639
|
+
message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
617
642
|
|
|
618
643
|
if (audio.byteLength > maxBytes) {
|
|
619
644
|
return {
|
|
@@ -659,6 +684,14 @@ async function readResponseBytesWithLimit(
|
|
|
659
684
|
|
|
660
685
|
chunks.push(chunk.value);
|
|
661
686
|
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
await reader.cancel().catch(() => undefined);
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
retryable: true,
|
|
692
|
+
code: "channel_provider_unavailable",
|
|
693
|
+
message: `Media download response could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
694
|
+
};
|
|
662
695
|
} finally {
|
|
663
696
|
reader.releaseLock();
|
|
664
697
|
}
|