ai-responder/index.ts
Rama main · réplica de lectura del repositorio original.
import type { IPlugin, PluginContext, HookContext, HookResult, IncomingMessage } from '../types/openwa';
import { fetchChatCompletion, type ChatCompletionConfig } from './api-client.ts';
import { isBroadcastJid } from './jid.ts';
import { mdToWhatsApp } from './md-to-wa.ts';
declare const __PLUGIN_VERSION__: string;
const PLUGIN_VERSION = typeof __PLUGIN_VERSION__ !== 'undefined' ? __PLUGIN_VERSION__ : '0.0.0-dev';
export interface ResponderConfig extends ChatCompletionConfig {
maxOutputTokens: number;
timeoutMs: number;
maxInputChars: number;
maxRepliesPerChatPerHour: number;
maxRepliesPerSessionPerHour: number;
respondInGroups: boolean;
}
// Plain http is accepted only for a model on the gateway's own machine. Anywhere else it would carry the
// API key and every contact's message in clear text. `URL.hostname` keeps the brackets on IPv6.
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
// A whole number of at least 1, else the fallback. The host never enforces a configSchema bound.
function atLeastOne(raw: unknown, fallback: number): number {
const n = Math.floor(Number(raw));
return Number.isFinite(n) && n >= 1 ? n : fallback;
}
export function parseConfig(raw: Record<string, unknown>): ResponderConfig {
// No code-side default for apiBaseUrl: the host admits the provider's host from the RAW config value,
// so a default here would enable the plugin while every call is refused at the net gate. The manifest
// default is seeded into the stored config, which is what the gate reads.
const apiBaseUrl = String(raw.apiBaseUrl ?? '').trim();
if (!apiBaseUrl) throw new Error('ai-responder: apiBaseUrl is required');
let url: URL;
try {
url = new URL(apiBaseUrl);
} catch {
throw new Error('ai-responder: apiBaseUrl must be a valid URL');
}
const loopbackHttp = url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
if ((url.protocol !== 'https:' && !loopbackHttp) || url.username || url.password || url.search || url.hash) {
throw new Error(
'ai-responder: apiBaseUrl must be an https URL without embedded credentials, query string or fragment (http only for localhost, 127.0.0.1 or [::1])',
);
}
const apiKey = String(raw.apiKey ?? '').trim();
if (!apiKey) throw new Error('ai-responder: apiKey is required');
const model = String(raw.model ?? 'gpt-4o-mini').trim();
if (!model) throw new Error('ai-responder: model is required');
return {
apiBaseUrl,
apiKey,
model,
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : undefined,
maxOutputTokens: atLeastOne(raw.maxOutputTokens, 500),
// Kept below the host's 30 s per-capability ceiling, so a slow provider surfaces as a fetch timeout
// rather than a capability timeout. A blank value (the config API stores "" verbatim) reads as unset,
// not as 0, which would clamp to one second and time out nearly every call.
timeoutMs: Math.min(25000, Math.max(1000, atLeastOne(raw.timeoutMs, 15000))),
maxInputChars: atLeastOne(raw.maxInputChars, 2000),
maxRepliesPerChatPerHour: atLeastOne(raw.maxRepliesPerChatPerHour, 20),
maxRepliesPerSessionPerHour: atLeastOne(raw.maxRepliesPerSessionPerHour, 200),
respondInGroups: raw.respondInGroups === true,
};
}
// Responder band, last: this plugin answers anything, so it runs after every more specific responder and
// after the after-hours away message (95), and only sees what none of them claimed.
const HOOK_PRIORITY = 97;
// A message sent longer ago than this is never answered. From OpenWA 0.23.6 a Baileys session delivers,
// after it reconnects, what WhatsApp queued while it was disconnected, each message with its original send
// time. Five minutes is far above clock skew between WhatsApp and the gateway and matches the host's own
// auto-reply age limit.
const LATE_AFTER_MS = 5 * 60_000;
// Recently claimed message ids remembered per session, so a redelivery is not answered twice. From 0.23.6
// the host drops most re-deliveries itself; this only has to cover a short burst.
const SEEN_PER_SESSION = 500;
const HOUR_MS = 60 * 60_000;
// Provider calls open at once. The host allows 16 concurrent `net.fetch` calls across every plugin and
// refuses the next one, and a model call can hold its slot for the whole timeout, so an unbounded burst
// would starve the other plugins' requests and fail its own excess calls after claiming them.
const MAX_IN_FLIGHT = 8;
// Cut to `max` UTF-16 code units without leaving half of a surrogate pair at the end.
function truncate(s: string, max: number): string {
const cut = s.slice(0, max);
return /[\ud800-\udbff]$/.test(cut) ? cut.slice(0, -1) : cut;
}
export default class AIResponder implements IPlugin {
// Held for healthCheck(), which the host calls with no context of its own.
private ctx: PluginContext | null = null;
// Rate limits, counted per clock hour and kept in memory only, so a restart or re-enable starts a fresh
// window. All of it is dropped when the hour turns, which also keeps the maps bounded.
private hour = -1;
private readonly perChat = new Map<string, number>();
private readonly perSession = new Map<string, number>();
private readonly warned = new Set<string>();
private inFlight = 0;
/** sessionId -> recently claimed message ids, oldest first. */
private readonly seen = new Map<string, Set<string>>();
async onEnable(ctx: PluginContext): Promise<void> {
this.ctx = ctx;
const cfg = parseConfig(ctx.config); // fail fast: a bad base config aborts enable
ctx.registerHook('message:received', (hook: HookContext) => this.onMessage(ctx, hook), HOOK_PRIORITY);
ctx.logger.log(`ai-responder v${PLUGIN_VERSION} enabled with model ${cfg.model}`);
}
async onConfigChange(ctx: PluginContext): Promise<void> {
parseConfig(ctx.config); // re-validate on change (fail-fast feedback in the dashboard)
}
/** Reports on the BASE config: outside a hook the host resolves no per-session slice. */
async healthCheck(): Promise<{ healthy: boolean; message?: string }> {
if (!this.ctx) return { healthy: false, message: 'ai-responder: not enabled' };
try {
return { healthy: true, message: `ai-responder v${PLUGIN_VERSION}: ${parseConfig(this.ctx.config).model}` };
} catch (e) {
return { healthy: false, message: e instanceof Error ? e.message : String(e) };
}
}
// Synchronous: decides whether the message is this plugin's and floats the provider call, which can
// take far longer than the host's 5 s hook budget. Every early exit means "not mine".
private onMessage(ctx: PluginContext, hook: HookContext): HookResult {
const pass = { continue: true };
if (hook.source !== 'Engine' || !hook.sessionId) return pass;
const m = (hook.data ?? {}) as Partial<IncomingMessage>;
if (m.fromMe || m.isForwarded || typeof m.body !== 'string' || !m.body.trim() || !m.chatId || !m.id) return pass;
// A contact card carries its vCard in the body, a poll its question, and from 0.23.5 a Baileys order
// its note and a product card its title: text nobody typed at this bot, and a vCard would send a
// contact's details to the provider. 'unknown' stays admitted, since business button and list
// replies land there on whatsapp-web.js, and 'text' is not allowlisted, since media captions arrive
// under their own media type.
if (m.type === 'contact' || m.type === 'poll' || m.type === 'order' || m.type === 'product') return pass;
// On whatsapp-web.js a shared location's body is its base64 map thumbnail (Baileys leaves it empty), so
// it would reach the provider as a block of image data and draw a meaningless answer.
if (m.type === 'location') return pass;
// Re-parse per event so a per-session config override (resolved by the host for this hook fire) is
// honored; a snapshot cached at enable would only ever see the base config.
let cfg: ResponderConfig;
try {
cfg = parseConfig(ctx.config);
} catch (e) {
ctx.logger.warn(`ai-responder: skipping message, config invalid: ${e instanceof Error ? e.message : String(e)}`);
return pass;
}
// An authorless group message cannot be attributed to a participant, so it is never answered.
if (m.isGroup && (!cfg.respondInGroups || !m.author)) return pass;
// `isGroup` cannot see a WhatsApp Channel: a `@newsletter` post arrives as a non-group chat, and a
// reply would go to a chat this account can never post in. Broadcast lists are the same shape.
if (isBroadcastJid(m.chatId)) return pass;
// `timestamp` is unix seconds; a missing, zero, negative or unrepresentable one counts as sent now.
const sent = new Date((m.timestamp ?? 0) * 1000);
if (sent.getTime() > 0 && Date.now() - sent.getTime() > LATE_AFTER_MS) return pass;
const sessionId = hook.sessionId;
let seen = this.seen.get(sessionId);
if (!seen) this.seen.set(sessionId, (seen = new Set()));
// A redelivery of a message this plugin already claimed is still its own: claim it again, do nothing.
if (seen.has(m.id)) return { continue: false };
if (!this.withinLimits(ctx, cfg, sessionId, m.chatId)) return pass;
seen.add(m.id);
if (seen.size > SEEN_PER_SESSION) seen.delete(seen.values().next().value as string);
const { chatId, id, body } = m;
this.inFlight++;
void this.answer(ctx, cfg, sessionId, chatId, id, body)
.catch(e => ctx.logger.error(`ai-responder: reply to ${id} failed`, e))
.finally(() => this.inFlight--);
// Claimed before the outcome is known. A provider failure is logged and ends in silence rather than
// in another plugin answering a message that was meant for this one.
return { continue: false };
}
// Per-chat and per-session hourly limits, and the cap on open provider calls. Every claimed message
// counts, answered or not: the provider call is what costs, and a timed-out call may still be billed. A
// message over a limit is not claimed.
private withinLimits(ctx: PluginContext, cfg: ResponderConfig, sessionId: string, chatId: string): boolean {
const hour = Math.floor(Date.now() / HOUR_MS);
if (hour !== this.hour) {
this.hour = hour;
this.perChat.clear();
this.perSession.clear();
this.warned.clear();
}
if (this.inFlight >= MAX_IN_FLIGHT) {
if (!this.warned.has('busy')) {
this.warned.add('busy');
ctx.logger.warn(`ai-responder: ${MAX_IN_FLIGHT} provider calls already open, leaving new messages unanswered`);
}
return false;
}
const chatKey = `${sessionId}:${chatId}`;
const chat = this.perChat.get(chatKey) ?? 0;
const session = this.perSession.get(sessionId) ?? 0;
if (chat >= cfg.maxRepliesPerChatPerHour || session >= cfg.maxRepliesPerSessionPerHour) {
// Once per chat for its own limit, once per session for the session cap: keyed per chat, a session
// over its cap would log a line for every new chat that writes in, and keep one set entry for each.
const perChat = chat >= cfg.maxRepliesPerChatPerHour;
const warnKey = perChat ? `chat ${chatKey}` : `session ${sessionId}`;
if (!this.warned.has(warnKey)) {
this.warned.add(warnKey);
ctx.logger.warn(
perChat
? `ai-responder: per-chat hourly limit reached, not answering ${chatId} until the hour turns`
: `ai-responder: per-session hourly limit reached, not answering on ${sessionId} until the hour turns`,
);
}
return false;
}
this.perChat.set(chatKey, chat + 1);
this.perSession.set(sessionId, session + 1);
return true;
}
// Single turn: only this message (and the system prompt) is sent, with no earlier conversation.
private async answer(
ctx: PluginContext,
cfg: ResponderConfig,
sessionId: string,
chatId: string,
messageId: string,
body: string,
): Promise<void> {
const text = await fetchChatCompletion(ctx.net.fetch.bind(ctx.net), cfg, truncate(body.trim(), cfg.maxInputChars));
const formatted = mdToWhatsApp(text.trim()).trim();
if (!formatted) throw new Error('AI API returned an empty reply after formatting');
await ctx.messages.reply(sessionId, chatId, messageId, formatted);
}
}