Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type | DynamicModule> = [];
Expand All @@ -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<Type | DynamicModule> = [];
if (process.env.TRANSLATION_ENABLED === 'true') {
translationModules.push(TranslationModule);
}

@Module({
imports: [
// Configuration
Expand Down Expand Up @@ -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<boolean>('dataDatabase.logging', false),
Expand Down Expand Up @@ -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 {}
13 changes: 13 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
37 changes: 37 additions & 0 deletions src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
17 changes: 17 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 - ')}`);
}
Expand Down
31 changes: 31 additions & 0 deletions src/database/migrations/1779950000000-AddTranslationGroups.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await queryRunner.query(`DROP INDEX "IDX_translation_groups_sessionId"`);
await queryRunner.query(`DROP TABLE "translation_groups"`);
}
}
28 changes: 28 additions & 0 deletions src/engine/adapters/message-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
6 changes: 6 additions & 0 deletions src/engine/adapters/message-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/engine/interfaces/whatsapp-engine.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/modules/message/message.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
49 changes: 49 additions & 0 deletions src/modules/translation/adapters/libretranslate.client.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<unknown>, [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<string, unknown>;
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);
});
});
86 changes: 86 additions & 0 deletions src/modules/translation/adapters/libretranslate.client.ts
Original file line number Diff line number Diff line change
@@ -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<DetectResult> {
const data = (await this.post('/detect', { q: text })) as Array<{ language: string; confidence: number }>;
const top = data[0];
if (!top) throw new Error('LibreTranslate /detect returned no result');
return { lang: top.language, confidence: top.confidence };
}

async translate(text: string, source: string, target: string): Promise<string> {
const data = (await this.post('/translate', { q: text, source, target, format: 'text' })) as {
translatedText: string;
};
return data.translatedText;
}

async languages(): Promise<string[]> {
const data = (await this.post('/languages', {}, 'GET')) as Array<{ code: string }>;
return data.map(l => l.code);
}

private async post(
path: string,
payload: Record<string, unknown>,
method: 'GET' | 'POST' = 'POST',
): Promise<unknown> {
const now = Date.now();
if (now < this.openUntil) {
throw new Error('LibreTranslate circuit open');
}

const 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);
}
}
}
Loading