UNPKG

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