UNPKG

@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
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