@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
208 lines • 8.33 kB
JavaScript
import { createHash, timingSafeEqual } from 'node:crypto';
/** A provider refused (or could not be reached for) a delivery. `status` is its HTTP status, if any. */
export class ChannelDeliveryError extends Error {
channel;
status;
constructor(channel, status, message) {
super(message);
this.channel = channel;
this.status = status;
this.name = 'ChannelDeliveryError';
}
/**
* The provider answered and said no (400, 403, 404, 422): the message was certainly not delivered,
* so sending something else in its place cannot duplicate it.
*/
get definite() {
return this.status !== null && [400, 403, 404, 422].includes(this.status);
}
}
/** A file was larger than the attachment limit; it was not (fully) downloaded. */
export class ChannelMediaTooLargeError extends Error {
maxBytes;
constructor(maxBytes) {
super(`the file exceeds the ${maxBytes}-byte limit`);
this.maxBytes = maxBytes;
this.name = 'ChannelMediaTooLargeError';
}
}
/**
* GET a file, refusing one past `maxBytes` — by its `content-length` before reading, and by what
* actually arrives while reading. Throws a {@link ChannelDeliveryError} on a non-2xx.
*/
export async function fetchBytes(channel, fetcher, url, headers, timeoutMs, maxBytes) {
let response;
try {
response = await fetcher(url, {
method: 'GET',
headers,
signal: AbortSignal.timeout(timeoutMs),
});
}
catch (error) {
throw new ChannelDeliveryError(channel, null, `${channel}: download failed (${error instanceof Error ? error.name : 'network error'})`);
}
if (!response.ok) {
await response.body?.cancel().catch(() => { });
throw new ChannelDeliveryError(channel, response.status, `${channel}: the provider refused the download (HTTP ${response.status})`);
}
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
await response.body?.cancel().catch(() => { });
throw new ChannelMediaTooLargeError(maxBytes);
}
const chunks = [];
let size = 0;
if (response.body) {
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel().catch(() => { });
throw new ChannelMediaTooLargeError(maxBytes);
}
chunks.push(value);
}
}
return {
data: Buffer.concat(chunks),
contentType: response.headers.get('content-type')?.split(';')[0]?.trim() || undefined,
};
}
/** Bytes of a base64 string, refusing past `maxBytes` before decoding. */
export function decodeBase64(base64, maxBytes) {
const body = base64.replace(/^data:[^,]*,/, '');
if (Math.floor((body.length * 3) / 4) - 2 > maxBytes)
throw new ChannelMediaTooLargeError(maxBytes);
const data = Buffer.from(body, 'base64');
if (data.byteLength > maxBytes)
throw new ChannelMediaTooLargeError(maxBytes);
return data;
}
/** A positive number out of a webhook field that may be a number, a numeric string or a Long object. */
export function sizeOf(value) {
const raw = typeof value === 'object' && value !== null && 'low' in value
? value.low
: value;
const size = typeof raw === 'string' ? Number(raw) : raw;
return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : undefined;
}
/** Compare two secrets in constant time (over their hashes, so lengths leak nothing either). */
export function safeEqual(supplied, expected) {
if (typeof supplied !== 'string' || expected.length === 0)
return false;
const digest = (value) => createHash('sha256').update(value).digest();
return timingSafeEqual(digest(supplied), digest(expected)) && supplied === expected;
}
/**
* POST a JSON body. Resolves with the parsed JSON answer (`null` when it is not JSON); throws a
* {@link ChannelDeliveryError} on a non-2xx or a network failure. The provider's error body is never
* put in the error — it can echo the message text or credentials.
*/
export async function postJson(channel, fetcher, url, headers, body, timeoutMs) {
let response;
try {
response = await fetcher(url, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
redirect: 'error',
});
}
catch (error) {
throw new ChannelDeliveryError(channel, null, `${channel}: delivery failed (${error instanceof Error ? error.name : 'network error'})`);
}
const text = await response.text().catch(() => '');
if (!response.ok) {
throw new ChannelDeliveryError(channel, response.status, `${channel}: the provider refused the message (HTTP ${response.status})`);
}
try {
return text === '' ? null : JSON.parse(text);
}
catch {
return null;
}
}
/**
* POST a `multipart/form-data` body (a file upload). Same answers and errors as {@link postJson}.
* A `Buffer` field is sent as a file part: `{ data, contentType, filename }`.
*/
export async function postForm(channel, fetcher, url, headers, fields, timeoutMs) {
const form = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (typeof value === 'string')
form.append(key, value);
else
form.append(key, new Blob([new Uint8Array(value.data)], { type: value.contentType }), value.filename);
}
let response;
try {
response = await fetcher(url, {
method: 'POST',
headers,
body: form,
signal: AbortSignal.timeout(timeoutMs),
redirect: 'error',
});
}
catch (error) {
throw new ChannelDeliveryError(channel, null, `${channel}: delivery failed (${error instanceof Error ? error.name : 'network error'})`);
}
const text = await response.text().catch(() => '');
if (!response.ok) {
throw new ChannelDeliveryError(channel, response.status, `${channel}: the provider refused the message (HTTP ${response.status})`);
}
try {
return text === '' ? null : JSON.parse(text);
}
catch {
return null;
}
}
/** A file's name when it has none: `image.png`, `document.pdf`. */
export function mediaFilename(media) {
if (media.filename !== undefined)
return media.filename;
const extension = (media.contentType?.split('/')[1] ?? 'bin').split(/[;+]/)[0] ?? 'bin';
return `${media.kind}.${extension}`;
}
/** A query-string parameter of a request's url. */
export function queryParam(url, name) {
const query = url.indexOf('?');
if (query === -1)
return undefined;
return new URLSearchParams(url.slice(query + 1)).get(name) ?? undefined;
}
/** `value` when it is a plain object, else `undefined` — for reading untyped webhook bodies. */
export function record(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value
: undefined;
}
/** `value` when it is a non-empty string. */
export function str(value) {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
/**
* A provider's send timestamp as epoch milliseconds: Unix seconds (WhatsApp, Telegram) or
* milliseconds, as a number, a numeric string, or a protobuf `Long` (`{ low, high }`, what Baileys can
* forward). `undefined` for anything else.
*/
export function providerSentAt(value) {
const long = record(value);
const raw = typeof value === 'number'
? value
: typeof value === 'string' && /^\d+$/.test(value.trim())
? Number(value.trim())
: typeof long?.low === 'number'
? (long.low >>> 0) + (typeof long.high === 'number' ? long.high : 0) * 2 ** 32
: Number.NaN;
if (!Number.isFinite(raw) || raw <= 0)
return undefined;
return Math.round(raw > 1e12 ? raw : raw * 1000);
}
//# sourceMappingURL=http.js.map