@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
379 lines • 17.8 kB
JavaScript
import { ChannelDeliveryError, ChannelMediaTooLargeError, decodeBase64, fetchBytes, mediaFilename, postJson, providerSentAt, queryParam, record, safeEqual, sizeOf, str, } from '../http.js';
const PHONE_JID = /^(\d{5,20})@s\.whatsapp\.net$/;
/** A phone jid's digits; any other jid as is. */
function addressOf(jid) {
return PHONE_JID.exec(jid)?.[1] ?? jid;
}
/** The id and label of a button / list reply, in any of the shapes Evolution forwards them. */
function buttonReply(message) {
const buttons = record(message.buttonsResponseMessage);
const template = record(message.templateButtonReplyMessage);
const list = record(message.listResponseMessage);
const interactive = record(record(message.interactiveResponseMessage)?.nativeFlowResponseMessage);
const id = str(buttons?.selectedButtonId) ??
str(template?.selectedId) ??
str(record(list?.singleSelectReply)?.selectedRowId) ??
(() => {
const params = str(interactive?.paramsJson);
if (params === undefined)
return undefined;
try {
return str(record(JSON.parse(params))?.id);
}
catch {
return undefined;
}
})();
if (id === undefined)
return undefined;
return {
id,
label: str(buttons?.selectedDisplayText) ?? str(template?.selectedDisplayText) ?? str(list?.title),
};
}
const MEDIA_FIELDS = [
['imageMessage', 'image'],
['audioMessage', 'audio'],
['videoMessage', 'video'],
['documentMessage', 'document'],
['stickerMessage', 'sticker'],
];
/** The file a message carries, and its caption. */
function mediaOf(message, key) {
// A document sent with a caption arrives wrapped.
const wrapped = record(record(message.documentWithCaptionMessage)?.message);
const source = wrapped ?? message;
for (const [field, kind] of MEDIA_FIELDS) {
const body = record(source[field]);
if (!body)
continue;
const ref = {
key,
...(str(message.base64) !== undefined ? { base64: str(message.base64) } : {}),
...(str(message.mediaUrl) !== undefined ? { mediaUrl: str(message.mediaUrl) } : {}),
};
const contentType = str(body.mimetype);
const filename = str(body.fileName);
const sizeBytes = sizeOf(body.fileLength);
return {
media: {
kind,
ref,
...(contentType !== undefined ? { contentType } : {}),
...(filename !== undefined ? { filename } : {}),
...(sizeBytes !== undefined ? { sizeBytes } : {}),
},
caption: str(body.caption),
};
}
return undefined;
}
const LID_JID = /@lid$/;
/** The message types of a button or list reply. */
const REPLY_TYPES = new Set([
'buttonsResponseMessage',
'templateButtonReplyMessage',
'listResponseMessage',
'interactiveResponseMessage',
]);
/**
* Is the message from the person (not sent by the instance itself)? Explicit `fromMe: false` → yes,
* `fromMe: true` → no. Absent from both the key and the data — Whatsmiau (Go, `omitempty`) drops a
* `false` — only a `status: 'received'` says it came in; any other status is never assumed incoming,
* so the agent never answers its own messages.
*/
function direction(key, data) {
if (key.fromMe === true || data.fromMe === true)
return 'own';
if (key.fromMe === false || data.fromMe === false)
return 'incoming';
if (key.fromMe === undefined && data.fromMe === undefined && data.status === 'received') {
return 'incoming';
}
return 'unknown';
}
function parseOne(data, options) {
const key = record(data.key);
const remoteJid = str(key?.remoteJid);
const id = str(key?.id);
if (!key || !remoteJid || !id)
return { skipped: 'no message key', unexpected: true };
const from = direction(key, data);
if (from === 'own')
return { skipped: 'own message' };
if (from === 'unknown') {
return { skipped: `fromMe missing and status ${String(data.status)}`, unexpected: true };
}
if (remoteJid.endsWith('@broadcast') || remoteJid.endsWith('@newsletter')) {
return { skipped: 'broadcast' };
}
const group = remoteJid.endsWith('@g.us');
if (group && options.groups !== true)
return { skipped: 'group' };
// A chat is addressed by its phone jid or by its `@lid` one (`addressingMode: 'lid'`), and the
// server adds the other alongside when it knows it: Evolution `remoteJidAlt` (older versions
// `senderPn`), Whatsmiau `remoteLid`. The phone wins, so the chat keeps one id either way.
const aliases = [remoteJid, str(key.remoteJidAlt), str(key.senderPn), str(key.remoteLid)];
const phoneJid = aliases.find((jid) => jid !== undefined && PHONE_JID.test(jid));
const lidJid = aliases.find((jid) => jid !== undefined && LID_JID.test(jid));
const chat = phoneJid ?? lidJid ?? remoteJid;
const sender = group ? str(key.participant) : chat;
if (sender === undefined)
return { skipped: 'no group participant', unexpected: true };
const message = record(data.message);
if (!message)
return { skipped: 'no message body' };
const button = buttonReply(message);
const file = button ? undefined : mediaOf(message, key);
const text = button
? (button.label ?? button.id)
: file
? (file.caption ?? '')
: (str(message.conversation) ?? str(record(message.extendedTextMessage)?.text));
if (text === undefined || (text.trim() === '' && !file)) {
return { skipped: 'unsupported message type' };
}
// Whatsmiau forwards a button press as `messageType: 'buttonsResponseMessage'` with only the
// label in `message.conversation` — the button's id is lost.
const pressWithoutId = !button && !file && REPLY_TYPES.has(str(data.messageType) ?? '');
const sentAt = providerSentAt(data.messageTimestamp);
return {
id,
from: addressOf(sender),
// Replies go to the phone when it is known — `sendText`'s `number` is a phone number; the `@lid`
// jid only when it is all there is.
conversation: group ? remoteJid : chat,
text: text.trim(),
...(button ? { buttonId: button.id } : {}),
...(pressWithoutId ? { buttonWithoutId: true } : {}),
...(file ? { media: [file.media] } : {}),
...(sentAt !== undefined ? { sentAt } : {}),
raw: data,
};
}
/** The messages in a webhook body, or why there are none. */
function readBody(body, options) {
const envelope = record(body);
if (!envelope)
return { messages: null, ignored: { reason: 'not a JSON object' } };
const rawEvent = str(envelope.event);
const event = rawEvent?.toLowerCase().replace(/_/g, '.');
const named = rawEvent !== undefined ? { event: rawEvent } : {};
if (event !== 'messages.upsert') {
return { messages: null, ignored: { ...named, reason: 'not a messages.upsert event' } };
}
const from = str(envelope.instance);
if (from !== undefined && from !== options.instance) {
return { messages: null, ignored: { ...named, reason: 'another instance' } };
}
const items = (Array.isArray(envelope.data) ? envelope.data : [envelope.data])
.map((item) => record(item))
.filter((item) => item !== undefined)
.map((item) => parseOne(item, options));
const messages = items.filter((item) => !('skipped' in item));
if (messages.length > 0)
return { messages };
const skipped = items.filter((item) => 'skipped' in item);
if (skipped.length === 0)
return { messages, ignored: { ...named, reason: 'no data' } };
return {
messages,
ignored: {
...named,
reason: [...new Set(skipped.map((item) => item.skipped))].join(', '),
...(skipped.some((item) => item.unexpected) ? { unexpected: true } : {}),
},
};
}
/** The WhatsApp reply-button limit. */
const MAX_BUTTONS = 3;
/** The longest caption WhatsApp shows under a file. */
const MAX_CAPTION = 1024;
/** The longest footer a buttons message carries. */
const MAX_FOOTER = 60;
/**
* WhatsApp through [Evolution API](https://doc.evolution-api.com) v2 (or a server with the same
* routes). Point the instance's webhook — event `MESSAGES_UPSERT` — at the route, with the token:
* `https://app.example.com/webhooks/whatsapp?token=<webhookToken>`.
*
* Reads `messages.upsert` (text, extended text, button and list replies, images, audio, video,
* documents and stickers); ignores the instance's own messages (an item without `fromMe` counts as
* incoming only with `status: 'received'`), broadcasts and (unless `groups`) groups. Sends with `POST {url}/message/sendText/{instance}`. Downloads media from the webhook's
* inline `base64` (Evolution's "webhook base64" setting), else its `mediaUrl`, else
* `POST {url}/chat/getBase64FromMediaMessage/{instance}`.
*/
export function evolutionApi(options) {
return evolutionFormatAdapter(options, { buttons: false, instructionWithButtons: true });
}
/**
* The adapter for any server speaking Evolution's webhook and routes — `evolutionApi()` and
* `whatsmiau()` differ only in their defaults. Internal.
*/
export function evolutionFormatAdapter(options, defaults) {
const name = options.name ?? 'whatsapp';
const fetcher = options.fetch ?? globalThis.fetch;
const timeoutMs = options.timeoutMs ?? 20_000;
const base = options.url.replace(/\/+$/, '');
const instance = encodeURIComponent(options.instance);
const headers = { apikey: options.apiKey };
const buttons = options.buttons ?? defaults.buttons;
const post = (path, body) => postJson(name, fetcher, `${base}${path}/${instance}`, headers, body, timeoutMs);
return {
name,
kind: 'whatsapp',
capabilities: {
...(buttons ? { buttons: MAX_BUTTONS } : {}),
// Lists ride the same interactive messages as the buttons: only where those render.
...(buttons ? { lists: 10 } : {}),
markdown: 'whatsapp',
maxLength: options.maxLength ?? 4096,
media: true,
maxCaptionLength: MAX_CAPTION,
},
verify(request) {
if (options.webhookToken === false)
return true;
const bearer = request.header('authorization')?.replace(/^Bearer\s+/i, '');
const supplied = [
queryParam(request.url, 'token'),
typeof request.params.token === 'string' ? request.params.token : undefined,
bearer,
request.header('x-webhook-token'),
];
return supplied.some((token) => safeEqual(token, options.webhookToken));
},
parse(body) {
return readBody(body, options).messages;
},
ignored(body) {
return readBody(body, options).ignored ?? null;
},
async download(media, { maxBytes }) {
const ref = media.ref;
if (media.sizeBytes !== undefined && media.sizeBytes > maxBytes) {
throw new ChannelMediaTooLargeError(maxBytes);
}
const fallbackType = media.contentType ?? 'application/octet-stream';
const named = media.filename !== undefined ? { filename: media.filename } : {};
if (ref.base64 !== undefined) {
return { data: decodeBase64(ref.base64, maxBytes), contentType: fallbackType, ...named };
}
if (ref.mediaUrl !== undefined) {
// The instance's key goes only to the Evolution server itself, never to a storage host.
const sameHost = new URL(ref.mediaUrl).origin === new URL(base).origin;
const file = await fetchBytes(name, fetcher, ref.mediaUrl, sameHost ? headers : {}, timeoutMs, maxBytes);
return {
data: file.data,
contentType: media.contentType ?? file.contentType ?? fallbackType,
...named,
};
}
const answer = record(await post('/chat/getBase64FromMediaMessage', {
message: { key: ref.key },
convertToMp4: false,
}));
const base64 = str(answer?.base64);
if (base64 === undefined) {
throw new ChannelDeliveryError(name, null, `${name}: the media could not be downloaded`);
}
const filename = str(answer?.fileName) ?? media.filename;
return {
data: decodeBase64(base64, maxBytes),
contentType: str(answer?.mimetype) ?? fallbackType,
...(filename !== undefined ? { filename } : {}),
};
},
async send(conversation, message) {
const number = addressOf(conversation);
if (message.media !== undefined) {
const { media } = message;
const source = media.url ?? media.data?.toString('base64');
if (source === undefined)
throw new Error(`${name}: a file needs a url or its data`);
await post('/message/sendMedia', {
number,
mediatype: media.kind,
media: source,
...(media.contentType !== undefined ? { mimetype: media.contentType } : {}),
...(message.text !== '' ? { caption: message.text.slice(0, MAX_CAPTION) } : {}),
...(media.kind === 'document' ? { fileName: mediaFilename(media) } : {}),
});
return;
}
if (message.list !== undefined) {
if (buttons) {
try {
await post('/message/sendList', {
number,
title: (message.list.title ?? '').replace(/[*_~]/g, '').trim().slice(0, 60),
description: message.text.trim() === '' ? '…' : message.text.slice(0, 1024),
buttonText: message.list.button.slice(0, 20),
footerText: '',
sections: [
{
title: (message.list.title ?? message.list.button)
.replace(/[*_~]/g, '')
.slice(0, 24),
rows: message.list.rows.slice(0, 10).map((row) => ({
title: row.title.slice(0, 24),
description: (row.description ?? '').slice(0, 72),
rowId: row.id,
})),
},
],
});
return;
}
catch (error) {
if (!(error instanceof ChannelDeliveryError) || !error.definite)
throw error;
}
}
await post('/message/sendText', { number, text: message.fallbackText });
return;
}
if (message.buttons !== undefined && buttons) {
const [first = '', ...rest] = message.text.split('\n');
const title = first.replace(/[*_~]/g, '').trim();
// Where buttons may not render, the reply instruction rides along: a phone that shows the
// text but not the buttons can still answer by text.
const instruction = defaults.instructionWithButtons
? (message.instruction?.trim() ?? '')
: '';
const description = [rest.join('\n').trim(), instruction]
.filter((part) => part !== '')
.join('\n\n');
if (title.length <= 60 && description.length <= 1024 && message.buttons.length > 0) {
try {
await post('/message/sendButtons', {
number,
title,
description: description === '' ? title : description,
...(message.footer !== undefined && message.footer.trim() !== ''
? { footer: message.footer.replace(/[*_~]/g, '').trim().slice(0, MAX_FOOTER) }
: {}),
buttons: message.buttons.slice(0, MAX_BUTTONS).map((button) => ({
type: 'reply',
displayText: button.label.slice(0, 20),
id: button.id,
})),
});
return;
}
catch (error) {
// Only a definite refusal falls back: a timeout may have delivered the buttons already.
if (!(error instanceof ChannelDeliveryError) || !error.definite)
throw error;
}
}
await post('/message/sendText', { number, text: message.fallbackText });
return;
}
await post('/message/sendText', {
number,
text: message.buttons !== undefined ? message.fallbackText : message.text,
});
},
};
}
//# sourceMappingURL=evolution_api.js.map