diff --git a/README.md b/README.md index 15dee2f..9d1c855 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.1.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 | | [`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 190d35e..21af00b 100644 --- a/group-translate/CHANGELOG.md +++ b/group-translate/CHANGELOG.md @@ -8,6 +8,21 @@ The version here always matches `manifest.json`'s `version`. ## [Unreleased] +## [1.2.0] — 2026-08-01 + +### Changed + +- **This plugin no longer introduces itself in your groups unless you ask it to.** Until now, enabling + it made it post a "👋 Translation bot…" message into a group the first time it saw any message there. + That fired **per group**, so a single enable announced the bot into every group the WhatsApp account + belonged to — and the account running a plugin is usually somebody's own WhatsApp, not a dedicated + bot number. The introduction is now controlled by a new **Announce this bot in new groups** setting, + which is **off by default**, including for installs upgrading from an earlier version. + + Nothing else about the plugin was reachable without addressing it first: translation still requires + an admin to run `/tr on`, and the same introduction text is still available to anyone on demand with + `/tr help`. If you want the old behaviour, turn the new setting on. + ## [1.1.1] — 2026-08-01 No behaviour change. 1.1.0 has now been smoke-tested against a newer host, so the tested-version field diff --git a/group-translate/README.md b/group-translate/README.md index f4ae99e..8588cc3 100644 --- a/group-translate/README.md +++ b/group-translate/README.md @@ -14,7 +14,7 @@ | Field | Value | | ----- | ----- | | **Identifier** | `group-translate` | -| **Version** | 1.1.1 | +| **Version** | 1.2.0 | | **Released** | 2026-08-01 | | **Status** | stable | | **Author** | Yudhi Armyndharis | @@ -62,6 +62,10 @@ Default prefix `/tr` (configurable). Read-only commands are open; the rest are a `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`. + 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 @@ -94,6 +98,7 @@ Then, in the group, an admin runs `/tr on`. Or install the packaged `.zip` from | `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 @@ -104,7 +109,8 @@ Targets OpenWA **≥ 0.7.0** — outbound HTTP uses the v0.7 `ctx.net.fetch` cap **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`) takes +`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. diff --git a/group-translate/core/translation.coordinator.test.ts b/group-translate/core/translation.coordinator.test.ts index 050679f..2970849 100644 --- a/group-translate/core/translation.coordinator.test.ts +++ b/group-translate/core/translation.coordinator.test.ts @@ -3,7 +3,14 @@ import assert from 'node:assert/strict'; import { TranslationCoordinator, CoordinatorOptions } from './translation.coordinator'; import { ChatGateway, ConfigStore, GroupState, InboundMessage, Translator, TranslationLogger } from './ports'; -const OPTS: CoordinatorOptions = { prefix: '/tr', minLength: 2, maxLength: 2000, denyReply: false }; +// The shipped defaults, so every test below runs against what an operator actually gets. +const OPTS: CoordinatorOptions = { + prefix: '/tr', + minLength: 2, + maxLength: 2000, + denyReply: false, + announceInGroups: false, +}; function freshState(over: Partial = {}): GroupState { return { @@ -150,12 +157,35 @@ describe('TranslationCoordinator', () => { assert.equal(mocks.sendText.calls.length, 0); }); - test('announces once on first contact then stays dormant', async () => { + // Regression: enabling this plugin used to post an unsolicited introduction into a group the first + // time it saw any message there. Per group, so a single enable announced the bot into every group + // the account belonged to — on a personal WhatsApp that is every group the person is in. The + // introduction is now opt-in, and this asserts the DEFAULT, which is the value that shipped harm. + test('says nothing in a group it has never seen before (announcement off by default)', async () => { const { store, gateway, translator, mocks } = makeDeps(freshState()); const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg()); + assert.deepEqual(res, { swallow: false }, 'an ordinary group message is not claimed'); + assert.equal(mocks.sendText.calls.length, 0, 'the plugin must not speak until it is addressed'); + }); + + test('announces once per group when the operator opts in', async () => { + const state = freshState(); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + const c = new TranslationCoordinator(translator, store, gateway, { ...OPTS, announceInGroups: true }); await c.handleMessage('s', msg()); assert.equal(mocks.sendText.calls.length, 1); - assert.ok(mocks.save.calls.length > 0); + assert.equal(saved.at(-1)?.announced, true, 'the group is marked so the intro is not repeated'); + }); + + // The intro is still reachable on demand — which is why suppressing it costs nothing. + test('replies with the help text when asked, whatever the announcement setting', async () => { + const { store, gateway, translator, mocks } = makeDeps(freshState()); + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ body: '/tr help' })); + assert.deepEqual(res, { swallow: true }); + assert.equal(mocks.sendText.calls.length, 1); + assert.match(String(mocks.sendText.calls[0][2]), /Translation bot/); }); test('activates only for an admin', async () => { @@ -502,7 +532,9 @@ describe('TranslationCoordinator', () => { detect: async () => ({ lang: 'en', confidence: 1 }), translate: async () => '', languages: async () => ['en'], isHealthy: () => true, }; - const c = new TranslationCoordinator(translator, store, gateway, OPTS); + // Opted in deliberately: the announcement is this test's observable for the per-chat lock, and + // it is the one send that a load/save race could duplicate. + const c = new TranslationCoordinator(translator, store, gateway, { ...OPTS, announceInGroups: true }); await Promise.all([ c.handleMessage('s', msg({ id: 'm1', body: 'hello there' })), c.handleMessage('s', msg({ id: 'm2', body: 'hello again' })), diff --git a/group-translate/core/translation.coordinator.ts b/group-translate/core/translation.coordinator.ts index 8ac5491..3e804f9 100644 --- a/group-translate/core/translation.coordinator.ts +++ b/group-translate/core/translation.coordinator.ts @@ -18,6 +18,10 @@ export interface CoordinatorOptions { minLength: number; maxLength: number; denyReply: boolean; + /** Post the unsolicited introduction into a group the first time this plugin sees a message there. + * Off unless the operator turns it on: the account running this plugin is usually a person's own + * WhatsApp, and enabling the plugin would otherwise announce it into every group that account is in. */ + announceInGroups: boolean; } const URL_OR_EMOJI_ONLY = /^(?:\s|\p{Emoji}|https?:\/\/\S+)+$/u; @@ -71,7 +75,10 @@ export class TranslationCoordinator { private async handleMessageLocked(sessionId: string, msg: InboundMessage): Promise<{ swallow: boolean }> { const state = await this.store.load(sessionId, msg.chatId); - if (!state.announced) { + // Opt-in, and off by default. This is the only place the plugin speaks without being addressed, + // and it fires per GROUP, so simply enabling the plugin used to post an introduction into every + // group the account was in. `/tr help` gives anyone the same text on request. + if (this.opts.announceInGroups && !state.announced) { await this.gateway.sendText(sessionId, msg.chatId, buildHelpText(this.opts.prefix)); state.announced = true; await this.store.save(state); diff --git a/group-translate/index.ts b/group-translate/index.ts index 13d5710..e49c6dd 100644 --- a/group-translate/index.ts +++ b/group-translate/index.ts @@ -113,6 +113,7 @@ export class TranslationPlugin implements IPlugin { readNumber(cfg, "minLength", 2), readNumber(cfg, "maxLength", 2000), readBool(cfg, "denyReply", false), + readBool(cfg, "announceInGroups", false), ]); } @@ -137,6 +138,9 @@ export class TranslationPlugin implements IPlugin { minLength: readNumber(cfg, "minLength", 2), maxLength: readNumber(cfg, "maxLength", 2000), denyReply: readBool(cfg, "denyReply", false), + // Default false in the code as well as the manifest: an install that predates this field has no + // stored value, and the safe reading of a missing opt-in is "not opted in". + announceInGroups: readBool(cfg, "announceInGroups", false), }; return new TranslationCoordinator(translator, store, gateway, opts, logger); } diff --git a/group-translate/manifest.json b/group-translate/manifest.json index 762e94d..01b0c97 100644 --- a/group-translate/manifest.json +++ b/group-translate/manifest.json @@ -1,7 +1,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.1.1", + "version": "1.2.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.", @@ -83,6 +83,12 @@ "title": "Reply on denied commands", "default": false, "description": "If true, reply 'admins only' when a non-admin runs a restricted command." + }, + "announceInGroups": { + "type": "boolean", + "title": "Announce this bot in new groups", + "default": false, + "description": "If true, the first time this plugin sees a message in a group it posts an introduction there, once per group. Off by default: the account running this plugin is normally a real person's WhatsApp, so turning this on announces the bot into every group that account belongs to. Anyone can get the same text on demand with the /tr help command." } } }, @@ -111,6 +117,9 @@ }, "denyReply": { "title": "Responder a comandos denegados" + }, + "announceInGroups": { + "title": "Anunciar este bot en grupos nuevos" } } }, @@ -138,6 +147,9 @@ }, "denyReply": { "title": "Répondre aux commandes refusées" + }, + "announceInGroups": { + "title": "Annoncer ce bot dans les nouveaux groupes" } } }, @@ -165,6 +177,9 @@ }, "denyReply": { "title": "Rispondi ai comandi negati" + }, + "announceInGroups": { + "title": "Annunciare questo bot nei nuovi gruppi" } } }, @@ -192,6 +207,9 @@ }, "denyReply": { "title": "الرد على الأوامر المرفوضة" + }, + "announceInGroups": { + "title": "الإعلان عن هذا البوت في المجموعات الجديدة" } } }, @@ -219,6 +237,9 @@ }, "denyReply": { "title": "השב על פקודות שנדחו" + }, + "announceInGroups": { + "title": "להכריז על הבוט בקבוצות חדשות" } } }, @@ -246,6 +267,9 @@ }, "denyReply": { "title": "తిరస్కరించిన కమాండ్‌లకు జవాబివ్వు" + }, + "announceInGroups": { + "title": "కొత్త గ్రూపుల్లో ఈ బాట్‌ను పరిచయం చేయి" } } }, @@ -273,6 +297,9 @@ }, "denyReply": { "title": "拒绝命令时回复" + }, + "announceInGroups": { + "title": "在新群组中介绍此机器人" } } }, @@ -300,8 +327,11 @@ }, "denyReply": { "title": "拒絕指令時回覆" + }, + "announceInGroups": { + "title": "喺新群組介紹呢個機械人" } } } } -} \ No newline at end of file +} diff --git a/plugins.json b/plugins.json index 6b42913..c78c22f 100644 --- a/plugins.json +++ b/plugins.json @@ -833,7 +833,7 @@ { "id": "group-translate", "name": "Group Auto-Translation", - "version": "1.1.1", + "version": "1.2.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.1.1/group-translate.zip", + "download": "https://github.com/rmyndharis/OpenWA-plugins/releases/download/group-translate-v1.2.0/group-translate.zip", "i18n": { "es": { "name": "Traducción Automática de Grupos", @@ -879,6 +879,9 @@ }, "denyReply": { "title": "Responder a comandos denegados" + }, + "announceInGroups": { + "title": "Anunciar este bot en grupos nuevos" } } }, @@ -906,6 +909,9 @@ }, "denyReply": { "title": "Répondre aux commandes refusées" + }, + "announceInGroups": { + "title": "Annoncer ce bot dans les nouveaux groupes" } } }, @@ -933,6 +939,9 @@ }, "denyReply": { "title": "Rispondi ai comandi negati" + }, + "announceInGroups": { + "title": "Annunciare questo bot nei nuovi gruppi" } } }, @@ -960,6 +969,9 @@ }, "denyReply": { "title": "الرد على الأوامر المرفوضة" + }, + "announceInGroups": { + "title": "الإعلان عن هذا البوت في المجموعات الجديدة" } } }, @@ -987,6 +999,9 @@ }, "denyReply": { "title": "השב על פקודות שנדחו" + }, + "announceInGroups": { + "title": "להכריז על הבוט בקבוצות חדשות" } } }, @@ -1014,6 +1029,9 @@ }, "denyReply": { "title": "తిరస్కరించిన కమాండ్‌లకు జవాబివ్వు" + }, + "announceInGroups": { + "title": "కొత్త గ్రూపుల్లో ఈ బాట్‌ను పరిచయం చేయి" } } }, @@ -1041,6 +1059,9 @@ }, "denyReply": { "title": "拒绝命令时回复" + }, + "announceInGroups": { + "title": "在新群组中介绍此机器人" } } }, @@ -1068,6 +1089,9 @@ }, "denyReply": { "title": "拒絕指令時回覆" + }, + "announceInGroups": { + "title": "喺新群組介紹呢個機械人" } } }