Ctrl K
ComunidadGuías…

ai-responder/index.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import type { HookResult, PluginNetRequestInit, PluginNetResponse } from '../types/openwa';
import AIResponder, { parseConfig } from './index.ts';

const KEY = 'sk-test-SECRET-0123456789';
const BASE: Record<string, unknown> = { apiBaseUrl: 'https://api.openai.com/v1', apiKey: KEY, model: 'gpt-4o-mini' };

type Handler = (hook: unknown) => Promise<HookResult> | HookResult;
type Respond = (url: string, init?: PluginNetRequestInit) => Promise<PluginNetResponse>;

const okBody = (content: string): PluginNetResponse => ({
  ok: true,
  status: 200,
  statusText: 'OK',
  headers: {},
  body: JSON.stringify({ choices: [{ message: { content } }] }),
});

// Enables a fresh plugin against a fake ctx. `config` may be a function of the firing session, which is
// how the host resolves a per-session override: ctx.config is a getter that answers for the session
// whose event is being dispatched. Storage rejects every call, since the plugin declares no storage.
async function enable(config: Record<string, unknown> | ((sessionId?: string) => Record<string, unknown>) = BASE) {
  const fetches: Array<{ url: string; init?: PluginNetRequestInit }> = [];
  const replies: Array<{ sessionId: string; chatId: string; quoted: string; text: string }> = [];
  const logs: string[] = [];
  const hooks: Array<{ event: string; handler: Handler; priority?: number }> = [];
  const state: { firing?: string; respond: Respond; sendFails?: boolean } = { respond: async () => okBody('Hello!') };
  const record = (level: string) => (m: string, a?: unknown) =>
    logs.push(`${level} ${m} ${a instanceof Error ? `${a.message}\n${a.stack}` : JSON.stringify(a ?? null)}`);
  const noStorage = () => Promise.reject(new Error('storage is not used by this plugin'));
  const ctx = {
    pluginId: 'ai-responder',
    get config() {
      return typeof config === 'function' ? config(state.firing) : config;
    },
    logger: { log: record('log'), debug: record('debug'), warn: record('warn'), error: record('error') },
    registerHook: (event: string, handler: Handler, priority?: number) => hooks.push({ event, handler, priority }),
    messages: {
      reply: async (sessionId: string, chatId: string, quoted: string, text: string) => {
        if (state.sendFails) throw new Error('send refused');
        replies.push({ sessionId, chatId, quoted, text });
        return { messageId: 'r', timestamp: 0 };
      },
      sendText: async () => {
        throw new Error('every answer must be a quoted reply');
      },
    },
    net: {
      fetch: (url: string, init?: PluginNetRequestInit) => {
        fetches.push({ url, init });
        return state.respond(url, init);
      },
    },
    storage: { get: noStorage, set: noStorage, delete: noStorage, list: noStorage },
  };
  await new AIResponder().onEnable(ctx as never);
  const fire = async (data: Record<string, unknown>, hook: Record<string, unknown> = {}): Promise<HookResult> => {
    const sessionId = 'sessionId' in hook ? (hook.sessionId as string | undefined) : 's1';
    state.firing = sessionId;
    const out = await hooks[0].handler({ event: 'message:received', source: 'Engine', timestamp: new Date(), ...hook, sessionId, data });
    await settle();
    return out;
  };
  return { fetches, replies, logs, hooks, state, fire };
}

let seq = 0;
const msg = (over: Record<string, unknown> = {}) => ({
  id: `m${++seq}`,
  chatId: '628111000111@c.us',
  from: '628111000111@c.us',
  to: '628999000999@c.us',
  body: 'hello',
  type: 'text',
  fromMe: false,
  isGroup: false,
  timestamp: Math.floor(Date.now() / 1000),
  ...over,
});

// Floated work runs on promise callbacks; one macrotask turn lets all of them drain.
const settle = () => new Promise(r => setImmediate(r));

const CLAIMED = { continue: false };
const PASSED = { continue: true };

test('registers message:received at priority 97, after after-hours', async () => {
  const h = await enable();
  assert.equal(h.hooks.length, 1);
  assert.equal(h.hooks[0].event, 'message:received');
  assert.equal(h.hooks[0].priority, 97);
});

test('claims an in-scope message and answers it as a quoted reply in WhatsApp formatting', async () => {
  const h = await enable();
  h.state.respond = async () => okBody('**Bold** and *italic*, see [docs](https://example.com)');
  const m = msg({ body: 'what are your hours?' });
  assert.deepEqual(await h.fire(m), CLAIMED);
  assert.equal(h.fetches.length, 1);
  assert.equal(h.fetches[0].url, 'https://api.openai.com/v1/chat/completions');
  assert.deepEqual(h.replies, [
    { sessionId: 's1', chatId: m.chatId, quoted: m.id, text: '*Bold* and _italic_, see docs (https://example.com)' },
  ]);
});

test('the hook resolves before the provider answers', async () => {
  const h = await enable();
  let release!: (r: PluginNetResponse) => void;
  h.state.respond = () => new Promise(r => (release = r));
  assert.deepEqual(await h.fire(msg()), CLAIMED, 'claimed while the provider call is still open');
  assert.equal(h.fetches.length, 1);
  assert.equal(h.replies.length, 0, 'nothing sent yet');
  release(okBody('late but fine'));
  await settle();
  assert.equal(h.replies.length, 1);
  assert.equal(h.replies[0].text, 'late but fine');
});

test('events that are not a live inbound message are never claimed or sent to the provider', async () => {
  const h = await enable();
  const cases: Array<[string, Record<string, unknown>, Record<string, unknown>?]> = [
    ['not from the engine', msg(), { source: 'Webhook' }],
    ['no session id', msg(), { sessionId: undefined }],
    ['no message id', msg({ id: '' })],
    ['no chat id', msg({ chatId: '' })],
    ['sent by this account', msg({ fromMe: true })],
    ['forwarded message', msg({ isForwarded: true })],
    ['empty body', msg({ body: '' })],
    ['whitespace body', msg({ body: '  \n ' })],
    ['non-string body', msg({ body: 42 })],
  ];
  for (const [label, data, hook] of cases) {
    assert.deepEqual(await h.fire(data, hook), PASSED, label);
  }
  assert.equal(h.fetches.length, 0);
  assert.deepEqual(h.replies, []);
});

test('contact cards, polls, orders, products and locations are not prose and are never answered', async () => {
  const h = await enable();
  for (const type of ['contact', 'poll', 'order', 'product']) {
    assert.deepEqual(await h.fire(msg({ type, body: 'BEGIN:VCARD\nFN:Budi\nEND:VCARD' })), PASSED, type);
  }
  // whatsapp-web.js delivers a shared location with its base64 map thumbnail as the body.
  assert.deepEqual(await h.fire(msg({ type: 'location', body: '/9j/4AAQSkZJRgABAQAAAQABAAD' })), PASSED, 'location');
  assert.equal(h.fetches.length, 0);
  // Guard rails: a button reply ('unknown') and a captioned image are real input.
  assert.deepEqual(await h.fire(msg({ type: 'unknown', body: 'Yes, book it' })), CLAIMED);
  assert.deepEqual(await h.fire(msg({ type: 'image', body: 'is this in stock?', chatId: '628222@c.us' })), CLAIMED);
  assert.equal(h.fetches.length, 2);
});

test('groups are skipped unless respondInGroups is on, and an authorless group message always is', async () => {
  const off = await enable();
  assert.deepEqual(await off.fire(msg({ chatId: '1203@g.us', isGroup: true, author: '628111@c.us' })), PASSED);
  assert.equal(off.fetches.length, 0);

  const on = await enable({ ...BASE, respondInGroups: true });
  assert.deepEqual(await on.fire(msg({ chatId: '1203@g.us', isGroup: true })), PASSED, 'no author');
  assert.equal(on.fetches.length, 0);
  assert.deepEqual(await on.fire(msg({ chatId: '1203@g.us', isGroup: true, author: '628111@c.us' })), CLAIMED);
  assert.equal(on.replies.length, 1);
});

test('channel, broadcast-list and status chats are never answered', async () => {
  const h = await enable();
  for (const chatId of ['120363000000000000@newsletter', '628123-456@broadcast', 'status@broadcast']) {
    assert.deepEqual(await h.fire(msg({ chatId })), PASSED, chatId);
  }
  assert.equal(h.fetches.length, 0);
  // Guard rail: the same fixture in a 1:1 chat is answered, so the gate is what stopped the others.
  assert.deepEqual(await h.fire(msg()), CLAIMED);
});

test('a message delivered more than five minutes after it was sent is not answered', async () => {
  const h = await enable();
  const now = Math.floor(Date.now() / 1000);
  assert.deepEqual(await h.fire(msg({ timestamp: now - 6 * 60 })), PASSED);
  assert.equal(h.fetches.length, 0);
  assert.deepEqual(await h.fire(msg({ timestamp: now - 4 * 60 })), CLAIMED, 'four minutes is live');
  assert.deepEqual(await h.fire(msg({ timestamp: 0, chatId: '628222@c.us' })), CLAIMED, 'no send time counts as now');
  assert.deepEqual(await h.fire(msg({ timestamp: undefined, chatId: '628333@c.us' })), CLAIMED);
  assert.equal(h.fetches.length, 3);
});

test('a redelivered message id is answered once per session', async () => {
  const h = await enable();
  const m = msg();
  assert.deepEqual(await h.fire(m), CLAIMED);
  assert.deepEqual(await h.fire(m), CLAIMED, 'still ours, so still claimed');
  assert.equal(h.fetches.length, 1, 'but not sent to the provider again');
  assert.equal(h.replies.length, 1);
  assert.deepEqual(await h.fire(m, { sessionId: 's2' }), CLAIMED, 'the same id on another session is its own message');
  assert.equal(h.fetches.length, 2);
});

test('the per-chat hourly limit stops claiming and warns once per chat', async () => {
  const h = await enable({ ...BASE, maxRepliesPerChatPerHour: 2 });
  assert.deepEqual(await h.fire(msg()), CLAIMED);
  assert.deepEqual(await h.fire(msg()), CLAIMED);
  assert.deepEqual(await h.fire(msg()), PASSED, 'over the limit, left to the rest of the chain');
  assert.deepEqual(await h.fire(msg()), PASSED);
  assert.equal(h.fetches.length, 2);
  assert.equal(h.logs.filter(l => l.startsWith('warn') && l.includes('limit')).length, 1, 'one warning per chat per window');
  assert.deepEqual(await h.fire(msg({ chatId: '628222@c.us' })), CLAIMED, 'another chat is unaffected');
});

test('the per-session hourly cap stops claiming across chats and leaves other sessions alone', async () => {
  const h = await enable({ ...BASE, maxRepliesPerSessionPerHour: 3 });
  for (const chat of ['a', 'b', 'c']) assert.deepEqual(await h.fire(msg({ chatId: `${chat}@c.us` })), CLAIMED);
  for (const chat of ['d', 'e', 'f']) assert.deepEqual(await h.fire(msg({ chatId: `${chat}@c.us` })), PASSED);
  assert.equal(h.fetches.length, 3);
  assert.equal(h.logs.filter(l => l.startsWith('warn') && l.includes('limit')).length, 1, 'one warning per session, not per chat');
  assert.deepEqual(await h.fire(msg({ chatId: 'd@c.us' }), { sessionId: 's2' }), CLAIMED);
});

test('at most eight provider calls are open at once; the next message is not claimed', async () => {
  const h = await enable();
  const pending: Array<(r: PluginNetResponse) => void> = [];
  h.state.respond = () => new Promise(r => pending.push(r));
  for (let i = 0; i < 8; i++) assert.deepEqual(await h.fire(msg({ chatId: `${i}@c.us` })), CLAIMED);
  assert.deepEqual(await h.fire(msg({ chatId: '9@c.us' })), PASSED, 'over the cap, left to the rest of the chain');
  assert.equal(h.fetches.length, 8);
  assert.equal(h.logs.filter(l => l.startsWith('warn')).length, 1);
  pending[0](okBody('done'));
  await settle();
  assert.deepEqual(await h.fire(msg({ chatId: '9@c.us' })), CLAIMED, 'a finished call frees its slot');
});

test('the hourly limits reset in the next hour', async () => {
  mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 8, 24, 10, 0, 0) });
  try {
    const h = await enable({ ...BASE, maxRepliesPerChatPerHour: 1 });
    const at = () => Math.floor(Date.now() / 1000);
    assert.deepEqual(await h.fire(msg({ timestamp: at() })), CLAIMED);
    assert.deepEqual(await h.fire(msg({ timestamp: at() })), PASSED);
    mock.timers.tick(60 * 60_000);
    assert.deepEqual(await h.fire(msg({ timestamp: at() })), CLAIMED);
  } finally {
    mock.timers.reset();
  }
});

test('config is resolved per event, so a per-session override applies', async () => {
  const h = await enable(sessionId =>
    sessionId === 's2'
      ? { ...BASE, model: 'llama-3.1-8b', systemPrompt: 'Answer in Spanish.', respondInGroups: true }
      : BASE,
  );
  await h.fire(msg({ chatId: '1203@g.us', isGroup: true, author: 'x@c.us' }));
  assert.equal(h.fetches.length, 0, 'the base config keeps groups off');
  await h.fire(msg({ chatId: '1203@g.us', isGroup: true, author: 'x@c.us' }), { sessionId: 's2' });
  assert.equal(h.fetches.length, 1);
  const body = JSON.parse(h.fetches[0].init?.body as string);
  assert.equal(body.model, 'llama-3.1-8b');
  assert.deepEqual(body.messages[0], { role: 'system', content: 'Answer in Spanish.' });
});

test('an invalid per-session config skips the message without claiming it', async () => {
  const h = await enable(sessionId => (sessionId === 's2' ? { ...BASE, apiBaseUrl: 'http://example.com/v1' } : BASE));
  assert.deepEqual(await h.fire(msg(), { sessionId: 's2' }), PASSED);
  assert.equal(h.fetches.length, 0);
  assert.ok(h.logs.some(l => l.startsWith('warn') && l.includes('apiBaseUrl')));
});

test('apiBaseUrl must be https, or http on a loopback host, without credentials', async () => {
  for (const ok of [
    'https://api.openai.com/v1',
    'https://api.groq.com/openai/v1',
    'http://localhost:11434/v1',
    'http://127.0.0.1:8000/v1',
    'http://[::1]:8080/v1',
  ]) {
    assert.doesNotThrow(() => parseConfig({ ...BASE, apiBaseUrl: ok }), ok);
  }
  for (const bad of [
    '',
    'not a url',
    'http://api.openai.com/v1',
    'http://192.168.1.10:11434/v1',
    'ftp://api.openai.com/v1',
    'https://user:pass@api.openai.com/v1',
    'http://user@localhost:11434/v1',
    'https://api.openai.com/v1?x=1',
    'https://api.openai.com/v1#frag',
  ]) {
    assert.throws(() => parseConfig({ ...BASE, apiBaseUrl: bad }), /apiBaseUrl/, JSON.stringify(bad));
  }
  assert.throws(() => parseConfig({ ...BASE, apiBaseUrl: undefined }), /apiBaseUrl/, 'no code-side default');
  assert.throws(() => parseConfig({ ...BASE, apiKey: '' }), /apiKey/);
  await assert.rejects(enable({ ...BASE, apiBaseUrl: 'http://example.com/v1' }), /apiBaseUrl/, 'a bad config fails enable');
});

test('the input is truncated and the output bounded by max_tokens', async () => {
  const h = await enable({ ...BASE, maxInputChars: 10, maxOutputTokens: 64 });
  await h.fire(msg({ body: '  0123456789abcdef  ' }));
  const body = JSON.parse(h.fetches[0].init?.body as string);
  assert.equal(body.messages.at(-1).content, '0123456789');
  assert.equal(body.max_tokens, 64);

  const d = await enable();
  await d.fire(msg({ body: 'x'.repeat(5000) }));
  const def = JSON.parse(d.fetches[0].init?.body as string);
  assert.equal(def.messages.at(-1).content.length, 2000, 'default maxInputChars');
  assert.equal(def.max_tokens, 500, 'default maxOutputTokens');
  assert.equal(d.fetches[0].init?.timeoutMs, 15000, 'default timeout');
});

test('truncation never leaves half of a surrogate pair', async () => {
  const h = await enable({ ...BASE, maxInputChars: 3 });
  await h.fire(msg({ body: 'ab😀c' }));
  assert.equal(JSON.parse(h.fetches[0].init?.body as string).messages.at(-1).content, 'ab');
});

test('the fetch timeout is clamped below the host capability ceiling', async () => {
  const hi = await enable({ ...BASE, timeoutMs: 60000 });
  await hi.fire(msg());
  assert.equal(hi.fetches[0].init?.timeoutMs, 25000);
  const lo = await enable({ ...BASE, timeoutMs: 500 });
  await lo.fire(msg());
  assert.equal(lo.fetches[0].init?.timeoutMs, 1000);
  // The config API stores a cleared field as "", which must not read as 0 and clamp to one second.
  const blank = await enable({ ...BASE, timeoutMs: '' });
  await blank.fire(msg());
  assert.equal(blank.fetches[0].init?.timeoutMs, 15000);
});

test('a provider error is logged with its status and a short body, never the API key', async () => {
  const h = await enable();
  h.state.respond = async () => ({
    ok: false,
    status: 401,
    statusText: 'Unauthorized',
    headers: {},
    body: `{"error":"Incorrect API key provided: ${KEY}"}${'x'.repeat(5000)}`,
  });
  assert.deepEqual(await h.fire(msg()), CLAIMED, 'claimed before the outcome was known');
  assert.deepEqual(h.replies, [], 'a failure is silence, not a second bot');
  const errors = h.logs.filter(l => l.startsWith('error'));
  assert.equal(errors.length, 1);
  assert.match(errors[0], /401/);
  assert.ok(!h.logs.some(l => l.includes(KEY)), 'the key never reaches a log line');
  assert.ok(errors[0].includes('x'.repeat(100)), 'guard rail: the body is in the log');
  assert.ok(!errors[0].includes('x'.repeat(300)), 'but only a short slice of it');
});

test('a failed send is logged', async () => {
  const h = await enable();
  h.state.sendFails = true;
  assert.deepEqual(await h.fire(msg()), CLAIMED);
  const errors = h.logs.filter(l => l.startsWith('error'));
  assert.equal(errors.length, 1);
  assert.match(errors[0], /send refused/);
});

test('strips multiple trailing slashes from apiBaseUrl and rejects replies that become empty after formatting', async () => {
  const h = await enable({ ...BASE, apiBaseUrl: 'https://api.openai.com/v1///' });
  h.state.respond = async () => okBody('`   `');
  assert.deepEqual(await h.fire(msg()), CLAIMED);
  assert.equal(h.fetches[0].url, 'https://api.openai.com/v1/chat/completions');
  assert.deepEqual(h.replies, []);
  const errors = h.logs.filter(l => l.startsWith('error'));
  assert.equal(errors.length, 1);
  assert.match(errors[0], /empty reply after formatting/);
});