From c13fe433389defd008945c7bc463ba5e3a95ae52 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 01/14] feat(engine): surface mentionedIds on IncomingMessage --- src/engine/adapters/message-mapper.spec.ts | 28 +++++++++++++++++++ src/engine/adapters/message-mapper.ts | 6 ++++ .../interfaces/whatsapp-engine.interface.ts | 2 ++ 3 files changed, 36 insertions(+) diff --git a/src/engine/adapters/message-mapper.spec.ts b/src/engine/adapters/message-mapper.spec.ts index 4d145cd9..b18f7bb2 100644 --- a/src/engine/adapters/message-mapper.spec.ts +++ b/src/engine/adapters/message-mapper.spec.ts @@ -49,4 +49,32 @@ describe('buildIncomingMessageBase', () => { expect(r.chatId).toBe('group-1@g.us'); expect(r.isGroup).toBe(true); }); + + it('maps mentionedIds when present on the raw message', () => { + const result = buildIncomingMessageBase({ + id: { _serialized: 'ABC' }, + from: '123-456@g.us', + to: 'me@c.us', + body: '/tr grant', + type: 'chat', + timestamp: 1700000000, + fromMe: false, + author: '111@c.us', + mentionedIds: ['222@c.us', '333@c.us'], + }); + expect(result.mentionedIds).toEqual(['222@c.us', '333@c.us']); + }); + + it('omits mentionedIds when absent', () => { + const result = buildIncomingMessageBase({ + id: { _serialized: 'ABC' }, + from: '123@c.us', + to: 'me@c.us', + body: 'hi', + type: 'chat', + timestamp: 1700000000, + fromMe: false, + }); + expect(result.mentionedIds).toBeUndefined(); + }); }); diff --git a/src/engine/adapters/message-mapper.ts b/src/engine/adapters/message-mapper.ts index dd4fb7c2..0ce08589 100644 --- a/src/engine/adapters/message-mapper.ts +++ b/src/engine/adapters/message-mapper.ts @@ -15,6 +15,8 @@ export interface RawMessageFields { fromMe: boolean; /** Set on group messages: the participant WID that actually sent the message. */ author?: string; + /** WIDs @mentioned in the message; whatsapp-web.js attaches this to every Message. */ + mentionedIds?: string[]; /** Raw wwebjs payload; `notifyName` carries the sender's push name without an extra lookup. */ _data?: { notifyName?: string }; } @@ -45,6 +47,10 @@ export function buildIncomingMessageBase(msg: RawMessageFields): IncomingMessage incoming.author = msg.author; } + if (msg.mentionedIds && msg.mentionedIds.length > 0) { + incoming.mentionedIds = msg.mentionedIds; + } + // Push name is available synchronously on the raw payload โ€” no contact lookup needed. const pushName = msg._data?.notifyName; if (pushName) { diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index fbfa3853..b18b2a7f 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -34,6 +34,8 @@ export interface IncomingMessage { isGroup: boolean; /** For group messages, the WID of the participant who actually sent it (`from` is the group JID there). */ author?: string; + /** WIDs @mentioned in the message (empty/absent when none). Surfaced for command targeting. */ + mentionedIds?: string[]; /** Sender display info, best-effort from the WhatsApp Web contact cache. */ contact?: { name?: string; From 23b4751befd45943ed5d15d8760fd3539eb50f39 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 02/14] feat(translation): add translation config block and env validation --- .env.example | 16 ++++++++++++++++ src/config/configuration.ts | 13 +++++++++++++ src/config/env.validation.spec.ts | 14 ++++++++++++++ src/config/env.validation.ts | 4 ++++ 4 files changed, 47 insertions(+) diff --git a/.env.example b/.env.example index 8e95be4e..d710497d 100644 --- a/.env.example +++ b/.env.example @@ -187,3 +187,19 @@ API_MASTER_KEY= # ============================================================================= ENABLE_SWAGGER=true # Enable API documentation at /api/docs (set false to disable on exposed deployments) BODY_SIZE_LIMIT=25mb # Max request body size (base64 media sends ride in the JSON body) + +# =========================================== +# WhatsApp Group Auto-Translation (optional) +# =========================================== +TRANSLATION_ENABLED=false +# LibreTranslate base URL (Docker network name or host:port) +LIBRETRANSLATE_URL=http://libretranslate:7001 +# LIBRETRANSLATE_API_KEY= +LIBRETRANSLATE_TIMEOUT_MS=5000 +TRANSLATION_COMMAND_PREFIX=/tr +TRANSLATION_MIN_LENGTH=2 +TRANSLATION_MAX_LENGTH=2000 +# Min ms between outbound translation sends per group (0 = no extra throttle) +TRANSLATION_THROTTLE_INTERVAL_MS=0 +# Reply "admins only" on denied commands instead of staying silent +TRANSLATION_DENY_REPLY=false diff --git a/src/config/configuration.ts b/src/config/configuration.ts index f08c9b35..e142a894 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -112,4 +112,17 @@ export default () => ({ endpoint: process.env.S3_ENDPOINT, }, }, + + // WhatsApp group auto-translation + translation: { + enabled: process.env.TRANSLATION_ENABLED === 'true', + libretranslateUrl: process.env.LIBRETRANSLATE_URL || 'http://localhost:7001', + libretranslateApiKey: process.env.LIBRETRANSLATE_API_KEY || undefined, + timeoutMs: parseInt(process.env.LIBRETRANSLATE_TIMEOUT_MS || '5000', 10), + commandPrefix: process.env.TRANSLATION_COMMAND_PREFIX || '/tr', + minLength: parseInt(process.env.TRANSLATION_MIN_LENGTH || '2', 10), + maxLength: parseInt(process.env.TRANSLATION_MAX_LENGTH || '2000', 10), + throttleIntervalMs: parseInt(process.env.TRANSLATION_THROTTLE_INTERVAL_MS || '0', 10), + denyReply: process.env.TRANSLATION_DENY_REPLY === 'true', + }, }); diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index ee0ca324..d2d8daf9 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -23,4 +23,18 @@ describe('validateEnv', () => { expect(() => validateEnv({ PORT: '70000' })).toThrow(/PORT/); expect(() => validateEnv({ PORT: '2785' })).not.toThrow(); }); + + it('requires LIBRETRANSLATE_URL when TRANSLATION_ENABLED=true', () => { + expect(() => validateEnv({ TRANSLATION_ENABLED: 'true' })).toThrow(/LIBRETRANSLATE_URL/); + }); + + it('accepts TRANSLATION_ENABLED=true with a URL', () => { + expect(() => + validateEnv({ TRANSLATION_ENABLED: 'true', LIBRETRANSLATE_URL: 'http://localhost:7001' }), + ).not.toThrow(); + }); + + it('ignores translation vars when disabled', () => { + expect(() => validateEnv({ TRANSLATION_ENABLED: 'false' })).not.toThrow(); + }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index de17d8b1..df9b3b54 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -42,6 +42,10 @@ export function validateEnv(config: EnvConfig): EnvConfig { checkPort('DATABASE_PORT'); checkPort('REDIS_PORT'); + if (str('TRANSLATION_ENABLED') === 'true' && !str('LIBRETRANSLATE_URL')) { + errors.push('LIBRETRANSLATE_URL is required when TRANSLATION_ENABLED=true'); + } + if (errors.length > 0) { throw new Error(`Invalid environment configuration:\n - ${errors.join('\n - ')}`); } From 3698c76e5353f884d07583da08391c2b0af60ed7 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 03/14] feat(translation): add domain ports and command parser --- .../translation/core/command.parser.spec.ts | 43 ++++++++++ .../translation/core/command.parser.ts | 64 +++++++++++++++ src/modules/translation/core/ports.ts | 80 +++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/modules/translation/core/command.parser.spec.ts create mode 100644 src/modules/translation/core/command.parser.ts create mode 100644 src/modules/translation/core/ports.ts diff --git a/src/modules/translation/core/command.parser.spec.ts b/src/modules/translation/core/command.parser.spec.ts new file mode 100644 index 00000000..138a125a --- /dev/null +++ b/src/modules/translation/core/command.parser.spec.ts @@ -0,0 +1,43 @@ +// src/modules/translation/core/command.parser.spec.ts +import { parseCommand } from './command.parser'; + +describe('parseCommand', () => { + it('returns null for non-prefixed text', () => { + expect(parseCommand('hello world', '/tr')).toBeNull(); + }); + + it('parses bare commands', () => { + expect(parseCommand('/tr on', '/tr')).toEqual({ name: 'on' }); + expect(parseCommand('/tr help', '/tr')).toEqual({ name: 'help' }); + }); + + it('accepts the /translate alias and is case-insensitive on the verb', () => { + expect(parseCommand('/translate OFF', '/tr')).toEqual({ name: 'off' }); + }); + + it('parses setlang with default me target', () => { + expect(parseCommand('/tr setlang es', '/tr')).toEqual({ + name: 'setlang', + lang: 'es', + target: { kind: 'me' }, + }); + }); + + it('parses a number target', () => { + expect(parseCommand('/tr grant 14155551212', '/tr')).toEqual({ + name: 'grant', + target: { kind: 'number', number: '14155551212' }, + }); + }); + + it('parses a mention target', () => { + expect(parseCommand('/tr ignore @someone', '/tr')).toEqual({ + name: 'ignore', + target: { kind: 'mention' }, + }); + }); + + it('returns null for an unknown verb', () => { + expect(parseCommand('/tr frobnicate', '/tr')).toBeNull(); + }); +}); diff --git a/src/modules/translation/core/command.parser.ts b/src/modules/translation/core/command.parser.ts new file mode 100644 index 00000000..69f94496 --- /dev/null +++ b/src/modules/translation/core/command.parser.ts @@ -0,0 +1,64 @@ +// src/modules/translation/core/command.parser.ts +import { ParsedCommand, CommandName, CommandTarget } from './ports'; + +const COMMANDS: ReadonlySet = new Set([ + 'help', + 'status', + 'on', + 'off', + 'setlang', + 'auto', + 'ignore', + 'unignore', + 'grant', + 'revoke', +]); + +const NEEDS_TARGET: ReadonlySet = new Set(['setlang', 'auto', 'ignore', 'unignore', 'grant', 'revoke']); + +/** + * Parse a chat message into a control command, or null if it isn't one. + * Accepts the configured prefix and the `/translate` alias. + */ +export function parseCommand(body: string, prefix: string): ParsedCommand | null { + const trimmed = body.trim(); + const lower = trimmed.toLowerCase(); + // Check the '/translate' alias BEFORE the configured prefix: a short prefix like + // '/tr' is a leading substring of '/translate', so testing the prefix first would + // match '/translate' against '/tr' and strip only two chars. Alias-first avoids that. + const matched = lower.startsWith('/translate') + ? '/translate' + : lower.startsWith(prefix.toLowerCase()) + ? prefix + : null; + if (!matched) return null; + + const rest = trimmed.slice(matched.length).trim(); + if (!rest) return null; + + const tokens = rest.split(/\s+/); + const verb = tokens[0].toLowerCase(); + if (!COMMANDS.has(verb)) return null; + + const name = verb as CommandName; + const args = tokens.slice(1); + + if (name === 'setlang') { + const lang = args[0]?.toLowerCase(); + if (!lang) return null; + return { name, lang, target: parseTarget(args.slice(1)) }; + } + + if (NEEDS_TARGET.has(name)) { + return { name, target: parseTarget(args) }; + } + + return { name }; +} + +function parseTarget(args: string[]): CommandTarget { + const raw = args[0]; + if (!raw || raw.toLowerCase() === 'me') return { kind: 'me' }; + if (raw.startsWith('@')) return { kind: 'mention' }; + return { kind: 'number', number: raw.replace(/[^0-9]/g, '') }; +} diff --git a/src/modules/translation/core/ports.ts b/src/modules/translation/core/ports.ts new file mode 100644 index 00000000..d7eb5c1b --- /dev/null +++ b/src/modules/translation/core/ports.ts @@ -0,0 +1,80 @@ +// src/modules/translation/core/ports.ts +// Framework-agnostic contracts for the translation core. NO NestJS/TypeORM/engine imports. + +export interface DetectResult { + lang: string; // ISO 639-1 + confidence: number; // 0..1 +} + +export interface Translation { + lang: string; + text: string; +} + +export interface ParticipantState { + lang: string | null; // null = not learned yet + source: 'learned' | 'pinned'; + enabled: boolean; + samples: number; + updatedAt: string; +} + +export type ParticipantMap = Record; // key = author WID + +export interface GroupState { + sessionId: string; + chatId: string; + active: boolean; + participants: ParticipantMap; + delegatedControllers: string[]; + announced: boolean; +} + +export interface InboundMessage { + id: string; + chatId: string; + body: string; + author: string; // sender WID (group participant) + isGroup: boolean; + fromMe: boolean; + mentionedIds: string[]; + pushName?: string; +} + +export type CommandName = + | 'help' + | 'status' + | 'on' + | 'off' + | 'setlang' + | 'auto' + | 'ignore' + | 'unignore' + | 'grant' + | 'revoke'; + +export type CommandTarget = { kind: 'me' } | { kind: 'mention' } | { kind: 'number'; number: string }; + +export interface ParsedCommand { + name: CommandName; + lang?: string; // setlang only + target?: CommandTarget; // setlang/auto/ignore/unignore/grant/revoke +} + +export interface Translator { + detect(text: string): Promise; + translate(text: string, source: string, target: string): Promise; + languages(): Promise; + isHealthy(): boolean; +} + +export interface ConfigStore { + load(sessionId: string, chatId: string): Promise; + save(state: GroupState): Promise; +} + +export interface ChatGateway { + sendText(sessionId: string, chatId: string, text: string): Promise; + sendCombinedReply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise; + getGroupAdmins(sessionId: string, chatId: string): Promise; +} From 3f5204d5b12d62740cbe4bf94c98ced62072ccde Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 04/14] feat(translation): add reply/help/status formatter --- .../translation/core/reply.formatter.spec.ts | 36 +++++++++++ .../translation/core/reply.formatter.ts | 60 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/modules/translation/core/reply.formatter.spec.ts create mode 100644 src/modules/translation/core/reply.formatter.ts diff --git a/src/modules/translation/core/reply.formatter.spec.ts b/src/modules/translation/core/reply.formatter.spec.ts new file mode 100644 index 00000000..beb3ffc1 --- /dev/null +++ b/src/modules/translation/core/reply.formatter.spec.ts @@ -0,0 +1,36 @@ +// src/modules/translation/core/reply.formatter.spec.ts +import { formatCombinedReply, buildHelpText, formatStatus } from './reply.formatter'; +import { GroupState } from './ports'; + +describe('reply.formatter', () => { + it('formats one line per translation with an uppercased code label', () => { + const out = formatCombinedReply([ + { lang: 'es', text: 'Hola' }, + { lang: 'fr', text: 'Bonjour' }, + ]); + expect(out).toContain('Hola'); + expect(out).toContain('Bonjour'); + expect(out.split('\n')).toHaveLength(2); + expect(out).toMatch(/ES/); + }); + + it('buildHelpText lists key commands with the active prefix', () => { + const help = buildHelpText('/tr'); + expect(help).toContain('/tr on'); + expect(help).toContain('/tr setlang'); + }); + + it('formatStatus reports active state and participants', () => { + const state: GroupState = { + sessionId: 's', + chatId: 'c@g.us', + active: true, + participants: { '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 3, updatedAt: 'x' } }, + delegatedControllers: [], + announced: true, + }; + const out = formatStatus(state, true); + expect(out).toMatch(/active/i); + expect(out).toContain('en'); + }); +}); diff --git a/src/modules/translation/core/reply.formatter.ts b/src/modules/translation/core/reply.formatter.ts new file mode 100644 index 00000000..4fcac07e --- /dev/null +++ b/src/modules/translation/core/reply.formatter.ts @@ -0,0 +1,60 @@ +// src/modules/translation/core/reply.formatter.ts +import { Translation, GroupState } from './ports'; + +const FLAGS: Record = { + en: '๐Ÿ‡ฌ๐Ÿ‡ง', + es: '๐Ÿ‡ช๐Ÿ‡ธ', + fr: '๐Ÿ‡ซ๐Ÿ‡ท', + de: '๐Ÿ‡ฉ๐Ÿ‡ช', + pt: '๐Ÿ‡ต๐Ÿ‡น', + it: '๐Ÿ‡ฎ๐Ÿ‡น', + nl: '๐Ÿ‡ณ๐Ÿ‡ฑ', + ru: '๐Ÿ‡ท๐Ÿ‡บ', + ar: '๐Ÿ‡ธ๐Ÿ‡ฆ', + zh: '๐Ÿ‡จ๐Ÿ‡ณ', + ja: '๐Ÿ‡ฏ๐Ÿ‡ต', +}; + +function label(lang: string): string { + const flag = FLAGS[lang]; + return flag ? `${flag} ${lang.toUpperCase()}` : lang.toUpperCase(); +} + +export function formatCombinedReply(translations: Translation[]): string { + return translations.map(t => `${label(t.lang)}: ${t.text}`).join('\n'); +} + +export function buildHelpText(prefix: string): string { + return [ + '๐Ÿ‘‹ Translation bot. I am OFF in this group until an admin runs `' + prefix + ' on`.', + 'Commands:', + `${prefix} on / ${prefix} off โ€” enable/disable translation here`, + `${prefix} setlang [me|@user|number] โ€” pin a language (default: you)`, + `${prefix} auto [me|@user|number] โ€” go back to auto-detect`, + `${prefix} ignore <@user|number> / ${prefix} unignore <@user|number>`, + `${prefix} grant <@user|number> / ${prefix} revoke <@user|number> โ€” delegate control (admins)`, + `${prefix} status โ€” show settings`, + `${prefix} help โ€” this message`, + ].join('\n'); +} + +export function formatStatus(state: GroupState, translatorHealthy: boolean): string { + const lines: string[] = []; + lines.push(`Translation: ${state.active ? 'ACTIVE' : 'inactive'}`); + lines.push(`Translator: ${translatorHealthy ? 'ok' : 'unreachable'}`); + const entries = Object.entries(state.participants); + if (entries.length === 0) { + lines.push('No participants learned yet.'); + } else { + lines.push('Participants:'); + for (const [wid, p] of entries) { + const lang = p.lang ?? 'unknown'; + const flags = `${p.source}${p.enabled ? '' : ', ignored'}`; + lines.push(`โ€ข ${wid}: ${lang} (${flags})`); + } + } + if (state.delegatedControllers.length > 0) { + lines.push(`Delegated controllers: ${state.delegatedControllers.join(', ')}`); + } + return lines.join('\n'); +} From 89b002eeee75dbb2076da3b2578911102ffeda1d Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 05/14] feat(translation): add LibreTranslate client with timeout and circuit breaker --- .../adapters/libretranslate.client.spec.ts | 49 +++++++++++ .../adapters/libretranslate.client.ts | 86 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/modules/translation/adapters/libretranslate.client.spec.ts create mode 100644 src/modules/translation/adapters/libretranslate.client.ts diff --git a/src/modules/translation/adapters/libretranslate.client.spec.ts b/src/modules/translation/adapters/libretranslate.client.spec.ts new file mode 100644 index 00000000..f9afa159 --- /dev/null +++ b/src/modules/translation/adapters/libretranslate.client.spec.ts @@ -0,0 +1,49 @@ +// src/modules/translation/adapters/libretranslate.client.spec.ts +import { LibreTranslateClient } from './libretranslate.client'; + +describe('LibreTranslateClient', () => { + const makeFetch = (impl: jest.Mock) => { + global.fetch = impl; + }; + + afterEach(() => jest.restoreAllMocks()); + + it('translate() posts q/source/target and returns translatedText', async () => { + const fetchMock = jest.fn, [string, RequestInit?]>().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ translatedText: 'Hola' }), + }); + makeFetch(fetchMock); + const client = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 1000 }); + const out = await client.translate('Hello', 'en', 'es'); + expect(out).toBe('Hola'); + const init = fetchMock.mock.calls[0][1] as RequestInit; + const body = JSON.parse(init.body as string) as Record; + expect(body).toMatchObject({ q: 'Hello', source: 'en', target: 'es' }); + }); + + it('detect() returns the top language', async () => { + makeFetch( + jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ language: 'fr', confidence: 0.97 }]), + }), + ); + const client = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 1000 }); + expect(await client.detect('Bonjour')).toEqual({ lang: 'fr', confidence: 0.97 }); + }); + + it('opens the circuit after N consecutive failures and reports unhealthy', async () => { + makeFetch(jest.fn().mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve('err') })); + const client = new LibreTranslateClient({ + url: 'http://lt:7001', + timeoutMs: 1000, + failureThreshold: 2, + cooldownMs: 60000, + }); + await expect(client.translate('a', 'en', 'es')).rejects.toThrow(); + await expect(client.translate('a', 'en', 'es')).rejects.toThrow(); + expect(client.isHealthy()).toBe(false); + await expect(client.translate('a', 'en', 'es')).rejects.toThrow(/circuit open/i); + }); +}); diff --git a/src/modules/translation/adapters/libretranslate.client.ts b/src/modules/translation/adapters/libretranslate.client.ts new file mode 100644 index 00000000..288c9bdd --- /dev/null +++ b/src/modules/translation/adapters/libretranslate.client.ts @@ -0,0 +1,86 @@ +// src/modules/translation/adapters/libretranslate.client.ts +import { Translator, DetectResult } from '../core/ports'; +import { createLogger } from '../../../common/services/logger.service'; + +export interface LibreTranslateOptions { + url: string; + apiKey?: string; + timeoutMs: number; + failureThreshold?: number; + cooldownMs?: number; +} + +export class LibreTranslateClient implements Translator { + private readonly logger = createLogger('LibreTranslateClient'); + private readonly base: string; + private readonly failureThreshold: number; + private readonly cooldownMs: number; + 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; + } + + isHealthy(): boolean { + return this.consecutiveFailures < this.failureThreshold; + } + + async detect(text: string): Promise { + 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 { + const data = (await this.post('/translate', { q: text, source, target, format: 'text' })) as { + translatedText: string; + }; + return data.translatedText; + } + + async languages(): Promise { + const data = (await this.post('/languages', {}, 'GET')) as Array<{ code: string }>; + return data.map(l => l.code); + } + + private async post( + path: string, + payload: Record, + method: 'GET' | 'POST' = 'POST', + ): Promise { + const now = Date.now(); + if (now < this.openUntil) { + throw new Error('LibreTranslate circuit open'); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs); + try { + const body = method === 'POST' ? JSON.stringify({ ...payload, api_key: this.opts.apiKey }) : undefined; + const res = await fetch(`${this.base}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body, + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`LibreTranslate ${path} -> HTTP ${res.status}`); + } + this.consecutiveFailures = 0; + return await res.json(); + } catch (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' }); + } + throw err; + } finally { + clearTimeout(timer); + } + } +} From b48655a648f5bddad38d2fbb888434d7068c7bb0 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 06/14] feat(translation): add coordinator (detect, learn, translate, commands) --- src/modules/translation/core/ports.ts | 2 + .../core/translation.coordinator.spec.ts | 145 +++++++++++ .../core/translation.coordinator.ts | 241 ++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 src/modules/translation/core/translation.coordinator.spec.ts create mode 100644 src/modules/translation/core/translation.coordinator.ts diff --git a/src/modules/translation/core/ports.ts b/src/modules/translation/core/ports.ts index d7eb5c1b..44800ca8 100644 --- a/src/modules/translation/core/ports.ts +++ b/src/modules/translation/core/ports.ts @@ -16,6 +16,8 @@ export interface ParticipantState { source: 'learned' | 'pinned'; enabled: boolean; samples: number; + /** Candidate language awaiting a 2nd consecutive detection before a learned switch. */ + pendingLang?: string; updatedAt: string; } diff --git a/src/modules/translation/core/translation.coordinator.spec.ts b/src/modules/translation/core/translation.coordinator.spec.ts new file mode 100644 index 00000000..378b4cfa --- /dev/null +++ b/src/modules/translation/core/translation.coordinator.spec.ts @@ -0,0 +1,145 @@ +// src/modules/translation/core/translation.coordinator.spec.ts +import { TranslationCoordinator, CoordinatorOptions } from './translation.coordinator'; +import { ChatGateway, ConfigStore, GroupState, InboundMessage, Translator } from './ports'; + +const OPTS: CoordinatorOptions = { prefix: '/tr', minLength: 2, maxLength: 2000, denyReply: false }; + +function freshState(over: Partial = {}): GroupState { + return { + sessionId: 's', + chatId: 'g@g.us', + active: false, + participants: {}, + delegatedControllers: [], + announced: false, + ...over, + }; +} + +function makeDeps(state: GroupState) { + const saved: GroupState[] = []; + const load = jest.fn().mockResolvedValue(state); + const save = jest.fn().mockImplementation((s: GroupState) => { + saved.push(JSON.parse(JSON.stringify(s)) as GroupState); + return Promise.resolve(); + }); + const sendText = jest.fn().mockResolvedValue(undefined); + const sendCombinedReply = jest.fn().mockResolvedValue(undefined); + const getGroupAdmins = jest.fn().mockResolvedValue([]); + const detect = jest.fn(); + const translate = jest.fn(); + const languages = jest.fn().mockResolvedValue(['en', 'es', 'fr']); + const isHealthy = jest.fn().mockReturnValue(true); + + const store: ConfigStore = { load, save }; + const gateway: ChatGateway = { sendText, sendCombinedReply, getGroupAdmins }; + const translator: Translator = { detect, translate, languages, isHealthy }; + + return { + store, + gateway, + translator, + saved, + mocks: { load, save, sendText, sendCombinedReply, getGroupAdmins, detect, translate, languages, isHealthy }, + }; +} + +function msg(over: Partial = {}): InboundMessage { + return { + id: 'M1', + chatId: 'g@g.us', + body: 'hello', + author: '111@c.us', + isGroup: true, + fromMe: false, + mentionedIds: [], + ...over, + }; +} + +describe('TranslationCoordinator', () => { + it('ignores non-group and fromMe messages', async () => { + const { store, gateway, translator, mocks } = makeDeps(freshState()); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + expect(await c.handleMessage('s', msg({ isGroup: false }))).toEqual({ swallow: false }); + expect(await c.handleMessage('s', msg({ fromMe: true }))).toEqual({ swallow: false }); + expect(mocks.sendText).not.toHaveBeenCalled(); + }); + + it('announces once on first contact then stays dormant', async () => { + const { store, gateway, translator, mocks } = makeDeps(freshState()); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + await c.handleMessage('s', msg()); + expect(mocks.sendText).toHaveBeenCalledTimes(1); + expect(mocks.save).toHaveBeenCalled(); + }); + + it('activates only for an admin', async () => { + const state = freshState({ announced: true }); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + mocks.getGroupAdmins.mockResolvedValue(['111@c.us']); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ body: '/tr on' })); + expect(res).toEqual({ swallow: true }); + expect(saved.at(-1)?.active).toBe(true); + }); + + it('rejects activation from a non-admin (silent by default)', async () => { + const state = freshState({ announced: true }); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + mocks.getGroupAdmins.mockResolvedValue(['999@c.us']); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ body: '/tr on' })); + expect(res).toEqual({ swallow: true }); + expect(saved.at(-1)?.active ?? false).toBe(false); + }); + + it('translates an active-group message into other participants languages (skipping the source)', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'learned', enabled: true, samples: 2, updatedAt: 'x' }, + '222@c.us': { lang: 'es', source: 'learned', enabled: true, samples: 2, updatedAt: 'x' }, + }, + }); + const { store, gateway, translator, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'en', confidence: 0.99 }); + mocks.translate.mockResolvedValue('Hola'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ author: '111@c.us', body: 'Hello' })); + expect(res).toEqual({ swallow: false }); + expect(mocks.translate).toHaveBeenCalledWith('Hello', 'en', 'es'); + expect(mocks.sendCombinedReply).toHaveBeenCalledWith('s', 'g@g.us', 'M1', expect.stringContaining('Hola')); + }); + + it('learns a sender language only after a 2-message debounce', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'learned', enabled: true, samples: 5, updatedAt: 'x' }, + '222@c.us': { lang: 'es', source: 'learned', enabled: true, samples: 2, updatedAt: 'x' }, + }, + }); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'fr', confidence: 0.99 }); + mocks.translate.mockResolvedValue('x'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + // First foreign detection: lang stays 'en' + await c.handleMessage('s', msg({ author: '111@c.us', body: 'Bonjour' })); + expect(saved.at(-1)?.participants['111@c.us'].lang).toBe('en'); + // Second consecutive foreign detection: switches to 'fr' + await c.handleMessage('s', msg({ author: '111@c.us', body: 'Salut' })); + expect(saved.at(-1)?.participants['111@c.us'].lang).toBe('fr'); + }); + + it('skips trivial messages below minLength', async () => { + const state = freshState({ announced: true, active: true }); + const { store, gateway, translator, mocks } = makeDeps(state); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + await c.handleMessage('s', msg({ body: '.' })); + expect(mocks.detect).not.toHaveBeenCalled(); + expect(mocks.sendCombinedReply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/translation/core/translation.coordinator.ts b/src/modules/translation/core/translation.coordinator.ts new file mode 100644 index 00000000..68e20ab0 --- /dev/null +++ b/src/modules/translation/core/translation.coordinator.ts @@ -0,0 +1,241 @@ +// src/modules/translation/core/translation.coordinator.ts +import { + ChatGateway, + ConfigStore, + GroupState, + InboundMessage, + ParsedCommand, + ParticipantState, + Translation, + Translator, + CommandTarget, +} from './ports'; +import { parseCommand } from './command.parser'; +import { buildHelpText, formatCombinedReply, formatStatus } from './reply.formatter'; + +export interface CoordinatorOptions { + prefix: string; + minLength: number; + maxLength: number; + denyReply: boolean; +} + +const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u; + +export class TranslationCoordinator { + constructor( + private readonly translator: Translator, + private readonly store: ConfigStore, + private readonly gateway: ChatGateway, + private readonly opts: CoordinatorOptions, + ) {} + + async handleMessage(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> { + if (!msg.isGroup || msg.fromMe || !msg.author) return { swallow: false }; + + const state = await this.store.load(sessionId, msg.chatId); + + if (!state.announced) { + await this.gateway.sendText(sessionId, msg.chatId, buildHelpText(this.opts.prefix)); + state.announced = true; + await this.store.save(state); + } + + const command = parseCommand(msg.body, this.opts.prefix); + if (command) { + await this.handleCommand(sessionId, msg, state, command); + return { swallow: true }; + } + + if (!state.active) return { swallow: false }; + await this.translateMessage(sessionId, msg, state); + return { swallow: false }; + } + + private async translateMessage(sessionId: string, msg: InboundMessage, state: GroupState): Promise { + const text = msg.body.trim(); + if (text.length < this.opts.minLength || text.length > this.opts.maxLength || URL_OR_EMOJI_ONLY.test(text)) { + return; + } + + const sender = this.ensureParticipant(state, msg.author); + if (!sender.enabled) return; + + let detected: string; + try { + detected = (await this.translator.detect(text)).lang; + } catch { + return; // translator down โ€” silent skip + } + this.applyLearning(sender, detected); + + const targets = this.targetLanguages(state, detected); + if (targets.length === 0) { + await this.store.save(state); + return; + } + + const settled = await Promise.allSettled(targets.map(t => this.translator.translate(text, detected, t))); + const translations: Translation[] = []; + settled.forEach((r, i) => { + if (r.status === 'fulfilled') translations.push({ lang: targets[i], text: r.value }); + }); + + if (translations.length > 0) { + await this.gateway.sendCombinedReply(sessionId, msg.chatId, msg.id, formatCombinedReply(translations)); + } + await this.store.save(state); + } + + /** Distinct languages of enabled participants, minus the detected source language. */ + private targetLanguages(state: GroupState, source: string): string[] { + const langs = new Set(); + for (const p of Object.values(state.participants)) { + if (p.enabled && p.lang && p.lang !== source) langs.add(p.lang); + } + return [...langs]; + } + + /** 2-message debounce: a learned language only switches after a new language is seen twice in a row. */ + private applyLearning(p: ParticipantState, detected: string): void { + p.samples++; + if (p.source === 'pinned') return; + if (p.lang === detected) { + p.pendingLang = undefined; + return; + } + if (p.pendingLang === detected) { + p.lang = detected; + p.pendingLang = undefined; + } else { + p.pendingLang = detected; + if (p.lang === null) p.lang = detected; // cold start: adopt immediately + } + p.updatedAt = new Date().toISOString(); + } + + private ensureParticipant(state: GroupState, wid: string): ParticipantState { + if (!state.participants[wid]) { + state.participants[wid] = { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' }; + } + return state.participants[wid]; + } + + private async handleCommand( + sessionId: string, + msg: InboundMessage, + state: GroupState, + cmd: ParsedCommand, + ): Promise { + if (cmd.name === 'help') { + await this.gateway.sendText(sessionId, msg.chatId, buildHelpText(this.opts.prefix)); + return; + } + if (cmd.name === 'status') { + await this.gateway.sendText(sessionId, msg.chatId, formatStatus(state, this.translator.isHealthy())); + return; + } + + const targetsSelf = cmd.target?.kind === 'me'; + const isSelfServe = (cmd.name === 'setlang' || cmd.name === 'auto') && targetsSelf; + if (!isSelfServe) { + const admins = await this.gateway.getGroupAdmins(sessionId, msg.chatId); + const isAdmin = admins.includes(msg.author); + const isController = isAdmin || state.delegatedControllers.includes(msg.author); + const adminOnly = cmd.name === 'grant' || cmd.name === 'revoke'; + if ((adminOnly && !isAdmin) || (!adminOnly && !isController)) { + if (this.opts.denyReply) { + await this.gateway.sendText(sessionId, msg.chatId, 'โ›” Only group admins can do that.'); + } + return; + } + } + + const targetWid = this.resolveTarget(msg, cmd.target); + + switch (cmd.name) { + case 'on': + state.active = true; + await this.confirm(sessionId, msg, 'โœ… Translation activated.', state); + return; + case 'off': + state.active = false; + await this.confirm(sessionId, msg, 'โœ… Translation deactivated.', state); + return; + case 'setlang': { + if (!targetWid || !cmd.lang) + return this.replyError(sessionId, msg, 'Usage: ' + this.opts.prefix + ' setlang [me|@user|number]'); + const langs = await this.safeLanguages(); + if (langs && !langs.includes(cmd.lang)) { + return this.replyError(sessionId, msg, `Unsupported language "${cmd.lang}". Supported: ${langs.join(', ')}`); + } + const p = this.ensureParticipant(state, targetWid); + p.lang = cmd.lang; + p.source = 'pinned'; + p.pendingLang = undefined; + p.updatedAt = new Date().toISOString(); + await this.confirm(sessionId, msg, `โœ… Set ${targetWid} to ${cmd.lang}.`, state); + return; + } + case 'auto': { + if (!targetWid) return; + const p = this.ensureParticipant(state, targetWid); + p.source = 'learned'; + p.pendingLang = undefined; + await this.confirm(sessionId, msg, `โœ… ${targetWid} set to auto-detect.`, state); + return; + } + case 'ignore': + case 'unignore': { + if (!targetWid) return; + const p = this.ensureParticipant(state, targetWid); + p.enabled = cmd.name === 'unignore'; + await this.confirm( + sessionId, + msg, + `โœ… ${cmd.name === 'ignore' ? 'Ignoring' : 'Including'} ${targetWid}.`, + state, + ); + return; + } + case 'grant': + case 'revoke': { + if (!targetWid) return; + const set = new Set(state.delegatedControllers); + if (cmd.name === 'grant') set.add(targetWid); + else set.delete(targetWid); + state.delegatedControllers = [...set]; + await this.confirm( + sessionId, + msg, + `โœ… ${cmd.name === 'grant' ? 'Granted' : 'Revoked'} control for ${targetWid}.`, + state, + ); + return; + } + } + } + + private resolveTarget(msg: InboundMessage, target?: CommandTarget): string | null { + if (!target || target.kind === 'me') return msg.author; + if (target.kind === 'mention') return msg.mentionedIds[0] ?? null; + return `${target.number}@c.us`; + } + + private async safeLanguages(): Promise { + try { + return await this.translator.languages(); + } catch { + return null; // can't validate โ€” allow + } + } + + private async confirm(sessionId: string, msg: InboundMessage, text: string, state: GroupState): Promise { + await this.store.save(state); + await this.gateway.sendText(sessionId, msg.chatId, text); + } + + private replyError(sessionId: string, msg: InboundMessage, text: string): Promise { + return this.gateway.sendText(sessionId, msg.chatId, text); + } +} From b3d67ecd875f2e8b87ae455dc038e24e5dab425c Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 07/14] feat(translation): add TranslationGroup entity and migration --- .../1779950000000-AddTranslationGroups.ts | 31 ++++++++++++++++ .../entities/translation-group.entity.ts | 36 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/database/migrations/1779950000000-AddTranslationGroups.ts create mode 100644 src/modules/translation/entities/translation-group.entity.ts diff --git a/src/database/migrations/1779950000000-AddTranslationGroups.ts b/src/database/migrations/1779950000000-AddTranslationGroups.ts new file mode 100644 index 00000000..187e5d16 --- /dev/null +++ b/src/database/migrations/1779950000000-AddTranslationGroups.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates `translation_groups` (per-session, per-group translation config). + * Hand-authored because `synchronize` is disabled for the `data` connection on + * PostgreSQL (and may be on SQLite via DATABASE_SYNCHRONIZE=false). + */ +export class AddTranslationGroups1779950000000 implements MigrationInterface { + name = 'AddTranslationGroups1779950000000'; + + public async up(queryRunner: QueryRunner): Promise { + const isPostgres = queryRunner.connection.options.type === 'postgres'; + if (await queryRunner.hasTable('translation_groups')) return; + + if (isPostgres) { + await queryRunner.query( + `CREATE TABLE "translation_groups" ("id" varchar PRIMARY KEY NOT NULL DEFAULT gen_random_uuid()::varchar, "sessionId" varchar(100) NOT NULL, "chatId" varchar(100) NOT NULL, "active" boolean NOT NULL DEFAULT false, "participants" jsonb NOT NULL DEFAULT '{}', "delegatedControllers" jsonb NOT NULL DEFAULT '[]', "announcedAt" timestamp, "createdAt" timestamp NOT NULL DEFAULT NOW(), "updatedAt" timestamp NOT NULL DEFAULT NOW(), CONSTRAINT "UQ_translation_groups_session_chat" UNIQUE ("sessionId", "chatId"))`, + ); + } else { + await queryRunner.query( + `CREATE TABLE "translation_groups" ("id" varchar PRIMARY KEY NOT NULL, "sessionId" varchar(100) NOT NULL, "chatId" varchar(100) NOT NULL, "active" boolean NOT NULL DEFAULT (0), "participants" text NOT NULL DEFAULT ('{}'), "delegatedControllers" text NOT NULL DEFAULT ('[]'), "announcedAt" text, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_translation_groups_session_chat" UNIQUE ("sessionId", "chatId"))`, + ); + } + await queryRunner.query(`CREATE INDEX "IDX_translation_groups_sessionId" ON "translation_groups" ("sessionId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_translation_groups_sessionId"`); + await queryRunner.query(`DROP TABLE "translation_groups"`); + } +} diff --git a/src/modules/translation/entities/translation-group.entity.ts b/src/modules/translation/entities/translation-group.entity.ts new file mode 100644 index 00000000..d27cd4d2 --- /dev/null +++ b/src/modules/translation/entities/translation-group.entity.ts @@ -0,0 +1,36 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Unique, Index } from 'typeorm'; +import { DateTransformer } from '../../../common/transformers/date.transformer'; +import { jsonColumnType, dateColumnType } from '../../../common/utils/column-types'; +import type { ParticipantMap } from '../core/ports'; + +@Entity('translation_groups') +@Unique(['sessionId', 'chatId']) +export class TranslationGroup { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'varchar', length: 100 }) + sessionId: string; + + @Column({ type: 'varchar', length: 100 }) + chatId: string; + + @Column({ type: 'boolean', default: false }) + active: boolean; + + @Column({ type: jsonColumnType(), default: '{}' }) + participants: ParticipantMap; + + @Column({ type: jsonColumnType(), default: '[]' }) + delegatedControllers: string[]; + + @Column({ type: dateColumnType(), nullable: true, transformer: DateTransformer }) + announcedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} From b5fcc8383e3e4d9de70df4e073cee891389af3da Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 08/14] feat(translation): add TypeORM config store adapter --- .../adapters/typeorm-config.store.spec.ts | 57 +++++++++++++++++++ .../adapters/typeorm-config.store.ts | 33 +++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/modules/translation/adapters/typeorm-config.store.spec.ts create mode 100644 src/modules/translation/adapters/typeorm-config.store.ts diff --git a/src/modules/translation/adapters/typeorm-config.store.spec.ts b/src/modules/translation/adapters/typeorm-config.store.spec.ts new file mode 100644 index 00000000..c31f40ff --- /dev/null +++ b/src/modules/translation/adapters/typeorm-config.store.spec.ts @@ -0,0 +1,57 @@ +// src/modules/translation/adapters/typeorm-config.store.spec.ts +import { Repository } from 'typeorm'; +import { TypeOrmConfigStore } from './typeorm-config.store'; +import { TranslationGroup } from '../entities/translation-group.entity'; +import { GroupState } from '../core/ports'; + +describe('TypeOrmConfigStore', () => { + function makeRepo(row: TranslationGroup | null): jest.Mocked>> { + return { + findOne: jest.fn().mockResolvedValue(row), + create: jest.fn().mockImplementation((data: Partial) => ({ ...data }) as TranslationGroup), + save: jest.fn().mockImplementation((e: unknown) => Promise.resolve(e)), + }; + } + + function makeStore(repo: jest.Mocked>>): TypeOrmConfigStore { + return new TypeOrmConfigStore(repo as unknown as Repository); + } + + it('returns a default inactive state for an unknown group', async () => { + const store = makeStore(makeRepo(null)); + const state = await store.load('s', 'g@g.us'); + expect(state).toMatchObject({ sessionId: 's', chatId: 'g@g.us', active: false, announced: false }); + expect(state.participants).toEqual({}); + }); + + it('maps a stored row to GroupState (announced = announcedAt !== null)', async () => { + const row = Object.assign(new TranslationGroup(), { + sessionId: 's', + chatId: 'g@g.us', + active: true, + participants: { '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x' } }, + delegatedControllers: ['222@c.us'], + announcedAt: new Date(), + }); + const store = makeStore(makeRepo(row)); + const state = await store.load('s', 'g@g.us'); + expect(state.active).toBe(true); + expect(state.announced).toBe(true); + expect(state.delegatedControllers).toEqual(['222@c.us']); + }); + + it('save() upserts via the repository', async () => { + const repo = makeRepo(null); + const store = makeStore(repo); + const state: GroupState = { + sessionId: 's', + chatId: 'g@g.us', + active: true, + participants: {}, + delegatedControllers: [], + announced: true, + }; + await store.save(state); + expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's', chatId: 'g@g.us', active: true })); + }); +}); diff --git a/src/modules/translation/adapters/typeorm-config.store.ts b/src/modules/translation/adapters/typeorm-config.store.ts new file mode 100644 index 00000000..78cf196e --- /dev/null +++ b/src/modules/translation/adapters/typeorm-config.store.ts @@ -0,0 +1,33 @@ +// src/modules/translation/adapters/typeorm-config.store.ts +import { Repository } from 'typeorm'; +import { ConfigStore, GroupState } from '../core/ports'; +import { TranslationGroup } from '../entities/translation-group.entity'; + +export class TypeOrmConfigStore implements ConfigStore { + constructor(private readonly repo: Repository) {} + + async load(sessionId: string, chatId: string): Promise { + const row = await this.repo.findOne({ where: { sessionId, chatId } }); + if (!row) { + return { sessionId, chatId, active: false, participants: {}, delegatedControllers: [], announced: false }; + } + return { + sessionId: row.sessionId, + chatId: row.chatId, + active: row.active, + participants: row.participants ?? {}, + delegatedControllers: row.delegatedControllers ?? [], + announced: row.announcedAt !== null && row.announcedAt !== undefined, + }; + } + + async save(state: GroupState): Promise { + const existing = await this.repo.findOne({ where: { sessionId: state.sessionId, chatId: state.chatId } }); + const entity = existing ?? this.repo.create({ sessionId: state.sessionId, chatId: state.chatId }); + entity.active = state.active; + entity.participants = state.participants; + entity.delegatedControllers = state.delegatedControllers; + if (state.announced && !entity.announcedAt) entity.announcedAt = new Date(); + await this.repo.save(entity); + } +} From 733ef4c674ea9795901b4b98a429080e44c95f93 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 09/14] feat(translation): add OpenWA chat gateway; reply() now runs typing simulation --- src/modules/message/message.service.ts | 3 ++ .../adapters/openwa-chat.gateway.spec.ts | 47 +++++++++++++++++++ .../adapters/openwa-chat.gateway.ts | 26 ++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/modules/translation/adapters/openwa-chat.gateway.spec.ts create mode 100644 src/modules/translation/adapters/openwa-chat.gateway.ts diff --git a/src/modules/message/message.service.ts b/src/modules/message/message.service.ts index 24e6a5d1..0d31e6d9 100644 --- a/src/modules/message/message.service.ts +++ b/src/modules/message/message.service.ts @@ -413,6 +413,9 @@ export class MessageService { }, }); + // Match sendText: humanising "typingโ€ฆ" pause before the real send (anti-ban). + await this.simulateTypingIfEnabled(engine, dto.chatId, dto.text); + try { const result = await engine.replyToMessage(dto.chatId, dto.quotedMessageId, dto.text); diff --git a/src/modules/translation/adapters/openwa-chat.gateway.spec.ts b/src/modules/translation/adapters/openwa-chat.gateway.spec.ts new file mode 100644 index 00000000..4400ae10 --- /dev/null +++ b/src/modules/translation/adapters/openwa-chat.gateway.spec.ts @@ -0,0 +1,47 @@ +import { OpenWaChatGateway } from './openwa-chat.gateway'; + +describe('OpenWaChatGateway', () => { + function deps(engine: unknown) { + const messageService = { + sendText: jest.fn().mockResolvedValue({ messageId: 'x', timestamp: 1 }), + reply: jest.fn().mockResolvedValue({ messageId: 'y', timestamp: 1 }), + }; + const sessionService = { getEngine: jest.fn().mockReturnValue(engine) }; + return { messageService, sessionService }; + } + + it('sendText delegates to MessageService.sendText', async () => { + const { messageService, sessionService } = deps({}); + const gw = new OpenWaChatGateway(messageService as never, sessionService as never); + await gw.sendText('s', 'c@g.us', 'hi'); + expect(messageService.sendText).toHaveBeenCalledWith('s', { chatId: 'c@g.us', text: 'hi' }); + }); + + it('sendCombinedReply delegates to MessageService.reply', async () => { + const { messageService, sessionService } = deps({}); + const gw = new OpenWaChatGateway(messageService as never, sessionService as never); + await gw.sendCombinedReply('s', 'c@g.us', 'M1', 'Hola'); + expect(messageService.reply).toHaveBeenCalledWith('s', { chatId: 'c@g.us', quotedMessageId: 'M1', text: 'Hola' }); + }); + + it('getGroupAdmins returns admin/superadmin WIDs from the engine', async () => { + const engine = { + getGroupInfo: jest.fn().mockResolvedValue({ + participants: [ + { id: '111@c.us', isAdmin: true, isSuperAdmin: false }, + { id: '222@c.us', isAdmin: false, isSuperAdmin: true }, + { id: '333@c.us', isAdmin: false, isSuperAdmin: false }, + ], + }), + }; + const { messageService, sessionService } = deps(engine); + const gw = new OpenWaChatGateway(messageService as never, sessionService as never); + expect(await gw.getGroupAdmins('s', 'c@g.us')).toEqual(['111@c.us', '222@c.us']); + }); + + it('getGroupAdmins returns [] when the session has no engine', async () => { + const { messageService, sessionService } = deps(undefined); + const gw = new OpenWaChatGateway(messageService as never, sessionService as never); + expect(await gw.getGroupAdmins('s', 'c@g.us')).toEqual([]); + }); +}); diff --git a/src/modules/translation/adapters/openwa-chat.gateway.ts b/src/modules/translation/adapters/openwa-chat.gateway.ts new file mode 100644 index 00000000..d08ca1bd --- /dev/null +++ b/src/modules/translation/adapters/openwa-chat.gateway.ts @@ -0,0 +1,26 @@ +import { ChatGateway } from '../core/ports'; +import { MessageService } from '../../message/message.service'; +import { SessionService } from '../../session/session.service'; + +export class OpenWaChatGateway implements ChatGateway { + constructor( + private readonly messageService: MessageService, + private readonly sessionService: SessionService, + ) {} + + async sendText(sessionId: string, chatId: string, text: string): Promise { + await this.messageService.sendText(sessionId, { chatId, text }); + } + + async sendCombinedReply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise { + await this.messageService.reply(sessionId, { chatId, quotedMessageId, text }); + } + + async getGroupAdmins(sessionId: string, chatId: string): Promise { + const engine = this.sessionService.getEngine(sessionId); + if (!engine) return []; + const info = await engine.getGroupInfo(chatId); + if (!info) return []; + return info.participants.filter(p => p.isAdmin || p.isSuperAdmin).map(p => p.id); + } +} From a83b6af9455a0b5632ed9a597e16bb8bf0f8bb7d Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 10/14] feat(translation): wire hook, module, and conditional app registration --- src/app.module.ts | 8 +++ .../translation/translation.hook.spec.ts | 60 +++++++++++++++++++ src/modules/translation/translation.hook.ts | 53 ++++++++++++++++ src/modules/translation/translation.module.ts | 47 +++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/modules/translation/translation.hook.spec.ts create mode 100644 src/modules/translation/translation.hook.ts create mode 100644 src/modules/translation/translation.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 7266e5ec..c7dec88b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { CatalogModule } from './modules/catalog/catalog.module'; import { HooksModule } from './core/hooks'; import { PluginsModule } from './core/plugins'; import { PluginsApiModule } from './modules/plugins/plugins.module'; +import { TranslationModule } from './modules/translation/translation.module'; // Only import QueueModule if explicitly enabled to avoid Redis connection errors const queueModules: Array = []; @@ -40,6 +41,12 @@ if (process.env.QUEUE_ENABLED === 'true') { queueModules.push(queueModule.QueueModule); } +// Only register the translation feature when explicitly enabled. +const translationModules: Array = []; +if (process.env.TRANSLATION_ENABLED === 'true') { + translationModules.push(TranslationModule); +} + @Module({ imports: [ // Configuration @@ -186,6 +193,7 @@ if (process.env.QUEUE_ENABLED === 'true') { StatusModule, // Phase 3: Status/Stories API CatalogModule, // Phase 3: Catalog API (WhatsApp Business) PluginsApiModule, // Phase 5: Plugins API + ...translationModules, // WhatsApp group auto-translation (TRANSLATION_ENABLED) ], }) export class AppModule {} diff --git a/src/modules/translation/translation.hook.spec.ts b/src/modules/translation/translation.hook.spec.ts new file mode 100644 index 00000000..69507ff2 --- /dev/null +++ b/src/modules/translation/translation.hook.spec.ts @@ -0,0 +1,60 @@ +import { TranslationHook } from './translation.hook'; +import { IncomingMessage } from '../../engine/interfaces/whatsapp-engine.interface'; + +describe('TranslationHook', () => { + function setup(handleImpl: jest.Mock) { + type Handler = (ctx: unknown) => Promise; + const register = jest.fn(); + const hookManager = { register }; + const coordinator = { handleMessage: handleImpl }; + const hook = new TranslationHook(hookManager as never, coordinator as never); + hook.onModuleInit(); + const handler = register.mock.calls[0][2]; + return { hook, handler, hookManager }; + } + + const ctx = (data: IncomingMessage) => ({ + event: 'message:received', + data, + sessionId: 's', + timestamp: new Date(), + source: 'Engine', + }); + const baseMsg: IncomingMessage = { + id: 'M1', + from: 'g@g.us', + to: 'me', + chatId: 'g@g.us', + body: 'hi', + type: 'chat', + timestamp: 1, + fromMe: false, + isGroup: true, + author: '111@c.us', + }; + + it('registers a message:received handler on init', () => { + const { hookManager } = setup(jest.fn().mockResolvedValue({ swallow: false })); + expect(hookManager.register).toHaveBeenCalledWith( + 'translation', + 'message:received', + expect.any(Function), + expect.any(Number), + ); + }); + + it('returns continue:false when the coordinator swallows a command', async () => { + const { handler } = setup(jest.fn().mockResolvedValue({ swallow: true })); + await expect(handler(ctx(baseMsg))).resolves.toMatchObject({ continue: false }); + }); + + it('returns continue:true on normal messages', async () => { + const { handler } = setup(jest.fn().mockResolvedValue({ swallow: false })); + await expect(handler(ctx(baseMsg))).resolves.toMatchObject({ continue: true }); + }); + + it('never throws; returns continue:true if the coordinator errors', async () => { + const { handler } = setup(jest.fn().mockRejectedValue(new Error('boom'))); + await expect(handler(ctx(baseMsg))).resolves.toMatchObject({ continue: true }); + }); +}); diff --git a/src/modules/translation/translation.hook.ts b/src/modules/translation/translation.hook.ts new file mode 100644 index 00000000..d549a39a --- /dev/null +++ b/src/modules/translation/translation.hook.ts @@ -0,0 +1,53 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { HookManager } from '../../core/hooks'; +import { HookContext } from '../../core/hooks/hook.interfaces'; +import { IncomingMessage } from '../../engine/interfaces/whatsapp-engine.interface'; +import { TranslationCoordinator } from './core/translation.coordinator'; +import { InboundMessage } from './core/ports'; +import { createLogger } from '../../common/services/logger.service'; + +@Injectable() +export class TranslationHook implements OnModuleInit { + private readonly logger = createLogger('TranslationHook'); + + constructor( + private readonly hookManager: HookManager, + private readonly coordinator: TranslationCoordinator, + ) {} + + onModuleInit(): void { + this.hookManager.register( + 'translation', + 'message:received', + ctx => this.handle(ctx as HookContext), + 100, + ); + } + + private async handle(ctx: HookContext): Promise<{ continue: boolean; data: IncomingMessage }> { + const sessionId = ctx.sessionId; + const msg = ctx.data; + if (!sessionId) return { continue: true, data: msg }; + + try { + const inbound: InboundMessage = { + id: msg.id, + chatId: msg.chatId, + body: msg.body, + author: msg.author ?? '', + isGroup: msg.isGroup, + fromMe: msg.fromMe, + mentionedIds: msg.mentionedIds ?? [], + pushName: msg.contact?.pushName, + }; + const { swallow } = await this.coordinator.handleMessage(sessionId, inbound); + return { continue: !swallow, data: msg }; + } catch (err) { + this.logger.error('Translation hook failed', err instanceof Error ? err.message : String(err), { + sessionId, + action: 'translation_hook_error', + }); + return { continue: true, data: msg }; + } + } +} diff --git a/src/modules/translation/translation.module.ts b/src/modules/translation/translation.module.ts new file mode 100644 index 00000000..9382d521 --- /dev/null +++ b/src/modules/translation/translation.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { TranslationGroup } from './entities/translation-group.entity'; +import { TranslationHook } from './translation.hook'; +import { TranslationCoordinator, CoordinatorOptions } from './core/translation.coordinator'; +import { LibreTranslateClient } from './adapters/libretranslate.client'; +import { TypeOrmConfigStore } from './adapters/typeorm-config.store'; +import { OpenWaChatGateway } from './adapters/openwa-chat.gateway'; +import { MessageModule } from '../message/message.module'; +import { MessageService } from '../message/message.service'; +import { SessionModule } from '../session/session.module'; +import { SessionService } from '../session/session.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TranslationGroup], 'data'), ConfigModule, MessageModule, SessionModule], + providers: [ + { + provide: TranslationCoordinator, + inject: [ConfigService, getRepositoryToken(TranslationGroup, 'data'), MessageService, SessionService], + useFactory: ( + config: ConfigService, + repo: Repository, + messageService: MessageService, + sessionService: SessionService, + ) => { + const translator = new LibreTranslateClient({ + url: config.get('translation.libretranslateUrl', 'http://localhost:7001'), + apiKey: config.get('translation.libretranslateApiKey'), + timeoutMs: config.get('translation.timeoutMs', 5000), + }); + const store = new TypeOrmConfigStore(repo); + const gateway = new OpenWaChatGateway(messageService, sessionService); + const opts: CoordinatorOptions = { + prefix: config.get('translation.commandPrefix', '/tr'), + minLength: config.get('translation.minLength', 2), + maxLength: config.get('translation.maxLength', 2000), + denyReply: config.get('translation.denyReply', false), + }; + return new TranslationCoordinator(translator, store, gateway, opts); + }, + }, + TranslationHook, + ], +}) +export class TranslationModule {} From 391bb7b7fffbac5004e16b3f493dbe98bdf18d86 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 11/14] fix(translation): validate numeric env vars; document LID target caveat --- src/config/env.validation.spec.ts | 23 +++++++++++++++++++ src/config/env.validation.ts | 13 +++++++++++ .../core/translation.coordinator.ts | 4 ++++ 3 files changed, 40 insertions(+) diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index d2d8daf9..9d2167eb 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -37,4 +37,27 @@ describe('validateEnv', () => { it('ignores translation vars when disabled', () => { expect(() => validateEnv({ TRANSLATION_ENABLED: 'false' })).not.toThrow(); }); + + it('rejects a non-numeric LIBRETRANSLATE_TIMEOUT_MS', () => { + expect(() => validateEnv({ LIBRETRANSLATE_TIMEOUT_MS: 'foo' })).toThrow(/LIBRETRANSLATE_TIMEOUT_MS/); + }); + + it('rejects a non-positive TRANSLATION_MAX_LENGTH', () => { + expect(() => validateEnv({ TRANSLATION_MAX_LENGTH: '0' })).toThrow(/TRANSLATION_MAX_LENGTH/); + }); + + it('accepts valid translation numerics', () => { + expect(() => + validateEnv({ + LIBRETRANSLATE_TIMEOUT_MS: '5000', + TRANSLATION_MIN_LENGTH: '2', + TRANSLATION_MAX_LENGTH: '2000', + TRANSLATION_THROTTLE_INTERVAL_MS: '0', + }), + ).not.toThrow(); + }); + + it('ignores translation numerics when absent', () => { + expect(() => validateEnv({})).not.toThrow(); + }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index df9b3b54..a7f5c3e3 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -46,6 +46,19 @@ export function validateEnv(config: EnvConfig): EnvConfig { errors.push('LIBRETRANSLATE_URL is required when TRANSLATION_ENABLED=true'); } + const checkInt = (key: string, min: number): void => { + const raw = str(key); + if (raw === undefined) return; + const n = Number(raw); + if (!Number.isInteger(n) || n < min) { + errors.push(`${key} must be an integer >= ${min} (got "${raw}")`); + } + }; + checkInt('LIBRETRANSLATE_TIMEOUT_MS', 1); + checkInt('TRANSLATION_MIN_LENGTH', 0); + checkInt('TRANSLATION_MAX_LENGTH', 1); + checkInt('TRANSLATION_THROTTLE_INTERVAL_MS', 0); + if (errors.length > 0) { throw new Error(`Invalid environment configuration:\n - ${errors.join('\n - ')}`); } diff --git a/src/modules/translation/core/translation.coordinator.ts b/src/modules/translation/core/translation.coordinator.ts index 68e20ab0..6c324cc6 100644 --- a/src/modules/translation/core/translation.coordinator.ts +++ b/src/modules/translation/core/translation.coordinator.ts @@ -219,6 +219,10 @@ export class TranslationCoordinator { private resolveTarget(msg: InboundMessage, target?: CommandTarget): string | null { if (!target || target.kind === 'me') return msg.author; if (target.kind === 'mention') return msg.mentionedIds[0] ?? null; + // NOTE: a `` target assumes phone-number JID keying (`@c.us`). Under + // WhatsApp's newer LID scheme participants may be keyed by an opaque `@lid` id instead, + // so this constructed wid can fail to match the stored participant. The `@mention` and + // `me` forms resolve to the actual wid and are robust to LID; prefer them. See spec ยง16. return `${target.number}@c.us`; } From 6064d3885b76a495997e41002867a11a9607f620 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 12/14] fix(translation): load TranslationGroup entity on the data DataSource forFeature() alone does not register an entity's metadata with the named 'data' connection (autoLoadEntities is not enabled), so runtime repository queries threw 'No metadata for TranslationGroup'. Add the modules/translation entity glob alongside the others. Surfaced by live testing (migrations passed because the CLI data-source uses a broad src/** glob). --- src/app.module.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app.module.ts b/src/app.module.ts index c7dec88b..6e5f2de7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -96,6 +96,7 @@ if (process.env.TRANSLATION_ENABLED === 'true') { __dirname + '/modules/webhook/**/*.entity{.ts,.js}', __dirname + '/modules/message/**/*.entity{.ts,.js}', __dirname + '/modules/template/**/*.entity{.ts,.js}', + __dirname + '/modules/translation/**/*.entity{.ts,.js}', ], migrations: [__dirname + '/database/migrations/*{.ts,.js}'], logging: configService.get('dataDatabase.logging', false), From e5c002728ed638af12f4c86db125fa3123863a94 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:38:09 -0500 Subject: [PATCH 13/14] fix(translation): always reply to commands, tolerant admin match, no self-language translation - Commands always reply now; denials and unresolved targets are no longer silent. - Admin/controller matching tolerates WID suffix differences (widEquals helper). - Effective source: trust detection only when it names a language the group uses, else fall back to the sender's known language; always exclude the sender's own language from targets. Fixes a message being translated into its own language when detection misfires (e.g. colloquial es misread as gl). - Gateway logs resolved group admins to diagnose the LID vs @c.us scheme mismatch. All surfaced by live testing. --- .../adapters/openwa-chat.gateway.ts | 24 ++++++- .../core/translation.coordinator.spec.ts | 22 ++++++ .../core/translation.coordinator.ts | 68 +++++++++++++++---- 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/modules/translation/adapters/openwa-chat.gateway.ts b/src/modules/translation/adapters/openwa-chat.gateway.ts index d08ca1bd..4d8c65f0 100644 --- a/src/modules/translation/adapters/openwa-chat.gateway.ts +++ b/src/modules/translation/adapters/openwa-chat.gateway.ts @@ -1,8 +1,11 @@ import { ChatGateway } from '../core/ports'; import { MessageService } from '../../message/message.service'; import { SessionService } from '../../session/session.service'; +import { createLogger } from '../../../common/services/logger.service'; export class OpenWaChatGateway implements ChatGateway { + private readonly logger = createLogger('OpenWaChatGateway'); + constructor( private readonly messageService: MessageService, private readonly sessionService: SessionService, @@ -18,9 +21,24 @@ export class OpenWaChatGateway implements ChatGateway { async getGroupAdmins(sessionId: string, chatId: string): Promise { const engine = this.sessionService.getEngine(sessionId); - if (!engine) return []; + if (!engine) { + this.logger.warn('getGroupAdmins: no active engine for session', { chatId, action: 'admins_no_engine' }); + return []; + } const info = await engine.getGroupInfo(chatId); - if (!info) return []; - return info.participants.filter(p => p.isAdmin || p.isSuperAdmin).map(p => p.id); + if (!info) { + this.logger.warn('getGroupAdmins: getGroupInfo returned null', { chatId, action: 'admins_no_info' }); + return []; + } + const admins = info.participants.filter(p => p.isAdmin || p.isSuperAdmin).map(p => p.id); + // Diagnostic: surface the exact WID format so admin-match failures (LID vs @c.us) are visible. + this.logger.debug('getGroupAdmins resolved', { + chatId, + action: 'admins_resolved', + adminCount: admins.length, + admins, + sampleParticipants: info.participants.slice(0, 5).map(p => ({ id: p.id, isAdmin: p.isAdmin })), + }); + return admins; } } diff --git a/src/modules/translation/core/translation.coordinator.spec.ts b/src/modules/translation/core/translation.coordinator.spec.ts index 378b4cfa..9800a104 100644 --- a/src/modules/translation/core/translation.coordinator.spec.ts +++ b/src/modules/translation/core/translation.coordinator.spec.ts @@ -113,6 +113,28 @@ describe('TranslationCoordinator', () => { expect(mocks.sendCombinedReply).toHaveBeenCalledWith('s', 'g@g.us', 'M1', expect.stringContaining('Hola')); }); + it('falls back to the sender language and never translates into the source when detection misfires', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'learned', enabled: true, samples: 3, updatedAt: 'x' }, + '222@c.us': { lang: 'es', source: 'pinned', enabled: true, samples: 3, updatedAt: 'x' }, + }, + }); + const { store, gateway, translator, mocks } = makeDeps(state); + // Detection misfires on colloquial Spanish, returning 'gl' โ€” a language the group does not use. + mocks.detect.mockResolvedValue({ lang: 'gl', confidence: 0.5 }); + mocks.translate.mockResolvedValue('Let me know'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + await c.handleMessage('s', msg({ author: '222@c.us', body: 'Haber dime que debo darte' })); + // Effective source falls back to the sender's known 'es'; 'en' is the only target. + expect(mocks.translate).toHaveBeenCalledTimes(1); + expect(mocks.translate).toHaveBeenCalledWith('Haber dime que debo darte', 'es', 'en'); + // Must never translate a message into the sender's own language. + expect(mocks.translate).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), 'es'); + }); + it('learns a sender language only after a 2-message debounce', async () => { const state = freshState({ announced: true, diff --git a/src/modules/translation/core/translation.coordinator.ts b/src/modules/translation/core/translation.coordinator.ts index 6c324cc6..d831feec 100644 --- a/src/modules/translation/core/translation.coordinator.ts +++ b/src/modules/translation/core/translation.coordinator.ts @@ -22,6 +22,18 @@ export interface CoordinatorOptions { const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u; +/** + * Compare two WhatsApp IDs tolerantly: exact match, or same user part ignoring + * an `@domain` and any `:device` suffix (e.g. `123@c.us` === `123:7@c.us`). + * Note: this does NOT bridge the LID (`@lid`) and phone (`@c.us`) namespaces โ€” + * those have different user numbers (see spec ยง16). + */ +function widEquals(a: string, b: string): boolean { + if (a === b) return true; + const userPart = (w: string): string => w.split('@')[0].split(':')[0]; + return userPart(a) === userPart(b); +} + export class TranslationCoordinator { constructor( private readonly translator: Translator, @@ -69,13 +81,21 @@ export class TranslationCoordinator { } this.applyLearning(sender, detected); - const targets = this.targetLanguages(state, detected); + // Pick the effective source language. Detection misfires on short/colloquial text โ€” it often + // returns a near-neighbour language (e.g. es misread as gl/ca) โ€” so trust the detected code only + // when it names a language the group actually uses; otherwise fall back to the sender's known + // language. Combined with excluding the sender's own language from the targets below, this stops + // a message ever being "translated" into its own language (the duplicate/echo bug). + const knownLangs = this.knownLanguages(state); + const source = knownLangs.includes(detected) ? detected : (sender.lang ?? detected); + + const targets = this.targetLanguages(state, source, sender.lang); if (targets.length === 0) { await this.store.save(state); return; } - const settled = await Promise.allSettled(targets.map(t => this.translator.translate(text, detected, t))); + const settled = await Promise.allSettled(targets.map(t => this.translator.translate(text, source, t))); const translations: Translation[] = []; settled.forEach((r, i) => { if (r.status === 'fulfilled') translations.push({ lang: targets[i], text: r.value }); @@ -87,11 +107,24 @@ export class TranslationCoordinator { await this.store.save(state); } - /** Distinct languages of enabled participants, minus the detected source language. */ - private targetLanguages(state: GroupState, source: string): string[] { + /** Distinct languages currently spoken by enabled participants. */ + private knownLanguages(state: GroupState): string[] { + const langs = new Set(); + for (const p of Object.values(state.participants)) { + if (p.enabled && p.lang) langs.add(p.lang); + } + return [...langs]; + } + + /** + * Distinct languages of enabled participants, excluding the message source language AND the + * sender's own language โ€” a sender never needs their own message translated back to themselves + * (this also guards against a detection misfire leaving the source language in the target set). + */ + private targetLanguages(state: GroupState, source: string, senderLang: string | null): string[] { const langs = new Set(); for (const p of Object.values(state.participants)) { - if (p.enabled && p.lang && p.lang !== source) langs.add(p.lang); + if (p.enabled && p.lang && p.lang !== source && p.lang !== senderLang) langs.add(p.lang); } return [...langs]; } @@ -140,13 +173,18 @@ export class TranslationCoordinator { const isSelfServe = (cmd.name === 'setlang' || cmd.name === 'auto') && targetsSelf; if (!isSelfServe) { const admins = await this.gateway.getGroupAdmins(sessionId, msg.chatId); - const isAdmin = admins.includes(msg.author); - const isController = isAdmin || state.delegatedControllers.includes(msg.author); + const isAdmin = admins.some(a => widEquals(a, msg.author)); + const isController = isAdmin || state.delegatedControllers.some(c => widEquals(c, msg.author)); const adminOnly = cmd.name === 'grant' || cmd.name === 'revoke'; if ((adminOnly && !isAdmin) || (!adminOnly && !isController)) { - if (this.opts.denyReply) { - await this.gateway.sendText(sessionId, msg.chatId, 'โ›” Only group admins can do that.'); - } + // Always reply on denial โ€” a command must never fail silently. + await this.gateway.sendText( + sessionId, + msg.chatId, + adminOnly + ? 'โ›” Only group admins can use that command.' + : 'โ›” Only group admins or delegated users can use that command.', + ); return; } } @@ -178,7 +216,7 @@ export class TranslationCoordinator { return; } case 'auto': { - if (!targetWid) return; + if (!targetWid) return this.replyError(sessionId, msg, this.targetHelp()); const p = this.ensureParticipant(state, targetWid); p.source = 'learned'; p.pendingLang = undefined; @@ -187,7 +225,7 @@ export class TranslationCoordinator { } case 'ignore': case 'unignore': { - if (!targetWid) return; + if (!targetWid) return this.replyError(sessionId, msg, this.targetHelp()); const p = this.ensureParticipant(state, targetWid); p.enabled = cmd.name === 'unignore'; await this.confirm( @@ -200,7 +238,7 @@ export class TranslationCoordinator { } case 'grant': case 'revoke': { - if (!targetWid) return; + if (!targetWid) return this.replyError(sessionId, msg, this.targetHelp()); const set = new Set(state.delegatedControllers); if (cmd.name === 'grant') set.add(targetWid); else set.delete(targetWid); @@ -242,4 +280,8 @@ export class TranslationCoordinator { private replyError(sessionId: string, msg: InboundMessage, text: string): Promise { return this.gateway.sendText(sessionId, msg.chatId, text); } + + private targetHelp(): string { + return "โš ๏ธ Couldn't identify that user. Target them by @mention, by phone number, or use 'me' for yourself."; + } } From 4f67ec90161d00961e6f5e91032de219c85792b3 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:12:01 -0500 Subject: [PATCH 14/14] fix(translation): recognize the group owner as an admin (LID-aware) getGroupInfo participant ids can be in the phone (@c.us) scheme while message authors arrive as LID (@lid), so a string match never recognized the group creator as an admin. The group 'owner' field is reported in the author's scheme, so include it in getGroupAdmins (deduped). Verified live: the creator (LID author) is now authorized for admin commands with no manual delegated-controller entry. Non-owner admins under a differing scheme still need '/tr grant @user'; full per-admin LID<->phone resolution (via enforceLidAndPnRetrieval) is a tracked follow-up. --- .../adapters/openwa-chat.gateway.spec.ts | 17 +++++++++++++++++ .../translation/adapters/openwa-chat.gateway.ts | 14 +++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/modules/translation/adapters/openwa-chat.gateway.spec.ts b/src/modules/translation/adapters/openwa-chat.gateway.spec.ts index 4400ae10..6adf78f1 100644 --- a/src/modules/translation/adapters/openwa-chat.gateway.spec.ts +++ b/src/modules/translation/adapters/openwa-chat.gateway.spec.ts @@ -39,6 +39,23 @@ describe('OpenWaChatGateway', () => { expect(await gw.getGroupAdmins('s', 'c@g.us')).toEqual(['111@c.us', '222@c.us']); }); + it('getGroupAdmins includes the group owner (LID scheme) alongside phone-scheme admins', async () => { + const engine = { + getGroupInfo: jest.fn().mockResolvedValue({ + owner: '149207180681386@lid', + participants: [ + { id: '19729002902@c.us', isAdmin: true, isSuperAdmin: true }, + { id: '573133889572@c.us', isAdmin: false, isSuperAdmin: false }, + ], + }), + }; + const { messageService, sessionService } = deps(engine); + const gw = new OpenWaChatGateway(messageService as never, sessionService as never); + const admins = await gw.getGroupAdmins('s', 'c@g.us'); + expect(admins).toContain('19729002902@c.us'); // admin participant (phone scheme) + expect(admins).toContain('149207180681386@lid'); // owner (LID scheme โ€” matches message authors) + }); + it('getGroupAdmins returns [] when the session has no engine', async () => { const { messageService, sessionService } = deps(undefined); const gw = new OpenWaChatGateway(messageService as never, sessionService as never); diff --git a/src/modules/translation/adapters/openwa-chat.gateway.ts b/src/modules/translation/adapters/openwa-chat.gateway.ts index 4d8c65f0..e7e3a438 100644 --- a/src/modules/translation/adapters/openwa-chat.gateway.ts +++ b/src/modules/translation/adapters/openwa-chat.gateway.ts @@ -31,14 +31,18 @@ export class OpenWaChatGateway implements ChatGateway { return []; } const admins = info.participants.filter(p => p.isAdmin || p.isSuperAdmin).map(p => p.id); - // Diagnostic: surface the exact WID format so admin-match failures (LID vs @c.us) are visible. + // Participant ids can be in the phone (@c.us) scheme while message authors arrive as LID + // (@lid). The group `owner` is reported in the author's scheme, so including it recognizes the + // group creator across that split (see spec ยง16, WID/LID). Non-owner admins on the differing + // scheme are not auto-resolved yet โ€” the owner can delegate them via `/tr grant @user`. + if (info.owner) admins.push(info.owner); + const uniqueAdmins = [...new Set(admins)]; this.logger.debug('getGroupAdmins resolved', { chatId, action: 'admins_resolved', - adminCount: admins.length, - admins, - sampleParticipants: info.participants.slice(0, 5).map(p => ({ id: p.id, isAdmin: p.isAdmin })), + adminCount: uniqueAdmins.length, + admins: uniqueAdmins, }); - return admins; + return uniqueAdmins; } }