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 @@ -38,7 +38,7 @@ This repository provides:
| [`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.1.1 | stable |
| [`chatwoot-adapter`](./chatwoot-adapter) | Two-way sync between a WhatsApp session and a Chatwoot inbox: relays WhatsApp messages (1:1 and groups, with media) into Chatwoot as an API-channel inbox, sends agent replies back to WhatsApp, and hands a chat over to a human agent — silencing other OpenWA bots — when an agent takes it in Chatwoot. First consumer of the OpenWA Integration SDK v1; runs sandboxed in the plugin worker. | 0.8.0 | stable |
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.2.1 | 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.2.0 | 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.3.0 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.3.2 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.2.1 | stable |
| [`supabase-otp-hook`](./supabase-otp-hook) | Deliver Supabase Auth phone OTPs over WhatsApp. | 0.3.0 | beta |
Expand Down
27 changes: 27 additions & 0 deletions group-translate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ The version here always matches `manifest.json`'s `version`.

## [Unreleased]

## [1.3.0] — 2026-08-01

### Fixed

- **Group admins were refused their own commands.** WhatsApp delivers the author of a group message
under a privacy id (`…@lid`) while the group's participant list comes back under phone ids
(`…@c.us`). Those two carry unrelated numbers, so an admin failed every comparison and `/tr on`,
`/tr setlang`, `/tr grant` and the rest were silently denied. Only the group's creator was ever
recognized, because WhatsApp reports the owner in the author's own dialect — and in a group created
by the bot's own number, even that did not help, since the bot's own messages are never processed.
The result was a group with no working administrator at all.

When the direct comparison finds nothing, the plugin now asks the host to resolve the author to its
phone identity and compares again. The same bridge fixes `/tr grant`: a controller delegated by
phone number is now recognized when they speak. The lookup costs one call, is spent only on what
would otherwise be a denial, and is remembered afterwards.

### Changed

- **The LibreTranslate backend is configurable without repackaging the plugin.** `libretranslateUrl`
was checked against a fixed `localhost:7001` entry, so pointing the plugin at any other instance
meant editing the manifest and rebuilding the `.zip` — and every other value silently failed. Any
loopback address on any port is now accepted as shipped, and any other host is accepted over https
through the manifest's `allowConfigHosts`. A loopback or private address still needs
`SSRF_ALLOWED_HOSTS` on the OpenWA host; a plain-http backend on a non-loopback host still needs the
manifest edit, because the host admits a config-supplied host only over https.

## [1.2.0] — 2026-08-01

### Changed
Expand Down
50 changes: 32 additions & 18 deletions group-translate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| Field | Value |
| ----- | ----- |
| **Identifier** | `group-translate` |
| **Version** | 1.2.0 |
| **Version** | 1.3.0 |
| **Released** | 2026-08-01 |
| **Status** | stable |
| **Author** | Yudhi Armyndharis |
Expand All @@ -30,8 +30,9 @@
- **Per-participant languages** — learns each member's language from what they type (or pin it with
`/tr setlang`), then posts a combined reply translating a message into the other languages in the group.
- **In-chat control** — manage everything with `/tr` commands; no dashboard round-trip for day-to-day use.
- **Admin-gated** — state-changing commands require a group admin (resolved via `ctx.engine.getGroupInfo`);
the group owner is always recognized. Disabled until an admin runs `/tr on`.
- **Admin-gated** — state-changing commands require a group admin (resolved via `ctx.engine.getGroupInfo`).
Authors that arrive under WhatsApp's `@lid` privacy id are resolved to their phone identity, so an
admin is recognized whichever dialect the host delivers. Disabled until an admin runs `/tr on`.
- **Resilient backend** — a per-call timeout plus a circuit breaker back off a slow or flaky LibreTranslate
instance instead of stalling the chat.
- **Guarded outbound HTTP** — all calls go through the host's SSRF-guarded `ctx.net.fetch`; the backend host
Expand All @@ -54,14 +55,21 @@ Default prefix `/tr` (configurable). Read-only commands are open; the rest are a
## Setup

1. **Run a LibreTranslate instance.** The plugin calls its `/translate` endpoint over `ctx.net.fetch`.
Add the instance's `host:port` to the manifest `net.allow` before packaging, and — if it's on
`localhost`/a private host — also set `SSRF_ALLOWED_HOSTS` on the OpenWA host (see [Security](#security)).
The default `libretranslateUrl` (`http://localhost:7001`) *is* a loopback address, so out of the box
this plugin cannot reach its backend at all until this is done: set the environment variable
**`SSRF_ALLOWED_HOSTS`** on the OpenWA host itself — not in this plugin's config — e.g.
`SSRF_ALLOWED_HOSTS=localhost,127.0.0.1`.
2. Have OpenWA **≥ 0.7.0** running with a logged-in WhatsApp session for the group(s) you want to translate.
3. Install and enable the plugin (see [Install](#install)), then have a group admin run `/tr on`.
Set `libretranslateUrl` to it — no repackaging needed for either supported shape:
- **loopback, any port** (`http://localhost:7001`, `http://127.0.0.1:5000`) — allowed as shipped
- **any other host, over https** (`https://translate.example.com`) — allowed via the manifest's
`allowConfigHosts`, which admits an operator-configured host only over https

A plain-http backend on a non-loopback host (e.g. `http://libretranslate:5000` inside a Docker
network) is the one case still outside that: add its `host:port` to the manifest `net.allow` and
repackage, or put it behind https.
2. **Allow the address on the OpenWA host if it is loopback or private.** The gateway's SSRF guard
blocks those by default, so set the environment variable **`SSRF_ALLOWED_HOSTS`** on the OpenWA host
itself — not in this plugin's config — e.g. `SSRF_ALLOWED_HOSTS=localhost,127.0.0.1`. The default
`libretranslateUrl` *is* a loopback address, so out of the box the plugin cannot reach its backend
until this is done (see [Security](#security)).
3. Have OpenWA **≥ 0.7.0** running with a logged-in WhatsApp session for the group(s) you want to translate.
4. Install and enable the plugin (see [Install](#install)), then have a group admin run `/tr on`.
Enabling the plugin is silent: it says nothing in any group until someone addresses it with a
`/tr` command. It never translates until an admin has run `/tr on` in that specific group, and it
never introduces itself unless you turn on `announceInGroups` — which posts into *every* group the
Expand All @@ -70,7 +78,7 @@ Default prefix `/tr` (configurable). Read-only commands are open; the rest are a
## Install

```bash
# 1. Set the manifest's net.allow to YOUR LibreTranslate host:port (see Security), then package:
# Build from source (released .zip files are on the Releases page):
node package.mjs group-translate # produces group-translate.zip at the repo root

curl -X POST "https://your-openwa-host/plugins/install" \
Expand All @@ -91,7 +99,7 @@ Then, in the group, an admin runs `/tr on`. Or install the packaged `.zip` from

| Key | Required | Default | Description |
| --- | -------- | ------- | ----------- |
| `libretranslateUrl` | yes | `http://localhost:7001` | Base URL of your LibreTranslate instance — its host:port **must** be in the manifest `net.allow`; a `localhost`/private host **also** requires `SSRF_ALLOWED_HOSTS` on the gateway (see [Security](#security)) |
| `libretranslateUrl` | yes | `http://localhost:7001` | Base URL of your LibreTranslate instance. Loopback on any port works as shipped; any other host is admitted over **https** only. A loopback/private address **also** requires `SSRF_ALLOWED_HOSTS` on the gateway (see [Security](#security)) |
| `libretranslateApiKey` | no | — | Secret API key, if your instance requires one (redacted on read) |
| `timeoutMs` | no | `4000` | Per-call timeout; keep ≤ the host hook budget (5000 ms) |
| `commandPrefix` | no | `/tr` | The in-chat command prefix |
Expand Down Expand Up @@ -125,8 +133,11 @@ to a single WhatsApp session via the dashboard's session scope).
Outbound translate calls go **exclusively** through the host's SSRF-guarded `ctx.net.fetch`; there is no
raw socket in the plugin. Two independent controls apply and **both** must pass:

1. **Manifest allowlist (`net.allow`, deny by default).** Before packaging you **must** set `net.allow`
to the `host:port` of your `libretranslateUrl` — e.g. `"net": { "allow": ["libretranslate:7001"] }`.
1. **Manifest allowlist (deny by default).** The plugin ships allowing `localhost` and `127.0.0.1` on
any port, plus — via `net.allowConfigHosts` — the host of whatever `libretranslateUrl` you configure,
**provided that URL is https**. The host deliberately ignores a non-https config value here, so a
plain-http backend on a non-loopback host is the one shape that still needs `net.allow` edited and the
plugin repackaged — e.g. `"net": { "allow": ["libretranslate:7001"] }`.
2. **Host SSRF guard.** The host additionally blocks private / loopback addresses (`127.0.0.0/8`, `::1`,
RFC-1918, link-local) at connect time, **regardless of `net.allow`**, with no plugin opt-out.

Expand All @@ -135,11 +146,14 @@ host** — including this plugin's default `http://localhost:7001` — is blocke
that hostname to the gateway environment variable **`SSRF_ALLOWED_HOSTS`** (e.g.
`SSRF_ALLOWED_HOSTS=localhost,127.0.0.1`, or the Docker service name). Without it, translate calls fail at
connect: the plugin fails open (messages pass through **untranslated**) and the circuit opens after a few
failures. A **public** LibreTranslate host needs only `net.allow`, not `SSRF_ALLOWED_HOSTS`.
failures. A **public** LibreTranslate host passes (2) unconditionally and needs no `SSRF_ALLOWED_HOSTS`.

The API key travels only in the request body to the allow-listed host and is stored as a redacted secret.
Commands that change group state are admin-gated via `ctx.engine.getGroupInfo`; the per-call timeout (≤ the
host hook budget) and circuit breaker keep a slow backend from stalling the host.
Commands that change group state are admin-gated via `ctx.engine.getGroupInfo`. When WhatsApp delivers the
author under its `@lid` privacy id, the plugin resolves that to the author's phone identity through
`ctx.engine.getContactById` before comparing — resolution widens *recognition*, never permission: an
author who resolves to someone outside the admin and delegated-controller lists is still refused. The
per-call timeout (≤ the host hook budget) and circuit breaker keep a slow backend from stalling the host.

## Changelog

Expand Down
6 changes: 6 additions & 0 deletions group-translate/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ 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[]>;
/**
* The `<phone>@c.us` form of a wid the host delivered in another dialect — in practice a `@lid`
* message author, which carries a different user number than the `@c.us` ids the group participant
* list uses, so the two can never be compared directly. Returns null when the host cannot resolve it.
*/
resolveCanonicalWid(sessionId: string, wid: string): Promise<string | null>;
}

/**
Expand Down
91 changes: 90 additions & 1 deletion group-translate/core/translation.coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ function makeDeps(state: GroupState) {
getGroupAdminsCalls.push([_sessionId, _chatId]);
return getGroupAdminsResult;
};
const resolveCanonicalWidCalls: unknown[][] = [];
// Default: the host resolves nothing, which is the pre-existing behaviour every older test asserts.
let resolveCanonicalWidResult: string | null = null;
const resolveCanonicalWid = async (_sessionId: string, _wid: string): Promise<string | null> => {
resolveCanonicalWidCalls.push([_sessionId, _wid]);
return resolveCanonicalWidResult;
};

// translator spies
const detectCalls: unknown[][] = [];
Expand Down Expand Up @@ -90,7 +97,7 @@ function makeDeps(state: GroupState) {
const warn = (message: string, meta?: Record<string, unknown>) => { warnCalls.push([message, meta]); };

const store: ConfigStore = { load, save };
const gateway: ChatGateway = { sendText, sendCombinedReply, getGroupAdmins };
const gateway: ChatGateway = { sendText, sendCombinedReply, getGroupAdmins, resolveCanonicalWid };
const translator: Translator = { detect, translate, languages, isHealthy };
const logger: TranslationLogger = { debug, info, warn };

Expand All @@ -103,6 +110,10 @@ function makeDeps(state: GroupState) {
calls: getGroupAdminsCalls,
mockResolvedValue: (v: string[]) => { getGroupAdminsResult = v; },
},
resolveCanonicalWid: {
calls: resolveCanonicalWidCalls,
mockResolvedValue: (v: string | null) => { resolveCanonicalWidResult = v; },
},
detect: {
calls: detectCalls,
mockResolvedValue: (v: { lang: string; confidence: number }) => { detectImpl = async () => v; },
Expand Down Expand Up @@ -218,6 +229,83 @@ describe('TranslationCoordinator', () => {
assert.equal(saved.at(-1)?.active ?? false, false);
});

// Regression, reproduced live on an OpenWA 0.12.1 host: WhatsApp delivered the author as
// `148004841455867@lid` while getGroupInfo listed that same person as `6281770008896@c.us`. The two
// user numbers are unrelated, so a promoted admin was refused `/tr on` and the group had no working
// administrator at all — its owner was the bot's own number, whose messages never reach this code.
test('activates for an admin whose author id arrives in the @lid dialect', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, saved, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['6281770008896@c.us']); // participant list, @c.us
mocks.resolveCanonicalWid.mockResolvedValue('6281770008896@c.us'); // what the host resolves it to
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

const res = await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr on' }));

assert.deepEqual(res, { swallow: true });
assert.equal(saved.at(-1)?.active, true, 'the admin must be recognized across the lid/phone split');
});

test('a delegated controller named by phone number is recognized behind a @lid author', async () => {
const state = freshState({ announced: true, delegatedControllers: ['6281770008896@c.us'] });
const { store, gateway, translator, saved, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['999@c.us']); // not an admin — delegation is the only route
mocks.resolveCanonicalWid.mockResolvedValue('6281770008896@c.us');
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr on' }));

assert.equal(saved.at(-1)?.active, true, '/tr grant stores @c.us, so it needs the same bridge');
});

test('still refuses a genuine non-admin after resolving their canonical id', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, saved, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['6289999999999@c.us']);
mocks.resolveCanonicalWid.mockResolvedValue('6281770008896@c.us'); // resolvable, but not an admin
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr on' }));

assert.equal(saved.at(-1)?.active ?? false, false, 'resolution must not become a bypass');
});

test('does not spend a resolution round-trip when the direct comparison already authorizes', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['111@c.us']); // matches the default author directly
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

await c.handleMessage('s', msg({ body: '/tr on' }));

assert.equal(mocks.resolveCanonicalWid.calls.length, 0, 'the extra engine call is for denials only');
});

test('memoizes the canonical id across commands from the same author', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['6281770008896@c.us']);
mocks.resolveCanonicalWid.mockResolvedValue('6281770008896@c.us');
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr on' }));
await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr off' }));

assert.equal(mocks.resolveCanonicalWid.calls.length, 1, 'a lid<->phone binding does not change');
});

test('falls back to the direct comparison when the host cannot resolve the author', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, saved, mocks } = makeDeps(state);
mocks.getGroupAdmins.mockResolvedValue(['6281770008896@c.us']);
mocks.resolveCanonicalWid.mockResolvedValue(null); // unknown contact, slow engine, dead session
const c = new TranslationCoordinator(translator, store, gateway, OPTS);

await c.handleMessage('s', msg({ author: '148004841455867@lid', body: '/tr on' }));

assert.equal(saved.at(-1)?.active ?? false, false, 'unresolvable must deny, never fail open');
});

test('rejects activation from a non-admin silently by default (denyReply false)', async () => {
const state = freshState({ announced: true });
const { store, gateway, translator, saved, mocks } = makeDeps(state);
Expand Down Expand Up @@ -527,6 +615,7 @@ describe('TranslationCoordinator', () => {
sendText: async (_s: string, _c: string, text: string) => { await Promise.resolve(); sends.push(text); },
sendCombinedReply: async () => {},
getGroupAdmins: async () => [],
resolveCanonicalWid: async () => null,
};
const translator: Translator = {
detect: async () => ({ lang: 'en', confidence: 1 }), translate: async () => '',
Expand Down
Loading
Loading