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 = {}) { const m = new Map(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, over: { net?: { fetch: (url: string, init?: unknown) => Promise }; messages?: Record; engine?: Record; seed?: Record; } = {}, ) { let hook: | ((ctx: HookContext) => Promise) | 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) => Promise, 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 = {}, ): HookContext => ({ 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((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) { 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((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 = { 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(resolve => { started = resolve; }); const languages = new Promise(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 (key: string) => structuredClone(await storage.get(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(); const store = new PluginConfigStore({ get: async (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; }>(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"); });