group-translate/index.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string, unknown>,
key: string,
fallback: string,
): string {
const v = cfg[key];
return typeof v === "string" && v.length > 0 ? v : fallback;
}
function readOptionalString(
cfg: Record<string, unknown>,
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<string, unknown>): 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<string, unknown>,
key: string,
fallback: number,
): number {
const v = cfg[key];
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
}
function readBool(
cfg: Record<string, unknown>,
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<string, Promise<unknown>>();
// 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<void> {
this.coordinator = this.buildCoordinator(context);
this.coordinatorSignature = this.configSignature(context.config);
context.registerHook(
"message:received",
(ctx) => this.onMessage(context, ctx as HookContext<IncomingMessage>),
HOOK_PRIORITY,
);
context.logger.log("Translation plugin enabled", {
action: "translation_enabled",
});
return Promise.resolve();
}
onConfigChange(context: PluginContext): Promise<void> {
// 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, unknown>): 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<void> {
// 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<IncomingMessage>,
): Promise<HookResult> {
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;