From 6c235c92118b26630d9088d6451530d9050c9807 Mon Sep 17 00:00:00 2001 From: Yudhi Armyndharis Date: Sat, 1 Aug 2026 15:03:08 +0700 Subject: [PATCH] fix(group-translate): recognize admins across the lid/phone split, and let the backend be configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp delivers a group message's author under a privacy id (`…@lid`) while getGroupInfo lists participants under phone ids (`…@c.us`). The two carry unrelated user numbers, so widEquals could never relate them: every admin failed the check and only the group `owner` — which WhatsApp reports in the author's own dialect — was recognized. In a group created by the bot's own number even that failed, since the owner's messages are fromMe and never reach the coordinator, leaving the group with no working administrator. When the direct comparison finds nothing, the author is now resolved to its canonical `@c.us` identity through getContactById and compared again. That is the same identity `/tr grant` stores for a controller named by phone number, so delegation starts working too. The call is spent only on what would otherwise be a denial, and memoized afterwards — a lid/phone binding does not change. Resolution widens recognition, never permission: an author who resolves to someone outside both lists is still refused, and an unresolvable one denies. Separately, `net.allow` pinned the backend to `localhost:7001`, so pointing the plugin at any other LibreTranslate meant editing the manifest and rebuilding the zip — while the free-form `libretranslateUrl` field implied otherwise and silently failed. It now allows loopback on any port and, through allowConfigHosts, whatever https host the operator configures, matching how voice-transcription already handles its backend. --- README.md | 2 +- group-translate/CHANGELOG.md | 27 ++++++ group-translate/README.md | 50 ++++++---- group-translate/core/ports.ts | 6 ++ .../core/translation.coordinator.test.ts | 91 ++++++++++++++++++- .../core/translation.coordinator.ts | 75 ++++++++++++++- group-translate/manifest.json | 10 +- group-translate/plugin-chat.gateway.ts | 20 ++++ plugins.json | 4 +- 9 files changed, 258 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9d1c855..e9e897b 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/group-translate/CHANGELOG.md b/group-translate/CHANGELOG.md index 21af00b..5c39f7a 100644 --- a/group-translate/CHANGELOG.md +++ b/group-translate/CHANGELOG.md @@ -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 diff --git a/group-translate/README.md b/group-translate/README.md index 8588cc3..422a3fd 100644 --- a/group-translate/README.md +++ b/group-translate/README.md @@ -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 | @@ -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 @@ -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 @@ -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" \ @@ -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 | @@ -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. @@ -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 diff --git a/group-translate/core/ports.ts b/group-translate/core/ports.ts index efed089..28a7ba5 100644 --- a/group-translate/core/ports.ts +++ b/group-translate/core/ports.ts @@ -81,6 +81,12 @@ 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; + /** + * The `@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; } /** diff --git a/group-translate/core/translation.coordinator.test.ts b/group-translate/core/translation.coordinator.test.ts index 2970849..596cc86 100644 --- a/group-translate/core/translation.coordinator.test.ts +++ b/group-translate/core/translation.coordinator.test.ts @@ -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 => { + resolveCanonicalWidCalls.push([_sessionId, _wid]); + return resolveCanonicalWidResult; + }; // translator spies const detectCalls: unknown[][] = []; @@ -90,7 +97,7 @@ function makeDeps(state: GroupState) { const warn = (message: string, meta?: Record) => { 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 }; @@ -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; }, @@ -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); @@ -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 () => '', diff --git a/group-translate/core/translation.coordinator.ts b/group-translate/core/translation.coordinator.ts index 3e804f9..0560bb5 100644 --- a/group-translate/core/translation.coordinator.ts +++ b/group-translate/core/translation.coordinator.ts @@ -31,6 +31,10 @@ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const NOOP_LOGGER: TranslationLogger = { debug: () => {}, info: () => {}, warn: () => {} }; +/** Cap on the memoized author -> canonical-wid table. One entry per distinct author whose command + * needed resolving, so this only fills up in a deployment with many groups and many commanders. */ +const MAX_CANONICAL_WIDS = 500; + /** * 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`). @@ -46,6 +50,8 @@ 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>(); + /** `${sessionId}:${wid}` -> canonical `@c.us` wid, or null when the host could not resolve it. */ + private readonly canonicalWids = new Map(); constructor( private readonly translator: Translator, @@ -293,8 +299,7 @@ export class TranslationCoordinator { 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 { isAdmin, isController } = await this.authorize(sessionId, msg, state, admins); const adminOnly = cmd.name === 'grant' || cmd.name === 'revoke'; if ((adminOnly && !isAdmin) || (!adminOnly && !isController)) { // Reply on denial only when the operator opted in (denyReply). Default is silent, so an @@ -407,4 +412,70 @@ export class TranslationCoordinator { private targetHelp(): string { return "⚠️ Couldn't identify that user. Target them by @mention, by phone number, or use 'me' for yourself."; } + + /** + * Decide whether the author may run a state-changing command. + * + * WhatsApp hands the author of a group message a `@lid` privacy id, while the group participant list + * comes back keyed `@c.us`. Those are different user numbers, so no amount of string normalization + * relates them (see widEquals): a promoted admin simply failed every comparison, and only the group + * `owner` — which WhatsApp reports in the author's own dialect — was ever recognized. A group created + * by the bot's own number therefore had NO usable administrator at all, because the owner's messages + * are fromMe and never reach this code. + * + * So when the direct comparison finds nothing, ask the host to resolve the author to its canonical + * `@c.us` identity and compare again. That second identity is what matches the participant list, and + * it is equally what a `/tr grant` stored for a delegated controller who was named by phone number. + * + * The resolution costs one engine round-trip, so it is spent only on a decision that is otherwise a + * denial, and the result is memoized: a lid↔phone binding does not change. + */ + private async authorize( + sessionId: string, + msg: InboundMessage, + state: GroupState, + admins: string[], + ): Promise<{ isAdmin: boolean; isController: boolean }> { + const decide = (identities: string[]) => { + const isAdmin = admins.some(a => identities.some(id => widEquals(a, id))); + return { + isAdmin, + isController: isAdmin || state.delegatedControllers.some(c => identities.some(id => widEquals(c, id))), + }; + }; + + const direct = decide([msg.author]); + if (direct.isController) return direct; + + const canonical = await this.canonicalWid(sessionId, msg.author); + if (!canonical || widEquals(canonical, msg.author)) return direct; + + const resolved = decide([msg.author, canonical]); + if (resolved.isController) { + this.logger.debug('author authorized via its canonical identity', { + action: 'translation_author_canonicalized', + author: msg.author, + canonical, + }); + } + return resolved; + } + + /** Memoized {@link ChatGateway.resolveCanonicalWid}. A null (unresolvable) answer is cached too — + * retrying it on every command would spend a round-trip per denial on a wid the host cannot map. */ + private async canonicalWid(sessionId: string, wid: string): Promise { + const key = `${sessionId}:${wid}`; + const hit = this.canonicalWids.get(key); + if (hit !== undefined) return hit; + + const resolved = await this.gateway.resolveCanonicalWid(sessionId, wid); + // Bounded: one entry per distinct author the plugin has had to resolve. Evict oldest-first (Map + // preserves insertion order) rather than growing without limit in a busy multi-group deployment. + if (this.canonicalWids.size >= MAX_CANONICAL_WIDS) { + const oldest = this.canonicalWids.keys().next().value; + if (oldest !== undefined) this.canonicalWids.delete(oldest); + } + this.canonicalWids.set(key, resolved); + return resolved; + } } diff --git a/group-translate/manifest.json b/group-translate/manifest.json index 01b0c97..3d9e2f1 100644 --- a/group-translate/manifest.json +++ b/group-translate/manifest.json @@ -1,7 +1,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.2.0", + "version": "1.3.0", "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.", @@ -30,7 +30,11 @@ ], "net": { "allow": [ - "localhost:7001" + "localhost", + "127.0.0.1" + ], + "allowConfigHosts": [ + "libretranslateUrl" ] }, "sessionScoped": true, @@ -48,7 +52,7 @@ "title": "LibreTranslate URL", "default": "http://localhost:7001", "required": true, - "description": "Base URL of your LibreTranslate instance. Its host:port MUST also appear in this plugin's manifest `net.allow` (the host denies outbound calls to anything not allow-listed)." + "description": "Base URL of your LibreTranslate instance. A loopback URL (localhost / 127.0.0.1, any port) is allowed out of the box; any other host is admitted only over https, via net.allowConfigHosts. A loopback or otherwise private address additionally requires SSRF_ALLOWED_HOSTS on the OpenWA host." }, "libretranslateApiKey": { "type": "string", diff --git a/group-translate/plugin-chat.gateway.ts b/group-translate/plugin-chat.gateway.ts index 489852f..c99d04d 100644 --- a/group-translate/plugin-chat.gateway.ts +++ b/group-translate/plugin-chat.gateway.ts @@ -44,4 +44,24 @@ export class PluginChatGateway implements ChatGateway { if (typeof info.owner === 'string' && info.owner) admins.push(info.owner); return [...new Set(admins)]; } + + /** + * `getContactById` is the bridge across the @lid/@c.us split: handed a `@lid`, the engine looks the + * contact up in its own store and returns the SAME contact keyed by its canonical `@c.us` id. + * (Its `number` field is not that — for a lid contact it is the lid's user part, which is exactly the + * value that cannot be compared with a participant list.) Read-only and permission-gated by + * `engine:read`, which this plugin already declares for the admin lookup above. + * + * Best-effort by contract: an unknown contact, a slow engine or a torn-down session all resolve to + * null, and the caller falls back to the direct comparison rather than failing the command. + */ + async resolveCanonicalWid(sessionId: string, wid: string): Promise { + try { + const contact = (await this.engine.getContactById(sessionId, wid)) as { id?: unknown } | null | undefined; + const id = contact?.id; + return typeof id === 'string' && id.length > 0 ? id : null; + } catch { + return null; + } + } } diff --git a/plugins.json b/plugins.json index c78c22f..cfe4bfe 100644 --- a/plugins.json +++ b/plugins.json @@ -833,7 +833,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.2.0", + "version": "1.3.0", "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.", @@ -853,7 +853,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.2.0/group-translate.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.3.0/group-translate.zip", "i18n": { "es": { "name": "Traducción Automática de Grupos",