From 6c9d0e89df81aa15c1bf84f9ce8da639ea966f07 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:56:22 -0500 Subject: [PATCH 1/7] feat(engine): surface mentionedIds on IncomingMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-field passthrough (whatsapp-web.js already attaches mentionedIds) so command handlers can target a user by @mention โ€” the reliable cross-user identity under WhatsApp's LID scheme, since mentions and message authors share the @lid namespace. Additive and optional. --- src/engine/adapters/message-mapper.spec.ts | 10 ++++++++++ src/engine/adapters/message-mapper.ts | 7 +++++++ src/engine/interfaces/whatsapp-engine.interface.ts | 2 ++ 3 files changed, 19 insertions(+) diff --git a/src/engine/adapters/message-mapper.spec.ts b/src/engine/adapters/message-mapper.spec.ts index b3d4a88a..25159670 100644 --- a/src/engine/adapters/message-mapper.spec.ts +++ b/src/engine/adapters/message-mapper.spec.ts @@ -72,6 +72,16 @@ describe('buildIncomingMessageBase', () => { expect(r.chatId).toBe('group-1@g.us'); expect(r.isGroup).toBe(true); }); + + it('maps mentionedIds when present', () => { + const r = buildIncomingMessageBase({ ...base, mentionedIds: ['222@lid', '333@lid'] }); + expect(r.mentionedIds).toEqual(['222@lid', '333@lid']); + }); + + it('omits mentionedIds when absent or empty', () => { + expect(buildIncomingMessageBase(base).mentionedIds).toBeUndefined(); + expect(buildIncomingMessageBase({ ...base, mentionedIds: [] }).mentionedIds).toBeUndefined(); + }); }); describe('mapWwebjsMessageType (engine type-token -> neutral MessageType boundary, #265)', () => { diff --git a/src/engine/adapters/message-mapper.ts b/src/engine/adapters/message-mapper.ts index a6ebc48a..4776e74a 100644 --- a/src/engine/adapters/message-mapper.ts +++ b/src/engine/adapters/message-mapper.ts @@ -49,6 +49,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 }; } @@ -82,6 +84,11 @@ export function buildIncomingMessageBase(msg: RawMessageFields): IncomingMessage incoming.author = msg.author; } + // @mentioned WIDs, when present โ€” used for command targeting (e.g. `/tr grant @user`). + if (msg.mentionedIds && msg.mentionedIds.length > 0) { + incoming.mentionedIds = msg.mentionedIds; + } + // Flag senders identified by a WhatsApp privacy id (`@lid`) so engine-neutral code can opt to // resolve a phone number without matching the engine-specific JID scheme itself (#263). const senderJid = msg.author ?? msg.from; diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index ffd2cde9..c189205c 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -57,6 +57,8 @@ export interface IncomingMessage { isStatusBroadcast?: 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[]; /** * Set by the adapter when the sender is identified by a privacy id (e.g. a WhatsApp `@lid`) rather * than a phone number, so engine-neutral code can decide whether to attempt phone resolution without From 401eb3c6b033bcb8488653b32ec073d3d02765aa Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:56:22 -0500 Subject: [PATCH 2/7] feat(plugins): add group auto-translation extension plugin Ports the translation feature onto the Tier-2 capability layer (#294): the framework-agnostic core/ (coordinator, command parser, reply formatter, ports) is reused unchanged, with ChatGateway/ConfigStore implemented over ctx.messages / ctx.engine / ctx.storage and per-group state in plugin KV storage. Registered disabled by default; enable via POST /plugins/translation/enable. Supersedes the core-module approach in #278 now that the plugin send-capability exists. --- src/plugins/extensions/extensions.module.ts | 16 + .../translation/core/command.parser.spec.ts | 43 +++ .../translation/core/command.parser.ts | 64 ++++ .../extensions/translation/core/ports.ts | 82 +++++ .../translation/core/reply.formatter.spec.ts | 36 +++ .../translation/core/reply.formatter.ts | 60 ++++ .../core/translation.coordinator.spec.ts | 167 ++++++++++ .../core/translation.coordinator.ts | 287 ++++++++++++++++++ src/plugins/extensions/translation/index.ts | 98 ++++++ .../translation/libretranslate.client.spec.ts | 49 +++ .../translation/libretranslate.client.ts | 86 ++++++ .../extensions/translation/manifest.json | 54 ++++ .../translation/plugin-chat.gateway.spec.ts | 45 +++ .../translation/plugin-chat.gateway.ts | 35 +++ .../translation/plugin-config.store.spec.ts | 44 +++ .../translation/plugin-config.store.ts | 34 +++ 16 files changed, 1200 insertions(+) create mode 100644 src/plugins/extensions/translation/core/command.parser.spec.ts create mode 100644 src/plugins/extensions/translation/core/command.parser.ts create mode 100644 src/plugins/extensions/translation/core/ports.ts create mode 100644 src/plugins/extensions/translation/core/reply.formatter.spec.ts create mode 100644 src/plugins/extensions/translation/core/reply.formatter.ts create mode 100644 src/plugins/extensions/translation/core/translation.coordinator.spec.ts create mode 100644 src/plugins/extensions/translation/core/translation.coordinator.ts create mode 100644 src/plugins/extensions/translation/index.ts create mode 100644 src/plugins/extensions/translation/libretranslate.client.spec.ts create mode 100644 src/plugins/extensions/translation/libretranslate.client.ts create mode 100644 src/plugins/extensions/translation/manifest.json create mode 100644 src/plugins/extensions/translation/plugin-chat.gateway.spec.ts create mode 100644 src/plugins/extensions/translation/plugin-chat.gateway.ts create mode 100644 src/plugins/extensions/translation/plugin-config.store.spec.ts create mode 100644 src/plugins/extensions/translation/plugin-config.store.ts diff --git a/src/plugins/extensions/extensions.module.ts b/src/plugins/extensions/extensions.module.ts index 29651cd5..2539eec0 100644 --- a/src/plugins/extensions/extensions.module.ts +++ b/src/plugins/extensions/extensions.module.ts @@ -1,6 +1,7 @@ import { Injectable, Module, OnModuleInit } from '@nestjs/common'; import { PluginLoaderService, PluginManifest, PluginType } from '../../core/plugins'; import { AutoReplyPlugin } from './auto-reply'; +import { TranslationPlugin } from './translation'; import { createLogger } from '../../common/services/logger.service'; /** @@ -28,6 +29,21 @@ export class ExtensionsRegistrar implements OnModuleInit { this.pluginLoader.registerBuiltInPlugin(autoReplyManifest, new AutoReplyPlugin()); this.logger.log('Auto-reply reference plugin registered (disabled)'); + + const translationManifest: PluginManifest = { + id: 'translation', + name: 'Group Auto-Translation', + version: '1.0.0', + type: PluginType.EXTENSION, + description: + "Auto-translates group messages between participants' languages via LibreTranslate. Configure in-group with /tr commands. Disabled by default.", + main: 'index.ts', + permissions: ['messages:send'], + sessions: ['*'], + }; + + this.pluginLoader.registerBuiltInPlugin(translationManifest, new TranslationPlugin()); + this.logger.log('Translation plugin registered (disabled)'); } } diff --git a/src/plugins/extensions/translation/core/command.parser.spec.ts b/src/plugins/extensions/translation/core/command.parser.spec.ts new file mode 100644 index 00000000..138a125a --- /dev/null +++ b/src/plugins/extensions/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/plugins/extensions/translation/core/command.parser.ts b/src/plugins/extensions/translation/core/command.parser.ts new file mode 100644 index 00000000..69f94496 --- /dev/null +++ b/src/plugins/extensions/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/plugins/extensions/translation/core/ports.ts b/src/plugins/extensions/translation/core/ports.ts new file mode 100644 index 00000000..44800ca8 --- /dev/null +++ b/src/plugins/extensions/translation/core/ports.ts @@ -0,0 +1,82 @@ +// 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; + /** Candidate language awaiting a 2nd consecutive detection before a learned switch. */ + pendingLang?: string; + 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; +} diff --git a/src/plugins/extensions/translation/core/reply.formatter.spec.ts b/src/plugins/extensions/translation/core/reply.formatter.spec.ts new file mode 100644 index 00000000..beb3ffc1 --- /dev/null +++ b/src/plugins/extensions/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/plugins/extensions/translation/core/reply.formatter.ts b/src/plugins/extensions/translation/core/reply.formatter.ts new file mode 100644 index 00000000..4fcac07e --- /dev/null +++ b/src/plugins/extensions/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'); +} diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts new file mode 100644 index 00000000..9800a104 --- /dev/null +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -0,0 +1,167 @@ +// 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('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, + 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/plugins/extensions/translation/core/translation.coordinator.ts b/src/plugins/extensions/translation/core/translation.coordinator.ts new file mode 100644 index 00000000..d831feec --- /dev/null +++ b/src/plugins/extensions/translation/core/translation.coordinator.ts @@ -0,0 +1,287 @@ +// 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; + +/** + * 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, + 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); + + // 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, source, 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 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 && p.lang !== senderLang) 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.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)) { + // 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; + } + } + + 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 this.replyError(sessionId, msg, this.targetHelp()); + 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 this.replyError(sessionId, msg, this.targetHelp()); + 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 this.replyError(sessionId, msg, this.targetHelp()); + 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; + // 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`; + } + + 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); + } + + private targetHelp(): string { + return "โš ๏ธ Couldn't identify that user. Target them by @mention, by phone number, or use 'me' for yourself."; + } +} diff --git a/src/plugins/extensions/translation/index.ts b/src/plugins/extensions/translation/index.ts new file mode 100644 index 00000000..c059f3c6 --- /dev/null +++ b/src/plugins/extensions/translation/index.ts @@ -0,0 +1,98 @@ +/** + * Group auto-translation extension plugin. + * + * Ports the former core `translation` module onto the Tier-2 capability layer (#294): the + * framework-agnostic `core/` (coordinator, parser, formatter, ports) is reused unchanged, with + * `ChatGateway`/`ConfigStore` implemented over `ctx.messages`/`ctx.engine`/`ctx.storage`. + * Registered DISABLED by default โ€” enable via `POST /plugins/translation/enable`. + */ +import { PluginContext, IPlugin } from '../../../core/plugins'; +import { HookContext, HookResult } from '../../../core/hooks'; +import { IncomingMessage } from '../../../engine/interfaces/whatsapp-engine.interface'; +import { TranslationCoordinator, CoordinatorOptions } from './core/translation.coordinator'; +import { InboundMessage } from './core/ports'; +import { LibreTranslateClient } from './libretranslate.client'; +import { PluginChatGateway } from './plugin-chat.gateway'; +import { PluginConfigStore } from './plugin-config.store'; + +function readString(cfg: Record, key: string, fallback: string): string { + const v = cfg[key]; + return typeof v === 'string' && v.length > 0 ? v : fallback; +} +function readOptionalString(cfg: Record, key: string): string | undefined { + const v = cfg[key]; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} +function readNumber(cfg: Record, key: string, fallback: number): number { + const v = cfg[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} +function readBool(cfg: Record, key: string, fallback: boolean): boolean { + const v = cfg[key]; + return typeof v === 'boolean' ? v : fallback; +} + +export class TranslationPlugin implements IPlugin { + private coordinator: TranslationCoordinator | null = null; + + onEnable(context: PluginContext): Promise { + const cfg = context.config; + + const translator = new LibreTranslateClient({ + url: readString(cfg, 'libretranslateUrl', 'http://localhost:7001'), + apiKey: readOptionalString(cfg, 'libretranslateApiKey'), + timeoutMs: readNumber(cfg, 'timeoutMs', 5000), + }); + 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), + }; + this.coordinator = new TranslationCoordinator(translator, store, gateway, opts); + + context.registerHook('message:received', ctx => this.onMessage(context, ctx as HookContext)); + context.logger.log('Translation plugin enabled', { action: 'translation_enabled' }); + return Promise.resolve(); + } + + onDisable(context: PluginContext): Promise { + // The loader unregisters this plugin's hooks on disable; drop the coordinator too. + this.coordinator = null; + context.logger.log('Translation plugin disabled', { action: 'translation_disabled' }); + return Promise.resolve(); + } + + private async onMessage(context: PluginContext, ctx: HookContext): Promise { + const msg = ctx.data; + // Only act on engine-originated inbound messages for a known session. The bot's own sends are + // `fromMe` and route through `message:sent`, so they never reach here (no translation loop). + if (!this.coordinator || ctx.source !== 'Engine' || !ctx.sessionId) { + return { continue: true }; + } + 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(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; diff --git a/src/plugins/extensions/translation/libretranslate.client.spec.ts b/src/plugins/extensions/translation/libretranslate.client.spec.ts new file mode 100644 index 00000000..f9afa159 --- /dev/null +++ b/src/plugins/extensions/translation/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/plugins/extensions/translation/libretranslate.client.ts b/src/plugins/extensions/translation/libretranslate.client.ts new file mode 100644 index 00000000..4861cc74 --- /dev/null +++ b/src/plugins/extensions/translation/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); + } + } +} diff --git a/src/plugins/extensions/translation/manifest.json b/src/plugins/extensions/translation/manifest.json new file mode 100644 index 00000000..eb016a9f --- /dev/null +++ b/src/plugins/extensions/translation/manifest.json @@ -0,0 +1,54 @@ +{ + "id": "translation", + "name": "Group Auto-Translation", + "version": "1.0.0", + "type": "extension", + "description": "Auto-translates group messages between participants' languages via LibreTranslate. Configure in-group with /tr commands. Disabled by default.", + "main": "index.ts", + "permissions": ["messages:send"], + "sessions": ["*"], + "configSchema": { + "type": "object", + "properties": { + "libretranslateUrl": { + "type": "string", + "title": "LibreTranslate URL", + "description": "Base URL of the LibreTranslate instance (e.g. http://libretranslate:7001).", + "default": "http://localhost:7001", + "required": true + }, + "libretranslateApiKey": { + "type": "string", + "title": "LibreTranslate API key", + "description": "Optional API key, if your LibreTranslate instance requires one.", + "secret": true + }, + "timeoutMs": { + "type": "number", + "title": "Translate timeout (ms)", + "default": 5000 + }, + "commandPrefix": { + "type": "string", + "title": "Command prefix", + "default": "/tr" + }, + "minLength": { + "type": "number", + "title": "Min message length to translate", + "default": 2 + }, + "maxLength": { + "type": "number", + "title": "Max message length to translate", + "default": 2000 + }, + "denyReply": { + "type": "boolean", + "title": "Reply on denied commands", + "description": "If true, reply with an 'admins only' message when a non-admin runs a restricted command.", + "default": false + } + } + } +} diff --git a/src/plugins/extensions/translation/plugin-chat.gateway.spec.ts b/src/plugins/extensions/translation/plugin-chat.gateway.spec.ts new file mode 100644 index 00000000..aadeb8ca --- /dev/null +++ b/src/plugins/extensions/translation/plugin-chat.gateway.spec.ts @@ -0,0 +1,45 @@ +import { PluginChatGateway } from './plugin-chat.gateway'; + +describe('PluginChatGateway', () => { + it('sendText routes through ctx.messages.sendText', async () => { + const messages = { sendText: jest.fn(() => Promise.resolve({})), reply: jest.fn(() => Promise.resolve({})) }; + const engine = { getGroupInfo: jest.fn() }; + const gw = new PluginChatGateway(messages as never, engine as never); + await gw.sendText('s', 'c@g.us', 'hi'); + expect(messages.sendText).toHaveBeenCalledWith('s', 'c@g.us', 'hi'); + }); + + it('sendCombinedReply routes through ctx.messages.reply', async () => { + const messages = { sendText: jest.fn(() => Promise.resolve({})), reply: jest.fn(() => Promise.resolve({})) }; + const engine = { getGroupInfo: jest.fn() }; + const gw = new PluginChatGateway(messages as never, engine as never); + await gw.sendCombinedReply('s', 'c@g.us', 'M1', 'Hola'); + expect(messages.reply).toHaveBeenCalledWith('s', 'c@g.us', 'M1', 'Hola'); + }); + + it('getGroupAdmins includes phone-scheme admins + the LID owner, deduped', async () => { + const messages = { sendText: jest.fn(), reply: jest.fn() }; + const engine = { + getGroupInfo: jest.fn(() => + Promise.resolve({ + owner: '149207180681386@lid', + participants: [ + { id: '19729002902@c.us', isAdmin: true, isSuperAdmin: true }, + { id: '573133889572@c.us', isAdmin: false, isSuperAdmin: false }, + ], + }), + ), + }; + const gw = new PluginChatGateway(messages, engine as never); + const admins = await gw.getGroupAdmins('s', 'c@g.us'); + expect(admins).toContain('19729002902@c.us'); + expect(admins).toContain('149207180681386@lid'); + }); + + it('getGroupAdmins returns [] when there is no group info', async () => { + const messages = { sendText: jest.fn(), reply: jest.fn() }; + const engine = { getGroupInfo: jest.fn(() => Promise.resolve(null)) }; + const gw = new PluginChatGateway(messages, engine as never); + expect(await gw.getGroupAdmins('s', 'c@g.us')).toEqual([]); + }); +}); diff --git a/src/plugins/extensions/translation/plugin-chat.gateway.ts b/src/plugins/extensions/translation/plugin-chat.gateway.ts new file mode 100644 index 00000000..1a226e21 --- /dev/null +++ b/src/plugins/extensions/translation/plugin-chat.gateway.ts @@ -0,0 +1,35 @@ +import { ChatGateway } from './core/ports'; +import { PluginMessagingCapability, PluginEngineReadCapability } from '../../../core/plugins'; + +/** + * ChatGateway backed by the Tier-2 plugin capability surface: writes go through + * `ctx.messages` (routed via MessageService, so persistence is preserved), and group-admin + * reads through `ctx.engine`. It implements the same port the translation core already + * depends on, so the coordinator/parser/formatter are reused unchanged. + */ +export class PluginChatGateway implements ChatGateway { + constructor( + private readonly messages: PluginMessagingCapability, + private readonly engine: PluginEngineReadCapability, + ) {} + + async sendText(sessionId: string, chatId: string, text: string): Promise { + await this.messages.sendText(sessionId, chatId, text); + } + + async sendCombinedReply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise { + await this.messages.reply(sessionId, chatId, quotedMessageId, text); + } + + async getGroupAdmins(sessionId: string, chatId: string): Promise { + const info = await this.engine.getGroupInfo(sessionId, chatId); + if (!info) return []; + const admins = info.participants.filter(p => p.isAdmin || p.isSuperAdmin).map(p => p.id); + // 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 include it to recognize + // the group creator across that split. Non-owner admins on a differing scheme can be granted + // control via `/tr grant`. + if (info.owner) admins.push(info.owner); + return [...new Set(admins)]; + } +} diff --git a/src/plugins/extensions/translation/plugin-config.store.spec.ts b/src/plugins/extensions/translation/plugin-config.store.spec.ts new file mode 100644 index 00000000..34abb248 --- /dev/null +++ b/src/plugins/extensions/translation/plugin-config.store.spec.ts @@ -0,0 +1,44 @@ +import { PluginConfigStore } from './plugin-config.store'; +import { GroupState } from './core/ports'; + +function makeStorage() { + const data = new Map(); + return { + get: jest.fn((k: string) => Promise.resolve(data.has(k) ? data.get(k) : null)), + set: jest.fn((k: string, v: unknown) => { + data.set(k, v); + return Promise.resolve(); + }), + delete: jest.fn((k: string) => { + data.delete(k); + return Promise.resolve(); + }), + list: jest.fn(() => Promise.resolve([...data.keys()])), + }; +} + +describe('PluginConfigStore', () => { + it('returns a default inactive state for an unknown group', async () => { + const store = new PluginConfigStore(makeStorage() as never); + 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({}); + expect(state.delegatedControllers).toEqual([]); + }); + + it('round-trips a saved state under a per-group key', async () => { + const storage = makeStorage(); + const store = new PluginConfigStore(storage as never); + const state: GroupState = { + sessionId: 's', + chatId: 'g@g.us', + active: true, + participants: { '111@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x' } }, + delegatedControllers: ['222@lid'], + announced: true, + }; + await store.save(state); + expect(storage.set).toHaveBeenCalledWith('group:s:g@g.us', state); + expect(await store.load('s', 'g@g.us')).toEqual(state); + }); +}); diff --git a/src/plugins/extensions/translation/plugin-config.store.ts b/src/plugins/extensions/translation/plugin-config.store.ts new file mode 100644 index 00000000..4a081e89 --- /dev/null +++ b/src/plugins/extensions/translation/plugin-config.store.ts @@ -0,0 +1,34 @@ +import { ConfigStore, GroupState } from './core/ports'; +import { PluginStorage } from '../../../core/plugins'; + +/** + * ConfigStore backed by the plugin's KV storage (`ctx.storage`). Persists one GroupState + * JSON document per (sessionId, chatId) and returns a default inactive state for groups the + * bot hasn't met yet. Replaces the TypeORM entity/migration the core-module version used โ€” + * a plugin owns its own state via the capability surface. + */ +export class PluginConfigStore implements ConfigStore { + constructor(private readonly storage: PluginStorage) {} + + private key(sessionId: string, chatId: string): string { + return `group:${sessionId}:${chatId}`; + } + + async load(sessionId: string, chatId: string): Promise { + const stored = await this.storage.get(this.key(sessionId, chatId)); + return ( + stored ?? { + sessionId, + chatId, + active: false, + participants: {}, + delegatedControllers: [], + announced: false, + } + ); + } + + async save(state: GroupState): Promise { + await this.storage.set(this.key(state.sessionId, state.chatId), state); + } +} From 897deaeb2c51fbc8d7fbb6718bc583acf7849f0e Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:13:21 -0500 Subject: [PATCH 3/7] feat(plugins): expose translation configSchema + apply config changes live - Add the configSchema to the translation plugin's registered manifest so GET /plugins exposes it and the dashboard config form (#303) can render the LibreTranslate URL + API key (and other) fields. The schema was previously only in manifest.json, which built-in registration does not read. - Implement onConfigChange to rebuild the coordinator from the updated config, so a URL/key change saved from the dashboard takes effect immediately without a disable/enable cycle. Extracted buildCoordinator() shared by onEnable/onConfigChange. --- src/plugins/extensions/extensions.module.ts | 31 +++++++++++++++++++++ src/plugins/extensions/translation/index.ts | 22 +++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/plugins/extensions/extensions.module.ts b/src/plugins/extensions/extensions.module.ts index 2539eec0..0d0a8089 100644 --- a/src/plugins/extensions/extensions.module.ts +++ b/src/plugins/extensions/extensions.module.ts @@ -40,6 +40,37 @@ export class ExtensionsRegistrar implements OnModuleInit { main: 'index.ts', permissions: ['messages:send'], sessions: ['*'], + // Exposed via GET /plugins so the dashboard renders an editable config form (URL + API key, etc.). + configSchema: { + type: 'object', + properties: { + libretranslateUrl: { + type: 'string', + title: 'LibreTranslate URL', + description: + 'Base URL of the LibreTranslate instance (e.g. http://libretranslate:7001 or https://libretranslate.com).', + default: 'http://localhost:7001', + required: true, + }, + libretranslateApiKey: { + type: 'string', + title: 'LibreTranslate API key', + description: + 'Optional API key, if your LibreTranslate instance requires one (e.g. hosted libretranslate.com).', + secret: true, + }, + timeoutMs: { type: 'number', title: 'Translate timeout (ms)', default: 5000 }, + commandPrefix: { type: 'string', title: 'Command prefix', default: '/tr' }, + minLength: { type: 'number', title: 'Min message length to translate', default: 2 }, + maxLength: { type: 'number', title: 'Max message length to translate', default: 2000 }, + denyReply: { + type: 'boolean', + title: 'Reply on denied commands', + description: "Reply with an 'admins only' message when a non-admin runs a restricted command.", + default: false, + }, + }, + }, }; this.pluginLoader.registerBuiltInPlugin(translationManifest, new TranslationPlugin()); diff --git a/src/plugins/extensions/translation/index.ts b/src/plugins/extensions/translation/index.ts index c059f3c6..6c006ecd 100644 --- a/src/plugins/extensions/translation/index.ts +++ b/src/plugins/extensions/translation/index.ts @@ -36,8 +36,22 @@ export class TranslationPlugin implements IPlugin { private coordinator: TranslationCoordinator | null = null; onEnable(context: PluginContext): Promise { - const cfg = context.config; + this.coordinator = this.buildCoordinator(context); + context.registerHook('message:received', ctx => this.onMessage(context, ctx as HookContext)); + context.logger.log('Translation plugin enabled', { action: 'translation_enabled' }); + return Promise.resolve(); + } + onConfigChange(context: PluginContext): Promise { + // Rebuild the coordinator so a config edit (e.g. a new LibreTranslate URL/key saved from the + // dashboard) takes effect immediately, without a disable/enable cycle. + this.coordinator = this.buildCoordinator(context); + context.logger.log('Translation plugin config updated', { action: 'translation_config_changed' }); + return Promise.resolve(); + } + + private buildCoordinator(context: PluginContext): TranslationCoordinator { + const cfg = context.config; const translator = new LibreTranslateClient({ url: readString(cfg, 'libretranslateUrl', 'http://localhost:7001'), apiKey: readOptionalString(cfg, 'libretranslateApiKey'), @@ -51,11 +65,7 @@ export class TranslationPlugin implements IPlugin { maxLength: readNumber(cfg, 'maxLength', 2000), denyReply: readBool(cfg, 'denyReply', false), }; - this.coordinator = new TranslationCoordinator(translator, store, gateway, opts); - - context.registerHook('message:received', ctx => this.onMessage(context, ctx as HookContext)); - context.logger.log('Translation plugin enabled', { action: 'translation_enabled' }); - return Promise.resolve(); + return new TranslationCoordinator(translator, store, gateway, opts); } onDisable(context: PluginContext): Promise { From fa2430522b3af536cd6c8f2b298f0de77f54d493 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:55:57 -0500 Subject: [PATCH 4/7] feat(translation): record participant pushName + add TranslationLogger port --- .../extensions/translation/core/ports.ts | 13 +++++++ .../core/translation.coordinator.spec.ts | 39 ++++++++++++++++++- .../core/translation.coordinator.ts | 9 +++++ src/plugins/extensions/translation/index.ts | 9 ++++- 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/plugins/extensions/translation/core/ports.ts b/src/plugins/extensions/translation/core/ports.ts index 44800ca8..2c6e2488 100644 --- a/src/plugins/extensions/translation/core/ports.ts +++ b/src/plugins/extensions/translation/core/ports.ts @@ -19,6 +19,9 @@ export interface ParticipantState { /** Candidate language awaiting a 2nd consecutive detection before a learned switch. */ pendingLang?: string; updatedAt: string; + /** Last-seen WhatsApp pushName; a secondary identity anchor used to reconcile a misrouted + * @lid author back to the real sender. */ + pushName?: string; } export type ParticipantMap = Record; // key = author WID @@ -80,3 +83,13 @@ export interface ChatGateway { sendCombinedReply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise; getGroupAdmins(sessionId: string, chatId: string): Promise; } + +/** + * Structured logging port for the translation core. Implemented at the plugin boundary over the + * host's PluginLogger; declared here so `core/` stays framework-agnostic. + */ +export interface TranslationLogger { + debug(message: string, meta?: Record): void; + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +} diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts index 9800a104..32cbf232 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -1,6 +1,6 @@ // src/modules/translation/core/translation.coordinator.spec.ts import { TranslationCoordinator, CoordinatorOptions } from './translation.coordinator'; -import { ChatGateway, ConfigStore, GroupState, InboundMessage, Translator } from './ports'; +import { ChatGateway, ConfigStore, GroupState, InboundMessage, Translator, TranslationLogger } from './ports'; const OPTS: CoordinatorOptions = { prefix: '/tr', minLength: 2, maxLength: 2000, denyReply: false }; @@ -30,17 +30,35 @@ function makeDeps(state: GroupState) { const translate = jest.fn(); const languages = jest.fn().mockResolvedValue(['en', 'es', 'fr']); const isHealthy = jest.fn().mockReturnValue(true); + const debug = jest.fn(); + const info = jest.fn(); + const warn = jest.fn(); const store: ConfigStore = { load, save }; const gateway: ChatGateway = { sendText, sendCombinedReply, getGroupAdmins }; const translator: Translator = { detect, translate, languages, isHealthy }; + const logger: TranslationLogger = { debug, info, warn }; return { store, gateway, translator, + logger, saved, - mocks: { load, save, sendText, sendCombinedReply, getGroupAdmins, detect, translate, languages, isHealthy }, + mocks: { + load, + save, + sendText, + sendCombinedReply, + getGroupAdmins, + detect, + translate, + languages, + isHealthy, + debug, + info, + warn, + }, }; } @@ -164,4 +182,21 @@ describe('TranslationCoordinator', () => { expect(mocks.detect).not.toHaveBeenCalled(); expect(mocks.sendCombinedReply).not.toHaveBeenCalled(); }); + + it('records the sender pushName on a translated message', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x' }, + '222@c.us': { lang: 'es', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x' }, + }, + }); + const { store, gateway, translator, logger, saved, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'en', confidence: 0.99 }); + mocks.translate.mockResolvedValue('Hola'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: '111@c.us', body: 'Hello', pushName: 'Doug' })); + expect(saved.at(-1)?.participants['111@c.us'].pushName).toBe('Doug'); + }); }); diff --git a/src/plugins/extensions/translation/core/translation.coordinator.ts b/src/plugins/extensions/translation/core/translation.coordinator.ts index d831feec..bf1a7e4d 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.ts @@ -8,6 +8,7 @@ import { ParticipantState, Translation, Translator, + TranslationLogger, CommandTarget, } from './ports'; import { parseCommand } from './command.parser'; @@ -22,6 +23,8 @@ export interface CoordinatorOptions { const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u; +const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: () => {} }; + /** * 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`). @@ -40,6 +43,7 @@ export class TranslationCoordinator { private readonly store: ConfigStore, private readonly gateway: ChatGateway, private readonly opts: CoordinatorOptions, + private readonly logger: TranslationLogger = NOOP_LOGGER, ) {} async handleMessage(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> { @@ -71,6 +75,11 @@ export class TranslationCoordinator { } const sender = this.ensureParticipant(state, msg.author); + // Record the pushName, but never overwrite a different existing value (a misrouted message + // could otherwise poison the identity anchor). + if (msg.pushName && (sender.pushName === undefined || sender.pushName === msg.pushName)) { + sender.pushName = msg.pushName; + } if (!sender.enabled) return; let detected: string; diff --git a/src/plugins/extensions/translation/index.ts b/src/plugins/extensions/translation/index.ts index 6c006ecd..56fbca05 100644 --- a/src/plugins/extensions/translation/index.ts +++ b/src/plugins/extensions/translation/index.ts @@ -10,7 +10,7 @@ import { PluginContext, IPlugin } from '../../../core/plugins'; import { HookContext, HookResult } from '../../../core/hooks'; import { IncomingMessage } from '../../../engine/interfaces/whatsapp-engine.interface'; import { TranslationCoordinator, CoordinatorOptions } from './core/translation.coordinator'; -import { InboundMessage } from './core/ports'; +import { InboundMessage, TranslationLogger } from './core/ports'; import { LibreTranslateClient } from './libretranslate.client'; import { PluginChatGateway } from './plugin-chat.gateway'; import { PluginConfigStore } from './plugin-config.store'; @@ -65,7 +65,12 @@ export class TranslationPlugin implements IPlugin { maxLength: readNumber(cfg, 'maxLength', 2000), denyReply: readBool(cfg, 'denyReply', false), }; - return new TranslationCoordinator(translator, store, gateway, opts); + 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), + }; + return new TranslationCoordinator(translator, store, gateway, opts, logger); } onDisable(context: PluginContext): Promise { From f3bf57e895789e86aa5d49d837369692526675a5 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:01:00 -0500 Subject: [PATCH 5/7] feat(translation): reconcile misrouted @lid author via pushName --- .../core/translation.coordinator.spec.ts | 65 +++++++++++++++++++ .../core/translation.coordinator.ts | 37 ++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts index 32cbf232..65ce0215 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -199,4 +199,69 @@ describe('TranslationCoordinator', () => { await c.handleMessage('s', msg({ author: '111@c.us', body: 'Hello', pushName: 'Doug' })); expect(saved.at(-1)?.participants['111@c.us'].pushName).toBe('Doug'); }); + + it('reconciles a misrouted @lid author via a uniquely-matching pushName', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 'liz@lid': { lang: 'es', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'Lizeth' }, + 'doug@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'Doug' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'es', confidence: 0.99 }); + mocks.translate.mockResolvedValue('I feel sick'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + // Liz's Spanish message is misrouted to Doug's @lid, but the pushName is still Liz's. + await c.handleMessage('s', msg({ author: 'doug@lid', pushName: 'Lizeth', body: 'Me siento mal' })); + expect(mocks.translate).toHaveBeenCalledWith('Me siento mal', 'es', 'en'); + expect(mocks.sendCombinedReply).toHaveBeenCalled(); + expect(mocks.info).toHaveBeenCalledWith( + 'sender reconciled by pushName', + expect.objectContaining({ resolvedKey: 'liz@lid' }), + ); + }); + + it('does not reconcile when the author already owns the pushName', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 'a@lid': { lang: 'es', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x', pushName: 'Sam' }, + 'b@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x', pushName: 'Sam' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'es', confidence: 0.99 }); + mocks.translate.mockResolvedValue('hi'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: 'a@lid', pushName: 'Sam', body: 'Hola amigo' })); + expect(mocks.info).not.toHaveBeenCalledWith('sender reconciled by pushName', expect.anything()); + // a@lid (es) wrote es -> target en (attributed to the author, not reconciled to b). + expect(mocks.translate).toHaveBeenCalledWith('Hola amigo', 'es', 'en'); + }); + + it('does not reconcile when the pushName is ambiguous across participants', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 'x@lid': { lang: 'fr', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x', pushName: 'Xavier' }, + 'a@lid': { lang: 'es', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x', pushName: 'Sam' }, + 'b@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 1, updatedAt: 'x', pushName: 'Sam' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'fr', confidence: 0.99 }); + mocks.translate.mockResolvedValue('x'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + // Author x@lid (Xavier); message pushName 'Sam' matches TWO other participants -> ambiguous. + await c.handleMessage('s', msg({ author: 'x@lid', pushName: 'Sam', body: 'Bonjour tout le monde' })); + expect(mocks.info).not.toHaveBeenCalledWith('sender reconciled by pushName', expect.anything()); + expect(mocks.debug).toHaveBeenCalledWith( + 'ambiguous pushName; not reconciling', + expect.objectContaining({ author: 'x@lid' }), + ); + }); }); diff --git a/src/plugins/extensions/translation/core/translation.coordinator.ts b/src/plugins/extensions/translation/core/translation.coordinator.ts index bf1a7e4d..5965e598 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.ts @@ -74,7 +74,8 @@ export class TranslationCoordinator { return; } - const sender = this.ensureParticipant(state, msg.author); + const senderKey = this.resolveSenderKey(state, msg); + const sender = this.ensureParticipant(state, senderKey); // Record the pushName, but never overwrite a different existing value (a misrouted message // could otherwise poison the identity anchor). if (msg.pushName && (sender.pushName === undefined || sender.pushName === msg.pushName)) { @@ -156,6 +157,40 @@ export class TranslationCoordinator { p.updatedAt = new Date().toISOString(); } + /** + * Resolve which participant a message belongs to. whatsapp-web.js can misroute a group message's + * `@lid` author after a reconnect; when the message's pushName uniquely identifies a DIFFERENT + * known participant (and the author doesn't already own that pushName), trust the pushName. + * Ambiguous (shared pushName) or no-match cases fall back to the raw author. + */ + private resolveSenderKey(state: GroupState, msg: InboundMessage): string { + const { author, pushName } = msg; + if (!pushName) return author; + // No conflict if the author already owns this pushName. + if (state.participants[author]?.pushName === pushName) return author; + const matches = Object.keys(state.participants).filter( + key => key !== author && state.participants[key].pushName === pushName, + ); + if (matches.length === 1) { + this.logger.info('sender reconciled by pushName', { + action: 'translation_sender_reconciled', + author, + resolvedKey: matches[0], + pushName, + }); + return matches[0]; + } + if (matches.length > 1) { + this.logger.debug('ambiguous pushName; not reconciling', { + action: 'translation_pushname_ambiguous', + author, + pushName, + matches, + }); + } + return author; + } + private ensureParticipant(state: GroupState, wid: string): ParticipantState { if (!state.participants[wid]) { state.participants[wid] = { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' }; From b374fa02776a9b12b9b701826e37a19e0b4564f5 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:06:29 -0500 Subject: [PATCH 6/7] feat(translation): language backstop so misrouted messages are never dropped --- .../core/translation.coordinator.spec.ts | 41 +++++++++++++++++++ .../core/translation.coordinator.ts | 25 +++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts index 65ce0215..c320926a 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -264,4 +264,45 @@ describe('TranslationCoordinator', () => { expect.objectContaining({ author: 'x@lid' }), ); }); + + it('engages the backstop instead of dropping when source != senderLang', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 'liz@lid': { lang: 'es', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'Lizeth' }, + 'doug@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'Doug' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + // Worst case: misrouted to Doug AND pushName also corrupted -> reconciliation can't help. + mocks.detect.mockResolvedValue({ lang: 'es', confidence: 0.99 }); + mocks.translate.mockResolvedValue('I feel sick'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: 'doug@lid', pushName: 'Doug', body: 'Me siento mal' })); + expect(mocks.warn).toHaveBeenCalledWith( + 'target backstop engaged (possible misroute or cross-language write)', + expect.objectContaining({ source: 'es' }), + ); + expect(mocks.translate).toHaveBeenCalledWith('Me siento mal', 'es', 'en'); + expect(mocks.sendCombinedReply).toHaveBeenCalled(); + }); + + it('does not warn or translate when the group speaks only the source language', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 'a@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'A' }, + 'b@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'B' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'en', confidence: 0.99 }); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: 'a@lid', pushName: 'A', body: 'Hello there' })); + expect(mocks.translate).not.toHaveBeenCalled(); + expect(mocks.warn).not.toHaveBeenCalled(); + expect(mocks.sendCombinedReply).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/extensions/translation/core/translation.coordinator.ts b/src/plugins/extensions/translation/core/translation.coordinator.ts index 5965e598..41b6e8ac 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.ts @@ -99,10 +99,29 @@ export class TranslationCoordinator { const knownLangs = this.knownLanguages(state); const source = knownLangs.includes(detected) ? detected : (sender.lang ?? detected); - const targets = this.targetLanguages(state, source, sender.lang); + let targets = this.targetLanguages(state, source, sender.lang); if (targets.length === 0) { - await this.store.save(state); - return; + // Backstop: a real message detected in a known language must never be silently dropped due + // to a sender/source mismatch (e.g. a misrouted @lid author keyed to the wrong participant). + // Translate into every known language except the source โ€” guarantees delivery. + const backstop = knownLangs.filter(l => l !== source); + if (backstop.length === 0) { + this.logger.debug('no targets; group speaks only the source language', { + action: 'translation_no_targets', + source, + }); + await this.store.save(state); + return; + } + this.logger.warn('target backstop engaged (possible misroute or cross-language write)', { + action: 'translation_backstop', + author: msg.author, + pushName: msg.pushName, + source, + senderLang: sender.lang, + targets: backstop, + }); + targets = backstop; } const settled = await Promise.allSettled(targets.map(t => this.translator.translate(text, source, t))); From 54c6c1a1e2c9e02cb67e5b3433337421918dbda0 Mon Sep 17 00:00:00 2001 From: dallascyclist <16263935+dallascyclist@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:11:11 -0500 Subject: [PATCH 7/7] feat(translation): log swallowed translate failures + per-message decision --- .../core/translation.coordinator.spec.ts | 41 +++++++++++++++++++ .../core/translation.coordinator.ts | 24 ++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts index c320926a..15b5adb2 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -305,4 +305,45 @@ describe('TranslationCoordinator', () => { expect(mocks.warn).not.toHaveBeenCalled(); expect(mocks.sendCombinedReply).not.toHaveBeenCalled(); }); + + it('warns on a failed translate call and still delivers the successful targets', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + 's1@lid': { lang: 'en', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'S1' }, + 's2@lid': { lang: 'es', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'S2' }, + 's3@lid': { lang: 'fr', source: 'pinned', enabled: true, samples: 5, updatedAt: 'x', pushName: 'S3' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'en', confidence: 0.99 }); + mocks.translate.mockImplementation((_t: string, _s: string, target: string) => + target === 'fr' ? Promise.reject(new Error('boom')) : Promise.resolve('Hola'), + ); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: 's1@lid', pushName: 'S1', body: 'Hello everyone' })); + expect(mocks.warn).toHaveBeenCalledWith('translate call failed', expect.objectContaining({ target: 'fr' })); + expect(mocks.sendCombinedReply).toHaveBeenCalledWith('s', 'g@g.us', 'M1', expect.stringContaining('Hola')); + }); + + it('emits a decision debug log for each translated message', async () => { + const state = freshState({ + announced: true, + active: true, + participants: { + '111@c.us': { lang: 'en', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x', pushName: 'D' }, + '222@c.us': { lang: 'es', source: 'pinned', enabled: true, samples: 2, updatedAt: 'x', pushName: 'L' }, + }, + }); + const { store, gateway, translator, logger, mocks } = makeDeps(state); + mocks.detect.mockResolvedValue({ lang: 'en', confidence: 0.99 }); + mocks.translate.mockResolvedValue('Hola'); + const c = new TranslationCoordinator(translator, store, gateway, OPTS, logger); + await c.handleMessage('s', msg({ author: '111@c.us', pushName: 'D', body: 'Hello' })); + expect(mocks.debug).toHaveBeenCalledWith( + 'translate decision', + expect.objectContaining({ detected: 'en', source: 'en', sent: 1 }), + ); + }); }); diff --git a/src/plugins/extensions/translation/core/translation.coordinator.ts b/src/plugins/extensions/translation/core/translation.coordinator.ts index 41b6e8ac..82f695b6 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.ts @@ -127,7 +127,29 @@ export class TranslationCoordinator { 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 }); + if (r.status === 'fulfilled') { + translations.push({ lang: targets[i], text: r.value }); + } else { + this.logger.warn('translate call failed', { + action: 'translation_translate_failed', + source, + target: targets[i], + error: String(r.reason), + }); + } + }); + + this.logger.debug('translate decision', { + action: 'translation_decision', + author: msg.author, + resolvedKey: senderKey, + pushName: msg.pushName, + detected, + source, + senderLang: sender.lang, + knownLangs, + targets, + sent: translations.length, }); if (translations.length > 0) {