# Group Auto-Translation > Auto-translates group messages between participants' languages via a LibreTranslate backend, controlled > in-chat with `/tr` commands — so a mixed-language group reads in everyone's own language. ![type: extension](https://img.shields.io/badge/type-extension-blue.svg) ![license: MIT](https://img.shields.io/badge/license-MIT-green.svg) ![built for OpenWA](https://img.shields.io/badge/OpenWA-%E2%89%A5%200.8.0-25D366.svg) [![downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Frmyndharis%2FOpenWA-plugins%2Fbadges%2Fdownloads%2Fgroup-translate.json)](https://github.com/rmyndharis/OpenWA-plugins/releases?q=group-translate) ## Details | Field | Value | | ----- | ----- | | **Identifier** | `group-translate` | | **Version** | 1.3.17 | | **Released** | 2026-11-10 | | **Status** | stable | | **Author** | Yudhi Armyndharis | | **License** | MIT | | **Type** | `extension` | | **Requires OpenWA** | ≥ 0.8.0 (tested 0.30.0) | | **Keywords** | translation, libretranslate, i18n, groups, whatsapp, openwa | | **Repository** | [OpenWA-plugins/group-translate](https://github.com/rmyndharis/OpenWA-plugins/tree/main/group-translate) | ## Features - **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`). 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 must be allow-listed (see **Security**). The API key is stored as a redacted secret. ## Commands Default prefix `/tr` (configurable). `/tr help` is open to anyone; every other command is admin-only. | Command | Who | Effect | | ------- | --- | ------ | | `/tr help` | anyone | Show the command list (answered at most once a minute per group) | | `/tr status` | admin | Show whether translation is on + per-participant languages | | `/tr on` · `/tr off` | admin | Enable / disable translation in this group | | `/tr setlang [@user]` | admin | Pin a language (e.g. `/tr setlang id @member`) | | `/tr auto [@user]` | admin | Go back to auto-learning a participant's language | | `/tr ignore` · `/tr unignore [@user]` | admin | Skip / resume translating a participant | | `/tr grant` · `/tr revoke [@user]` | admin | Delegate / remove control to a non-admin participant | ## Setup 1. **Run a LibreTranslate instance.** The plugin calls its `/translate` endpoint over `ctx.net.fetch`. 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.8.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 account is in, so leave it off unless the account is a dedicated bot number. ## Install ```bash # 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/api/plugins/install" \ -H "X-API-Key: " -F "file=@group-translate.zip" curl -X PUT "https://your-openwa-host/api/plugins/group-translate/config" \ -H "X-API-Key: " -H "Content-Type: application/json" \ -d '{ "config": { "libretranslateUrl": "https://translate.example.com", "timeoutMs": 4000 } }' curl -X POST "https://your-openwa-host/api/plugins/group-translate/enable" \ -H "X-API-Key: " ``` Then, in the group, an admin runs `/tr on`. Or install the packaged `.zip` from [Releases](https://github.com/rmyndharis/OpenWA-plugins/releases) via the dashboard **Plugins → Install**. ## Configuration | Key | Required | Default | Description | | --- | -------- | ------- | ----------- | | `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 | | `minLength` | no | `2` | Minimum message length to translate | | `maxLength` | no | `2000` | Maximum message length to translate | | `denyReply` | no | `false` | Reply "admins only" when a non-admin runs a restricted command | | `announceInGroups` | no | `false` | Post an introduction into a group the first time the plugin sees a message there, once per group. **Off by default** — it fires per group, so turning it on announces the bot into every group the WhatsApp account belongs to. `/tr help` gives anyone the same text on request | ## Compatibility Checked against the OpenWA v0.30.0 worker runtime with isolated services. This covers plugin lifecycle, hooks or ingress dispatch, capability calls and per-session configuration. Live WhatsApp sessions, provider authentication and dashboard rendering require separate deployment checks. Targets OpenWA **≥ 0.8.0**, the release that introduced `net.allowConfigHosts`. Outbound HTTP itself uses the v0.7 `ctx.net.fetch` capability, but on a 0.7.x host only the shipped loopback entries are reachable, so any other `libretranslateUrl` is refused and nothing is translated. Declares `messages:send`, `engine:read` (for admin checks), `net:fetch`, and `storage:use` (each group's settings and learned participant languages). Shared contact cards are never translated: from OpenWA 0.23.2 a card carries its vCard in the message body, which would otherwise send a third party's name and number to your translation backend, post a machine-translated card into the group, and let language detection pin the sender's language from vCard field names. Poll questions are translated normally. Catalog orders and shared product cards are never translated either. From OpenWA 0.23.5 they arrive typed `order` and `product` on both engines, and on Baileys they now carry the order note or the product title as the body. Earlier hosts deliver both as `unknown`, which the plugin cannot tell apart from other messages. On Baileys a share of a whole catalog still arrives as `unknown` with the catalog title as its body, and is translated like any other text. Messages delivered late are not acted on. From OpenWA 0.23.6 a Baileys session delivers, after it reconnects, the messages WhatsApp queued while it was disconnected. A message that reaches the plugin more than 5 minutes after it was sent is not translated, and a `/tr` command that late is not run, though it is still kept from other bots: send it again once the session is back. The check compares the message's WhatsApp send time with the gateway's clock, so keep that clock in sync. Forwarded messages are translated into the group's languages, including the forwarder's own, without teaching or switching the forwarder's learned language or running a `/tr` command. From OpenWA 0.29.1 both engines flag a forwarded message with `isForwarded`. ### Per-session config **Supported, with a caveat.** Every config field may be overridden per WhatsApp session via the dashboard; an override that changes a coordinator-affecting field (e.g. `libretranslateUrl`, `libretranslateApiKey`, `timeoutMs`, `commandPrefix`, `minLength`, `maxLength`, `denyReply`, `announceInGroups`) takes effect on the next inbound message. The plugin uses config-signature caching: the coordinator (and the LibreTranslate client's circuit breaker) is reused across messages with the same resolved config, and rebuilt only when the signature changes. Commands for one group remain serialized across coordinator rebuilds, so an older command cannot overwrite a later command after a configuration update. Different groups can still run concurrently. **Caveat for multi-backend deployments:** when two sessions point at *different* LibreTranslate instances, they share one coordinator slot and clobber each other's circuit-breaker state on every message alternation. OpenWA installs a plugin once per gateway (a second install of the same id is refused with `409`) and gives separate instances only to plugins with ingress routes, so this plugin has no per-session instance: keep one LibreTranslate for all sessions, or run one OpenWA gateway per backend. **Host overrides and the API key.** From OpenWA 0.26.0 a per-session override that points `libretranslateUrl` at another origin than the base config does not inherit the stored `libretranslateApiKey`. A LibreTranslate that needs a key must get it in that override, otherwise requests go out without one. ## Security 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 (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. Because of (2), a **self-hosted LibreTranslate on `localhost` / `127.0.0.1` / a private LAN or Docker host** — including this plugin's default `http://localhost:7001` — is blocked unless the operator adds 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 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`. 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. If WhatsApp does not return the group's admin list, nobody counts as an admin for that command (delegated controllers still do), and `denyReply` decides whether the sender is told. A `/tr` command is claimed as soon as it is recognized, so no other plugin answers it even when the lookup is slow. The per-call timeout (≤ the host hook budget) and circuit breaker keep a slow backend from stalling the host. ## Changelog See [CHANGELOG.md](./CHANGELOG.md). ## License [MIT](../LICENSE) © Yudhi Armyndharis & OpenWA Contributors.