/** * Group auto-translation extension plugin. * * Ports OpenWA's core `translation` module onto the v0.7 plugin capability surface: the * framework-agnostic `core/` (coordinator, parser, formatter, ports) is reused unchanged, with * `ChatGateway`/`ConfigStore` implemented over `ctx.messages`/`ctx.engine`/`ctx.storage`, and outbound * translate calls routed through `ctx.net.fetch`. Disabled until enabled via * `POST /plugins/group-translate/enable`. */ import type { PluginContext, IPlugin, HookContext, HookResult, IncomingMessage, } from "../types/openwa"; import { TranslationCoordinator, CoordinatorOptions, } from "./core/translation.coordinator"; import { InboundMessage, TranslationLogger } from "./core/ports"; import { LibreTranslateClient } from "./libretranslate.client"; import { PluginChatGateway } from "./plugin-chat.gateway"; import { PluginConfigStore } from "./plugin-config.store"; // Transformer band. This plugin claims only its own /tr admin commands — a control message addressed to // the plugin, not conversational content. A translated message is never claimed and still reaches any // responder registered after it. const HOOK_PRIORITY = 50; // 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; function readString( cfg: Record, key: string, fallback: string, ): string { const v = cfg[key]; return typeof v === "string" && v.length > 0 ? v : fallback; } function readOptionalString( cfg: Record, key: string, ): string | undefined { const v = cfg[key]; return typeof v === "string" && v.length > 0 ? v : undefined; } // The host clamps a net.fetch timeout of <= 0 up to 1 ms, so a config of 0 — which the schema's `min` // only discourages, never enforces — made every translate abort instantly, and the coordinator swallowed // the error with nothing logged. Clamp where it is actually enforceable: in code. function readTimeoutMs(cfg: Record): number { return Math.min(30_000, Math.max(500, readNumber(cfg, "timeoutMs", 4000))); } // The host resolves this plugin's outbound allowlist from the RAW config value, refuses the fetch at // the capability boundary when it cannot use that value, and says nothing on this side. detect() then // throws, the coordinator skips the message, and an unusable URL is indistinguishable from a group with // nothing to translate. Name the problem where the value is read. The value itself is left alone: // rewriting it here would change what this plugin calls without changing what the host admits. // // Deliberately NOT an https-only rule, even though the host admits a config-supplied host over https // only. The shipped default is loopback over http, and a plain-http backend on a non-loopback host is a // supported install (its host:port added to the manifest net.allow, then repackaged). A plugin cannot // see its own effective allowlist, so it must not second-guess it. function backendUrlProblem(url: string): string | null { let parsed: URL; try { parsed = new URL(url); } catch { return "is not a URL"; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return "is not an http(s) URL"; } // A credentialed value is dropped from the config-derived allowlist outright, so a non-loopback // backend named this way is refused on every single call. if (parsed.username || parsed.password) return "carries embedded credentials"; // The endpoint path is appended to this value, so a query or fragment lands in the middle of the // request path: "http://host/?lang=en" becomes "http://host/?lang=en/detect". if (parsed.search || parsed.hash) return "carries a query string or fragment"; return null; } function readNumber( cfg: Record, key: string, fallback: number, ): number { const v = cfg[key]; return typeof v === "number" && Number.isFinite(v) ? v : fallback; } function readBool( cfg: Record, key: string, fallback: boolean, ): boolean { const v = cfg[key]; return typeof v === "boolean" ? v : fallback; } export class TranslationPlugin implements IPlugin { private coordinator: TranslationCoordinator | null = null; // State writes must stay ordered when configuration replaces the coordinator. private readonly groupLocks = new Map>(); // Signature of the coordinator-affecting config last used to build `this.coordinator`. The hook // recomputes this per event and rebuilds the coordinator only when it changes — so a per-session // override (resolved by the host for the firing session) takes effect, WITHOUT resetting the // LibreTranslate client's circuit breaker on every message (a per-event rebuild would open/close the // backend anew on each call and defeat the breaker's purpose). private coordinatorSignature = ""; onEnable(context: PluginContext): Promise { this.coordinator = this.buildCoordinator(context); this.coordinatorSignature = this.configSignature(context.config); context.registerHook( "message:received", (ctx) => this.onMessage(context, ctx as HookContext), HOOK_PRIORITY, ); context.logger.log("Translation plugin enabled", { action: "translation_enabled", }); return Promise.resolve(); } onConfigChange(context: PluginContext): Promise { // Rebuild the coordinator so a config edit (e.g. a new LibreTranslate URL/key saved from the // dashboard) takes effect immediately, without a disable/enable cycle. this.coordinator = this.buildCoordinator(context); this.coordinatorSignature = this.configSignature(context.config); context.logger.log("Translation plugin config updated", { action: "translation_config_changed", }); return Promise.resolve(); } /** Stable signature of only the config fields that affect the coordinator's behavior. Two configs * with the same signature produce equivalent coordinators (same backend, same opts), so the circuit * breaker state can be safely reused across them. */ private configSignature(cfg: Record): string { return JSON.stringify([ readString(cfg, "libretranslateUrl", "http://localhost:7001"), readOptionalString(cfg, "libretranslateApiKey") ?? "", readTimeoutMs(cfg), readString(cfg, "commandPrefix", "/tr"), readNumber(cfg, "minLength", 2), readNumber(cfg, "maxLength", 2000), readBool(cfg, "denyReply", false), readBool(cfg, "announceInGroups", false), ]); } private buildCoordinator(context: PluginContext): TranslationCoordinator { const cfg = context.config; const logger: TranslationLogger = { debug: (m, meta) => context.logger.debug(m, meta), info: (m, meta) => context.logger.log(m, meta), warn: (m, meta) => context.logger.warn(m, meta), }; const url = readString(cfg, "libretranslateUrl", "http://localhost:7001"); const problem = backendUrlProblem(url); if (problem) { // Never the value itself: this is the one config field an operator may have put a password in. context.logger.warn( `libretranslateUrl ${problem}; every translate call will be refused`, { action: "translation_backend_url_invalid" }, ); } const translator = new LibreTranslateClient({ url, apiKey: readOptionalString(cfg, "libretranslateApiKey"), timeoutMs: readTimeoutMs(cfg), net: context.net, logger, }); const store = new PluginConfigStore(context.storage); const gateway = new PluginChatGateway(context.messages, context.engine); const opts: CoordinatorOptions = { prefix: readString(cfg, "commandPrefix", "/tr"), minLength: readNumber(cfg, "minLength", 2), maxLength: readNumber(cfg, "maxLength", 2000), denyReply: readBool(cfg, "denyReply", false), // Default false in the code as well as the manifest: an install that predates this field has no // stored value, and the safe reading of a missing opt-in is "not opted in". announceInGroups: readBool(cfg, "announceInGroups", false), }; return new TranslationCoordinator(translator, store, gateway, opts, logger, this.groupLocks); } onDisable(context: PluginContext): Promise { // The loader unregisters this plugin's hooks on disable; drop the coordinator too. this.coordinator = null; context.logger.log("Translation plugin disabled", { action: "translation_disabled", }); return Promise.resolve(); } private async onMessage( context: PluginContext, ctx: HookContext, ): Promise { const msg = ctx.data; // Only act on engine-originated inbound messages for a known session. The bot's own sends are // `fromMe` and route through `message:sent`, so they never reach here (no translation loop). if (ctx.source !== "Engine" || !ctx.sessionId) { return { continue: true }; } // Since host 0.23.2 a shared contact card arrives with its full vCard as the body. Translating one // POSTs a stranger's name and number to the translation backend, posts the machine-translated card // back into the group, and feeds the vCard to language detection, which pins the sender's learned // language on their first card. Since host 0.23.5 an order ('order') and a shared product card // ('product') are typed on both engines, and on Baileys carry a body too: the order note, else the // order or product title from the seller's catalog. A commerce message is not conversation, and // translating it posts a quote-reply into the group and lets detection learn the member's language // from the seller's wording. A poll is deliberately NOT denied here: its question is human-typed // prose and squarely inside what this plugin exists to translate. if (msg.type === "contact" || msg.type === "order" || msg.type === "product") { return { continue: true }; } // Re-check the config signature against the firing session's resolved config — if a per-session // override changed a coordinator-affecting field, rebuild now. Cheap (a JSON.stringify of a handful // of primitives) and runs only the equality check on the hot path; the rebuild is rare. Both // coordinators share the group locks, so an in-flight state write precedes newer commands. const sig = this.configSignature(context.config); if (sig !== this.coordinatorSignature || !this.coordinator) { this.coordinator = this.buildCoordinator(context); this.coordinatorSignature = sig; } if (!this.coordinator) return { continue: true }; // `timestamp` is unix seconds; a missing, zero, negative or unrepresentable one reads as sent now. // Judged here, at arrival, so a wait in the coordinator's per-group lock is never counted. const sent = new Date((msg.timestamp ?? 0) * 1000); try { const inbound: InboundMessage = { id: msg.id, chatId: msg.chatId, body: msg.body, author: msg.author ?? "", isGroup: msg.isGroup, fromMe: msg.fromMe, isForwarded: msg.isForwarded === true, mentionedIds: msg.mentionedIds ?? [], pushName: msg.contact?.pushName, late: sent.getTime() > 0 && Date.now() - sent.getTime() > LATE_AFTER_MS, }; // A command is claimed on the parse and run off-dispatch, so a failed or slow admin lookup can // never pass "/tr on" to the next responder: past the 5 s hook budget the host passes it on itself. if (this.coordinator.isCommand(inbound)) { const sessionId = ctx.sessionId; void this.coordinator.handleMessage(sessionId, inbound).catch((error) => context.logger.error("Translation command failed", error, { sessionId, action: "translation_command_error", }), ); return { continue: false }; } const { swallow } = await this.coordinator.handleMessage( ctx.sessionId, inbound, ); return { continue: !swallow }; } catch (error) { context.logger.error("Translation hook failed", error, { sessionId: ctx.sessionId, action: "translation_hook_error", }); return { continue: true }; } } } export default TranslationPlugin;