Skip to content
10 changes: 10 additions & 0 deletions src/engine/adapters/message-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/engine/adapters/message-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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;
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 @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/plugins/extensions/extensions.module.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -28,6 +29,52 @@ 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: ['*'],
// 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());
this.logger.log('Translation plugin registered (disabled)');
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/plugins/extensions/translation/core/command.parser.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
64 changes: 64 additions & 0 deletions src/plugins/extensions/translation/core/command.parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// src/modules/translation/core/command.parser.ts
import { ParsedCommand, CommandName, CommandTarget } from './ports';

const COMMANDS: ReadonlySet<string> = new Set<CommandName>([
'help',
'status',
'on',
'off',
'setlang',
'auto',
'ignore',
'unignore',
'grant',
'revoke',
]);

const NEEDS_TARGET: ReadonlySet<string> = 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, '') };
}
95 changes: 95 additions & 0 deletions src/plugins/extensions/translation/core/ports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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;
/** 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<string, ParticipantState>; // 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<DetectResult>;
translate(text: string, source: string, target: string): Promise<string>;
languages(): Promise<string[]>;
isHealthy(): boolean;
}

export interface ConfigStore {
load(sessionId: string, chatId: string): Promise<GroupState>;
save(state: GroupState): Promise<void>;
}

export interface ChatGateway {
sendText(sessionId: string, chatId: string, text: string): Promise<void>;
sendCombinedReply(sessionId: string, chatId: string, quotedMessageId: string, text: string): Promise<void>;
getGroupAdmins(sessionId: string, chatId: string): Promise<string[]>;
}

/**
* 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<string, unknown>): void;
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
}
36 changes: 36 additions & 0 deletions src/plugins/extensions/translation/core/reply.formatter.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading