@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
274 lines • 13.1 kB
JavaScript
import { createHmac } from 'node:crypto';
import { ChannelDeliveryError, ChannelMediaTooLargeError, fetchBytes, mediaFilename, postForm, postJson, providerSentAt, queryParam, record, safeEqual, sizeOf, str, } from '../http.js';
/** Cloud API limits: text body, interactive body, button title. */
const MAX_TEXT = 4096;
const MAX_INTERACTIVE_BODY = 1024;
const MAX_BUTTON_TITLE = 20;
const MAX_BUTTONS = 3;
const MAX_CAPTION = 1024;
const MAX_FOOTER = 60;
/** List messages: 10 rows, 20-character button, 24-character row titles, 72-character descriptions. */
const MAX_LIST_ROWS = 10;
const MAX_LIST_BUTTON = 20;
const MAX_ROW_TITLE = 24;
const MAX_ROW_DESCRIPTION = 72;
const MAX_HEADER = 60;
const MEDIA_TYPES = ['image', 'audio', 'video', 'document', 'sticker'];
/** The file a Cloud API message carries (`{ id, mime_type, caption?, filename? }`), and its caption. */
function mediaOf(message) {
const kind = MEDIA_TYPES.find((type) => type === message.type);
const body = kind === undefined ? undefined : record(message[kind]);
const mediaId = str(body?.id);
if (kind === undefined || !body || mediaId === undefined)
return undefined;
const contentType = str(body.mime_type);
const filename = str(body.filename);
return {
media: {
kind,
ref: mediaId,
...(contentType !== undefined ? { contentType } : {}),
...(filename !== undefined ? { filename } : {}),
},
caption: str(body.caption),
};
}
function parseMessage(message) {
const id = str(message.id);
const from = str(message.from);
if (!id || !from)
return null;
const interactive = record(message.interactive);
const reply = record(interactive?.button_reply) ?? record(interactive?.list_reply);
// A template's quick-reply button.
const button = record(message.button);
const buttonId = str(reply?.id) ?? str(button?.payload);
const file = mediaOf(message);
const text = file
? (file.caption ?? '')
: (str(record(message.text)?.body) ?? str(reply?.title) ?? str(button?.text) ?? buttonId);
if (text === undefined || (text.trim() === '' && !file))
return null;
const sentAt = providerSentAt(message.timestamp);
return {
id,
from,
conversation: from,
text: text.trim(),
...(buttonId !== undefined ? { buttonId } : {}),
...(file ? { media: [file.media] } : {}),
...(sentAt !== undefined ? { sentAt } : {}),
raw: message,
};
}
/**
* WhatsApp through Meta's [Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api).
* Route both `GET` (the subscription check) and `POST` (messages) to the same handler, and subscribe
* the app to the `messages` field.
*
* Verifies `X-Hub-Signature-256` over the raw body; reads text, interactive button/list replies,
* template quick replies and media (image, audio, video, document, sticker — downloaded through the
* Graph API media endpoint) addressed to `phoneNumberId` (status updates are ignored); sends text,
* interactive reply buttons (at most 3, 20-character titles, with a footer) and files (image,
* document, audio, video — by link, or uploaded first when given as bytes).
*/
export function whatsappCloud(options) {
const name = options.name ?? 'whatsapp';
const fetcher = options.fetch ?? globalThis.fetch;
const timeoutMs = options.timeoutMs ?? 20_000;
const graphVersion = options.graphVersion ?? 'v23.0';
const endpoint = `https://graph.facebook.com/${graphVersion}/${encodeURIComponent(options.phoneNumberId)}/messages`;
const post = (body) => postJson(name, fetcher, endpoint, { authorization: `Bearer ${options.accessToken}` }, { messaging_product: 'whatsapp', recipient_type: 'individual', ...body }, timeoutMs);
const sendText = (to, body) => post({ to, type: 'text', text: { body, preview_url: false } });
/** Bytes are uploaded first (`POST /{phone-number-id}/media`); the message names the upload. */
const upload = async (media) => {
const answer = record(await postForm(name, fetcher, endpoint.replace(/\/messages$/, '/media'), { authorization: `Bearer ${options.accessToken}` }, {
messaging_product: 'whatsapp',
type: media.contentType ?? 'application/octet-stream',
file: {
data: media.data,
contentType: media.contentType ?? 'application/octet-stream',
filename: mediaFilename(media),
},
}, timeoutMs));
const id = str(answer?.id);
if (id === undefined)
throw new ChannelDeliveryError(name, null, `${name}: upload failed`);
return id;
};
return {
name,
kind: 'whatsapp',
capabilities: {
buttons: MAX_BUTTONS,
markdown: 'whatsapp',
maxLength: MAX_TEXT,
media: true,
maxCaptionLength: MAX_CAPTION,
lists: MAX_LIST_ROWS,
},
challenge(request) {
if (request.method !== 'GET')
return null;
const mode = queryParam(request.url, 'hub.mode');
const token = queryParam(request.url, 'hub.verify_token');
const challenge = queryParam(request.url, 'hub.challenge');
if (mode === 'subscribe' && challenge !== undefined && safeEqual(token, options.verifyToken))
return { status: 200, body: challenge, contentType: 'text/plain' };
return { status: 403, body: 'Forbidden', contentType: 'text/plain' };
},
verify(request) {
const signature = request.header('x-hub-signature-256');
if (request.rawBody === null || signature === undefined)
return false;
const expected = `sha256=${createHmac('sha256', options.appSecret).update(request.rawBody, 'utf8').digest('hex')}`;
return safeEqual(signature, expected);
},
parse(body) {
const envelope = record(body);
if (envelope?.object !== 'whatsapp_business_account' || !Array.isArray(envelope.entry))
return null;
const messages = [];
for (const entry of envelope.entry) {
const changes = record(entry)?.changes;
if (!Array.isArray(changes))
continue;
for (const change of changes) {
const value = record(record(change)?.value);
if (record(change)?.field !== 'messages' || !value)
continue;
if (str(record(value.metadata)?.phone_number_id) !== options.phoneNumberId)
continue;
if (!Array.isArray(value.messages))
continue;
for (const message of value.messages) {
const parsed = record(message) ? parseMessage(record(message)) : null;
if (parsed)
messages.push(parsed);
}
}
}
return messages;
},
async download(media, { maxBytes }) {
const auth = { authorization: `Bearer ${options.accessToken}` };
// The media id resolves to a short-lived url, which wants the same token.
const lookup = await fetchBytes(name, fetcher, `https://graph.facebook.com/${graphVersion}/${encodeURIComponent(String(media.ref))}`, auth, timeoutMs, 64 * 1024);
let info;
try {
info = record(JSON.parse(lookup.data.toString('utf8')));
}
catch {
info = undefined;
}
const url = str(info?.url);
if (url === undefined) {
throw new ChannelDeliveryError(name, null, `${name}: the media could not be resolved`);
}
const declared = sizeOf(info?.file_size);
if (declared !== undefined && declared > maxBytes)
throw new ChannelMediaTooLargeError(maxBytes);
const file = await fetchBytes(name, fetcher, url, auth, timeoutMs, maxBytes);
return {
data: file.data,
contentType: media.contentType ??
str(info?.mime_type) ??
file.contentType ??
'application/octet-stream',
...(media.filename !== undefined ? { filename: media.filename } : {}),
};
},
async send(conversation, message) {
if (message.media !== undefined) {
const { media } = message;
const source = media.url !== undefined
? { link: media.url }
: media.data !== undefined
? { id: await upload({ ...media, data: media.data }) }
: undefined;
if (source === undefined)
throw new Error(`${name}: a file needs a url or its data`);
await post({
to: conversation,
type: media.kind,
[media.kind]: {
...source,
// Audio carries no caption on WhatsApp.
...(message.text !== '' && media.kind !== 'audio'
? { caption: message.text.slice(0, MAX_CAPTION) }
: {}),
...(media.kind === 'document' ? { filename: mediaFilename(media) } : {}),
},
});
return;
}
if (message.list !== undefined) {
const { list } = message;
await post({
to: conversation,
type: 'interactive',
interactive: {
type: 'list',
...(list.title !== undefined && list.title.trim() !== ''
? {
header: {
type: 'text',
text: list.title.replace(/[*_~]/g, '').slice(0, MAX_HEADER),
},
}
: {}),
body: {
text: (message.text.trim() === '' ? '…' : message.text).slice(0, MAX_INTERACTIVE_BODY),
},
action: {
button: list.button.slice(0, MAX_LIST_BUTTON),
sections: [
{
rows: list.rows.slice(0, MAX_LIST_ROWS).map((row) => ({
id: row.id,
title: row.title.slice(0, MAX_ROW_TITLE),
...(row.description !== undefined && row.description !== ''
? { description: row.description.slice(0, MAX_ROW_DESCRIPTION) }
: {}),
})),
},
],
},
},
});
return;
}
if (message.buttons === undefined || message.buttons.length === 0) {
await sendText(conversation, message.buttons === undefined ? message.text : message.fallbackText);
return;
}
let body = message.text;
if (body.length > MAX_INTERACTIVE_BODY) {
// The interactive body is short: the text goes first, the buttons under its last paragraph.
const cut = body.lastIndexOf('\n\n', body.length - 1);
const tail = cut > 0 ? body.slice(cut + 2) : '';
const head = tail.length > 0 && tail.length <= MAX_INTERACTIVE_BODY ? body.slice(0, cut) : body;
await sendText(conversation, head);
body = head === body ? '…' : tail;
}
await post({
to: conversation,
type: 'interactive',
interactive: {
type: 'button',
body: { text: body },
...(message.footer !== undefined && message.footer.trim() !== ''
? { footer: { text: message.footer.replace(/[*_~]/g, '').trim().slice(0, MAX_FOOTER) } }
: {}),
action: {
buttons: message.buttons.slice(0, MAX_BUTTONS).map((button) => ({
type: 'reply',
reply: { id: button.id, title: button.label.slice(0, MAX_BUTTON_TITLE) },
})),
},
},
});
},
};
}
//# sourceMappingURL=whatsapp_cloud.js.map