Ctrl K
ComunidadGuías…

group-translate/index.test.ts

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

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import type {
  PluginContext,
  HookContext,
  HookResult,
  IncomingMessage,
  PluginNetResponse,
} from "../types/openwa";
import { TranslationPlugin } from "./index.ts";

function makeStorage(seed: Record<string, unknown> = {}) {
  const m = new Map<string, unknown>(Object.entries(seed));
  return {
    get: async (k: string) => (m.has(k) ? m.get(k) : null),
    set: async (k: string, v: unknown) => void m.set(k, v),
    delete: async (k: string) => void m.delete(k),
    list: async () => [...m.keys()],
  };
}

function fakeContext(
  config: Record<string, unknown>,
  over: {
    net?: { fetch: (url: string, init?: unknown) => Promise<PluginNetResponse> };
    messages?: Record<string, unknown>;
    engine?: Record<string, unknown>;
    seed?: Record<string, unknown>;
  } = {},
) {
  let hook:
    | ((ctx: HookContext<IncomingMessage>) => Promise<HookResult>)
    | undefined;
  let priority: number | undefined;
  const net = over.net ?? {
    fetch: async () =>
      ({
        ok: true,
        status: 200,
        statusText: "",
        headers: {},
        body: '[{"code":"en"}]',
      }) as PluginNetResponse,
  };
  const ctx = {
    pluginId: "group-translate",
    manifest: { id: "group-translate" },
    config,
    logger: { log() {}, debug() {}, warn() {}, error() {} },
    storage: makeStorage(over.seed),
    registerHook: (
      _event: string,
      handler: (c: HookContext<IncomingMessage>) => Promise<HookResult>,
      p?: number,
    ) => {
      hook = handler;
      priority = p;
    },
    messages: over.messages ?? {},
    engine: over.engine ?? {},
    net,
    hookManager: {},
  } as unknown as PluginContext;
  return { ctx, getHook: () => hook, getPriority: () => priority };
}

const engineCtx = (
  data: Partial<IncomingMessage> = {},
): HookContext<IncomingMessage> => ({
  event: "message:received",
  source: "Engine",
  sessionId: "s1",
  timestamp: new Date(0),
  data: {
    id: "m1",
    from: "x@s.whatsapp.net",
    to: "y@s.whatsapp.net",
    chatId: "group@g.us",
    body: "hello",
    type: "text",
    timestamp: 0,
    fromMe: false,
    isGroup: true,
    author: "x@s.whatsapp.net",
    ...data,
  } as IncomingMessage,
});

// Transformer band (PLUGIN-STANDARD.md "Co-installation"): must run ahead of every responder, or a
// responder answers the untranslated original before this plugin's translation replaces it.
test("registers in the transformer band, ahead of every responder", async () => {
  const { ctx, getPriority } = fakeContext({});
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);
  assert.equal(getPriority(), 50);
});

// ── Claim wiring: the coordinator's claim must reach the host as `continue` ──────────────────────────
// The coordinator suites pin `{swallow: true/false}`, but nothing pinned that this plugin forwards it.
// The README, the CHANGELOG and PLUGIN-STANDARD.md all publish the resulting behavior — "claims only its
// own /tr admin commands, never a translated conversational message" — so a hook that hardcoded
// `continue: true` (or `false`) would make three public documents false with every test still green.

const GROUP_KEY = "group:s1:group@g.us";
const AUTHOR = "x@s.whatsapp.net";
// A claimed command runs after the hook returns; its work is all microtasks, so one turn drains it.
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));

test("claims a /tr admin command, so no responder answers a message addressed to this plugin", async () => {
  const sent: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: false,
          participants: {},
          delegatedControllers: [],
          announced: true, // already greeted, so the only send here is the command's own confirmation
        },
      },
      messages: { sendText: async (_s: string, _c: string, t: string) => void sent.push(t) },
      engine: {
        getGroupInfo: async () => ({ participants: [{ id: AUTHOR, isAdmin: true }] }),
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);

  const result = await getHook()!(engineCtx({ body: "/tr on" }));
  assert.equal(result.continue, false, "a control message addressed to this plugin is claimed");
  await settle();
  assert.equal(sent.length, 1, "the command was handled (its confirmation was sent)");
});

// The claim is decided on the parse, not on how the command ends. A group admin lookup that fails, or
// outlasts the host's 5 s hook budget (which then passes the message on by itself), must not hand
// "/tr on" to the next responder.
function commandContext(getGroupInfo: () => Promise<unknown>) {
  const sent: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: false,
          participants: {},
          delegatedControllers: [],
          announced: true,
        },
      },
      messages: { sendText: async (_s: string, _c: string, t: string) => void sent.push(t) },
      engine: { getGroupInfo, getContactById: async () => null },
    },
  );
  return { ctx, getHook, sent };
}

test("a /tr command is claimed at once when the admin lookup throws", async () => {
  const { ctx, getHook, sent } = commandContext(async () => {
    throw new Error("WhatsApp Web did not answer the read of group group@g.us in time");
  });
  await new TranslationPlugin().onEnable(ctx);

  const result = await getHook()!(engineCtx({ body: "/tr on" }));
  assert.equal(result.continue, false);
  await settle();
  assert.deepEqual(sent, [], "an unknown admin list denies, silently by default");
});

test("a /tr command is claimed at once when the admin lookup never answers", async () => {
  const { ctx, getHook, sent } = commandContext(() => new Promise(() => {}));
  await new TranslationPlugin().onEnable(ctx);

  // The host's hook budget in miniature: whichever settles first is what the next responder sees.
  let timer: NodeJS.Timeout | undefined;
  const budget = new Promise<HookResult>((resolve) => {
    timer = setTimeout(() => resolve({ continue: true }), 100);
  });
  const result = await Promise.race([getHook()!(engineCtx({ body: "/tr on" })), budget]);
  clearTimeout(timer);
  assert.equal(result.continue, false);
  await settle();
  assert.deepEqual(sent, []);
});

test("does NOT claim a translated conversational message — a co-installed responder still sees it", async () => {
  const replies: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: true,
          participants: {
            [AUTHOR]: { lang: "en", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
            "z@s.whatsapp.net": { lang: "id", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
          },
          delegatedControllers: [],
          announced: true,
        },
      },
      net: {
        fetch: async (url: string) =>
          ({
            ok: true,
            status: 200,
            statusText: "",
            headers: {},
            body: url.endsWith("/detect")
              ? '[{"language":"en","confidence":0.99}]'
              : '{"translatedText":"halo dunia"}',
          }) as PluginNetResponse,
      },
      messages: {
        sendText: async () => {},
        reply: async (_s: string, _c: string, _q: string, t: string) => void replies.push(t),
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);

  const result = await getHook()!(engineCtx({ body: "hello world" }));
  assert.equal(replies.length, 1, "the message really was translated (not skipped into a trivial pass)");
  assert.match(replies[0], /halo dunia/);
  assert.equal(result.continue, true, "conversational content is passed on, translated or not");
});

// From host 0.23.2 a shared contact card carries its full vCard as the body. Translating one would POST
// a stranger's name and number to the backend, post the machine-translated card back into the group,
// and feed the vCard to language detection, which pins the sender's language on their very first card.
// A poll is deliberately still translated: its question is human-typed prose.
test("a shared contact card is never translated; a poll question still is", async () => {
  const seed = {
    [GROUP_KEY]: {
      sessionId: "s1",
      chatId: "group@g.us",
      active: true,
      participants: {
        [AUTHOR]: { lang: "en", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
        "z@s.whatsapp.net": { lang: "id", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
      },
      delegatedControllers: [],
      announced: true,
    },
  };
  const urls: string[] = [];
  const replies: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed,
      net: {
        fetch: async (url: string) => {
          urls.push(url);
          return {
            ok: true,
            status: 200,
            statusText: "",
            headers: {},
            body: url.endsWith("/detect")
              ? '[{"language":"en","confidence":0.99}]'
              : '{"translatedText":"halo dunia"}',
          } as PluginNetResponse;
        },
      },
      messages: {
        sendText: async () => {},
        reply: async (_s: string, _c: string, _q: string, t: string) => void replies.push(t),
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);

  const vcard =
    "BEGIN:VCARD\nVERSION:3.0\nFN:Budi Santoso\nORG:Toko Berkah\nTEL;TYPE=CELL:+628123456789\nEND:VCARD";
  const card = await getHook()!(engineCtx({ body: vcard, type: "contact" }));
  assert.equal(card.continue, true, "a contact card is passed on untouched");
  assert.deepEqual(urls, [], "no vCard may reach the translation backend");
  assert.deepEqual(replies, [], "and no translated card may be posted into the group");

  const poll = await getHook()!(engineCtx({ body: "hello world", type: "poll" }));
  assert.equal(poll.continue, true);
  assert.ok(urls.length > 0, "a poll question is prose and is still translated");
  assert.equal(replies.length, 1);
});

// From host 0.23.5 a Baileys order arrives as 'order' with its note or title as the body, and a shared
// product card as 'product' with its text or title. A commerce message is not conversation: translating
// it posts a quote-reply into the group and lets detection learn the member's language from a seller's
// catalog wording.
for (const [type, body] of [
  ["order", "Please deliver before noon"],
  ["product", "Arabica coffee beans 250g"],
]) {
  test(`a Baileys '${type}' message is never translated`, async () => {
    const urls: string[] = [];
    const replies: string[] = [];
    const { ctx, getHook } = fakeContext(
      {},
      {
        seed: {
          [GROUP_KEY]: {
            sessionId: "s1",
            chatId: "group@g.us",
            active: true,
            participants: {
              [AUTHOR]: { lang: "en", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
              "z@s.whatsapp.net": { lang: "id", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
            },
            delegatedControllers: [],
            announced: true,
          },
        },
        net: {
          fetch: async (url: string) => {
            urls.push(url);
            return {
              ok: true,
              status: 200,
              statusText: "",
              headers: {},
              body: url.endsWith("/detect")
                ? '[{"language":"en","confidence":0.99}]'
                : '{"translatedText":"halo dunia"}',
            } as PluginNetResponse;
          },
        },
        messages: {
          sendText: async () => {},
          reply: async (_s: string, _c: string, _q: string, t: string) => void replies.push(t),
        },
      },
    );
    const plugin = new TranslationPlugin();
    await plugin.onEnable(ctx);

    const result = await getHook()!(engineCtx({ body, type }));
    assert.equal(result.continue, true, "passed on untouched");
    assert.deepEqual(urls, [], "no translation request is made");
    assert.deepEqual(replies, [], "and no reply is posted into the group");
  });
}

// Regression: the message hook must rebuild the coordinator when a coordinator-affecting config field
// changes (per-session override), and must NOT rebuild it when the config is unchanged (preserving the
// LibreTranslate client's circuit-breaker state across messages for the same backend).
test("coordinator rebuilds when coordinator-affecting config changes, is reused when unchanged", async () => {
  const config: Record<string, unknown> = {
    libretranslateUrl: "http://lt-a:7001",
  };
  const { ctx, getHook } = fakeContext(config);
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);
  const coordAfterEnable = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.ok(coordAfterEnable, "coordinator built at enable");

  // Fire a hook WITHOUT changing config → coordinator must be reused (circuit breaker preserved).
  await getHook()!(engineCtx());
  const coordUnchanged = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.strictEqual(
    coordUnchanged,
    coordAfterEnable,
    "coordinator reused for unchanged config",
  );

  // Change a coordinator-affecting field (libretranslateUrl) → coordinator must rebuild on next hook fire.
  config.libretranslateUrl = "http://lt-b:7001";
  await getHook()!(engineCtx());
  const coordRebuilt = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.notStrictEqual(
    coordRebuilt,
    coordAfterEnable,
    "coordinator rebuilt for changed config",
  );
});

// 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 in `timestamp` (unix seconds).
async function translatingHook() {
  const replies: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: true,
          participants: {
            [AUTHOR]: { lang: "en", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
            "z@s.whatsapp.net": { lang: "id", source: "pinned", enabled: true, samples: 0, updatedAt: "" },
          },
          delegatedControllers: [],
          announced: true,
        },
      },
      net: {
        fetch: async (url: string) =>
          ({
            ok: true,
            status: 200,
            statusText: "",
            headers: {},
            body: url.endsWith("/detect")
              ? '[{"language":"en","confidence":0.99}]'
              : '{"translatedText":"halo dunia"}',
          }) as PluginNetResponse,
      },
      messages: {
        sendText: async () => {},
        reply: async (_s: string, _c: string, _q: string, t: string) => void replies.push(t),
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);
  return { hook: getHook()!, replies };
}

test("a message sent more than 5 minutes before it arrives is not translated; a fresh one is", async (t) => {
  const nowSec = 1_790_000_000;
  t.mock.timers.enable({ apis: ["Date"], now: nowSec * 1000 });
  const cases: Array<[string, unknown, boolean]> = [
    ["10 minutes old", nowSec - 600, true],
    ["301 s old", nowSec - 301, true],
    ["exactly 5 minutes old", nowSec - 300, false],
    ["299 s old", nowSec - 299, false],
    ["a minute in the future", nowSec + 60, false],
    ["ten minutes in the future", nowSec + 600, false],
    ["zero", 0, false],
    ["missing", undefined, false],
    ["null", null, false],
    ["NaN", NaN, false],
    ["not a number", "x", false],
    ["negative", -5, false],
  ];
  for (const [label, timestamp, late] of cases) {
    const { hook, replies } = await translatingHook();
    const result = await hook(engineCtx({ body: "hello world", timestamp: timestamp as number }));
    assert.equal(result.continue, true, `${label}: conversation is passed on`);
    assert.equal(replies.length, late ? 0 : 1, `${label}: ${late ? "late, not" : "fresh, so"} translated`);
  }
});

test("a late /tr command is still claimed, but not run", async () => {
  const sent: string[] = [];
  const { ctx, getHook } = fakeContext(
    {},
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: false,
          participants: {},
          delegatedControllers: [],
          announced: true,
        },
      },
      messages: { sendText: async (_s: string, _c: string, t: string) => void sent.push(t) },
      engine: {
        getGroupInfo: async () => ({ participants: [{ id: AUTHOR, isAdmin: true }] }),
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);

  const tenMinutesAgo = Math.floor(Date.now() / 1000) - 600;
  const result = await getHook()!(engineCtx({ body: "/tr on", timestamp: tenMinutesAgo }));
  assert.equal(result.continue, false, "no other bot answers a control message addressed to this plugin");
  await settle();
  assert.deepEqual(sent, [], "the command is not run, so no confirmation is posted");
});

for (const update of ["lifecycle", "next hook"] as const) {
  test(`a ${update} config update keeps group commands ordered across coordinators`, async () => {
    const config = { libretranslateUrl: "https://translate.example.com", minLength: 2 };
    let started!: () => void;
    let release!: (response: PluginNetResponse) => void;
    const readingLanguages = new Promise<void>(resolve => { started = resolve; });
    const languages = new Promise<PluginNetResponse>(resolve => { release = resolve; });
    const otherKey = "group:s1:other@g.us";
    const state = (chatId: string) => ({
      sessionId: "s1", chatId, active: true, participants: {},
      delegatedControllers: [], announced: true,
    });
    const { ctx, getHook } = fakeContext(config, {
      seed: { [GROUP_KEY]: state("group@g.us"), [otherKey]: state("other@g.us") },
      net: { fetch: async url => {
        assert.ok(url.endsWith("/languages"));
        started();
        return languages;
      } },
      engine: { getGroupInfo: async () => ({ participants: [{ id: AUTHOR, isAdmin: true }] }) },
      messages: { sendText: async () => {} },
    });
    const storage = ctx.storage;
    const saved: boolean[] = [];
    ctx.storage = {
      ...storage,
      get: async <T>(key: string) => structuredClone(await storage.get<T>(key)),
      set: async (key, value) => {
        if (key === GROUP_KEY) saved.push((value as { active: boolean }).active);
        await storage.set(key, structuredClone(value));
      },
    };
    const plugin = new TranslationPlugin();
    await plugin.onEnable(ctx);
    try {
      assert.equal((await getHook()!(engineCtx({ id: "set-language", body: "/tr setlang es me" }))).continue, false);
      await readingLanguages;
      config.minLength = 3;
      if (update === "lifecycle") await plugin.onConfigChange(ctx);
      assert.equal((await getHook()!(engineCtx({ id: "stop", body: "/tr off" }))).continue, false);
      await getHook()!(engineCtx({ id: "other-stop", chatId: "other@g.us", body: "/tr off" }));
      await settle();
      assert.equal((await ctx.storage.get<{ active: boolean }>(otherKey))?.active, false, "another group is independent");
      assert.deepEqual(saved, [], "the newer command waits for the older group command");
    } finally {
      release({ ok: true, status: 200, statusText: "OK", headers: {}, body: '[{"code":"es"}]' } as PluginNetResponse);
      await settle();
      await plugin.onDisable(ctx);
    }
    assert.deepEqual(saved, [true, false]);
    assert.equal((await ctx.storage.get<{ active: boolean }>(GROUP_KEY))?.active, false);
  });
}

test("PluginConfigStore bounds keys exceeding OpenWA 0.28.0 184-byte storage key limit", async () => {
  const { PluginConfigStore } = await import("./plugin-config.store.ts");
  const m = new Map<string, unknown>();
  const store = new PluginConfigStore({
    get: async <T>(k: string) => (m.has(k) ? (m.get(k) as T) : null),
    set: async (k: string, v: unknown) => {
      assert.ok(Buffer.byteLength(k, "utf8") <= 184, `storage key exceeded 184 bytes (${Buffer.byteLength(k, "utf8")}): ${k}`);
      m.set(k, v);
    },
    delete: async (k: string) => void m.delete(k),
    list: async () => [...m.keys()],
  });
  const longSession = "s".repeat(128);
  const longGroup = "12036301234567890123456789012345678901234567890123456789@g.us";
  await store.save({
    sessionId: longSession,
    chatId: longGroup,
    active: true,
    participants: {},
    delegatedControllers: [],
    announced: true,
  });
  const loaded = await store.load(longSession, longGroup);
  assert.equal(loaded.active, true);
});

test("forwarded messages pass through without running /tr commands or teaching the forwarder's language", async () => {
  const replies: string[] = [];
  const { ctx, getHook } = fakeContext(
    { libretranslateUrl: "https://translate.example.com" },
    {
      seed: {
        [GROUP_KEY]: {
          sessionId: "s1",
          chatId: "group@g.us",
          active: true,
          participants: {
            "other@c.us": { lang: "en", source: "learned", enabled: true, samples: 3, updatedAt: "" },
          },
          delegatedControllers: [],
          announced: true,
        },
      },
      net: {
        fetch: async (url) => ({
          ok: true,
          status: 200,
          statusText: "OK",
          headers: {},
          body: url.endsWith("/detect")
            ? JSON.stringify([{ language: "es", confidence: 0.99 }])
            : JSON.stringify({ translatedText: "Hello from Spain" }),
        }),
      },
      messages: {
        reply: async (_s: string, _c: string, _id: string, text: string) => {
          replies.push(text);
          return { messageId: "r1", timestamp: 0 };
        },
      },
    },
  );
  const plugin = new TranslationPlugin();
  await plugin.onEnable(ctx);

  const cmdRes = await getHook()!(engineCtx({ body: "/tr off", isForwarded: true }));
  assert.equal(cmdRes.continue, true, "forwarded text starting with /tr is not claimed as a command");
  const afterCmd = (await ctx.storage.get<{
    active: boolean;
    participants: Record<string, { lang: string | null; samples: number }>;
  }>(GROUP_KEY))!;
  assert.equal(afterCmd.active, true, "forwarded /tr off does not deactivate group translation");
  assert.equal(afterCmd.participants[AUTHOR], undefined, "forwarded message does not teach the forwarder's language");
  assert.equal(replies.length, 1, "forwarded message is still translated for the group");
});