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",