group-translate/libretranslate.client.ts
Rama main · réplica de lectura del repositorio original.
import type { Translator, DetectResult, TranslationLogger } from './core/ports';
import type { PluginNetCapability } from '../types/openwa';
export interface LibreTranslateOptions {
url: string;
apiKey?: string;
timeoutMs: number;
failureThreshold?: number;
cooldownMs?: number;
net: PluginNetCapability; // host-proxied, SSRF-guarded fetch (v0.7)
logger?: TranslationLogger;
}
const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: () => {} };
/**
* Strip URL userinfo from text that is about to be logged or rethrown.
*
* The host quotes the FULL request URL when it refuses a fetch ("Plugin <id> may not fetch <url> ...
* add its host to net.allow"), and a credentialed `libretranslateUrl` is dropped from the
* config-derived allowlist, so that refusal fires on every single message. Rethrowing it verbatim put
* the operator's password in the host log once per message, which is exactly what index.ts refuses to
* do when it warns about the same field ("Never the value itself: this is the one config field an
* operator may have put a password in").
*/
export function redactUrlCredentials(text: string): string {
// The password half deliberately admits `@` and runs greedy to the LAST `@` before the host: a
// password containing `@` is common and legal, and stopping at the first one republished its tail
// (`admin:p@ssw0rd@host` redacted to `***:***@ssw0rd@host`). The user half keeps `@` excluded, which
// is what bounds the match to one URL. Both halves still stop at `/` and whitespace, so a port, an
// IPv6 host and a `:`/`@` inside a query string or in ordinary prose are all left alone.
return text.replace(/\/\/[^/@\s]*:[^/\s]*@/g, '//***:***@');
}
function isConcurrencyRejection(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /too many concurrent/i.test(msg);
}
export class LibreTranslateClient implements Translator {
private readonly base: string;
private readonly failureThreshold: number;
private readonly cooldownMs: number;
private readonly net: PluginNetCapability;
private readonly logger: TranslationLogger;
private consecutiveFailures = 0;
private openUntil = 0;
constructor(private readonly opts: LibreTranslateOptions) {
this.base = opts.url.replace(/\/+$/, '');
this.failureThreshold = opts.failureThreshold ?? 5;
this.cooldownMs = opts.cooldownMs ?? 30000;
this.net = opts.net;
this.logger = opts.logger ?? NOOP_LOGGER;
}
isHealthy(): boolean {
return this.consecutiveFailures < this.failureThreshold;
}
async detect(text: string): Promise<DetectResult> {
const data = (await this.post('/detect', { q: text })) as Array<{ language: string; confidence: number }>;
const top = data[0];
if (!top) throw new Error('LibreTranslate /detect returned no result');
return { lang: top.language, confidence: top.confidence };
}
async translate(text: string, source: string, target: string): Promise<string> {
const data = (await this.post('/translate', { q: text, source, target, format: 'text' })) as {
translatedText?: unknown;
};
if (typeof data?.translatedText !== 'string') {
// A partial/empty body must fail (counted by the circuit breaker, excluded from the reply)
// rather than become the literal string 'undefined' in the group.
throw new Error('LibreTranslate /translate returned no translatedText');
}
return data.translatedText;
}
async languages(): Promise<string[]> {
const data = (await this.post('/languages', {}, 'GET')) as Array<{ code: string }>;
return data.map(l => l.code);
}
private async post(
path: string,
payload: Record<string, unknown>,
method: 'GET' | 'POST' = 'POST',
): Promise<unknown> {
const now = Date.now();
if (now < this.openUntil) {
throw new Error('LibreTranslate circuit open');
}
const url = `${this.base}${path}`;
try {
// Outbound HTTP goes through the host's SSRF-guarded, allow-listed proxy (ctx.net.fetch). The host
// enforces the timeout (timeoutMs) and the manifest `net.allow` host allowlist — there is no raw
// socket here, so the old AbortController/withSafeFetch path is gone.
const body = method === 'POST' ? JSON.stringify({ ...payload, api_key: this.opts.apiKey }) : undefined;
const res = await this.net.fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body,
timeoutMs: this.opts.timeoutMs,
});
if (!res.ok) {
throw new Error(`LibreTranslate ${path} -> HTTP ${res.status}`);
}
// The sandbox runtime hands back the body as a string (no res.json() — functions can't cross the
// worker boundary), so parse it here. A malformed/empty body throws and is counted as a failure.
const data = JSON.parse(res.body);
this.consecutiveFailures = 0;
return data;
} catch (err) {
if (!isConcurrencyRejection(err)) {
this.consecutiveFailures++;
if (this.consecutiveFailures >= this.failureThreshold) {
this.openUntil = Date.now() + this.cooldownMs;
this.logger.warn(`LibreTranslate circuit opened for ${this.cooldownMs}ms`, { action: 'lt_circuit_open' });
}
}
// Redact before it leaves this method, not at each log site: this catch is the single point where
// a host-produced message (which quotes the full request URL) enters the plugin, and the
// coordinator logs the rejection reason verbatim on every failed translate and detect.
const message = err instanceof Error ? err.message : String(err);
throw new Error(redactUrlCredentials(message));
}
}
}