Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ This repository provides:
| [`after-hours`](./after-hours) | Auto-replies with a configurable away/closing message to messages received outside business hours. | 0.1.2 | stable |
| [`chat-flow`](./chat-flow) | Interactive, stateful auto-reply: a trigger word starts a greeting + numbered menu, replies traverse a configurable menu tree, and per-chat state expires after 15 minutes. | 1.0.2 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.2 | stable |
| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.2 | stable |
| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.3 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.2 | stable |
<!-- END PLUGIN CATALOG -->

Expand Down
13 changes: 13 additions & 0 deletions group-translate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

## [1.0.3] — 2026-06-23

### Fixed

- Participant lookups now reject prototype keys (`__proto__`, `constructor`, `prototype`) and test
existence with `hasOwnProperty`, so a crafted participant/target id can no longer read or write
`Object.prototype`.
- Concurrent messages for the same group are serialized through a per-(session, chat) lock, closing a
load→mutate→save race that could duplicate the help announcement or drop a participant-language update.
The lock map self-evicts when a chat's queue drains.
- A LibreTranslate `/translate` response without a string `translatedText` now fails the call (counted by
the circuit breaker and excluded from the reply) instead of posting the literal text `undefined`.

## [1.0.2] — 2026-06-23

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion group-translate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
| Field | Value |
| ----- | ----- |
| **Identifier** | `group-translate` |
| **Version** | 1.0.2 |
| **Version** | 1.0.3 |
| **Released** | 2026-06-23 |
| **Status** | stable |
| **Author** | Yudhi Armyndharis |
Expand Down
33 changes: 33 additions & 0 deletions group-translate/core/translation.coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,4 +442,37 @@ describe('TranslationCoordinator', () => {
}),
);
});

test('a sender wid of __proto__ does not pollute Object.prototype', async () => {
const { store, gateway, translator } = makeDeps(freshState({ active: true, announced: true }));
const c = new TranslationCoordinator(translator, store, gateway, OPTS);
await c.handleMessage('s', msg({ author: '__proto__', pushName: 'EVIL', body: 'hola amigo mio' }));
const leaked = (Object.prototype as Record<string, unknown>).pushName;
delete (Object.prototype as Record<string, unknown>).pushName; // cleanup regardless of assertion outcome
assert.equal(leaked, undefined, 'Object.prototype must not be polluted via a crafted participant wid');
});

test('concurrent first messages for the same group announce only once', async () => {
let current: GroupState = freshState({ active: false, announced: false });
const sends: string[] = [];
const store: ConfigStore = {
load: async () => { await Promise.resolve(); return JSON.parse(JSON.stringify(current)) as GroupState; },
save: async (s: GroupState) => { await Promise.resolve(); current = JSON.parse(JSON.stringify(s)) as GroupState; },
};
const gateway: ChatGateway = {
sendText: async (_s: string, _c: string, text: string) => { await Promise.resolve(); sends.push(text); },
sendCombinedReply: async () => {},
getGroupAdmins: async () => [],
};
const translator: Translator = {
detect: async () => ({ lang: 'en', confidence: 1 }), translate: async () => '',
languages: async () => ['en'], isHealthy: () => true,
};
const c = new TranslationCoordinator(translator, store, gateway, OPTS);
await Promise.all([
c.handleMessage('s', msg({ id: 'm1', body: 'hello there' })),
c.handleMessage('s', msg({ id: 'm2', body: 'hello again' })),
]);
assert.equal(sends.length, 1, 'the help announcement must be sent once, not duplicated by a load/save race');
});
});
28 changes: 27 additions & 1 deletion group-translate/core/translation.coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export interface CoordinatorOptions {

const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u;

/** Object keys that index the prototype chain rather than an own property; never valid as a wid. */
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: () => {} };

/**
Expand All @@ -37,6 +40,9 @@ function widEquals(a: string, b: string): boolean {
}

export class TranslationCoordinator {
/** Per (session,chat) promise chain serializing the load→mutate→save cycle. Self-evicts when drained. */
private readonly locks = new Map<string, Promise<unknown>>();

constructor(
private readonly translator: Translator,
private readonly store: ConfigStore,
Expand All @@ -47,7 +53,22 @@ export class TranslationCoordinator {

async handleMessage(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> {
if (!msg.isGroup || msg.fromMe || !msg.author) return { swallow: false };
// Concurrent messages for the same group must not interleave load→mutate→save (lost updates /
// duplicate announcements). Chain each behind the previous for the same key; store a settled tail
// so one rejection can't wedge the chain, and evict the entry once the chain drains.
const key = `${sessionId}:${msg.chatId}`;
const prev = this.locks.get(key) ?? Promise.resolve();
const run = prev.then(() => this.handleMessageLocked(sessionId, msg));
const tail = run.catch(() => {});
this.locks.set(key, tail);
try {
return await run;
} finally {
if (this.locks.get(key) === tail) this.locks.delete(key);
}
}

private async handleMessageLocked(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> {
const state = await this.store.load(sessionId, msg.chatId);

if (!state.announced) {
Expand Down Expand Up @@ -232,7 +253,12 @@ export class TranslationCoordinator {
}

private ensureParticipant(state: GroupState, wid: string): ParticipantState {
if (!state.participants[wid]) {
if (UNSAFE_KEYS.has(wid)) {
// A real WhatsApp id never equals a prototype key; refuse to index the map by it so a crafted
// author/target can't read or write Object.prototype. Return a throwaway, non-persisted state.
return { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' };
}
if (!Object.prototype.hasOwnProperty.call(state.participants, wid)) {
state.participants[wid] = { lang: null, source: 'learned', enabled: true, samples: 0, updatedAt: '' };
}
return state.participants[wid];
Expand Down
6 changes: 6 additions & 0 deletions group-translate/libretranslate.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ test('translate posts and returns translatedText on success', async () => {
assert.equal(calls[0], 'http://lt:7001/translate'); // trailing slash trimmed
});

test('translate throws when the response lacks a translatedText string', async () => {
const { net } = fakeNet([async () => res({ json: async () => ({}) })]); // partial/empty body
const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net });
await assert.rejects(c.translate('hi', 'en', 'es'), /translatedText/);
});

test('detect returns the top result', async () => {
const { net } = fakeNet([async () => res({ json: async () => [{ language: 'en', confidence: 0.9 }] })]);
const c = new LibreTranslateClient({ url: 'http://lt:7001', timeoutMs: 4000, net });
Expand Down
7 changes: 6 additions & 1 deletion group-translate/libretranslate.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ export class LibreTranslateClient implements Translator {

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;
translatedText?: unknown;
};
if (typeof data?.translatedText !== 'string') {
// A partial/empty body must fail (counted by the circuit breaker, excluded from the reply)
// rather than become the literal string 'undefined' in the group.
throw new Error('LibreTranslate /translate returned no translatedText');
}
return data.translatedText;
}

Expand Down
2 changes: 1 addition & 1 deletion group-translate/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "group-translate",
"name": "Group Auto-Translation",
"version": "1.0.2",
"version": "1.0.3",
"type": "extension",
"main": "dist/index.js",
"description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.",
Expand Down
4 changes: 2 additions & 2 deletions plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@
{
"id": "group-translate",
"name": "Group Auto-Translation",
"version": "1.0.2",
"version": "1.0.3",
"type": "extension",
"status": "stable",
"description": "Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled.",
Expand All @@ -560,7 +560,7 @@
"repoPath": "group-translate",
"repoUrl": "https://github.com/rmyndharis/OpenWA-plugins",
"homepage": "https://github.com/rmyndharis/OpenWA-plugins/tree/main/group-translate",
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.0.2/group-translate.zip",
"download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.0.3/group-translate.zip",
"i18n": {
"es": {
"name": "Traducción Automática de Grupos",
Expand Down
Loading