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/app.module.ts b/src/app.module.ts index 7266e5ec..6e5f2de7 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 @@ -89,6 +96,7 @@ if (process.env.QUEUE_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), @@ -186,6 +194,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/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..9d2167eb 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -23,4 +23,41 @@ 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(); + }); + + 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 de17d8b1..a7f5c3e3 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -42,6 +42,23 @@ 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'); + } + + 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/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/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; 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/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); + } + } +} 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..6adf78f1 --- /dev/null +++ b/src/modules/translation/adapters/openwa-chat.gateway.spec.ts @@ -0,0 +1,64 @@ +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 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); + 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..e7e3a438 --- /dev/null +++ b/src/modules/translation/adapters/openwa-chat.gateway.ts @@ -0,0 +1,48 @@ +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, + ) {} + + 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) { + this.logger.warn('getGroupAdmins: no active engine for session', { chatId, action: 'admins_no_engine' }); + return []; + } + const info = await engine.getGroupInfo(chatId); + 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); + // 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: uniqueAdmins.length, + admins: uniqueAdmins, + }); + return uniqueAdmins; + } +} 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); + } +} 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..44800ca8 --- /dev/null +++ b/src/modules/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/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'); +} 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..9800a104 --- /dev/null +++ b/src/modules/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/modules/translation/core/translation.coordinator.ts b/src/modules/translation/core/translation.coordinator.ts new file mode 100644 index 00000000..d831feec --- /dev/null +++ b/src/modules/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/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; +} 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 {}