From 09c805e2542315ed85fa797c87562d965d7552f5 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 10:47:06 +0200 Subject: [PATCH 01/12] feat: add Telegram group chat allowlists Closes #25 --- AGENTS.md | 3 +- FEATURES.md | 14 +- README.md | 26 +- TODO.md | 2 +- ...-05-28-telegram-group-bot-authorization.md | 243 ++++++++++++++++ ...telegram-group-bot-authorization-design.md | 117 ++++++++ src/adapters/telegram/auth.js | 28 +- src/adapters/telegram/bot.js | 26 +- src/config/configMigration.js | 43 +++ src/config/loadConfig.js | 29 +- src/config/setupConfig.js | 80 +++++- src/config/writeConfig.js | 3 +- src/runtime/bootstrap.js | 2 +- tests/adapters/telegramAuth.test.js | 29 +- tests/adapters/telegramBot.test.js | 272 ++++++++++++++---- tests/bin/gatewayProgram.test.js | 2 +- tests/config/loadConfig.test.js | 192 +++++++++++-- tests/config/writeConfig.test.js | 11 +- tests/runtime/background.test.js | 2 +- tests/runtime/bootstrap.test.js | 4 +- tests/runtime/startup.test.js | 2 +- 21 files changed, 1004 insertions(+), 126 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md create mode 100644 docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md create mode 100644 src/config/configMigration.js diff --git a/AGENTS.md b/AGENTS.md index d27d000..0a21028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,8 @@ Add modules only when they reduce real complexity. Prefer the smallest correct c ## Config And State - Runtime config is discovered from project-local `.opencode-remote/config.json`, then global `~/.opencode-remote/config.json`. -- `telegram.botToken` and `telegram.allowedUserId` are required and must stay private. +- `telegram.botToken` is required and must stay private. +- At least one of `telegram.allowedUserIds` or `telegram.allowedChatIds` is required. `allowedUserIds` authorizes private human DMs; `allowedChatIds` authorizes every sender in those group chats, including bots. - Project-local `.opencode-remote/` is ignored because `config.json` contains secrets. - App state is non-secret SQLite data in the platform app-data directory; see `DEVELOPMENT.md` for exact paths. - Telegram sticker pack state is non-secret adapter state in `telegram-stickers.db`; reusable visuals are disposable cache under `cache/stickers`. diff --git a/FEATURES.md b/FEATURES.md index 4264977..d29bd3e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,8 +4,9 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s ## Available Now -- Telegram private-chat gateway using grammY long polling. -- Single authorized Telegram user configured in `.opencode-remote/config.json`. +- Telegram private-chat and configured group gateway using grammY long polling. +- Optional authorized Telegram users for private-chat access configured in `.opencode-remote/config.json`. +- Optional allowed Telegram group chat IDs that authorize every sender in those groups, including other bots. - Local or remote OpenCode server connection configured with `opencode.apiUrl`. - Optional local OpenCode startup with `opencode.autoStart=true`. - OpenCode session creation, listing, switching, prompt sending, and stop requests. @@ -27,15 +28,15 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - `/new` creates and selects a new OpenCode session. - `/sessions` lists recent OpenCode sessions and lets the user switch with inline buttons. - `/stop` requests abort for the active OpenCode session. -- `/progress` shows or sets prompt activity visibility: `off`, `new`, `all`, or `verbose`. +- `/progress` shows or sets private-chat prompt activity visibility: `off`, `new`, `all`, or `verbose`. - `/voice` shows and controls voice mode, lists voices by required short country/locale filter, sets the active Edge TTS voice, and sends a test voice note. - `/stickers` saves, lists, and forgets sticker packs for future sticker replies. - `/help` shows the available bot commands. - The Telegram slash-command menu is refreshed on gateway startup. -- Non-command text from the authorized user is sent to OpenCode as a prompt. +- Non-command text from an authorized private user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. - Forwarded Telegram text, photo, album, and voice prompts include safe original-author context when Telegram provides it, with a safe fallback to the authorized user. - The bot shows Telegram typing activity while a prompt is running. -- The bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. +- In private chats, the bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. Group chats always suppress this activity message. - OpenCode permission requests are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled. - Incoming text prompts get a temporary eye reaction while processing. - OpenCode can request one Telegram emoji reaction by returning a hidden `[telegram_reaction: ...]` marker, which is removed before the user sees the reply. @@ -76,7 +77,8 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s ## State And Security -- The bot ignores Telegram users outside the configured allowlist. +- The bot ignores private Telegram users outside the configured user allowlist. +- The bot ignores group chats outside the configured chat allowlist. Allowed groups authorize all senders in that group, so configure only groups whose members and admins you trust. - Secrets are configured through private `.opencode-remote/config.json` files, not persisted settings. - The selected active session is persisted as non-secret JSON state. - Saved sticker packs persist only non-secret sticker identifiers and metadata. diff --git a/README.md b/README.md index e160dc5..aa73284 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCode Remote -OpenCode Remote lets you use OpenCode from Telegram. It runs on your machine, connects to your local or remote OpenCode server, and forwards messages from one authorized Telegram user to OpenCode sessions. +OpenCode Remote lets you use OpenCode from Telegram. It runs on your machine, connects to your local or remote OpenCode server, and forwards messages from authorized private users or allowed Telegram groups to OpenCode sessions. This is a Telegram MVP with text prompts, photo prompts, sticker prompts/replies, OpenCode permission approvals, and opt-in voice input/replies. Model switching and multi-messenger support are not implemented yet. @@ -11,7 +11,7 @@ See [Features](https://github.com/crankshift/opencode-remote/blob/main/FEATURES. - Node.js 22.18.0 or newer. Node.js 24 LTS is recommended. - OpenCode CLI available on the machine running the gateway. - A Telegram bot token from BotFather. -- Your Telegram numeric user ID for the allowlist. +- One or more Telegram numeric user IDs for private-chat access, or one or more Telegram group chat IDs for group access. - Optional voice mode: a free Groq API key for Whisper transcription and `ffmpeg` installed locally for Telegram voice-note conversion. ## Install @@ -46,7 +46,9 @@ Create the config interactively: opencode-remote setup ``` -The setup flow asks whether to write a project-local or global config, then prompts for the Telegram token, allowed Telegram user ID, progress verbosity, log level, optional voice mode, and optional user-level login startup from the current project folder. If a config already exists at the chosen location, setup shows current values and pressing Enter with no input keeps them; secret values are shown only as set. If voice mode is enabled and `ffmpeg` is missing, setup can try a detected installer and then waits while you install `ffmpeg` in another terminal before continuing. Choice prompts show all options in a highlighted list with arrow-key selection and Enter to confirm. +The setup flow asks whether to write a project-local or global config, then prompts for the Telegram token, optional comma-separated allowed direct user IDs, optional comma-separated allowed group chat IDs, progress verbosity, log level, optional voice mode, and optional user-level login startup from the current project folder. At least one direct user ID or group chat ID is required. If a config already exists at the chosen location, setup shows current values and pressing Enter with no input keeps them; secret values are shown only as set. If voice mode is enabled and `ffmpeg` is missing, setup can try a detected installer and then waits while you install `ffmpeg` in another terminal before continuing. Choice prompts show all options in a highlighted list with arrow-key selection and Enter to confirm. + +Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct private messages are accepted only from configured `allowedUserIds`. Config discovery order: @@ -111,9 +113,11 @@ The config file is JSON: ```json { + "schemaVersion": 2, "telegram": { "botToken": "123456:telegram-bot-token", - "allowedUserId": 123456789 + "allowedUserIds": [123456789], + "allowedChatIds": [-1001234567890] }, "voice": { "enabled": false, @@ -129,11 +133,13 @@ The config file is JSON: `telegram.botToken` is required. It is the token for the bot that receives Telegram messages. -`telegram.allowedUserId` is required. Updates from other Telegram users are ignored. +`telegram.allowedUserIds` is optional when `telegram.allowedChatIds` is configured. It is an array of trusted human Telegram user IDs that may use the bot in private direct chats. Setup accepts values such as `123456789` or `123456789, 222333444`. Direct messages from other users and all private bot-to-bot messages are ignored. + +`telegram.allowedChatIds` is optional when `telegram.allowedUserIds` is configured. It authorizes every sender in those group chats, including humans and other bots. Telegram group and supergroup IDs are usually negative, for example `-1001234567890`. Do not configure group IDs for groups whose members or admins you do not trust. `opencode.apiUrl` controls the OpenCode server URL. It defaults to `http://localhost:4096`. When `opencode.autoStart=true` and this URL points to `localhost` or `127.0.0.1` with a port, the gateway starts `opencode serve --port ` so it waits on the same URL it configured. -`progressVerbosity` controls the startup default for the prompt activity message. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime. +`progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message. `voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. When a voice-note reply succeeds, the bot does not also send the text reply; if speech generation or sending fails, it falls back to text. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled. @@ -170,7 +176,7 @@ The bot currently supports: /help Show available commands ``` -Any non-command text message from the authorized Telegram user is sent to OpenCode as a prompt. If no active session is selected, the gateway creates one automatically. +Any non-command text message from an authorized private Telegram user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. If no active session is selected, the gateway creates one automatically. Forwarded Telegram text, photo, album, and voice prompts include safe author context for OpenCode when Telegram provides the original author. If Telegram hides or omits the forwarded author, the prompt falls back to the authorized Telegram user without exposing raw Telegram payloads or numeric user IDs. @@ -208,9 +214,11 @@ Voice commands: ## Troubleshooting -If startup fails with a configuration error, check the selected `.opencode-remote/config.json` and make sure `telegram.botToken` is non-empty and `telegram.allowedUserId` is numeric. +If startup fails with a configuration error, check the selected `.opencode-remote/config.json` and make sure `telegram.botToken` is non-empty and at least one of `telegram.allowedUserIds` or `telegram.allowedChatIds` contains a numeric ID. + +If Telegram private messages from a human user appear to be ignored, confirm that `telegram.allowedUserIds` contains your Telegram user ID, not the bot ID or chat ID. -If Telegram messages appear to be ignored, confirm that `telegram.allowedUserId` matches your Telegram user ID, not the bot ID or chat ID. +If group messages appear to be ignored, confirm that `telegram.allowedChatIds` contains the group chat ID. To receive all messages in groups, this bot must be a group admin or Group Privacy Mode must be disabled in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. If startup fails because OpenCode is unreachable, make sure the OpenCode CLI is installed and available in `PATH`. With auto-start enabled, the gateway waits about 60 seconds for the configured OpenCode URL before exiting. diff --git a/TODO.md b/TODO.md index 2ca1336..d8a4410 100644 --- a/TODO.md +++ b/TODO.md @@ -70,7 +70,7 @@ 2. Global `~/.opencode-remote/config.json`. - If no config exists, prompt the CLI user to create one. - Ask whether the config should be local or global before writing it. - - Prompt for required values: Telegram bot token and Telegram allowed user ID. + - Prompt for required values: Telegram bot token and at least one allowed direct user ID or group chat ID. - Prompt for progress verbosity and log level; keep OpenCode API URL, command, auto-start, and workdir on validated defaults unless users edit JSON. - Store gateway state in the platform app-data SQLite database, separate from secrets. - Validate `config.json` with zod and show safe, user-friendly errors. diff --git a/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md b/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md new file mode 100644 index 0000000..5ee7910 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md @@ -0,0 +1,243 @@ +# Telegram Group Bot Authorization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace single-user Telegram authorization with migrated direct-user and allowed-group chat allowlists. + +**Architecture:** Config migration normalizes old JSON into a v2 shape before validation. Runtime receives normalized Telegram auth config and the Telegram adapter authorizes private direct chats by `allowedUserIds` and group chats by `allowedChatIds`. Group chats force tool progress/activity off. + +**Tech Stack:** Node.js ESM, grammY, Zod, Vitest, Biome, pnpm. + +--- + +## File Structure + +- Create `src/config/configMigration.js`: pure config migration helpers, including v1 `allowedUserId` to v2 `allowedUserIds` migration and obsolete `allowedBotIds` removal. +- Modify `src/config/loadConfig.js`: run migration before validation, validate `schemaVersion`, optional `allowedUserIds`, optional `allowedChatIds`, and require at least one of the two arrays to be non-empty. +- Modify `src/config/setupConfig.js`: collect optional comma-separated direct user IDs and group chat IDs, then require at least one configured list. +- Modify `src/config/writeConfig.js`: migrate current config before applying `config set` updates so old configs are rewritten as v2. +- Modify `src/adapters/telegram/auth.js`: authorize private human DMs by user ID and non-private chats by chat ID. +- Modify `src/adapters/telegram/bot.js`: pass normalized Telegram config, make `/progress` private-chat only, and force progress rendering off in groups. +- Modify `src/runtime/bootstrap.js`: pass normalized `resolvedConfig.telegram` to the bot factory. +- Modify tests in `tests/config/loadConfig.test.js`, `tests/config/writeConfig.test.js`, `tests/adapters/telegramBot.test.js`, and `tests/runtime/bootstrap.test.js`. +- Modify `README.md`, `FEATURES.md`, `AGENTS.md`, and `TODO.md` for public and maintainer behavior. + +## Task 1: Config Migration And Validation + +**Files:** +- Create: `src/config/configMigration.js` +- Modify: `src/config/loadConfig.js` +- Test: `tests/config/loadConfig.test.js` + +- [x] **Step 1: Write failing migration tests** + +Covered behaviors: + +```js +test("migrates singular Telegram allowed user ID to plural v2 config", () => {}) +test("prefers plural Telegram allowed user IDs when singular and plural are both present", () => {}) +test("normalizes group chat allowlists without direct users", () => {}) +test("rejects configs without direct users or allowed chats", () => {}) +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `pnpm test tests/config/loadConfig.test.js` + +Expected: FAIL before implementation because v2 schema and migration do not exist. + +- [x] **Step 3: Implement migration and validation** + +Implementation notes: + +```js +export const CURRENT_CONFIG_SCHEMA_VERSION = 2 +export function migrateConfig(rawConfig) { + // Treat unversioned configs as v1. + // Convert telegram.allowedUserId to telegram.allowedUserIds. + // Delete obsolete telegram.allowedUserId and telegram.allowedBotIds. +} +``` + +Validation notes: + +```js +const telegramConfigSchema = z + .object({ + botToken: z.string().min(1, "Telegram bot token is required"), + allowedUserIds: z.array(positiveTelegramIdSchema).default([]), + allowedChatIds: z.array(telegramChatIdSchema).default([]), + }) + .refine( + (telegram) => telegram.allowedUserIds.length > 0 || telegram.allowedChatIds.length > 0, + { + message: "Configure at least one Telegram allowed user ID or allowed chat ID", + path: ["allowedChatIds"], + }, + ) +``` + +- [x] **Step 4: Verify config tests** + +Run: `pnpm test tests/config/loadConfig.test.js` + +Expected: PASS. + +## Task 2: Setup List Parsing + +**Files:** +- Modify: `src/config/setupConfig.js` +- Test: `tests/config/loadConfig.test.js` + +- [x] **Step 1: Write failing setup tests** + +Covered behaviors: + +```js +test("collects comma-separated Telegram direct user and group chat allowlists", () => {}) +test("allows group-only setup with no direct user IDs", () => {}) +``` + +- [x] **Step 2: Implement setup prompts** + +Prompts: + +```text +Telegram allowed direct user IDs, comma-separated (optional) +Telegram allowed group chat IDs, comma-separated (optional) +``` + +Setup warning: + +```text +Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct messages are allowed only for configured direct user IDs. +``` + +Parsing rules: + +- `1,2` and `1, 3` both parse. +- Direct user IDs must be positive integers. +- Group chat IDs may be negative. +- At least one direct user ID or group chat ID is required. + +- [x] **Step 3: Verify setup tests** + +Run: `pnpm test tests/config/loadConfig.test.js` + +Expected: PASS. + +## Task 3: Telegram Authorization And Private Progress + +**Files:** +- Modify: `src/adapters/telegram/auth.js` +- Modify: `src/adapters/telegram/bot.js` +- Modify: `src/runtime/bootstrap.js` +- Test: `tests/adapters/telegramBot.test.js` +- Test: `tests/runtime/bootstrap.test.js` + +- [x] **Step 1: Write failing auth tests** + +Covered behaviors: + +```js +test("authorization middleware allows configured human users in private chats", () => {}) +test("authorization middleware rejects configured human users in unallowed groups", () => {}) +test("authorization middleware allows humans in allowed group chats", () => {}) +test("authorization middleware allows bots in allowed group chats", () => {}) +test("authorization middleware rejects messages in unallowed groups", () => {}) +``` + +- [x] **Step 2: Implement authorization helper** + +Rules: + +- Private chats: accept only non-bot senders in `allowedUserIds`. +- Non-private chats: accept any sender when chat ID is in `allowedChatIds`. +- Everything else is ignored without a chat reply. + +- [x] **Step 3: Write failing private-progress tests** + +Covered behaviors: + +```js +test("progress command is private-chat only", () => {}) +test("text prompts do not render tool progress in group chats", () => {}) +``` + +- [x] **Step 4: Implement private-only progress** + +Rules: + +- `/progress` in groups replies `Tool progress is only available in private chats.` +- Group prompts pass no `onProgress` callback to OpenCode, while preserving permission system events. +- Private chats keep existing progress behavior. + +- [x] **Step 5: Verify adapter/runtime tests** + +Run: `pnpm test tests/adapters/telegramBot.test.js tests/runtime/bootstrap.test.js` + +Expected: PASS. + +## Task 4: Config Writes And Docs + +**Files:** +- Modify: `src/config/writeConfig.js` +- Modify: `README.md` +- Modify: `FEATURES.md` +- Modify: `AGENTS.md` +- Modify: `TODO.md` +- Test: `tests/config/writeConfig.test.js` + +- [x] **Step 1: Write failing config write tests** + +Successful config writes should migrate old files to v2 and remove obsolete singular/bot fields. + +- [x] **Step 2: Migrate before config writes** + +```js +const rawConfig = migrateConfig(await readJsonConfig(configPath)) +``` + +- [x] **Step 3: Update docs** + +Docs describe: + +- `allowedUserIds` for private human DMs. +- `allowedChatIds` for group access. +- Groups authorize every sender, including bots. +- Group Privacy/admin Telegram requirements. +- Bot-to-Bot Communication Mode for receiving messages from other bots in groups. +- `Activity` progress is private-chat only. + +## Task 5: Final Verification + +**Files:** +- All modified files. + +- [x] **Step 1: Run lint** + +Run: `pnpm run lint` + +Expected: PASS. + +- [x] **Step 2: Run tests** + +Run: `pnpm test` + +Expected: PASS. + +- [x] **Step 3: Run full check** + +Run: `pnpm run check` + +Expected: PASS. + +- [x] **Step 4: Inspect diff** + +Run: `git diff --stat && git diff` + +Expected: Diff contains only config migration, Telegram auth allowlists, setup parsing, docs, specs, and tests for issue #25. + +## Self-Review + +The plan covers migration, setup parsing, runtime authorization, private-only progress, docs, and verification. It intentionally avoids per-bot allowlists because the current requirement uses allowed group chat IDs as the trust boundary. No placeholders remain. diff --git a/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md b/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md new file mode 100644 index 0000000..1017db1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md @@ -0,0 +1,117 @@ +# Telegram Group Bot Authorization Design + +## Goal + +Support Telegram group usage without weakening authorization. The gateway should accept private direct messages only from configured human user IDs and accept all messages, including bot messages, from explicitly allowed group chat IDs. The new config shape should replace the single `telegram.allowedUserId` value with direct-user and group-chat allowlists and migrate existing configs safely. + +## Context + +OpenCode Remote currently accepts Telegram updates only when `ctx.from.id` equals `telegram.allowedUserId`. This works for one private chat user but blocks group workflows where a trusted group should be the access boundary. + +To receive all messages in a group, the bot must be a group admin or Group Privacy Mode must be disabled in BotFather. To receive messages sent by other bots in those groups, Bot-to-Bot Communication Mode may also be required. Setup and docs should tell users this so they do not mistake Telegram delivery limits for gateway bugs. + +## Config Shape + +New configs use top-level `schemaVersion: 2`. + +```json +{ + "schemaVersion": 2, + "telegram": { + "botToken": "123456:telegram-bot-token", + "allowedUserIds": [123456789], + "allowedChatIds": [-1001234567890] + } +} +``` + +`telegram.allowedUserIds` fully replaces `telegram.allowedUserId`. It contains positive Telegram user IDs for trusted human operators who may use the bot in private direct chats. It may be empty when `allowedChatIds` is configured. + +`telegram.allowedChatIds` authorizes all senders in matching group chats, including humans and bots. Chat IDs may be negative because Telegram groups and supergroups use negative identifiers. It may be empty when `allowedUserIds` is configured. At least one of `allowedUserIds` or `allowedChatIds` must be non-empty. + +## Config Migration + +Add `src/config/configMigration.js` and run it before Zod validation in `loadConfigFromObject` and before writing config updates in `writeConfig.js`. + +Unversioned configs are treated as v1. V1 allows the old `telegram.allowedUserId` field. Migration to v2 creates `telegram.allowedUserIds` from the singular value, removes `telegram.allowedUserId`, and sets `schemaVersion: 2`. + +If both `telegram.allowedUserId` and `telegram.allowedUserIds` are present, `allowedUserIds` wins and the singular value is removed. Obsolete `allowedBotIds` values are removed because this design intentionally uses group chat IDs as the group trust boundary. + +Runtime code should consume only the normalized v2 config returned by `loadConfigFromObject`. It should not keep fallback checks against `allowedUserId` after migration. + +## Setup UX + +Setup prompts should write the v2 shape only. + +Prompts: + +- `Telegram allowed direct user IDs, comma-separated (optional)` +- `Telegram allowed group chat IDs, comma-separated (optional)` + +The parser accepts whitespace around comma-separated tokens, so `1,2` and `1, 3` both produce arrays. Blank user/chat prompts are allowed only when the other prompt contains at least one ID. Invalid tokens produce clear setup validation errors. + +Setup must print a short warning before or near the group chat prompt: + +```text +Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct messages are allowed only for configured direct user IDs. +``` + +## Authorization + +Authorization remains a Telegram adapter responsibility. Core gateway code should not receive Telegram-specific types or IDs. + +`isAuthorizedTelegramUser` should become a sender/chat authorization helper that accepts the Telegram context and normalized Telegram auth config. It authorizes when: + +- The update is in a private chat, `ctx.from.is_bot` is not true, and `ctx.from.id` is in `allowedUserIds`. +- The update is in a non-private chat and the update chat ID is in `allowedChatIds`, regardless of whether the sender is a human or a bot. + +Everything else is ignored. Unauthorized ignores must not reply to the chat or expose project/session state. Logs should stay safe and avoid raw payloads or secrets; numeric IDs are acceptable for local debug/warn logs because they are already user-provided config inputs, but no bot tokens or raw update bodies should be logged. + +## Data Flow + +1. Config JSON is read. +2. `migrateConfig` normalizes old config shapes to v2. +3. Zod validates the v2 config. +4. Runtime passes `resolvedConfig.telegram` to the Telegram bot factory. +5. Telegram middleware checks each update before command/message handlers run. +6. Authorized private users and allowed group senders continue into the existing command, prompt, media, voice, and permission flows. +7. Progress `Activity` messages are rendered only in private chats. Group chats force prompt progress off, and `/progress` replies that tool progress is private-chat only. + +## Error Handling + +Invalid config should fail startup with `GatewayConfigError` and safe messages that identify the invalid config path/key without printing secrets. + +Setup should keep asking until at least one direct user ID or group chat ID is configured. Direct user IDs must be positive integers. Group chat IDs may be negative. Optional list input with invalid tokens should be rejected with a clear message and re-prompted. + +Unauthorized Telegram updates should be ignored without chat replies. The existing best-effort logging style is preserved. + +## Documentation + +Update `README.md` and `FEATURES.md` to describe: + +- Optional allowed private direct user IDs. +- Optional allowed group chat IDs that authorize every sender in those groups. +- Group/supergroup chat IDs can be negative. +- Admin or disabled Group Privacy Mode requirement for receiving all bot messages in groups. +- Injection risk if untrusted groups are configured in `allowedChatIds`. +- Private-chat-only progress messages. + +## Testing + +Add focused Vitest coverage for: + +- Migration from v1 `allowedUserId` to v2 `allowedUserIds`. +- `allowedUserIds` winning when both singular and plural fields are present. +- Setup parsing `1,2` and `1, 3`. +- Setup rejecting invalid IDs. +- `allowedChatIds` accepting negative IDs. +- Human allowlist authorization. +- Private direct user authorization. +- Allowed group authorization for humans and bots. +- Rejection of private bot messages and unallowed group messages. +- Progress suppression in groups. +- Runtime passing normalized Telegram auth config into `createTelegramBot`. + +## Self-Review + +No placeholders remain. The design keeps Telegram IDs in the adapter/config boundary and does not move Telegram concepts into core gateway orchestration. The migration scope is limited to config JSON and does not create speculative project-state migrations. diff --git a/src/adapters/telegram/auth.js b/src/adapters/telegram/auth.js index d69b44c..7a82878 100644 --- a/src/adapters/telegram/auth.js +++ b/src/adapters/telegram/auth.js @@ -1,3 +1,27 @@ -export function isAuthorizedTelegramUser(ctx, allowedUserId) { - return ctx?.from?.id === allowedUserId +export function isAuthorizedTelegramUser(ctx, telegram) { + const senderId = ctx?.from?.id + if (!senderId) { + return false + } + + const chatId = getTelegramChatId(ctx) + if (isPrivateTelegramChat(ctx)) { + return ctx.from?.is_bot !== true && telegram.allowedUserIds.includes(senderId) + } + + return telegram.allowedChatIds.includes(chatId) +} + +function getTelegramChatId(ctx) { + return ctx?.chat?.id ?? ctx?.message?.chat?.id ?? ctx?.callbackQuery?.message?.chat?.id ?? null +} + +function isPrivateTelegramChat(ctx) { + return getTelegramChatType(ctx) === "private" +} + +function getTelegramChatType(ctx) { + return ( + ctx?.chat?.type ?? ctx?.message?.chat?.type ?? ctx?.callbackQuery?.message?.chat?.type ?? null + ) } diff --git a/src/adapters/telegram/bot.js b/src/adapters/telegram/bot.js index 142f98b..fd3d709 100644 --- a/src/adapters/telegram/bot.js +++ b/src/adapters/telegram/bot.js @@ -43,7 +43,7 @@ export async function registerTelegramBotCommands(bot, logger) { export function createTelegramBot({ token, - allowedUserId, + telegram, controller, logger, botFactory = Bot, @@ -79,8 +79,11 @@ export function createTelegramBot({ }) bot.use(async (ctx, next) => { - if (!isAuthorizedTelegramUser(ctx, allowedUserId)) { - logger.warn({ userId: ctx.from?.id }, "Ignoring unauthorized Telegram update") + if (!isAuthorizedTelegramUser(ctx, telegram)) { + logger.warn( + { userId: ctx.from?.id, chatId: ctx.chat?.id ?? ctx.message?.chat?.id }, + "Ignoring unauthorized Telegram update", + ) return } await next() @@ -192,6 +195,15 @@ export function createTelegramBot({ }) bot.command("progress", async (ctx) => { + if (!isPrivateTelegramChat(ctx)) { + await replyAndRemember( + ctx, + "Tool progress is only available in private chats.", + botMessageMemory, + ) + return + } + const requestedVerbosity = parseProgressVerbosity(ctx.message?.text) if (!requestedVerbosity) { const activeProgressVerbosity = await getActiveProgressVerbosity() @@ -682,7 +694,7 @@ export function createTelegramBot({ return createTelegramProgressRenderer({ ctx, logger, - verbosity: await getActiveProgressVerbosity(), + verbosity: isPrivateTelegramChat(ctx) ? await getActiveProgressVerbosity() : "off", editThrottleMs: progressEditThrottleMs, }) } @@ -976,6 +988,12 @@ function createTelegramProgressRenderer({ ctx, logger, verbosity, editThrottleMs } } +function isPrivateTelegramChat(ctx) { + const chatType = + ctx?.chat?.type ?? ctx?.message?.chat?.type ?? ctx?.callbackQuery?.message?.chat?.type + return chatType !== "group" && chatType !== "supergroup" && chatType !== "channel" +} + function rememberToolingTerms(toolingTerms, event) { if (event?.type !== "tool.updated") { return diff --git a/src/config/configMigration.js b/src/config/configMigration.js new file mode 100644 index 0000000..7c4bc19 --- /dev/null +++ b/src/config/configMigration.js @@ -0,0 +1,43 @@ +export const CURRENT_CONFIG_SCHEMA_VERSION = 2 + +export function migrateConfig(rawConfig) { + if (!rawConfig || typeof rawConfig !== "object" || Array.isArray(rawConfig)) { + return rawConfig + } + + const version = rawConfig.schemaVersion ?? 1 + if (version === 1) { + return migrateV1ToV2(rawConfig) + } + if (version === CURRENT_CONFIG_SCHEMA_VERSION) { + return normalizeV2Shape(rawConfig) + } + return rawConfig +} + +function migrateV1ToV2(rawConfig) { + const next = structuredClone(rawConfig) + next.schemaVersion = CURRENT_CONFIG_SCHEMA_VERSION + next.telegram = normalizeTelegramAllowlists(next.telegram) + return next +} + +function normalizeV2Shape(rawConfig) { + const next = structuredClone(rawConfig) + next.telegram = normalizeTelegramAllowlists(next.telegram) + return next +} + +function normalizeTelegramAllowlists(telegram) { + if (!telegram || typeof telegram !== "object" || Array.isArray(telegram)) { + return telegram + } + + const next = { ...telegram } + if (!Array.isArray(next.allowedUserIds) && next.allowedUserId !== undefined) { + next.allowedUserIds = [next.allowedUserId] + } + delete next.allowedUserId + delete next.allowedBotIds + return next +} diff --git a/src/config/loadConfig.js b/src/config/loadConfig.js index 1fe6dc1..7de631b 100644 --- a/src/config/loadConfig.js +++ b/src/config/loadConfig.js @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises" import { homedir } from "node:os" import { dirname, join } from "node:path" import { z } from "zod" +import { CURRENT_CONFIG_SCHEMA_VERSION, migrateConfig } from "./configMigration.js" export const CONFIG_DIR_NAME = ".opencode-remote" export const CONFIG_FILE_NAME = "config.json" @@ -10,6 +11,22 @@ export const SETTINGS_FILE_NAME = "settings.json" const progressVerbositySchema = z.enum(["off", "new", "all", "verbose"]) const voiceModeSchema = z.enum(["off", "on", "all"]) const logLevelSchema = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) +const positiveTelegramIdSchema = z.coerce + .number() + .int() + .positive("Telegram ID must be a positive integer") +const telegramChatIdSchema = z.coerce.number().int("Telegram chat ID must be an integer") + +const telegramConfigSchema = z + .object({ + botToken: z.string().min(1, "Telegram bot token is required"), + allowedUserIds: z.array(positiveTelegramIdSchema).default([]), + allowedChatIds: z.array(telegramChatIdSchema).default([]), + }) + .refine((telegram) => telegram.allowedUserIds.length > 0 || telegram.allowedChatIds.length > 0, { + message: "Configure at least one Telegram allowed user ID or allowed chat ID", + path: ["allowedChatIds"], + }) const defaultVoiceConfig = { enabled: false, @@ -20,10 +37,8 @@ const defaultVoiceConfig = { } const configSchema = z.object({ - telegram: z.object({ - botToken: z.string().min(1, "Telegram bot token is required"), - allowedUserId: z.coerce.number().int().positive("Telegram allowed user ID must be positive"), - }), + schemaVersion: z.literal(CURRENT_CONFIG_SCHEMA_VERSION).default(CURRENT_CONFIG_SCHEMA_VERSION), + telegram: telegramConfigSchema, opencode: z .object({ apiUrl: z.string().url().default("http://localhost:4096"), @@ -103,7 +118,7 @@ export async function loadConfig({ cwd = process.cwd(), homeDir = homedir() } = } export function loadConfigFromObject(rawConfig, { configPath, cwd = process.cwd() } = {}) { - const parsed = configSchema.safeParse(rawConfig) + const parsed = configSchema.safeParse(migrateConfig(rawConfig)) if (!parsed.success) { throw new GatewayConfigError( @@ -115,10 +130,12 @@ export function loadConfigFromObject(rawConfig, { configPath, cwd = process.cwd( const configDirectory = configPath ? dirname(configPath) : join(cwd, CONFIG_DIR_NAME) return { + schemaVersion: parsed.data.schemaVersion, configPath, telegram: { botToken: parsed.data.telegram.botToken, - allowedUserId: parsed.data.telegram.allowedUserId, + allowedUserIds: parsed.data.telegram.allowedUserIds, + allowedChatIds: parsed.data.telegram.allowedChatIds, }, opencode: { apiUrl: parsed.data.opencode.apiUrl ?? defaultOpencodeConfig.apiUrl, diff --git a/src/config/setupConfig.js b/src/config/setupConfig.js index aeea731..12eeeb5 100644 --- a/src/config/setupConfig.js +++ b/src/config/setupConfig.js @@ -9,6 +9,7 @@ import { detectFfmpegInstaller as defaultDetectFfmpegInstaller, installFfmpeg as defaultInstallFfmpeg, } from "../core/voice/audioConverter.js" +import { CURRENT_CONFIG_SCHEMA_VERSION } from "./configMigration.js" import { getConfigPaths, loadConfig, loadConfigFromObject } from "./loadConfig.js" const defaultPromptConfig = { @@ -89,8 +90,12 @@ export async function promptForConfig( const botToken = await askRequired(rl, "Telegram bot token", currentConfig?.telegram.botToken, { secret: true, }) - const allowedUserId = Number( - await askInteger(rl, "Telegram allowed user ID", currentConfig?.telegram.allowedUserId), + output.write( + "Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct messages are allowed only for configured direct user IDs.\n", + ) + const { allowedUserIds, allowedChatIds } = await askTelegramAuthorizationConfig( + rl, + currentConfig, ) const progressVerbosity = await askChoice( rl, @@ -137,9 +142,11 @@ export async function promptForConfig( return { scope, config: { + schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION, telegram: { botToken, - allowedUserId, + allowedUserIds, + ...(allowedChatIds.length > 0 ? { allowedChatIds } : {}), }, progressVerbosity, ...(voice ? { voice } : {}), @@ -289,15 +296,70 @@ async function askRequired(rl, label, currentValue, options = {}) { } } -async function askInteger(rl, label, currentValue) { +async function askTelegramAuthorizationConfig(rl, currentConfig) { while (true) { - const value = await askRequired(rl, label, currentValue) - const parsed = Number(value) - if (Number.isInteger(parsed) && parsed > 0) { - return value + const allowedUserIds = await askOptionalIntegerList( + rl, + "Telegram allowed direct user IDs, comma-separated (optional)", + currentConfig?.telegram.allowedUserIds, + { positiveOnly: true }, + ) + const allowedChatIds = await askOptionalIntegerList( + rl, + "Telegram allowed group chat IDs, comma-separated (optional)", + currentConfig?.telegram.allowedChatIds, + { positiveOnly: false }, + ) + if (allowedUserIds.length > 0 || allowedChatIds.length > 0) { + return { allowedUserIds, allowedChatIds } + } + rl.output.write("Configure at least one allowed direct user ID or allowed group chat ID.\n") + } +} + +async function askOptionalIntegerList(rl, label, currentValue, options) { + while (true) { + const value = ( + await rl.question(`${label}${formatCurrentHint(formatCurrentList(currentValue))}: `) + ).trim() + if (!value && Array.isArray(currentValue)) { + return currentValue + } + if (!value) { + return [] + } + const parsed = parseIntegerList(value, options) + if (parsed.ok) { + return parsed.value + } + rl.output.write(parsed.message) + } +} + +function parseIntegerList(value, { positiveOnly }) { + const values = String(value) + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + + const parsed = [] + for (const value of values) { + const number = Number(value) + if (!Number.isInteger(number) || (positiveOnly && number <= 0)) { + return { + ok: false, + message: positiveOnly + ? "IDs must be comma-separated positive integers.\n" + : "Chat IDs must be comma-separated integers.\n", + } } - rl.output.write(`${label} must be a positive integer.\n`) + parsed.push(number) } + return { ok: true, value: parsed } +} + +function formatCurrentList(value) { + return Array.isArray(value) && value.length > 0 ? value.join(",") : undefined } async function askChoice( diff --git a/src/config/writeConfig.js b/src/config/writeConfig.js index 89a621f..10738dd 100644 --- a/src/config/writeConfig.js +++ b/src/config/writeConfig.js @@ -1,5 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname } from "node:path" +import { migrateConfig } from "./configMigration.js" import { GatewayConfigError, getConfigPaths, loadConfigFromObject } from "./loadConfig.js" export async function setConfigValue({ @@ -21,7 +22,7 @@ export async function setConfigValue({ } export async function setConfigValuesAtPath({ configPath, values, cwd = process.cwd() } = {}) { - const rawConfig = await readJsonConfig(configPath) + const rawConfig = migrateConfig(await readJsonConfig(configPath)) let nextConfig = rawConfig for (const [key, value] of Object.entries(values ?? {})) { nextConfig = setNestedValue(nextConfig, key, value) diff --git a/src/runtime/bootstrap.js b/src/runtime/bootstrap.js index 1f72bae..8002274 100644 --- a/src/runtime/bootstrap.js +++ b/src/runtime/bootstrap.js @@ -75,7 +75,7 @@ export async function runGateway({ const stickerStore = openTelegramStickerStore() const bot = createTelegramBot({ token: resolvedConfig.telegram.botToken, - allowedUserId: resolvedConfig.telegram.allowedUserId, + telegram: resolvedConfig.telegram, controller, logger: resolvedLogger, progressVerbosity: resolvedConfig.progressVerbosity, diff --git a/tests/adapters/telegramAuth.test.js b/tests/adapters/telegramAuth.test.js index aac4dc5..ab58370 100644 --- a/tests/adapters/telegramAuth.test.js +++ b/tests/adapters/telegramAuth.test.js @@ -2,15 +2,34 @@ import { describe, expect, test } from "vitest" import { isAuthorizedTelegramUser } from "../../src/adapters/telegram/auth.js" describe("isAuthorizedTelegramUser", () => { - test("allows the configured Telegram user ID", () => { - expect(isAuthorizedTelegramUser({ from: { id: 123 } }, 123)).toBe(true) + test("allows configured direct users in private chats", () => { + expect( + isAuthorizedTelegramUser( + { from: { id: 123, is_bot: false }, chat: { id: 123, type: "private" } }, + { allowedUserIds: [123], allowedChatIds: [] }, + ), + ).toBe(true) }) - test("rejects other Telegram user IDs", () => { - expect(isAuthorizedTelegramUser({ from: { id: 999 } }, 123)).toBe(false) + test("rejects other direct users in private chats", () => { + expect( + isAuthorizedTelegramUser( + { from: { id: 999, is_bot: false }, chat: { id: 999, type: "private" } }, + { allowedUserIds: [123], allowedChatIds: [] }, + ), + ).toBe(false) + }) + + test("allows any sender in configured group chats", () => { + expect( + isAuthorizedTelegramUser( + { from: { id: 999, is_bot: true }, chat: { id: -1001, type: "supergroup" } }, + { allowedUserIds: [], allowedChatIds: [-1001] }, + ), + ).toBe(true) }) test("rejects updates without a sender", () => { - expect(isAuthorizedTelegramUser({}, 123)).toBe(false) + expect(isAuthorizedTelegramUser({}, { allowedUserIds: [123], allowedChatIds: [] })).toBe(false) }) }) diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index cc7183e..296121f 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -34,6 +34,15 @@ class FakeBot { } } +function testTelegram(overrides = {}) { + return { + botToken: "token", + allowedUserIds: [123], + allowedChatIds: [], + ...overrides, + } +} + describe("createTelegramBot", () => { afterEach(() => { vi.useRealTimers() @@ -42,7 +51,7 @@ describe("createTelegramBot", () => { test("registers v1 command handlers and message handlers", () => { const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, logger: { warn: vi.fn() }, botFactory: FakeBot, @@ -71,7 +80,7 @@ describe("createTelegramBot", () => { test("status command reports progress verbosity", async () => { const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: { status: vi.fn(async () => ({ activeSessionId: "ses_1", progressVerbosity: "verbose" })), }, @@ -90,7 +99,7 @@ describe("createTelegramBot", () => { test("progress command reports current verbosity", async () => { const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: { getProgressVerbosity: vi.fn(async () => "all"), }, @@ -112,7 +121,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -125,13 +134,36 @@ describe("createTelegramBot", () => { expect(reply).toHaveBeenCalledWith("Tool progress set to verbose.") }) + test("progress command is private-chat only", async () => { + const controller = { + setProgressVerbosity: vi.fn(), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const reply = vi.fn(async () => undefined) + + await bot.commands.get("progress")({ + message: { text: "/progress verbose", chat: { id: -1001, type: "supergroup" } }, + chat: { id: -1001, type: "supergroup" }, + reply, + }) + + expect(controller.setProgressVerbosity).not.toHaveBeenCalled() + expect(reply).toHaveBeenCalledWith("Tool progress is only available in private chats.") + }) + test("progress command rejects unknown verbosity", async () => { const controller = { setProgressVerbosity: vi.fn(), } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -158,7 +190,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -184,7 +216,7 @@ describe("createTelegramBot", () => { const voiceService = { setMode: vi.fn(async () => ({ enabled: true, mode: "all" })) } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -216,7 +248,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -245,7 +277,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -265,7 +297,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -297,7 +329,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -326,7 +358,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -347,7 +379,7 @@ describe("createTelegramBot", () => { const sendVoice = vi.fn(async () => ({ message_id: 10, chat: { id: 456 } })) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, voiceService, sendVoice, @@ -367,7 +399,7 @@ describe("createTelegramBot", () => { const logger = { warn: vi.fn() } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, logger, botFactory: FakeBot, @@ -380,11 +412,105 @@ describe("createTelegramBot", () => { expect(logger.warn).toHaveBeenCalled() }) + test("authorization middleware allows configured human users in private chats", async () => { + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123, 456] }), + controller: {}, + logger: { warn: vi.fn() }, + botFactory: FakeBot, + }) + const next = vi.fn() + + await bot.middlewares[0]( + { from: { id: 456, is_bot: false }, chat: { id: 456, type: "private" } }, + next, + ) + + expect(next).toHaveBeenCalled() + }) + + test("authorization middleware rejects configured human users in unallowed groups", async () => { + const logger = { warn: vi.fn() } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [456], allowedChatIds: [] }), + controller: {}, + logger, + botFactory: FakeBot, + }) + const next = vi.fn() + + await bot.middlewares[0]( + { from: { id: 456, is_bot: false }, chat: { id: -1001, type: "supergroup" } }, + next, + ) + + expect(next).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalled() + }) + + test("authorization middleware allows humans in allowed group chats", async () => { + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [], allowedChatIds: [-1001] }), + controller: {}, + logger: { warn: vi.fn() }, + botFactory: FakeBot, + }) + const next = vi.fn() + + await bot.middlewares[0]( + { from: { id: 777, is_bot: false }, chat: { id: -1001, type: "supergroup" } }, + next, + ) + + expect(next).toHaveBeenCalled() + }) + + test("authorization middleware allows bots in allowed group chats", async () => { + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [], allowedChatIds: [-1001] }), + controller: {}, + logger: { warn: vi.fn() }, + botFactory: FakeBot, + }) + const next = vi.fn() + + await bot.middlewares[0]( + { from: { id: 777, is_bot: true }, chat: { id: -1001, type: "supergroup" } }, + next, + ) + + expect(next).toHaveBeenCalled() + }) + + test("authorization middleware rejects messages in unallowed groups", async () => { + const logger = { warn: vi.fn() } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [], allowedChatIds: [-1001] }), + controller: {}, + logger, + botFactory: FakeBot, + }) + const next = vi.fn() + + await bot.middlewares[0]( + { from: { id: 777, is_bot: true }, chat: { id: -2002, type: "supergroup" } }, + next, + ) + + expect(next).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalled() + }) + test("error handler logs and sends a safe reply", async () => { const logger = { warn: vi.fn(), error: vi.fn() } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, logger, botFactory: FakeBot, @@ -402,7 +528,7 @@ describe("createTelegramBot", () => { const longId = "ses_".padEnd(120, "x") const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: { listSessions: vi.fn(async () => [{ id: longId, title: longTitle }]), }, @@ -427,7 +553,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -446,7 +572,7 @@ describe("createTelegramBot", () => { test("stop command reports when there is no active session", async () => { const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: { stop: vi.fn(async () => ({ stopped: false, reason: "no_active_session" })), }, @@ -472,7 +598,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -503,7 +629,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -541,7 +667,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -585,7 +711,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -621,7 +747,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -661,7 +787,7 @@ describe("createTelegramBot", () => { const sendVoice = vi.fn(async () => ({ message_id: 12, chat: { id: 456 } })) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, voiceService, sendVoice, @@ -716,7 +842,7 @@ describe("createTelegramBot", () => { const sendVoice = vi.fn(async () => ({ message_id: 12, chat: { id: 456 } })) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, voiceService, sendVoice, @@ -792,7 +918,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -837,7 +963,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, voiceService, logger, @@ -881,7 +1007,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -914,6 +1040,46 @@ describe("createTelegramBot", () => { expect(reply).toHaveBeenCalledWith("answer") }) + test("text prompts do not render tool progress in group chats", async () => { + const controller = { + sendPrompt: vi.fn(async (_prompt, options) => { + expect(options).not.toHaveProperty("onProgress") + return "answer" + }), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + progressVerbosity: "all", + progressEditThrottleMs: 0, + }) + const reply = vi.fn(async (text) => ({ message_id: 21, chat: { id: -1001 }, text })) + const editMessageText = vi.fn(async () => true) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "hello", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Group" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + editMessageText, + }, + reply, + }) + + expect(reply).not.toHaveBeenCalledWith(expect.stringContaining("Activity")) + expect(editMessageText).not.toHaveBeenCalled() + expect(reply).toHaveBeenCalledWith("answer") + }) + test("text prompts strip tool usage announcements from the final answer", async () => { const controller = { sendPrompt: vi.fn(async (_prompt, options) => { @@ -928,7 +1094,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -964,7 +1130,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1000,7 +1166,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1038,7 +1204,7 @@ describe("createTelegramBot", () => { const logger = { warn: vi.fn(), error: vi.fn() } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger, botFactory: FakeBot, @@ -1087,7 +1253,7 @@ describe("createTelegramBot", () => { const logger = { warn: vi.fn(), error: vi.fn() } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger, botFactory: FakeBot, @@ -1126,7 +1292,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1183,7 +1349,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, logger: { warn: vi.fn(), error: vi.fn() }, @@ -1220,7 +1386,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1267,7 +1433,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, random: vi.fn(() => 0), @@ -1311,7 +1477,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, random: vi.fn(() => 0), @@ -1363,7 +1529,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, random: vi.fn(() => 0), @@ -1403,7 +1569,7 @@ describe("createTelegramBot", () => { const cleanupStickerFiles = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, createStickerPrompt, @@ -1450,7 +1616,7 @@ describe("createTelegramBot", () => { const stickerStore = createMemoryStickerStore() const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, stickerStore, logger: { warn: vi.fn(), error: vi.fn() }, @@ -1482,7 +1648,7 @@ describe("createTelegramBot", () => { const stickerStore = createMemoryStickerStore() const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, stickerStore, logger: { warn: vi.fn(), error: vi.fn() }, @@ -1520,7 +1686,7 @@ describe("createTelegramBot", () => { const cleanupStickerFiles = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller: {}, stickerStore, cleanupStickerFiles, @@ -1556,7 +1722,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, random: vi.fn(() => 0), @@ -1600,7 +1766,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, stickerStore, random: vi.fn(() => 0), @@ -1633,7 +1799,7 @@ describe("createTelegramBot", () => { const cleanupMediaAttachments = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger, botFactory: FakeBot, @@ -1699,7 +1865,7 @@ describe("createTelegramBot", () => { const cleanupMediaAttachments = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -1748,7 +1914,7 @@ describe("createTelegramBot", () => { const cleanupMediaAttachments = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, voiceService, logger: { warn: vi.fn(), error: vi.fn() }, @@ -1807,7 +1973,7 @@ describe("createTelegramBot", () => { const cleanupMediaAttachments = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger, botFactory: FakeBot, @@ -1869,7 +2035,7 @@ describe("createTelegramBot", () => { const cleanupMediaAttachments = vi.fn(async () => undefined) const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger, botFactory: FakeBot, @@ -1905,7 +2071,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1956,7 +2122,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, @@ -1981,7 +2147,7 @@ describe("createTelegramBot", () => { } const bot = createTelegramBot({ token: "token", - allowedUserId: 123, + telegram: testTelegram(), controller, logger: { warn: vi.fn(), error: vi.fn() }, botFactory: FakeBot, diff --git a/tests/bin/gatewayProgram.test.js b/tests/bin/gatewayProgram.test.js index e6f1696..8bda666 100644 --- a/tests/bin/gatewayProgram.test.js +++ b/tests/bin/gatewayProgram.test.js @@ -338,7 +338,7 @@ describe("opencode-remote CLI program", () => { function testConfig() { return { configPath: ".opencode-remote/config.json", - telegram: { botToken: "token", allowedUserId: 123 }, + telegram: { botToken: "token", allowedUserIds: [123], allowedChatIds: [] }, opencode: { apiUrl: "http://localhost:4096", command: "opencode", diff --git a/tests/config/loadConfig.test.js b/tests/config/loadConfig.test.js index 9cc0d48..4bd4e67 100644 --- a/tests/config/loadConfig.test.js +++ b/tests/config/loadConfig.test.js @@ -33,10 +33,12 @@ describe("loadConfig", () => { ) expect(config).toEqual({ + schemaVersion: 2, configPath, telegram: { botToken: "token", - allowedUserId: 12345, + allowedUserIds: [12345], + allowedChatIds: [], }, opencode: { apiUrl: "http://localhost:4096", @@ -57,6 +59,75 @@ describe("loadConfig", () => { }) }) + test("migrates singular Telegram allowed user ID to plural v2 config", () => { + const cwd = "/project" + const configPath = join(cwd, ".opencode-remote", "config.json") + + const config = loadConfigFromObject( + { + telegram: { + botToken: "token", + allowedUserId: "12345", + }, + }, + { configPath, cwd }, + ) + + expect(config.schemaVersion).toBe(2) + expect(config.telegram).toEqual({ + botToken: "token", + allowedUserIds: [12345], + allowedChatIds: [], + }) + }) + + test("prefers plural Telegram allowed user IDs when singular and plural are both present", () => { + const config = loadConfigFromObject( + { + telegram: { + botToken: "token", + allowedUserId: 111, + allowedUserIds: [222, 333], + }, + }, + { configPath: "/project/.opencode-remote/config.json", cwd: "/project" }, + ) + + expect(config.telegram.allowedUserIds).toEqual([222, 333]) + }) + + test("normalizes group chat allowlists without direct users", () => { + const config = loadConfigFromObject( + { + schemaVersion: 2, + telegram: { + botToken: "token", + allowedUserIds: [], + allowedChatIds: [-1001, 789], + }, + }, + { configPath: "/project/.opencode-remote/config.json", cwd: "/project" }, + ) + + expect(config.telegram).toEqual({ + botToken: "token", + allowedUserIds: [], + allowedChatIds: [-1001, 789], + }) + }) + + test("rejects configs without direct users or allowed chats", () => { + expect(() => + loadConfigFromObject( + { + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [] }, + }, + { configPath: "/project/.opencode-remote/config.json", cwd: "/project" }, + ), + ).toThrow(/telegram/) + }) + test("normalizes custom voice config", () => { const cwd = "/project" const configPath = join(cwd, ".opencode-remote", "config.json") @@ -99,7 +170,11 @@ describe("loadConfig", () => { const config = await loadConfig({ cwd, homeDir }) expect(config.configPath).toBe(join(cwd, ".opencode-remote", "config.json")) - expect(config.telegram).toEqual({ botToken: "local-token", allowedUserId: 222 }) + expect(config.telegram).toEqual({ + botToken: "local-token", + allowedUserIds: [222], + allowedChatIds: [], + }) expect(config.settingsPath).toBe(join(cwd, ".opencode-remote", "settings.json")) }) @@ -112,7 +187,11 @@ describe("loadConfig", () => { const config = await loadConfig({ cwd, homeDir }) expect(config.configPath).toBe(join(homeDir, ".opencode-remote", "config.json")) - expect(config.telegram).toEqual({ botToken: "global-token", allowedUserId: 333 }) + expect(config.telegram).toEqual({ + botToken: "global-token", + allowedUserIds: [333], + allowedChatIds: [], + }) expect(config.settingsPath).toBe(join(homeDir, ".opencode-remote", "settings.json")) }) @@ -179,7 +258,8 @@ describe("loadOrCreateConfig", () => { const prompter = vi.fn(async () => ({ scope: "local", config: { - telegram: { botToken: "created-token", allowedUserId: 444 }, + schemaVersion: 2, + telegram: { botToken: "created-token", allowedUserIds: [444] }, opencode: { apiUrl: "http://localhost:4096", command: "opencode", autoStart: true }, progressVerbosity: "all", logLevel: "info", @@ -196,7 +276,8 @@ describe("loadOrCreateConfig", () => { expect(config.configPath).toBe(localPath) expect(config.settingsPath).toBe(join(cwd, ".opencode-remote", "settings.json")) await expect(readJson(localPath)).resolves.toMatchObject({ - telegram: { botToken: "created-token", allowedUserId: 444 }, + schemaVersion: 2, + telegram: { botToken: "created-token", allowedUserIds: [444] }, }) }) @@ -205,7 +286,8 @@ describe("loadOrCreateConfig", () => { const prompter = vi.fn(async () => ({ scope: "global", config: { - telegram: { botToken: "created-token", allowedUserId: 555 }, + schemaVersion: 2, + telegram: { botToken: "created-token", allowedUserIds: [555] }, }, })) @@ -215,7 +297,8 @@ describe("loadOrCreateConfig", () => { expect(config.configPath).toBe(globalPath) expect(config.settingsPath).toBe(join(homeDir, ".opencode-remote", "settings.json")) await expect(readJson(globalPath)).resolves.toMatchObject({ - telegram: { botToken: "created-token", allowedUserId: 555 }, + schemaVersion: 2, + telegram: { botToken: "created-token", allowedUserIds: [555] }, }) }) }) @@ -231,16 +314,22 @@ describe("createConfig", () => { const prompter = vi.fn(async () => ({ scope: "local", config: { - telegram: { botToken: "new-token", allowedUserId: 222 }, + schemaVersion: 2, + telegram: { botToken: "new-token", allowedUserIds: [222] }, }, })) const config = await createConfig({ cwd, homeDir, prompter, confirmOverwrite }) expect(confirmOverwrite).not.toHaveBeenCalled() - expect(config.telegram).toEqual({ botToken: "new-token", allowedUserId: 222 }) + expect(config.telegram).toEqual({ + botToken: "new-token", + allowedUserIds: [222], + allowedChatIds: [], + }) await expect(readJson(existingPath)).resolves.toMatchObject({ - telegram: { botToken: "new-token", allowedUserId: 222 }, + schemaVersion: 2, + telegram: { botToken: "new-token", allowedUserIds: [222] }, }) }) @@ -249,7 +338,8 @@ describe("createConfig", () => { const prompter = vi.fn(async () => ({ scope: "local", config: { - telegram: { botToken: "token", allowedUserId: 123 }, + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, }, startup: { enabled: true }, })) @@ -279,18 +369,20 @@ describe("promptForConfig", () => { }, { input, output }, ) - await writeAnswers(input, ["", "token", "123", "", "", "", ""]) + await writeAnswers(input, ["", "token", "123", "", "", "", "", ""]) const answers = await prompt expect(answers).toEqual({ scope: "local", config: { - telegram: { botToken: "token", allowedUserId: 123 }, + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, progressVerbosity: "verbose", logLevel: "info", }, startup: { enabled: false }, }) + expect(output.text()).toContain("Group Privacy Mode") expect(output.text()).not.toMatch(/OpenCode API URL/) expect(output.text()).not.toMatch(/OpenCode command/) expect(output.text()).not.toMatch(/Auto-start OpenCode/) @@ -298,6 +390,52 @@ describe("promptForConfig", () => { expect(output.text()).not.toMatch(/Settings path/) }) + test("collects comma-separated Telegram direct user and group chat allowlists", async () => { + const { cwd, homeDir } = await tempWorkspace() + const input = new PassThrough() + const output = captureOutput() + + const prompt = promptForConfig( + { + localConfigPath: join(cwd, ".opencode-remote", "config.json"), + globalConfigPath: join(homeDir, ".opencode-remote", "config.json"), + }, + { input, output }, + ) + await writeAnswers(input, ["", "token", "1, 3", "-1001, 42", "", "", "", ""]) + const answers = await prompt + + expect(answers.config.schemaVersion).toBe(2) + expect(answers.config.telegram).toEqual({ + botToken: "token", + allowedUserIds: [1, 3], + allowedChatIds: [-1001, 42], + }) + expect(output.text()).toContain("Group Privacy Mode") + }) + + test("allows group-only setup with no direct user IDs", async () => { + const { cwd, homeDir } = await tempWorkspace() + const input = new PassThrough() + const output = captureOutput() + + const prompt = promptForConfig( + { + localConfigPath: join(cwd, ".opencode-remote", "config.json"), + globalConfigPath: join(homeDir, ".opencode-remote", "config.json"), + }, + { input, output }, + ) + await writeAnswers(input, ["", "token", "", "-1001", "", "", "", ""]) + const answers = await prompt + + expect(answers.config.telegram).toEqual({ + botToken: "token", + allowedUserIds: [], + allowedChatIds: [-1001], + }) + }) + test("uses existing local config values when local setup input is blank", async () => { const { cwd, homeDir } = await tempWorkspace() const localConfigPath = join(cwd, ".opencode-remote", "config.json") @@ -316,13 +454,14 @@ describe("promptForConfig", () => { }, { input, output }, ) - await writeAnswers(input, ["", "", "", "", "", "", ""]) + await writeAnswers(input, ["", "", "", "", "", "", "", ""]) const answers = await prompt expect(answers).toEqual({ scope: "local", config: { - telegram: { botToken: "existing-token", allowedUserId: 321 }, + schemaVersion: 2, + telegram: { botToken: "existing-token", allowedUserIds: [321] }, progressVerbosity: "all", logLevel: "debug", }, @@ -330,7 +469,9 @@ describe("promptForConfig", () => { }) expect(output.text()).toContain("Current config found") expect(output.text()).toContain("Telegram bot token (current: set; press Enter to keep)") - expect(output.text()).toContain("Telegram allowed user ID (current: 321; press Enter to keep)") + expect(output.text()).toContain( + "Telegram allowed direct user IDs, comma-separated (optional) (current: 321; press Enter to keep)", + ) }) test("uses existing global config values when global setup input is blank", async () => { @@ -351,13 +492,14 @@ describe("promptForConfig", () => { }, { input, output }, ) - await writeAnswers(input, ["global", "", "", "", "", "", ""]) + await writeAnswers(input, ["global", "", "", "", "", "", "", ""]) const answers = await prompt expect(answers).toEqual({ scope: "global", config: { - telegram: { botToken: "global-token", allowedUserId: 654 }, + schemaVersion: 2, + telegram: { botToken: "global-token", allowedUserIds: [654] }, progressVerbosity: "new", logLevel: "warn", }, @@ -382,10 +524,10 @@ describe("promptForConfig", () => { }, { input, output }, ) - await writeAnswers(input, ["", "local-token", "111", "", "", "", ""]) + await writeAnswers(input, ["", "local-token", "111", "", "", "", "", ""]) const answers = await prompt - expect(answers.config.telegram).toEqual({ botToken: "local-token", allowedUserId: 111 }) + expect(answers.config.telegram).toEqual({ botToken: "local-token", allowedUserIds: [111] }) expect(output.text()).not.toContain("Current config found") }) @@ -411,6 +553,7 @@ describe("promptForConfig", () => { "123", "", "", + "", "yes", "gsk_test", "uk-UA-OstapNeural", @@ -439,7 +582,7 @@ describe("promptForConfig", () => { }, { input, output }, ) - await writeAnswers(input, ["", "token", "123", "", "", "", "yes"]) + await writeAnswers(input, ["", "token", "123", "", "", "", "", "yes"]) const answers = await prompt expect(answers.startup).toEqual({ enabled: true }) @@ -476,7 +619,7 @@ describe("promptForConfig", () => { checkFfmpeg: vi.fn(async () => ({ available: true })), }, ) - await writeAnswers(input, ["", "", "", "", "", "", "", "", ""]) + await writeAnswers(input, ["", "", "", "", "", "", "", "", "", ""]) const answers = await prompt expect(answers.config.voice).toEqual({ @@ -524,6 +667,7 @@ describe("promptForConfig", () => { "123", "", "", + "", "yes", "", "gsk_test", @@ -579,6 +723,7 @@ describe("promptForConfig", () => { "123", "", "", + "", "yes", "", "", @@ -618,7 +763,7 @@ describe("promptForConfig", () => { detectFfmpegInstaller: vi.fn(async () => null), }, ) - await writeAnswers(input, ["", "token", "123", "", "", "yes", "skip", ""]) + await writeAnswers(input, ["", "token", "123", "", "", "", "yes", "skip", ""]) const answers = await prompt expect(answers.config.voice).toBeUndefined() @@ -644,6 +789,7 @@ describe("promptForConfig", () => { await pressKey(input, "\r") await pressKey(input, "token\n") await pressKey(input, "123\n") + await pressKey(input, "\r") await pressKey(input, "\x1b[A") await pressKey(input, "\r") await pressKey(input, "\r") diff --git a/tests/config/writeConfig.test.js b/tests/config/writeConfig.test.js index cacc6dc..6de1ba5 100644 --- a/tests/config/writeConfig.test.js +++ b/tests/config/writeConfig.test.js @@ -29,7 +29,8 @@ describe("setConfigValue", () => { expect(result.configPath).toBe(configPath) expect(result.config.voice.enabled).toBe(true) await expect(readJson(configPath)).resolves.toMatchObject({ - telegram: { botToken: "token", allowedUserId: 123 }, + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, voice: { enabled: true }, }) }) @@ -51,6 +52,8 @@ describe("setConfigValue", () => { expect(result.configPath).toBe(configPath) await expect(readJson(configPath)).resolves.toMatchObject({ + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, voice: { mode: "all" }, }) }) @@ -71,6 +74,8 @@ describe("setConfigValue", () => { expect(result.configPath).toBe(configPath) await expect(readJson(configPath)).resolves.toMatchObject({ + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, voice: { enabled: true }, }) }) @@ -91,6 +96,8 @@ describe("setConfigValue", () => { }) await expect(readJson(configPath)).resolves.toMatchObject({ + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, voice: { groqApiKey: null }, }) }) @@ -130,6 +137,8 @@ describe("setConfigValue", () => { expect(result.config.voice.enabled).toBe(true) expect(result.config.voice.mode).toBe("all") await expect(readJson(configPath)).resolves.toMatchObject({ + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123] }, voice: { enabled: true, mode: "all" }, }) }) diff --git a/tests/runtime/background.test.js b/tests/runtime/background.test.js index 97d2d58..86c2d04 100644 --- a/tests/runtime/background.test.js +++ b/tests/runtime/background.test.js @@ -161,7 +161,7 @@ async function tempRoot() { function testConfig(configPath) { return { configPath, - telegram: { botToken: "token", allowedUserId: 123 }, + telegram: { botToken: "token", allowedUserIds: [123], allowedChatIds: [] }, opencode: { apiUrl: "http://localhost:4096", command: "opencode", diff --git a/tests/runtime/bootstrap.test.js b/tests/runtime/bootstrap.test.js index 2cb8b7e..53817cb 100644 --- a/tests/runtime/bootstrap.test.js +++ b/tests/runtime/bootstrap.test.js @@ -99,6 +99,7 @@ describe("runGateway", () => { expect(ensureOpenCodeServer).toHaveBeenCalledWith(testConfig().opencode) expect(createBot).toHaveBeenCalledWith( expect.objectContaining({ + telegram: testConfig().telegram, progressVerbosity: "all", }), ) @@ -405,7 +406,8 @@ describe("runGateway", () => { function testConfig() { return { - telegram: { botToken: "token", allowedUserId: 123 }, + schemaVersion: 2, + telegram: { botToken: "token", allowedUserIds: [123], allowedChatIds: [] }, opencode: { apiUrl: "http://localhost:4096", command: "opencode", diff --git a/tests/runtime/startup.test.js b/tests/runtime/startup.test.js index e49aa25..ecd6aef 100644 --- a/tests/runtime/startup.test.js +++ b/tests/runtime/startup.test.js @@ -243,7 +243,7 @@ async function tempRoot() { function testConfig(configPath) { return { configPath, - telegram: { botToken: "token", allowedUserId: 123 }, + telegram: { botToken: "token", allowedUserIds: [123], allowedChatIds: [] }, opencode: { apiUrl: "http://localhost:4096", command: "opencode", From 76dfa9f1d2408413474e99ba2761070e02a6974d Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 10:54:53 +0200 Subject: [PATCH 02/12] fix: clarify Telegram DM setup prompt --- README.md | 2 +- .../plans/2026-05-28-telegram-group-bot-authorization.md | 2 +- .../specs/2026-05-28-telegram-group-bot-authorization-design.md | 2 +- src/config/setupConfig.js | 2 +- tests/config/loadConfig.test.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa73284..5df349b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Create the config interactively: opencode-remote setup ``` -The setup flow asks whether to write a project-local or global config, then prompts for the Telegram token, optional comma-separated allowed direct user IDs, optional comma-separated allowed group chat IDs, progress verbosity, log level, optional voice mode, and optional user-level login startup from the current project folder. At least one direct user ID or group chat ID is required. If a config already exists at the chosen location, setup shows current values and pressing Enter with no input keeps them; secret values are shown only as set. If voice mode is enabled and `ffmpeg` is missing, setup can try a detected installer and then waits while you install `ffmpeg` in another terminal before continuing. Choice prompts show all options in a highlighted list with arrow-key selection and Enter to confirm. +The setup flow asks whether to write a project-local or global config, then prompts for the Telegram token, optional comma-separated user IDs allowed to DM the bot directly, optional comma-separated allowed group chat IDs, progress verbosity, log level, optional voice mode, and optional user-level login startup from the current project folder. At least one direct user ID or group chat ID is required. If a config already exists at the chosen location, setup shows current values and pressing Enter with no input keeps them; secret values are shown only as set. If voice mode is enabled and `ffmpeg` is missing, setup can try a detected installer and then waits while you install `ffmpeg` in another terminal before continuing. Choice prompts show all options in a highlighted list with arrow-key selection and Enter to confirm. Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct private messages are accepted only from configured `allowedUserIds`. diff --git a/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md b/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md index 5ee7910..90f2b04 100644 --- a/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md +++ b/docs/superpowers/plans/2026-05-28-telegram-group-bot-authorization.md @@ -103,7 +103,7 @@ test("allows group-only setup with no direct user IDs", () => {}) Prompts: ```text -Telegram allowed direct user IDs, comma-separated (optional) +Telegram user IDs allowed to DM this bot directly, comma-separated (optional) Telegram allowed group chat IDs, comma-separated (optional) ``` diff --git a/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md b/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md index 1017db1..3d324cb 100644 --- a/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md +++ b/docs/superpowers/specs/2026-05-28-telegram-group-bot-authorization-design.md @@ -45,7 +45,7 @@ Setup prompts should write the v2 shape only. Prompts: -- `Telegram allowed direct user IDs, comma-separated (optional)` +- `Telegram user IDs allowed to DM this bot directly, comma-separated (optional)` - `Telegram allowed group chat IDs, comma-separated (optional)` The parser accepts whitespace around comma-separated tokens, so `1,2` and `1, 3` both produce arrays. Blank user/chat prompts are allowed only when the other prompt contains at least one ID. Invalid tokens produce clear setup validation errors. diff --git a/src/config/setupConfig.js b/src/config/setupConfig.js index 12eeeb5..b6a9eca 100644 --- a/src/config/setupConfig.js +++ b/src/config/setupConfig.js @@ -300,7 +300,7 @@ async function askTelegramAuthorizationConfig(rl, currentConfig) { while (true) { const allowedUserIds = await askOptionalIntegerList( rl, - "Telegram allowed direct user IDs, comma-separated (optional)", + "Telegram user IDs allowed to DM this bot directly, comma-separated (optional)", currentConfig?.telegram.allowedUserIds, { positiveOnly: true }, ) diff --git a/tests/config/loadConfig.test.js b/tests/config/loadConfig.test.js index 4bd4e67..64ea32b 100644 --- a/tests/config/loadConfig.test.js +++ b/tests/config/loadConfig.test.js @@ -470,7 +470,7 @@ describe("promptForConfig", () => { expect(output.text()).toContain("Current config found") expect(output.text()).toContain("Telegram bot token (current: set; press Enter to keep)") expect(output.text()).toContain( - "Telegram allowed direct user IDs, comma-separated (optional) (current: 321; press Enter to keep)", + "Telegram user IDs allowed to DM this bot directly, comma-separated (optional) (current: 321; press Enter to keep)", ) }) From 0acd9c8be4862dc83e57cfbca0dc3837831fee87 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 11:15:41 +0200 Subject: [PATCH 03/12] fix: use Telegram sender chat authors --- CHANGELOG.md | 6 ++++ FEATURES.md | 2 +- README.md | 2 +- package.json | 2 +- src/adapters/telegram/author.js | 5 ++++ src/bin/program.js | 2 +- tests/adapters/telegramAuthor.test.js | 42 +++++++++++++++++++++++++++ tests/adapters/telegramBot.test.js | 36 +++++++++++++++++++++++ 8 files changed, 93 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7413b6e..31f2f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ This project follows Semantic Versioning. ## Unreleased +## [0.6.1] - 2026-05-28 + +### Fixed + +- Fixed Telegram prompt author context for messages sent by anonymous admins or on behalf of chats/channels by using Telegram `sender_chat` names when available. + ## [0.6.0] - 2026-05-28 ### Added diff --git a/FEATURES.md b/FEATURES.md index d29bd3e..5196c5c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -34,7 +34,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - `/help` shows the available bot commands. - The Telegram slash-command menu is refreshed on gateway startup. - Non-command text from an authorized private user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. -- Forwarded Telegram text, photo, album, and voice prompts include safe original-author context when Telegram provides it, with a safe fallback to the authorized user. +- Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names. - The bot shows Telegram typing activity while a prompt is running. - In private chats, the bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. Group chats always suppress this activity message. - OpenCode permission requests are sent as text with `Allow once`, `Always allow`, and `Deny` buttons, even when voice replies are enabled. diff --git a/README.md b/README.md index 5df349b..b114a00 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ The bot currently supports: Any non-command text message from an authorized private Telegram user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. If no active session is selected, the gateway creates one automatically. -Forwarded Telegram text, photo, album, and voice prompts include safe author context for OpenCode when Telegram provides the original author. If Telegram hides or omits the forwarded author, the prompt falls back to the authorized Telegram user without exposing raw Telegram payloads or numeric user IDs. +Telegram text, photo, album, voice, and sticker prompts include safe author context for OpenCode. Forwarded prompts prefer the original author when Telegram provides it. Messages sent by anonymous admins or on behalf of a chat/channel use the sender chat title or username when available. If Telegram hides or omits usable author data, the prompt falls back to the authorized Telegram user without exposing raw Telegram payloads or numeric IDs. When a new OpenCode session starts, OpenCode Remote sends hidden gateway context with no assistant reply. This helps the agent understand that voice input may arrive as transcripts and that final text can be delivered as voice notes when voice mode is enabled. diff --git a/package.json b/package.json index a45b6a2..b14a50e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@crankshift/opencode-remote", "description": "A messenger-based chat interface for OpenCode, starting with Telegram.", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "repository": { "type": "git", diff --git a/src/adapters/telegram/author.js b/src/adapters/telegram/author.js index 6b495c5..86575ac 100644 --- a/src/adapters/telegram/author.js +++ b/src/adapters/telegram/author.js @@ -4,6 +4,11 @@ export function authorContextFromTelegramMessage(message) { return { name: forwardedName, source: "forwarded" } } + const senderChatName = telegramChatDisplayName(message?.sender_chat) + if (senderChatName) { + return { name: senderChatName, source: "sender" } + } + return { name: telegramUserDisplayName(message?.from) ?? "Authorized Telegram user", source: "sender", diff --git a/src/bin/program.js b/src/bin/program.js index f8ec027..9939241 100644 --- a/src/bin/program.js +++ b/src/bin/program.js @@ -36,7 +36,7 @@ export function createGatewayProgram({ const program = new Command() const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output }) - program.name("opencode-remote").description("OpenCode messaging gateway").version("0.6.0") + program.name("opencode-remote").description("OpenCode messaging gateway").version("0.6.1") program .command("setup") diff --git a/tests/adapters/telegramAuthor.test.js b/tests/adapters/telegramAuthor.test.js index 669c4e7..0d705fa 100644 --- a/tests/adapters/telegramAuthor.test.js +++ b/tests/adapters/telegramAuthor.test.js @@ -56,6 +56,37 @@ describe("telegram author context", () => { expect(author).toEqual({ name: "Release Notes", source: "forwarded" }) }) + test("uses sender chat titles as current message author context", () => { + const author = authorContextFromTelegramMessage({ + from: { id: 123, is_bot: false, first_name: "Admin" }, + sender_chat: { id: -1001, type: "supergroup", title: "Release Room" }, + }) + + expect(author).toEqual({ name: "Release Room", source: "sender" }) + }) + + test("uses sender chat usernames when titles are unavailable", () => { + const author = authorContextFromTelegramMessage({ + from: { id: 123, is_bot: false, first_name: "Admin" }, + sender_chat: { id: -1002, type: "channel", username: "release_notes" }, + }) + + expect(author).toEqual({ name: "@release_notes", source: "sender" }) + }) + + test("keeps forwarded authors ahead of sender chat context", () => { + const author = authorContextFromTelegramMessage({ + from: { id: 123, is_bot: false, first_name: "Forwarder" }, + sender_chat: { id: -1001, type: "supergroup", title: "Forwarding Room" }, + forward_origin: { + type: "hidden_user", + sender_user_name: "Original Author", + }, + }) + + expect(author).toEqual({ name: "Original Author", source: "forwarded" }) + }) + test("falls back to the authorized sender when forwarded author data is unavailable", () => { const author = authorContextFromTelegramMessage({ from: { id: 123, is_bot: false, first_name: "Authorized", last_name: "User" }, @@ -89,4 +120,15 @@ describe("telegram author context", () => { expect(author.name).not.toContain("999") expect(author.name).not.toContain("123") }) + + test("does not expose numeric sender chat IDs as author names", () => { + const author = authorContextFromTelegramMessage({ + from: { id: 123, is_bot: false, first_name: "Authorized" }, + sender_chat: { id: -1001, type: "supergroup" }, + }) + + expect(author).toEqual({ name: "Authorized", source: "sender" }) + expect(author.name).not.toContain("1001") + expect(author.name).not.toContain("123") + }) }) diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index 296121f..4765f9f 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -776,6 +776,42 @@ describe("createTelegramBot", () => { ) }) + test("normal text prompts include sender chat author context", async () => { + const controller = { + sendPrompt: vi.fn(async () => "answer"), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram(), + controller, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "hello from the room", + chat: { id: 456 }, + from: { id: 123, is_bot: false, first_name: "Admin" }, + sender_chat: { id: -1001, type: "supergroup", title: "Release Room" }, + }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: vi.fn(async () => ({ message_id: 11, chat: { id: 456 }, text: "answer" })), + }) + + expect(controller.sendPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("hello from the room"), + author: { name: "Release Room", source: "sender" }, + }), + expect.objectContaining({ onProgress: expect.any(Function) }), + ) + }) + test("text prompts in voice all mode send voice replies without text", async () => { const controller = { sendPrompt: vi.fn(async () => "answer"), From 2146e00dc703d481f825078d7cea5f0120a4a506 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:26:59 +0200 Subject: [PATCH 04/12] feat: add Telegram group routing --- AGENTS.md | 2 + FEATURES.md | 5 +- README.md | 13 +- .../2026-05-28-telegram-group-routing.md | 75 +++ src/adapters/telegram/bot.js | 129 ++++- src/adapters/telegram/groupMemory.js | 164 ++++++ src/adapters/telegram/groupMenu.js | 187 +++++++ src/adapters/telegram/groupPrompts.js | 243 +++++++++ src/adapters/telegram/groupRegistry.js | 61 +++ src/adapters/telegram/groupRouting.js | 148 ++++++ src/adapters/telegram/groupStore.js | 312 ++++++++++++ src/config/setupConfig.js | 2 +- src/core/commands/commands.js | 11 +- src/core/gateway/controller.js | 13 +- src/runtime/bootstrap.js | 19 +- tests/adapters/telegramBot.test.js | 474 +++++++++++++++++- tests/adapters/telegramGroupMemory.test.js | 121 +++++ tests/adapters/telegramGroupMenu.test.js | 107 ++++ tests/adapters/telegramGroupRegistry.test.js | 81 +++ tests/adapters/telegramGroupRouting.test.js | 112 +++++ tests/adapters/telegramGroupStore.test.js | 104 ++++ tests/core/commands.test.js | 2 + tests/runtime/bootstrap.test.js | 72 ++- 23 files changed, 2426 insertions(+), 31 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-telegram-group-routing.md create mode 100644 src/adapters/telegram/groupMemory.js create mode 100644 src/adapters/telegram/groupMenu.js create mode 100644 src/adapters/telegram/groupPrompts.js create mode 100644 src/adapters/telegram/groupRegistry.js create mode 100644 src/adapters/telegram/groupRouting.js create mode 100644 src/adapters/telegram/groupStore.js create mode 100644 tests/adapters/telegramGroupMemory.test.js create mode 100644 tests/adapters/telegramGroupMenu.test.js create mode 100644 tests/adapters/telegramGroupRegistry.test.js create mode 100644 tests/adapters/telegramGroupRouting.test.js create mode 100644 tests/adapters/telegramGroupStore.test.js diff --git a/AGENTS.md b/AGENTS.md index 0a21028..91697ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,12 +74,14 @@ Add modules only when they reduce real complexity. Prefer the smallest correct c ## Telegram Adapter - Authorization middleware should ignore unauthorized users and avoid leaking project state. +- Group routing, known group metadata, DM configuration menus, and ephemeral group memory belong in `src/adapters/telegram/`; do not move Telegram chat IDs, topics, or inline menus into core. - Reaction API calls are best-effort warnings and must not block prompt delivery. - `replyAndRemember` stores bot replies for reaction feedback. Use it for bot messages that should be remembered. - Inline callback data must use short bounded tokens, not raw long session IDs or permission IDs. - Permission prompts must remain text-only, even when voice replies are enabled. - Photo downloads must not expose bot tokens in persisted attachment URLs. - Sticker cache and saved pack state must not persist bot tokens, raw download URLs, chat IDs, user IDs, or raw Telegram payloads. +- Group message memory is in-memory only and must not persist message text. Persistent group state may store settings and non-secret group metadata. - Always clean up downloaded media files in `finally` or equivalent cleanup paths. - Keep Telegram UX in the adapter; do not move Telegram reactions, message IDs, chat actions, or grammY types into core. diff --git a/FEATURES.md b/FEATURES.md index 5196c5c..296cc30 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -31,9 +31,11 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - `/progress` shows or sets private-chat prompt activity visibility: `off`, `new`, `all`, or `verbose`. - `/voice` shows and controls voice mode, lists voices by required short country/locale filter, sets the active Edge TTS voice, and sends a test voice note. - `/stickers` saves, lists, and forgets sticker packs for future sticker replies. +- `/group` opens a private-chat management menu for known allowed groups. In groups, `/group` replies with a short DM-only notice. - `/help` shows the available bot commands. - The Telegram slash-command menu is refreshed on gateway startup. -- Non-command text from an authorized private user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. +- Non-command text from an authorized private user is sent to OpenCode as a prompt. In allowed groups, text, photo, voice, and sticker messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. +- Allowed groups keep bounded in-memory recent context while the gateway is running. Routed group prompts include capped recent context, but passive messages are not sent to OpenCode by themselves. - Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names. - The bot shows Telegram typing activity while a prompt is running. - In private chats, the bot can show an editable `Activity` message with OpenCode tools and skills used during a prompt. Group chats always suppress this activity message. @@ -79,6 +81,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - The bot ignores private Telegram users outside the configured user allowlist. - The bot ignores group chats outside the configured chat allowlist. Allowed groups authorize all senders in that group, so configure only groups whose members and admins you trust. +- Group conversation memory is ephemeral, bounded, and cleared on gateway restart or OpenCode session changes. Persistent group state stores settings and known group metadata, not message text. - Secrets are configured through private `.opencode-remote/config.json` files, not persisted settings. - The selected active session is persisted as non-secret JSON state. - Saved sticker packs persist only non-secret sticker identifiers and metadata. diff --git a/README.md b/README.md index b114a00..3d834ac 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ opencode-remote setup The setup flow asks whether to write a project-local or global config, then prompts for the Telegram token, optional comma-separated user IDs allowed to DM the bot directly, optional comma-separated allowed group chat IDs, progress verbosity, log level, optional voice mode, and optional user-level login startup from the current project folder. At least one direct user ID or group chat ID is required. If a config already exists at the chosen location, setup shows current values and pressing Enter with no input keeps them; secret values are shown only as set. If voice mode is enabled and `ffmpeg` is missing, setup can try a detected installer and then waits while you install `ffmpeg` in another terminal before continuing. Choice prompts show all options in a highlighted list with arrow-key selection and Enter to confirm. -Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct private messages are accepted only from configured `allowedUserIds`. +Allowed chat IDs let the gateway observe messages in those groups and decide whether they are addressed to the bot. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct private messages are accepted only from configured `allowedUserIds`. Config discovery order: @@ -135,12 +135,14 @@ The config file is JSON: `telegram.allowedUserIds` is optional when `telegram.allowedChatIds` is configured. It is an array of trusted human Telegram user IDs that may use the bot in private direct chats. Setup accepts values such as `123456789` or `123456789, 222333444`. Direct messages from other users and all private bot-to-bot messages are ignored. -`telegram.allowedChatIds` is optional when `telegram.allowedUserIds` is configured. It authorizes every sender in those group chats, including humans and other bots. Telegram group and supergroup IDs are usually negative, for example `-1001234567890`. Do not configure group IDs for groups whose members or admins you do not trust. +`telegram.allowedChatIds` is optional when `telegram.allowedUserIds` is configured. It allows the gateway to observe every sender in those group chats, including humans and other bots, and then apply group routing settings before prompting OpenCode. Telegram group and supergroup IDs are usually negative, for example `-1001234567890`. Do not configure group IDs for groups whose members or admins you do not trust. `opencode.apiUrl` controls the OpenCode server URL. It defaults to `http://localhost:4096`. When `opencode.autoStart=true` and this URL points to `localhost` or `127.0.0.1` with a port, the gateway starts `opencode serve --port ` so it waits on the same URL it configured. `progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message. +Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. + `voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. When a voice-note reply succeeds, the bot does not also send the text reply; if speech generation or sending fails, it falls back to text. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled. `logLevel` controls structured log verbosity. Supported values are `fatal`, `error`, `warn`, `info`, `debug`, `trace`, and `silent`. @@ -173,10 +175,13 @@ The bot currently supports: /progress Show or set tool progress visibility: off, new, all, verbose /voice Show or set voice mode /stickers Manage saved sticker packs +/group Manage Telegram group behavior in DM /help Show available commands ``` -Any non-command text message from an authorized private Telegram user, or from any sender in an allowed group chat, is sent to OpenCode as a prompt. If no active session is selected, the gateway creates one automatically. +Any non-command text message from an authorized private Telegram user is sent to OpenCode as a prompt. In allowed group chats, messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. Defaults are conservative: human senders can trigger replies by replying to the bot, mentioning the bot username, or starting text with the bot name. Other bots are remembered as passive context by default but do not trigger replies unless group settings are changed in the DM `/group` menu. If no active session is selected, the gateway creates one automatically. + +Allowed group chats keep bounded in-memory recent context while the gateway process runs. When a group message is routed, the gateway sends OpenCode the addressed message plus a capped recent-context transcript. It does not persist group message text; memory is cleared on gateway restart and when the active OpenCode session changes. Passive stickers and photos are stored as lightweight metadata and are not downloaded for OpenCode unless routed. Group voice messages may be transcribed before routing when voice mode is enabled so the gateway can decide whether the transcript addresses the bot. Telegram text, photo, album, voice, and sticker prompts include safe author context for OpenCode. Forwarded prompts prefer the original author when Telegram provides it. Messages sent by anonymous admins or on behalf of a chat/channel use the sender chat title or username when available. If Telegram hides or omits usable author data, the prompt falls back to the authorized Telegram user without exposing raw Telegram payloads or numeric IDs. @@ -218,7 +223,7 @@ If startup fails with a configuration error, check the selected `.opencode-remot If Telegram private messages from a human user appear to be ignored, confirm that `telegram.allowedUserIds` contains your Telegram user ID, not the bot ID or chat ID. -If group messages appear to be ignored, confirm that `telegram.allowedChatIds` contains the group chat ID. To receive all messages in groups, this bot must be a group admin or Group Privacy Mode must be disabled in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. +If group messages appear to be ignored, confirm that `telegram.allowedChatIds` contains the group chat ID and that the message addresses the bot under the current `/group` settings. To receive all messages in groups, this bot must be a group admin or Group Privacy Mode must be disabled in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. If startup fails because OpenCode is unreachable, make sure the OpenCode CLI is installed and available in `PATH`. With auto-start enabled, the gateway waits about 60 seconds for the configured OpenCode URL before exiting. diff --git a/docs/superpowers/plans/2026-05-28-telegram-group-routing.md b/docs/superpowers/plans/2026-05-28-telegram-group-routing.md new file mode 100644 index 0000000..b15b720 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-telegram-group-routing.md @@ -0,0 +1,75 @@ +# Telegram Group Routing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add DM-managed Telegram group routing so the gateway responds only when addressed, while keeping bounded recent group context for routed prompts. + +**Architecture:** Keep Telegram-specific behavior inside `src/adapters/telegram/`. Add focused modules for group settings persistence, known group registry, routing decisions, in-memory context, and DM inline menus; keep `bot.js` as the orchestrator that delegates to those modules. + +**Tech Stack:** Node.js ESM, grammY inline keyboards/callback queries, SQLite via `node:sqlite`, Vitest, Biome. + +--- + +### Task 1: Routing And Memory Modules + +**Files:** +- Create: `src/adapters/telegram/groupRouting.js` +- Create: `src/adapters/telegram/groupMemory.js` +- Test: `tests/adapters/telegramGroupRouting.test.js` +- Test: `tests/adapters/telegramGroupMemory.test.js` + +- [ ] Write failing tests for sender policy, reply/mention/name-prefix triggers, own-bot ignore, topic/session memory keys, caps, per-message truncation, cursor overlap, and current-message exclusion. +- [ ] Run focused tests and confirm they fail because the modules do not exist. +- [ ] Implement pure routing helpers and in-memory rolling context with hard caps. +- [ ] Run focused tests and confirm they pass. + +### Task 2: Persistent Group Store And Registry + +**Files:** +- Create: `src/adapters/telegram/groupStore.js` +- Create: `src/adapters/telegram/groupRegistry.js` +- Test: `tests/adapters/telegramGroupStore.test.js` +- Test: `tests/adapters/telegramGroupRegistry.test.js` + +- [ ] Write failing tests for persisted known groups, settings defaults, settings updates, reset, unavailable state, seeding from `allowedChatIds`, `getChat` refresh, and `my_chat_member` removal/addition. +- [ ] Run focused tests and confirm they fail because the modules do not exist. +- [ ] Implement SQLite-backed group store with an in-memory test store and registry helpers. +- [ ] Run focused tests and confirm they pass. + +### Task 3: DM Group Menu + +**Files:** +- Create: `src/adapters/telegram/groupMenu.js` +- Modify: `src/adapters/telegram/bot.js` +- Test: `tests/adapters/telegramGroupMenu.test.js` +- Test: `tests/adapters/telegramBot.test.js` + +- [ ] Write failing tests for `/group` in DM, unauthorized DM rejection, group notice in groups, short callback tokens, callback user binding, selecting a group, toggling settings with buttons, clearing memory, and help/status rendering. +- [ ] Run focused tests and confirm they fail for missing behavior. +- [ ] Implement menu rendering and callback handlers, then wire `/group` into `bot.js`. +- [ ] Run focused tests and confirm they pass. + +### Task 4: Bot Integration + +**Files:** +- Modify: `src/adapters/telegram/bot.js` +- Modify: `src/runtime/bootstrap.js` +- Test: `tests/adapters/telegramBot.test.js` +- Test: `tests/runtime/bootstrap.test.js` + +- [ ] Write failing integration tests for group passive memory, addressed text routing with eye reaction, sticker reply-to-bot routing with recent context, passive sticker metadata without downloads, voice transcript routing, reaction feedback gating, and memory reset on session create/select. +- [ ] Run focused tests and confirm they fail for missing behavior. +- [ ] Wire group routing before prompt sending, attach formatted context, remember bot replies, suppress group progress, gate group reactions, and include `my_chat_member` in `allowed_updates`. +- [ ] Run focused tests and confirm they pass. + +### Task 5: Docs And Verification + +**Files:** +- Modify: `src/core/commands/commands.js` +- Modify: `README.md` +- Modify: `FEATURES.md` +- Modify: `AGENTS.md` if durable architecture guidance changes + +- [ ] Add `/group` to private command help and command registration behavior. +- [ ] Document DM-managed group settings, routing defaults, Telegram delivery requirements, ephemeral memory, voice transcription behavior, and sticker behavior. +- [ ] Run `pnpm run lint`, `pnpm test`, and `pnpm run check`. diff --git a/src/adapters/telegram/bot.js b/src/adapters/telegram/bot.js index fd3d709..d12eb76 100644 --- a/src/adapters/telegram/bot.js +++ b/src/adapters/telegram/bot.js @@ -1,6 +1,10 @@ import { rm } from "node:fs/promises" import { Bot, InlineKeyboard } from "grammy" -import { botCommands, renderHelpText } from "../../core/commands/commands.js" +import { + privateBotCommands, + publicBotCommands, + renderHelpText, +} from "../../core/commands/commands.js" import { chunkText } from "../../core/formatting/chunkText.js" import { createProgressTextState, @@ -9,6 +13,10 @@ import { } from "../../core/formatting/progressText.js" import { isAuthorizedTelegramUser } from "./auth.js" import { authorContextFromTelegramMessage } from "./author.js" +import { createGroupMemory as defaultCreateGroupMemory } from "./groupMemory.js" +import { createTelegramGroupMenu } from "./groupMenu.js" +import { createTelegramGroupPromptHelper } from "./groupPrompts.js" +import { createMemoryGroupStore } from "./groupStore.js" import { captionFromMessages, cleanupAttachments as defaultCleanupMediaAttachments, @@ -28,12 +36,15 @@ import { const SAFE_ERROR_REPLY = "OpenCode Remote failed while handling that request." export async function registerTelegramBotCommands(bot, logger) { - for (const scope of [null, { type: "all_private_chats" }]) { + for (const { commands, scope } of [ + { commands: publicBotCommands, scope: null }, + { commands: privateBotCommands, scope: { type: "all_private_chats" } }, + ]) { try { if (scope) { - await bot.api.setMyCommands(botCommands, { scope }) + await bot.api.setMyCommands(commands, { scope }) } else { - await bot.api.setMyCommands(botCommands) + await bot.api.setMyCommands(commands) } } catch (error) { logger.warn({ error }, "Could not register Telegram commands") @@ -57,9 +68,14 @@ export function createTelegramBot({ cleanupMediaAttachments = defaultCleanupMediaAttachments, voiceService = null, stickerStore = null, + groupStore = createMemoryGroupStore({ allowedChatIds: telegram.allowedChatIds }), + groupMemory = defaultCreateGroupMemory(), + groupRegistry = null, + botIdentity = {}, createStickerPrompt = defaultCreateStickerPrompt, cleanupStickerFiles = defaultCleanupStickerFiles, random = Math.random, + groupNoticeCooldownMs, }) { const bot = new botFactory(token) let fallbackProgressVerbosity = progressVerbosity @@ -67,6 +83,19 @@ export function createTelegramBot({ const permissionResponseTokens = createBoundedTokenStore(200) const stickerSaveTokens = createBoundedTokenStore(200) const botMessageMemory = createBotMessageMemory(200) + const groupMenu = createTelegramGroupMenu({ + store: groupStore, + memory: groupMemory, + noticeCooldownMs: groupNoticeCooldownMs, + }) + const groupPrompts = createTelegramGroupPromptHelper({ + groupStore, + groupMemory, + groupRegistry, + controller, + botIdentity, + logger, + }) const mediaGroupBuffer = createMediaGroupBuffer({ waitMs: mediaGroupWaitMs, logger, @@ -116,7 +145,10 @@ export function createTelegramBot({ }) bot.command("new", async (ctx) => { - const session = await controller.createSession() + const session = await controller.createSession({ + context: await formatPromptForTelegramGateway(""), + }) + groupMemory.clearAll?.() await replyAndRemember(ctx, `Created session ${session.title ?? session.id}`, botMessageMemory) }) @@ -149,6 +181,7 @@ export function createTelegramBot({ return } await controller.selectSession(sessionId) + groupMemory.clearAll?.() await ctx.answerCallbackQuery({ text: "Session selected" }) await replyAndRemember(ctx, `Selected session ${sessionId}`, botMessageMemory) }) @@ -279,8 +312,19 @@ export function createTelegramBot({ await handleStickersCommand(ctx) }) + bot.command("group", async (ctx) => { + await groupMenu.handleCommand(ctx) + }) + + bot.callbackQuery(/^group:(.+)$/u, async (ctx) => { + await groupMenu.handleCallback(ctx) + }) + bot.on("message_reaction", async (ctx) => { const update = ctx.messageReaction + if (!isPrivateTelegramChat(ctx) && !(await groupReactionsEnabled(update.chat.id))) { + return + } const botMessage = botMessageMemory.get(update.chat.id, update.message_id) if (!botMessage) { return @@ -302,11 +346,28 @@ export function createTelegramBot({ } }) + bot.on("my_chat_member", async (ctx) => { + await groupRegistry?.handleMyChatMember?.(ctx.myChatMember ?? ctx.update?.my_chat_member) + }) + bot.on("message:text", async (ctx) => { if (ctx.message.text.startsWith("/")) { return } + let groupScope = null + let groupCurrentRecord = null + let groupContextText = "" + if (!isPrivateTelegramChat(ctx)) { + const groupResult = await groupPrompts.prepareText(ctx) + if (!groupResult.route) { + return + } + groupScope = groupResult.scope + groupCurrentRecord = groupResult.currentRecord + groupContextText = groupResult.contextText + } + const chatId = ctx.message.chat.id const messageId = ctx.message.message_id const stopTyping = startTypingIndicator(ctx, logger) @@ -316,7 +377,7 @@ export function createTelegramBot({ await setEmojiReaction(ctx, chatId, messageId, "👀", logger) const response = await sendPromptWithProgress( await formatPromptForTelegramGateway({ - text: ctx.message.text, + text: groupPrompts.withContext(ctx.message.text, groupContextText), author: authorContextFromTelegramMessage(ctx.message), }), progress, @@ -327,6 +388,7 @@ export function createTelegramBot({ requestedReaction = parsedResponse.requestedReaction const requestedSticker = parsedResponse.requestedSticker await replyWithPreferredMode(ctx, parsedResponse.visibleText, "text") + groupPrompts.complete(groupScope, groupCurrentRecord, parsedResponse.visibleText) if (requestedSticker) { await sendRequestedSticker(ctx, requestedSticker) requestedReaction = null @@ -361,6 +423,19 @@ export function createTelegramBot({ return bot async function handlePhotoMessages(ctx, messages) { + let groupScope = null + let groupCurrentRecord = null + let groupContextText = "" + if (!isPrivateTelegramChat(ctx)) { + const groupResult = await groupPrompts.preparePhoto(ctx, messages) + if (!groupResult.route) { + return + } + groupScope = groupResult.scope + groupCurrentRecord = groupResult.currentRecord + groupContextText = groupResult.contextText + } + const attachments = [] const stopTyping = startTypingIndicator(ctx, logger) @@ -388,7 +463,7 @@ export function createTelegramBot({ const progress = await createPromptProgressRenderer(ctx) const response = await sendPromptWithProgress( await formatPromptForTelegramGateway({ - text: captionFromMessages(messages), + text: groupPrompts.withContext(captionFromMessages(messages), groupContextText), author: authorContextFromTelegramMessage(messages[0]), attachments, }), @@ -398,6 +473,7 @@ export function createTelegramBot({ await progress.flush() const parsedResponse = parseTelegramGatewayMarkers(response, progress) await replyWithPreferredMode(ctx, parsedResponse.visibleText, "photo") + groupPrompts.complete(groupScope, groupCurrentRecord, parsedResponse.visibleText) if (parsedResponse.requestedSticker) { await sendRequestedSticker(ctx, parsedResponse.requestedSticker) } else if (parsedResponse.requestedReaction) { @@ -450,10 +526,22 @@ export function createTelegramBot({ attachments.push(attachment) const transcript = await voiceService.transcribe(attachment.filePath) + let groupScope = null + let groupCurrentRecord = null + let groupContextText = "" + if (!isPrivateTelegramChat(ctx)) { + const groupResult = await groupPrompts.prepareVoice(ctx, transcript) + if (!groupResult.route) { + return + } + groupScope = groupResult.scope + groupCurrentRecord = groupResult.currentRecord + groupContextText = groupResult.contextText + } const progress = await createPromptProgressRenderer(ctx) const response = await sendPromptWithProgress( await formatPromptForTelegramGateway({ - text: transcript, + text: groupPrompts.withContext(transcript, groupContextText), author: authorContextFromTelegramMessage(ctx.message), }), progress, @@ -462,6 +550,7 @@ export function createTelegramBot({ await progress.flush() const parsedResponse = parseTelegramGatewayMarkers(response, progress) await replyWithPreferredMode(ctx, parsedResponse.visibleText, "voice") + groupPrompts.complete(groupScope, groupCurrentRecord, parsedResponse.visibleText) if (parsedResponse.requestedSticker) { await sendRequestedSticker(ctx, parsedResponse.requestedSticker) } @@ -472,6 +561,19 @@ export function createTelegramBot({ } async function handleStickerMessage(ctx) { + let groupScope = null + let groupCurrentRecord = null + let groupContextText = "" + if (!isPrivateTelegramChat(ctx)) { + const groupResult = await groupPrompts.prepareSticker(ctx) + if (!groupResult.route) { + return + } + groupScope = groupResult.scope + groupCurrentRecord = groupResult.currentRecord + groupContextText = groupResult.contextText + } + let cleanupFiles = [] const stopTyping = startTypingIndicator(ctx, logger) try { @@ -489,6 +591,7 @@ export function createTelegramBot({ const response = await sendPromptWithProgress( await formatPromptForTelegramGateway({ ...result.prompt, + text: groupPrompts.withContext(result.prompt?.text ?? "", groupContextText), author: authorContextFromTelegramMessage(ctx.message), }), progress, @@ -497,6 +600,7 @@ export function createTelegramBot({ await progress.flush() const parsedResponse = parseTelegramGatewayMarkers(response, progress) await replyWithPreferredMode(ctx, parsedResponse.visibleText, "sticker") + groupPrompts.complete(groupScope, groupCurrentRecord, parsedResponse.visibleText) if (parsedResponse.requestedSticker) { await sendRequestedSticker(ctx, parsedResponse.requestedSticker) } else if (parsedResponse.requestedReaction) { @@ -514,6 +618,15 @@ export function createTelegramBot({ } } + async function groupReactionsEnabled(chatId) { + try { + return (await groupStore.getSettings(chatId))?.reactions?.enabled === true + } catch (error) { + logger?.warn?.({ error, chatId }, "Could not read Telegram group reaction settings") + return false + } + } + async function replyWithPreferredMode(ctx, text, source) { if (!String(text ?? "").trim()) { return diff --git a/src/adapters/telegram/groupMemory.js b/src/adapters/telegram/groupMemory.js new file mode 100644 index 0000000..a26a545 --- /dev/null +++ b/src/adapters/telegram/groupMemory.js @@ -0,0 +1,164 @@ +const DEFAULT_LIMITS = { + storeMessages: 200, + storeChars: 50_000, + contextMessages: 30, + contextChars: 12_000, + overlap: 5, + maxEntryChars: 1_000, +} + +export function createGroupMemory(options = {}) { + const limits = normalizeLimits(options) + const scopes = new Map() + const cursors = new Map() + let nextId = 1 + + return { + record(scope, entry) { + const key = scopeKey(scope) + const record = normalizeEntry(entry, nextId) + nextId += 1 + const entries = scopes.get(key) ?? [] + entries.push(record) + scopes.set(key, pruneEntries(entries, limits)) + return record + }, + + buildContext(scope, options = {}) { + const contextLimits = normalizeContextLimits(limits, options) + const key = scopeKey(scope) + const entries = scopes.get(key) ?? [] + const cursorId = cursors.get(key) + const cursorIndex = entries.findIndex((entry) => entry.id === cursorId) + const startIndex = cursorIndex >= 0 ? Math.max(0, cursorIndex - contextLimits.overlap + 1) : 0 + const selected = entries + .slice(startIndex) + .filter((entry) => entry.messageId !== options.currentMessageId) + .slice(-contextLimits.contextMessages) + const capped = fitContextEntries(selected, contextLimits) + return { entries: capped, text: formatContextText(capped, contextLimits) } + }, + + markPromptCursor(scope, id) { + cursors.set(scopeKey(scope), id) + }, + + snapshot(scope) { + return [...(scopes.get(scopeKey(scope)) ?? [])] + }, + + clearScope(scope) { + const key = scopeKey(scope) + scopes.delete(key) + cursors.delete(key) + }, + + clearChat(chatId) { + const prefix = `${chatId}:` + for (const key of [...scopes.keys()]) { + if (key.startsWith(prefix)) { + scopes.delete(key) + cursors.delete(key) + } + } + }, + + clearAll() { + scopes.clear() + cursors.clear() + }, + } +} + +function normalizeLimits(options) { + return { + storeMessages: boundedInteger(options.storeMessages, DEFAULT_LIMITS.storeMessages, 1, 1_000), + storeChars: boundedInteger(options.storeChars, DEFAULT_LIMITS.storeChars, 1, 200_000), + contextMessages: boundedInteger( + options.contextMessages, + DEFAULT_LIMITS.contextMessages, + 1, + 100, + ), + contextChars: boundedInteger(options.contextChars, DEFAULT_LIMITS.contextChars, 1, 40_000), + overlap: boundedInteger(options.overlap, DEFAULT_LIMITS.overlap, 0, 20), + maxEntryChars: boundedInteger(options.maxEntryChars, DEFAULT_LIMITS.maxEntryChars, 1, 4_000), + } +} + +function normalizeContextLimits(base, options) { + return { + ...base, + contextMessages: boundedInteger(options.contextMessages, base.contextMessages, 1, 100), + contextChars: boundedInteger(options.contextChars, base.contextChars, 1, 40_000), + overlap: boundedInteger(options.overlap, base.overlap, 0, 20), + maxEntryChars: boundedInteger(options.maxEntryChars, base.maxEntryChars, 1, 4_000), + } +} + +function boundedInteger(value, fallback, min, max) { + const number = Number(value) + if (!Number.isInteger(number)) { + return fallback + } + return Math.min(max, Math.max(min, number)) +} + +function normalizeEntry(entry, id) { + return { + id, + messageId: Number.isInteger(entry?.messageId) ? entry.messageId : null, + author: safeText(entry?.author, "Unknown"), + text: safeText(entry?.text, ""), + kind: safeText(entry?.kind, "text"), + timestamp: Number.isFinite(entry?.timestamp) ? entry.timestamp : Date.now(), + } +} + +function safeText(value, fallback) { + const text = String(value ?? "") + .replace(/\s+/gu, " ") + .trim() + return text || fallback +} + +function pruneEntries(entries, limits) { + const pruned = entries.slice(-limits.storeMessages) + while (totalEntryChars(pruned) > limits.storeChars && pruned.length > 0) { + pruned.shift() + } + return pruned +} + +function totalEntryChars(entries) { + return entries.reduce((total, entry) => total + entry.text.length, 0) +} + +function fitContextEntries(entries, limits) { + const result = [] + for (const entry of entries.slice().reverse()) { + const candidate = [entry, ...result] + if (formatContextText(candidate, limits).length > limits.contextChars) { + continue + } + result.unshift(entry) + } + return result +} + +function formatContextText(entries, limits) { + return entries + .map((entry) => `${entry.author}: ${truncateText(entry.text, limits.maxEntryChars)}`) + .join("\n") +} + +function truncateText(text, limit) { + if (text.length <= limit) { + return text + } + return `${text.slice(0, limit)}...` +} + +function scopeKey(scope = {}) { + return [scope.chatId ?? "chat", scope.threadId ?? "main", scope.sessionId ?? "session"].join(":") +} diff --git a/src/adapters/telegram/groupMenu.js b/src/adapters/telegram/groupMenu.js new file mode 100644 index 0000000..7d3f5a2 --- /dev/null +++ b/src/adapters/telegram/groupMenu.js @@ -0,0 +1,187 @@ +import { InlineKeyboard } from "grammy" + +const GROUP_NOTICE_TEXT = "Group settings are managed in DM. Message me and run /group." + +export function createTelegramGroupMenu({ + store, + memory, + noticeCooldownMs = 10 * 60 * 1000, + now = Date.now, +} = {}) { + const noticeTimes = new Map() + const groupTokens = createTokenStore(200) + + return { + async handleCommand(ctx) { + if (!isPrivateChat(ctx)) { + await maybeSendGroupNotice(ctx) + return + } + + const groups = typeof store?.listGroups === "function" ? await store.listGroups() : [] + if (groups.length === 0) { + await ctx.reply("No known Telegram groups are configured for this gateway.") + return + } + + const keyboard = new InlineKeyboard() + for (const group of groups) { + const token = groupTokens.add({ + action: "select", + chatId: group.chatId, + userId: ctx.from?.id, + }) + keyboard.text(group.title, `group:${token}`).row() + } + await ctx.reply("Select a Telegram group to configure:", { reply_markup: keyboard }) + }, + + async handleCallback(ctx) { + const token = ctx.match?.[1] + const selection = groupTokens.get(token) + if (!selection || selection.userId !== ctx.from?.id) { + await ctx.answerCallbackQuery({ text: "Group menu expired" }) + return + } + if (selection.action === "reply") { + await store.updateSettings(selection.chatId, { replyPolicy: selection.replyPolicy }) + await ctx.answerCallbackQuery({ text: "Reply policy updated" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "toggle_trigger") { + const settings = await store.getSettings(selection.chatId) + await store.updateSettings(selection.chatId, { + triggers: { [selection.trigger]: !settings.triggers[selection.trigger] }, + }) + await ctx.answerCallbackQuery({ text: "Trigger updated" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "toggle_memory") { + const settings = await store.getSettings(selection.chatId) + await store.updateSettings(selection.chatId, { + memory: { enabled: !settings.memory.enabled }, + }) + await ctx.answerCallbackQuery({ text: "Memory updated" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "context_messages") { + await store.updateSettings(selection.chatId, { context: { messages: selection.messages } }) + await ctx.answerCallbackQuery({ text: "Context messages updated" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "context_chars") { + await store.updateSettings(selection.chatId, { context: { chars: selection.chars } }) + await ctx.answerCallbackQuery({ text: "Context chars updated" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "clear_memory") { + memory?.clearChat?.(selection.chatId) + await ctx.answerCallbackQuery({ text: "Group memory cleared" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + + await ctx.answerCallbackQuery({ text: "Group selected" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + }, + } + + async function replyWithSettingsMenu(ctx, chatId, userId) { + const settings = await store.getSettings(chatId) + const groups = typeof store?.listGroups === "function" ? await store.listGroups() : [] + const group = groups.find((candidate) => candidate.chatId === chatId) + const keyboard = new InlineKeyboard() + for (const replyPolicy of ["off", "humans", "bots", "all"]) { + const token = groupTokens.add({ action: "reply", chatId, userId, replyPolicy }) + keyboard.text(`Reply: ${replyPolicy}`, `group:${token}`).row() + } + for (const trigger of ["reply", "mention", "namePrefix", "nameAnywhere"]) { + const token = groupTokens.add({ action: "toggle_trigger", chatId, userId, trigger }) + keyboard + .text( + `Trigger ${formatTriggerLabel(trigger)}: ${settings.triggers[trigger] ? "on" : "off"}`, + `group:${token}`, + ) + .row() + } + const memoryToken = groupTokens.add({ action: "toggle_memory", chatId, userId }) + keyboard.text(`Memory: ${settings.memory.enabled ? "off" : "on"}`, `group:${memoryToken}`).row() + for (const messages of [10, 30, 50]) { + const token = groupTokens.add({ action: "context_messages", chatId, userId, messages }) + keyboard.text(`Context messages: ${messages}`, `group:${token}`).row() + } + for (const chars of [4_000, 12_000, 24_000]) { + const token = groupTokens.add({ action: "context_chars", chatId, userId, chars }) + keyboard.text(`Context chars: ${formatChars(chars)}`, `group:${token}`).row() + } + const clearToken = groupTokens.add({ action: "clear_memory", chatId, userId }) + keyboard.text("Clear memory", `group:${clearToken}`) + await ctx.reply(formatGroupSettings(group?.title ?? `Group ${chatId}`, settings), { + reply_markup: keyboard, + }) + } + + async function maybeSendGroupNotice(ctx) { + const chatId = ctx.chat?.id ?? ctx.message?.chat?.id + const lastNoticeAt = noticeTimes.get(chatId) ?? 0 + if (now() - lastNoticeAt < noticeCooldownMs) { + return + } + noticeTimes.set(chatId, now()) + await ctx.reply(GROUP_NOTICE_TEXT) + } +} + +function isPrivateChat(ctx) { + const chatType = ctx.chat?.type ?? ctx.message?.chat?.type + return chatType === "private" +} + +function formatGroupSettings(groupTitle, settings) { + return [ + `${groupTitle} settings:`, + `Reply policy: ${settings.replyPolicy}`, + `Triggers: ${formatEnabledTriggers(settings.triggers)}`, + `Memory: ${settings.memory.enabled ? "on" : "off"}`, + `Context: ${settings.context.messages} messages, ${settings.context.chars} chars, ${settings.context.overlap} overlap`, + ].join("\n") +} + +function formatEnabledTriggers(triggers) { + return Object.entries(triggers) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .join(", ") +} + +function formatTriggerLabel(trigger) { + return trigger.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`) +} + +function formatChars(chars) { + return `${chars / 1_000}k` +} + +function createTokenStore(limit) { + const values = new Map() + let nextToken = 0 + return { + add(value) { + const token = String(nextToken) + nextToken += 1 + values.set(token, value) + while (values.size > limit) { + values.delete(values.keys().next().value) + } + return token + }, + get(token) { + return values.get(token) + }, + } +} diff --git a/src/adapters/telegram/groupPrompts.js b/src/adapters/telegram/groupPrompts.js new file mode 100644 index 0000000..9920a0d --- /dev/null +++ b/src/adapters/telegram/groupPrompts.js @@ -0,0 +1,243 @@ +import { authorContextFromTelegramMessage } from "./author.js" +import { evaluateGroupMessageRouting } from "./groupRouting.js" + +export function createTelegramGroupPromptHelper({ + groupStore, + groupMemory, + groupRegistry, + controller, + botIdentity = {}, + logger, +} = {}) { + return { + async prepareText(ctx) { + await rememberKnownGroup(ctx.message) + const settings = await groupStore.getSettings(ctx.message.chat.id) + const scope = await groupMemoryScope(ctx.message) + const decision = evaluateGroupMessageRouting({ + message: ctx.message, + settings, + botIdentity: botIdentityForContext(ctx), + }) + + if (!decision.route) { + rememberText(scope, ctx.message, settings) + return { route: false } + } + + const context = buildContext(scope, settings, ctx.message.message_id) + const currentRecord = rememberText(scope, ctx.message, settings) + return routedPrompt(scope, currentRecord, context) + }, + + async preparePhoto(ctx, messages) { + const message = messages[0] + await rememberKnownGroup(message) + const settings = await groupStore.getSettings(message.chat.id) + const scope = await groupMemoryScope(message) + const decision = evaluateGroupMessageRouting({ + message: { ...message, text: captionTextForRouting(messages) }, + settings, + botIdentity: botIdentityForContext(ctx), + }) + + if (!decision.route) { + rememberPhoto(scope, messages, settings) + return { route: false } + } + + const context = buildContext(scope, settings, message.message_id) + const currentRecord = rememberPhoto(scope, messages, settings) + return routedPrompt(scope, currentRecord, context) + }, + + async prepareSticker(ctx) { + await rememberKnownGroup(ctx.message) + const settings = await groupStore.getSettings(ctx.message.chat.id) + const scope = await groupMemoryScope(ctx.message) + const decision = evaluateGroupMessageRouting({ + message: ctx.message, + settings, + botIdentity: botIdentityForContext(ctx), + }) + + if (!decision.route) { + rememberSticker(scope, ctx.message, settings) + return { route: false } + } + + const context = buildContext(scope, settings, ctx.message.message_id) + const currentRecord = rememberSticker(scope, ctx.message, settings) + return routedPrompt(scope, currentRecord, context) + }, + + async prepareVoice(ctx, transcript) { + await rememberKnownGroup(ctx.message) + const settings = await groupStore.getSettings(ctx.message.chat.id) + const scope = await groupMemoryScope(ctx.message) + const messageWithTranscript = { ...ctx.message, text: transcript } + const decision = evaluateGroupMessageRouting({ + message: messageWithTranscript, + settings, + botIdentity: botIdentityForContext(ctx), + }) + + if (!decision.route) { + rememberTranscript(scope, ctx.message, transcript, settings) + return { route: false } + } + + const context = buildContext(scope, settings, ctx.message.message_id) + const currentRecord = rememberTranscript(scope, ctx.message, transcript, settings) + return routedPrompt(scope, currentRecord, context) + }, + + complete(scope, currentRecord, replyText) { + if (!scope || !currentRecord) { + return + } + groupMemory.markPromptCursor(scope, currentRecord.id) + if (!String(replyText ?? "").trim()) { + return + } + groupMemory.record(scope, { + author: botIdentity.firstName ?? botIdentity.username ?? "OpenCode Remote", + text: replyText, + kind: "bot_reply", + timestamp: Date.now(), + }) + }, + + withContext(text, contextText) { + if (!String(contextText ?? "").trim()) { + return text + } + return [ + "Recent Telegram group context:", + contextText, + "", + "Current addressed message:", + text, + ].join("\n") + }, + } + + async function rememberKnownGroup(message) { + if (typeof groupRegistry?.recordGroupMessage === "function") { + await groupRegistry.recordGroupMessage(message) + return + } + if (typeof groupStore?.upsertKnownGroup !== "function") { + return + } + const chat = message?.chat + if (!chat?.id) { + return + } + await groupStore.upsertKnownGroup({ + chatId: chat.id, + title: chat.title ?? chat.username ?? `Group ${chat.id}`, + username: chat.username ?? null, + type: chat.type ?? "supergroup", + status: "active", + }) + } + + async function groupMemoryScope(message) { + let sessionId = "active" + try { + sessionId = (await controller.status?.())?.activeSessionId ?? sessionId + } catch (error) { + logger?.warn?.({ error }, "Could not read active session for Telegram group memory") + } + return { + chatId: message.chat.id, + threadId: message.message_thread_id ?? null, + sessionId, + } + } + + function buildContext(scope, settings, currentMessageId) { + return settings.memory?.enabled === false + ? { text: "" } + : groupMemory.buildContext(scope, { + currentMessageId, + contextMessages: settings.context?.messages, + contextChars: settings.context?.chars, + overlap: settings.context?.overlap, + }) + } + + function routedPrompt(scope, currentRecord, context) { + return { + route: true, + scope, + currentRecord, + contextText: context.text, + } + } + + function rememberText(scope, message, settings) { + return rememberEntry(scope, message, settings, { + text: message.text, + kind: "text", + }) + } + + function rememberSticker(scope, message, settings) { + const sticker = message.sticker + const pack = sticker?.set_name ? ` from ${sticker.set_name}` : "" + const emoji = sticker?.emoji ? ` ${sticker.emoji}` : "" + return rememberEntry(scope, message, settings, { + text: `sent sticker${emoji}${pack}`, + kind: "sticker", + }) + } + + function rememberTranscript(scope, message, transcript, settings) { + return rememberEntry(scope, message, settings, { + text: transcript, + kind: "voice", + }) + } + + function rememberPhoto(scope, messages, settings) { + const message = messages[0] + const caption = captionTextForRouting(messages) + const album = messages.length > 1 ? " album" : "" + return rememberEntry(scope, message, settings, { + text: caption ? `sent photo${album}: ${caption}` : `sent photo${album}`, + kind: "photo", + }) + } + + function rememberEntry(scope, message, settings, entry) { + if (settings?.memory?.enabled === false) { + return null + } + const author = authorContextFromTelegramMessage(message) + return groupMemory.record(scope, { + messageId: message.message_id, + author: author.name, + text: entry.text, + kind: entry.kind, + timestamp: message.date ? message.date * 1000 : Date.now(), + }) + } + + function botIdentityForContext(ctx) { + return { + ...botIdentity, + id: botIdentity.id ?? ctx.me?.id, + username: botIdentity.username ?? ctx.me?.username, + firstName: botIdentity.firstName ?? ctx.me?.first_name ?? ctx.me?.firstName, + } + } +} + +function captionTextForRouting(messages) { + return messages + .map((message) => String(message?.caption ?? "").trim()) + .filter(Boolean) + .join("\n") +} diff --git a/src/adapters/telegram/groupRegistry.js b/src/adapters/telegram/groupRegistry.js new file mode 100644 index 0000000..49180d1 --- /dev/null +++ b/src/adapters/telegram/groupRegistry.js @@ -0,0 +1,61 @@ +export function createTelegramGroupRegistry({ telegram, store, api, logger } = {}) { + const allowedChatIds = new Set(telegram?.allowedChatIds ?? []) + let currentApi = api + + return { + setApi(api) { + currentApi = api + }, + + async refreshAllowedGroups() { + for (const chatId of allowedChatIds) { + try { + const chat = await currentApi?.getChat?.(chatId) + if (chat) { + await store.upsertKnownGroup(groupFromTelegramChat(chat, "active")) + } + } catch (error) { + logger?.warn?.({ error, chatId }, "Could not refresh Telegram group metadata") + await store.markGroupUnavailable(chatId) + } + } + }, + + async recordGroupMessage(message) { + const chat = message?.chat + if (!isAllowedGroup(chat?.id, allowedChatIds)) { + return + } + await store.upsertKnownGroup(groupFromTelegramChat(chat, "active")) + }, + + async handleMyChatMember(update) { + const chat = update?.chat + if (!isAllowedGroup(chat?.id, allowedChatIds)) { + return + } + const status = update?.new_chat_member?.status + if (status === "left" || status === "kicked") { + await store.upsertKnownGroup(groupFromTelegramChat(chat, "unavailable")) + return + } + if (["member", "administrator", "creator"].includes(status)) { + await store.upsertKnownGroup(groupFromTelegramChat(chat, "active")) + } + }, + } +} + +function groupFromTelegramChat(chat, status) { + return { + chatId: chat.id, + title: chat.title ?? chat.username ?? `Group ${chat.id}`, + username: chat.username ?? null, + type: chat.type ?? "supergroup", + status, + } +} + +function isAllowedGroup(chatId, allowedChatIds) { + return Number.isInteger(chatId) && allowedChatIds.has(chatId) +} diff --git a/src/adapters/telegram/groupRouting.js b/src/adapters/telegram/groupRouting.js new file mode 100644 index 0000000..1ce208a --- /dev/null +++ b/src/adapters/telegram/groupRouting.js @@ -0,0 +1,148 @@ +export const DEFAULT_GROUP_SETTINGS = { + replyPolicy: "humans", + triggers: { + reply: true, + mention: true, + namePrefix: true, + nameAnywhere: false, + voiceName: false, + }, +} + +export function evaluateGroupMessageRouting({ message, settings, botIdentity } = {}) { + if (!message) { + return { route: false, reason: "no_message" } + } + + const normalizedIdentity = normalizeBotIdentity(botIdentity) + if (normalizedIdentity.id && message.from?.id === normalizedIdentity.id) { + return { route: false, reason: "own_message" } + } + + const normalizedSettings = normalizeGroupSettings(settings) + if (!senderAllowed(message, normalizedSettings.replyPolicy)) { + return { route: false, reason: "sender_policy" } + } + + const text = messageText(message) + if (normalizedSettings.triggers.reply && repliesToBot(message, normalizedIdentity)) { + return { route: true, trigger: "reply" } + } + if (normalizedSettings.triggers.mention && mentionsBot(text, normalizedIdentity)) { + return { route: true, trigger: "mention" } + } + if (normalizedSettings.triggers.namePrefix && startsWithBotName(text, normalizedIdentity.names)) { + return { route: true, trigger: "name_prefix" } + } + if (normalizedSettings.triggers.nameAnywhere && containsBotName(text, normalizedIdentity.names)) { + return { route: true, trigger: "name_anywhere" } + } + + return { route: false, reason: "not_addressed" } +} + +export function normalizeGroupSettings(settings = {}) { + const triggers = { ...DEFAULT_GROUP_SETTINGS.triggers, ...(settings.triggers ?? {}) } + return { + ...DEFAULT_GROUP_SETTINGS, + ...settings, + triggers, + } +} + +function senderAllowed(message, replyPolicy) { + switch (replyPolicy) { + case "off": + return false + case "bots": + return message.from?.is_bot === true + case "all": + return true + default: + return message.from?.is_bot !== true + } +} + +function repliesToBot(message, botIdentity) { + const repliedSenderId = message.reply_to_message?.from?.id + return Boolean(botIdentity.id && repliedSenderId === botIdentity.id) +} + +function mentionsBot(text, botIdentity) { + if (!text || !botIdentity.username) { + return false + } + return new RegExp( + `(^|[^\\p{Letter}\\p{Number}_])@${escapeRegex(botIdentity.username)}\\b`, + "iu", + ).test(text) +} + +function startsWithBotName(text, names) { + if (!text) { + return false + } + const trimmed = text.trimStart() + return names.some((name) => { + const pattern = new RegExp(`^${escapeRegex(name)}(?:$|[\\s,.:;!?\\-—])`, "iu") + return pattern.test(trimmed) + }) +} + +function containsBotName(text, names) { + if (!text) { + return false + } + return names.some((name) => { + const pattern = new RegExp( + `(^|[^\\p{Letter}\\p{Number}_])${escapeRegex(name)}($|[^\\p{Letter}\\p{Number}_])`, + "iu", + ) + return pattern.test(text) + }) +} + +function normalizeBotIdentity(identity = {}) { + const username = normalizeUsername(identity.username) + const names = uniqueStrings([ + identity.firstName, + identity.name, + username, + ...(Array.isArray(identity.aliases) ? identity.aliases : []), + ]) + return { + id: Number.isInteger(identity.id) ? identity.id : null, + username, + names, + } +} + +function normalizeUsername(username) { + const normalized = String(username ?? "") + .replace(/^@/u, "") + .trim() + return normalized || null +} + +function uniqueStrings(values) { + const result = [] + const seen = new Set() + for (const value of values) { + const text = String(value ?? "").trim() + const key = text.toLocaleLowerCase("en-US") + if (!text || seen.has(key)) { + continue + } + seen.add(key) + result.push(text) + } + return result +} + +function messageText(message) { + return String(message?.text ?? message?.caption ?? "") +} + +function escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/src/adapters/telegram/groupStore.js b/src/adapters/telegram/groupStore.js new file mode 100644 index 0000000..f00c8d0 --- /dev/null +++ b/src/adapters/telegram/groupStore.js @@ -0,0 +1,312 @@ +import { mkdirSync } from "node:fs" +import { dirname, posix, win32 } from "node:path" +import { DatabaseSync } from "node:sqlite" +import { getAppDataDir } from "../../core/state/appDataPath.js" +import { DEFAULT_GROUP_SETTINGS, normalizeGroupSettings } from "./groupRouting.js" + +const GROUP_DB_FILE_NAME = "telegram-groups.db" + +export const DEFAULT_GROUP_CONFIG = { + ...DEFAULT_GROUP_SETTINGS, + memory: { + enabled: true, + storeMessages: 200, + storeChars: 50_000, + ttlHours: 24, + }, + context: { + messages: 30, + chars: 12_000, + overlap: 5, + }, + reactions: { + enabled: false, + }, +} + +export function getDefaultTelegramGroupDbPath(options = {}) { + const platform = options.platform ?? process.platform + const pathApi = platform === "win32" ? win32 : posix + return pathApi.join(getAppDataDir({ ...options, platform }), GROUP_DB_FILE_NAME) +} + +export function openTelegramGroupStore(dbPath, { Database = DatabaseSync, pathOptions = {} } = {}) { + const resolvedDbPath = dbPath ?? getDefaultTelegramGroupDbPath(pathOptions) + mkdirSync(dirname(resolvedDbPath), { recursive: true }) + const database = new Database(resolvedDbPath) + initialize(database) + + return { + path: resolvedDbPath, + async listGroups() { + return listGroups(database) + }, + async upsertKnownGroup(group) { + upsertKnownGroup(database, group) + }, + async markGroupUnavailable(chatId) { + markGroupUnavailable(database, chatId) + }, + async getSettings(chatId) { + return getSettings(database, chatId) + }, + async updateSettings(chatId, patch) { + return updateSettings(database, chatId, patch) + }, + async resetSettings(chatId) { + return resetSettings(database, chatId) + }, + close() { + database.close() + }, + } +} + +export function createMemoryGroupStore({ allowedChatIds = [] } = {}) { + const groups = new Map() + const settings = new Map() + let nextCreated = 1 + + for (const chatId of allowedChatIds) { + groups.set(chatId, { + chatId, + title: `Group ${chatId}`, + username: null, + type: "supergroup", + status: "configured", + timeCreated: nextCreated, + }) + nextCreated += 1 + } + + return { + async listGroups() { + return [...groups.values()] + .sort((left, right) => left.timeCreated - right.timeCreated) + .map(publicGroup) + }, + async upsertKnownGroup(group) { + const normalized = normalizeGroup(group) + const existing = groups.get(normalized.chatId) + groups.set(normalized.chatId, { + ...existing, + ...normalized, + timeCreated: existing?.timeCreated ?? nextCreated, + }) + if (!existing) { + nextCreated += 1 + } + }, + async markGroupUnavailable(chatId) { + const group = groups.get(chatId) ?? defaultKnownGroup(chatId) + groups.set(chatId, { + ...group, + status: "unavailable", + timeCreated: group.timeCreated ?? nextCreated, + }) + if (!group.timeCreated) { + nextCreated += 1 + } + }, + async getSettings(chatId) { + return normalizeGroupConfig(settings.get(chatId)) + }, + async updateSettings(chatId, patch) { + const next = mergeGroupConfig(normalizeGroupConfig(settings.get(chatId)), patch) + settings.set(chatId, next) + return next + }, + async resetSettings(chatId) { + settings.delete(chatId) + return normalizeGroupConfig() + }, + close() {}, + } +} + +function initialize(database) { + database.exec(` + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + + CREATE TABLE IF NOT EXISTS telegram_group ( + chat_id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + username TEXT, + type TEXT NOT NULL, + status TEXT NOT NULL, + settings_json TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + ) STRICT; + `) +} + +function listGroups(database) { + return database + .prepare( + "SELECT chat_id, title, username, type, status FROM telegram_group ORDER BY time_created ASC", + ) + .all() + .map(rowToGroup) +} + +function upsertKnownGroup(database, group) { + const normalized = normalizeGroup(group) + const existing = getRawGroup(database, normalized.chatId) + const now = Date.now() + database + .prepare( + `INSERT INTO telegram_group + (chat_id, title, username, type, status, settings_json, time_created, time_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET + title = excluded.title, + username = excluded.username, + type = excluded.type, + status = excluded.status, + time_updated = excluded.time_updated`, + ) + .run( + normalized.chatId, + normalized.title, + normalized.username, + normalized.type, + normalized.status, + existing?.settings_json ?? null, + existing?.time_created ?? now, + now, + ) +} + +function markGroupUnavailable(database, chatId) { + const existing = getRawGroup(database, chatId) + const group = existing ? rowToGroup(existing) : defaultKnownGroup(chatId) + upsertKnownGroup(database, { ...group, status: "unavailable" }) +} + +function getSettings(database, chatId) { + const raw = getRawGroup(database, chatId)?.settings_json + return normalizeGroupConfig(parseSettings(raw)) +} + +function updateSettings(database, chatId, patch) { + const next = mergeGroupConfig(getSettings(database, chatId), patch) + ensureGroup(database, chatId) + database + .prepare("UPDATE telegram_group SET settings_json = ?, time_updated = ? WHERE chat_id = ?") + .run(JSON.stringify(next), Date.now(), chatId) + return next +} + +function resetSettings(database, chatId) { + ensureGroup(database, chatId) + database + .prepare("UPDATE telegram_group SET settings_json = NULL, time_updated = ? WHERE chat_id = ?") + .run(Date.now(), chatId) + return normalizeGroupConfig() +} + +function ensureGroup(database, chatId) { + if (!getRawGroup(database, chatId)) { + upsertKnownGroup(database, defaultKnownGroup(chatId)) + } +} + +function getRawGroup(database, chatId) { + return database.prepare("SELECT * FROM telegram_group WHERE chat_id = ?").get(chatId) +} + +function normalizeGroup(group = {}) { + const chatId = Number(group.chatId ?? group.id) + if (!Number.isInteger(chatId)) { + throw new Error("Telegram group requires an integer chat ID") + } + return { + chatId, + title: safeString(group.title) ?? `Group ${chatId}`, + username: safeString(group.username), + type: safeString(group.type) ?? "supergroup", + status: safeString(group.status) ?? "active", + } +} + +function defaultKnownGroup(chatId) { + return { + chatId, + title: `Group ${chatId}`, + username: null, + type: "supergroup", + status: "configured", + } +} + +function rowToGroup(row) { + return { + chatId: row.chat_id, + title: row.title, + username: row.username ?? null, + type: row.type, + status: row.status, + } +} + +function publicGroup(group) { + return { + chatId: group.chatId, + title: group.title, + username: group.username ?? null, + type: group.type, + status: group.status, + } +} + +function parseSettings(raw) { + if (!raw) { + return null + } + try { + return JSON.parse(raw) + } catch { + return null + } +} + +function normalizeGroupConfig(value = {}) { + return mergeGroupConfig(DEFAULT_GROUP_CONFIG, value) +} + +function mergeGroupConfig(base, patch = {}) { + return { + ...DEFAULT_GROUP_CONFIG, + ...base, + ...patch, + ...normalizeGroupSettings({ ...base, ...patch }), + triggers: { + ...DEFAULT_GROUP_CONFIG.triggers, + ...(base?.triggers ?? {}), + ...(patch?.triggers ?? {}), + }, + memory: { + ...DEFAULT_GROUP_CONFIG.memory, + ...(base?.memory ?? {}), + ...(patch?.memory ?? {}), + }, + context: { + ...DEFAULT_GROUP_CONFIG.context, + ...(base?.context ?? {}), + ...(patch?.context ?? {}), + }, + reactions: { + ...DEFAULT_GROUP_CONFIG.reactions, + ...(base?.reactions ?? {}), + ...(patch?.reactions ?? {}), + }, + } +} + +function safeString(value) { + const text = String(value ?? "").trim() + return text || null +} diff --git a/src/config/setupConfig.js b/src/config/setupConfig.js index b6a9eca..1dc57c2 100644 --- a/src/config/setupConfig.js +++ b/src/config/setupConfig.js @@ -91,7 +91,7 @@ export async function promptForConfig( secret: true, }) output.write( - "Allowed chat IDs authorize all messages in those groups, including messages from other bots. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct messages are allowed only for configured direct user IDs.\n", + "Allowed chat IDs let the gateway observe group messages, including messages from other bots, before group routing decides whether to prompt OpenCode. To receive all group messages, make this bot a group admin or disable Group Privacy Mode in BotFather. To receive messages from other bots in groups, also enable Bot-to-Bot Communication Mode. Direct messages are allowed only for configured direct user IDs.\n", ) const { allowedUserIds, allowedChatIds } = await askTelegramAuthorizationConfig( rl, diff --git a/src/core/commands/commands.js b/src/core/commands/commands.js index 31f6032..105066a 100644 --- a/src/core/commands/commands.js +++ b/src/core/commands/commands.js @@ -1,4 +1,4 @@ -export const botCommands = [ +export const publicBotCommands = [ { command: "status", description: "Show gateway and OpenCode status" }, { command: "new", description: "Create and select a new OpenCode session" }, { command: "sessions", description: "List and switch OpenCode sessions" }, @@ -6,13 +6,20 @@ export const botCommands = [ { command: "progress", description: "Set tool progress visibility" }, { command: "voice", description: "Show or set voice mode" }, { command: "stickers", description: "Manage saved sticker packs" }, +] + +export const privateBotCommands = [ + ...publicBotCommands, + { command: "group", description: "Manage Telegram group behavior" }, { command: "help", description: "Show available commands" }, ] +export const botCommands = privateBotCommands + export function renderHelpText() { return [ "OpenCode Remote commands:", "", - ...botCommands.map((command) => `/${command.command} - ${command.description}`), + ...privateBotCommands.map((command) => `/${command.command} - ${command.description}`), ].join("\n") } diff --git a/src/core/gateway/controller.js b/src/core/gateway/controller.js index 41866fc..0c2790e 100644 --- a/src/core/gateway/controller.js +++ b/src/core/gateway/controller.js @@ -18,19 +18,20 @@ export function createGatewayController({ return session.id } - async function createSession() { + async function createSession(options = {}) { const session = await opencode.createSession() await store.write({ activeSessionId: session.id }) - await primeSession(session.id) + await primeSession(session.id, options.context) return session } - async function primeSession(sessionId) { + async function primeSession(sessionId, additionalContext) { if (!gatewayContext || typeof opencode.sendContext !== "function") { return } + const context = [gatewayContext, additionalContext].filter(Boolean).join("\n\n") try { - await opencode.sendContext(sessionId, gatewayContext) + await opencode.sendContext(sessionId, context) } catch (error) { logger?.warn?.({ error, sessionId }, "Could not send OpenCode gateway context") } @@ -65,8 +66,8 @@ export function createGatewayController({ return { progressVerbosity } }, - async createSession() { - return createSession() + async createSession(options) { + return createSession(options) }, async listSessions() { diff --git a/src/runtime/bootstrap.js b/src/runtime/bootstrap.js index 8002274..e6c7d49 100644 --- a/src/runtime/bootstrap.js +++ b/src/runtime/bootstrap.js @@ -2,6 +2,8 @@ import { createTelegramBot as defaultCreateTelegramBot, registerTelegramBotCommands as defaultRegisterTelegramBotCommands, } from "../adapters/telegram/bot.js" +import { createTelegramGroupRegistry as defaultCreateTelegramGroupRegistry } from "../adapters/telegram/groupRegistry.js" +import { openTelegramGroupStore as defaultOpenTelegramGroupStore } from "../adapters/telegram/groupStore.js" import { openTelegramStickerStore as defaultOpenTelegramStickerStore } from "../adapters/telegram/stickerStore.js" import { loadConfig } from "../config/loadConfig.js" import { setConfigValuesAtPath as defaultSetConfigValuesAtPath } from "../config/writeConfig.js" @@ -39,6 +41,10 @@ export async function runGateway({ const createVoiceService = dependencies.createVoiceService ?? defaultCreateVoiceService const openTelegramStickerStore = dependencies.openTelegramStickerStore ?? defaultOpenTelegramStickerStore + const openTelegramGroupStore = + dependencies.openTelegramGroupStore ?? defaultOpenTelegramGroupStore + const createTelegramGroupRegistry = + dependencies.createTelegramGroupRegistry ?? defaultCreateTelegramGroupRegistry const assertFfmpegAvailable = dependencies.assertFfmpegAvailable ?? defaultAssertFfmpegAvailable const setConfigValuesAtPath = dependencies.setConfigValuesAtPath ?? defaultSetConfigValuesAtPath @@ -73,6 +79,12 @@ export async function runGateway({ }, }) const stickerStore = openTelegramStickerStore() + const groupStore = openTelegramGroupStore() + const groupRegistry = createTelegramGroupRegistry({ + telegram: resolvedConfig.telegram, + store: groupStore, + logger: resolvedLogger, + }) const bot = createTelegramBot({ token: resolvedConfig.telegram.botToken, telegram: resolvedConfig.telegram, @@ -81,7 +93,10 @@ export async function runGateway({ progressVerbosity: resolvedConfig.progressVerbosity, voiceService, stickerStore, + groupStore, + groupRegistry, }) + groupRegistry.setApi?.(bot.api) let stopping = false async function shutdown(signal) { @@ -93,15 +108,17 @@ export async function runGateway({ await bot.stop() await server.stop() stickerStore.close?.() + groupStore.close?.() } processLike.once("SIGINT", shutdown) processLike.once("SIGTERM", shutdown) await registerTelegramBotCommands(bot, resolvedLogger) + await groupRegistry.refreshAllowedGroups?.() resolvedLogger.info("Starting Telegram polling") await bot.start({ - allowed_updates: ["message", "callback_query", "message_reaction"], + allowed_updates: ["message", "callback_query", "message_reaction", "my_chat_member"], }) } diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index 4765f9f..040393e 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test, vi } from "vitest" import { createTelegramBot } from "../../src/adapters/telegram/bot.js" +import { createGroupMemory } from "../../src/adapters/telegram/groupMemory.js" +import { createMemoryGroupStore } from "../../src/adapters/telegram/groupStore.js" import { createMemoryStickerStore } from "../../src/adapters/telegram/stickerStore.js" class FakeBot { @@ -67,6 +69,7 @@ describe("createTelegramBot", () => { "progress", "voice", "stickers", + "group", ]) expect(bot.messageHandlers.has("message:text")).toBe(true) expect(bot.messageHandlers.has("message:photo")).toBe(true) @@ -96,6 +99,27 @@ describe("createTelegramBot", () => { ) }) + test("new command primes the session with Telegram gateway instructions", async () => { + const controller = { + createSession: vi.fn(async () => ({ id: "ses_1", title: "New session" })), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram(), + controller, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const reply = vi.fn(async () => undefined) + + await bot.commands.get("new")({ reply }) + + expect(controller.createSession).toHaveBeenCalledWith({ + context: expect.stringContaining("Telegram gateway note:"), + }) + expect(reply).toHaveBeenCalledWith("Created session New session") + }) + test("progress command reports current verbosity", async () => { const bot = createTelegramBot({ token: "token", @@ -506,6 +530,67 @@ describe("createTelegramBot", () => { expect(logger.warn).toHaveBeenCalled() }) + test("group command opens a DM menu of known groups", async () => { + const groupStore = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await groupStore.upsertKnownGroup({ + chatId: -1001, + title: "Build Room", + username: "build_room", + type: "supergroup", + status: "active", + }) + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123], allowedChatIds: [-1001] }), + controller: {}, + groupStore, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const reply = vi.fn(async () => undefined) + + await bot.commands.get("group")({ + from: { id: 123, is_bot: false }, + chat: { id: 123, type: "private" }, + message: { text: "/group", chat: { id: 123, type: "private" } }, + reply, + }) + + expect(reply).toHaveBeenCalledWith( + "Select a Telegram group to configure:", + expect.objectContaining({ + reply_markup: expect.objectContaining({ + inline_keyboard: expect.arrayContaining([ + [expect.objectContaining({ text: "Build Room" })], + ]), + }), + }), + ) + }) + + test("group command in group replies with a DM-only notice", async () => { + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123], allowedChatIds: [-1001] }), + controller: {}, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + botIdentity: { username: "OpenCodeRemoteBot" }, + }) + const reply = vi.fn(async () => undefined) + + await bot.commands.get("group")({ + from: { id: 777, is_bot: false }, + chat: { id: -1001, type: "supergroup" }, + message: { text: "/group@OpenCodeRemoteBot", chat: { id: -1001, type: "supergroup" } }, + reply, + }) + + expect(reply).toHaveBeenCalledWith( + "Group settings are managed in DM. Message me and run /group.", + ) + }) + test("error handler logs and sends a safe reply", async () => { const logger = { warn: vi.fn(), error: vi.fn() } const bot = createTelegramBot({ @@ -569,6 +654,33 @@ describe("createTelegramBot", () => { expect(controller.selectSession).toHaveBeenCalledWith(longId) }) + test("new and session selection clear ephemeral group memory", async () => { + const groupMemory = { ...createGroupMemory(), clearAll: vi.fn() } + const controller = { + createSession: vi.fn(async () => ({ id: "ses_new", title: "New" })), + listSessions: vi.fn(async () => [{ id: "ses_existing", title: "Existing" }]), + selectSession: vi.fn(async () => undefined), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram(), + controller, + groupMemory, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + + await bot.commands.get("new")({ reply: vi.fn(async () => undefined) }) + await bot.commands.get("sessions")({ reply: vi.fn(async () => undefined) }) + await bot.callbackHandlers[0].handler({ + match: ["session:0", "0"], + answerCallbackQuery: vi.fn(async () => undefined), + reply: vi.fn(async () => undefined), + }) + + expect(groupMemory.clearAll).toHaveBeenCalledTimes(2) + }) + test("stop command reports when there is no active session", async () => { const bot = createTelegramBot({ token: "token", @@ -1091,6 +1203,7 @@ describe("createTelegramBot", () => { botFactory: FakeBot, progressVerbosity: "all", progressEditThrottleMs: 0, + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, }) const reply = vi.fn(async (text) => ({ message_id: 21, chat: { id: -1001 }, text })) const editMessageText = vi.fn(async () => true) @@ -1098,7 +1211,7 @@ describe("createTelegramBot", () => { await bot.messageHandlers.get("message:text")({ message: { message_id: 10, - text: "hello", + text: "Khmara, hello", chat: { id: -1001, type: "supergroup" }, from: { id: 777, is_bot: false, first_name: "Group" }, }, @@ -1116,6 +1229,365 @@ describe("createTelegramBot", () => { expect(reply).toHaveBeenCalledWith("answer") }) + test("group text is remembered passively and only routed when addressed", async () => { + const controller = { + sendPrompt: vi.fn(async () => "group answer"), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123], allowedChatIds: [-1001] }), + controller, + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory({ contextMessages: 10, contextChars: 1_000 }), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const passiveReply = vi.fn(async () => undefined) + const activeReply = vi.fn(async () => ({ + message_id: 12, + chat: { id: -1001 }, + text: "group answer", + })) + const setMessageReaction = vi.fn(async () => true) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "we should use sqlite", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { sendChatAction: vi.fn(async () => undefined), setMessageReaction }, + reply: passiveReply, + }) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 11, + text: "Khmara, what do you think?", + chat: { id: -1001, type: "supergroup" }, + from: { id: 778, is_bot: false, first_name: "Grace" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { sendChatAction: vi.fn(async () => undefined), setMessageReaction }, + reply: activeReply, + }) + + expect(passiveReply).not.toHaveBeenCalled() + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining("Recent Telegram group context:"), + }), + ) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Ada: we should use sqlite") + expect(controller.sendPrompt.mock.calls[0][0].text).not.toContain( + "Grace: Khmara, what do you think?", + ) + expect(activeReply).toHaveBeenCalledWith("group answer") + expect(setMessageReaction).toHaveBeenNthCalledWith(1, -1001, 11, [ + { type: "emoji", emoji: "👀" }, + ]) + expect(setMessageReaction).toHaveBeenNthCalledWith(2, -1001, 11, []) + }) + + test("group routing can use grammY ctx.me as bot identity", async () => { + const controller = { sendPrompt: vi.fn(async () => "answer") } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory(), + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + + await bot.messageHandlers.get("message:text")({ + me: { id: 9001, username: "OpenCodeRemoteBot", first_name: "Khmara" }, + message: { + message_id: 10, + text: "Khmara, answer with ctx.me identity", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: vi.fn(async () => ({ message_id: 20, chat: { id: -1001 }, text: "answer" })), + }) + + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + }) + + test("group reactions to bot messages do not send feedback prompts by default", async () => { + const controller = { + sendPrompt: vi.fn(async () => "group answer"), + } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory(), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "Khmara, answer this", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: vi.fn(async () => ({ message_id: 20, chat: { id: -1001 }, text: "group answer" })), + }) + + await bot.messageHandlers.get("message_reaction")({ + messageReaction: { + chat: { id: -1001, type: "supergroup" }, + message_id: 20, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + }, + chat: { id: -1001, type: "supergroup" }, + reply: vi.fn(async () => undefined), + }) + + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + }) + + test("group stickers are passive unless they reply to the bot", async () => { + const stickerPrompt = { + prompt: { + text: "User sent a Telegram sticker.", + attachments: [{ url: "file:///cache/sticker.webp", mime: "image/webp" }], + }, + cleanupFiles: [], + packName: null, + } + const createStickerPrompt = vi.fn(async () => stickerPrompt) + const controller = { sendPrompt: vi.fn(async () => "sticker answer") } + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory(), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + createStickerPrompt, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const reply = vi.fn(async () => ({ + message_id: 30, + chat: { id: -1001 }, + text: "sticker answer", + })) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 9, + text: "this is our current idea", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: vi.fn(async () => undefined), + }) + + await bot.messageHandlers.get("message:sticker")({ + message: { + message_id: 10, + sticker: telegramSticker({ set_name: "funny_cats", emoji: "😹" }), + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(createStickerPrompt).not.toHaveBeenCalled() + expect(controller.sendPrompt).not.toHaveBeenCalled() + + await bot.messageHandlers.get("message:sticker")({ + message: { + message_id: 11, + sticker: telegramSticker({ set_name: "funny_cats", emoji: "😹" }), + reply_to_message: { message_id: 8, from: { id: 9001, is_bot: true } }, + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(createStickerPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Recent Telegram group context:") + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Ada: this is our current idea") + expect(reply).toHaveBeenCalledWith("sticker answer") + }) + + test("group voice transcripts route only when addressed", async () => { + const controller = { sendPrompt: vi.fn(async () => "voice answer") } + const voiceService = { + isEnabled: vi.fn(() => true), + transcribe: vi + .fn() + .mockResolvedValueOnce("this is passive voice context") + .mockResolvedValueOnce("Khmara, answer the voice note"), + shouldSpeak: vi.fn(() => false), + } + const downloadVoice = vi.fn(async () => ({ + url: "file:///cache/voice.ogg", + filePath: "/cache/voice.ogg", + mime: "audio/ogg", + })) + const cleanupMediaAttachments = vi.fn(async () => undefined) + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + voiceService, + downloadVoice, + cleanupMediaAttachments, + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory(), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const reply = vi.fn(async () => ({ message_id: 30, chat: { id: -1001 }, text: "voice answer" })) + + await bot.messageHandlers.get("message:voice")({ + message: { + message_id: 10, + voice: { file_id: "voice-1" }, + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + await bot.messageHandlers.get("message:voice")({ + message: { + message_id: 11, + voice: { file_id: "voice-2" }, + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(voiceService.transcribe).toHaveBeenCalledTimes(2) + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain( + "Ada: this is passive voice context", + ) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Khmara, answer the voice note") + expect(reply).toHaveBeenCalledTimes(1) + expect(cleanupMediaAttachments).toHaveBeenCalledTimes(2) + }) + + test("group photos are passive unless captions address the bot", async () => { + const controller = { sendPrompt: vi.fn(async () => "photo answer") } + const downloadPhoto = vi.fn(async () => ({ + url: "file:///cache/photo.jpg", + filePath: "/cache/photo.jpg", + mime: "image/jpeg", + })) + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedChatIds: [-1001] }), + controller, + downloadPhoto, + cleanupMediaAttachments: vi.fn(async () => undefined), + groupStore: createMemoryGroupStore({ allowedChatIds: [-1001] }), + groupMemory: createGroupMemory(), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const photo = [ + { file_id: "small", width: 100, height: 100 }, + { file_id: "large", width: 500, height: 500 }, + ] + const reply = vi.fn(async () => ({ message_id: 30, chat: { id: -1001 }, text: "photo answer" })) + + await bot.messageHandlers.get("message:photo")({ + message: { + message_id: 10, + photo, + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(downloadPhoto).not.toHaveBeenCalled() + expect(controller.sendPrompt).not.toHaveBeenCalled() + + await bot.messageHandlers.get("message:photo")({ + message: { + message_id: 11, + caption: "Khmara, inspect this photo", + photo, + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply, + }) + + expect(downloadPhoto).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Khmara, inspect this photo") + expect(reply).toHaveBeenCalledWith("photo answer") + }) + test("text prompts strip tool usage announcements from the final answer", async () => { const controller = { sendPrompt: vi.fn(async (_prompt, options) => { diff --git a/tests/adapters/telegramGroupMemory.test.js b/tests/adapters/telegramGroupMemory.test.js new file mode 100644 index 0000000..031d95c --- /dev/null +++ b/tests/adapters/telegramGroupMemory.test.js @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest" +import { createGroupMemory } from "../../src/adapters/telegram/groupMemory.js" + +const scope = { chatId: -1001, threadId: 7, sessionId: "ses_1" } + +function entry(index, overrides = {}) { + return { + messageId: index, + author: `User ${index}`, + text: `message ${index}`, + kind: "text", + timestamp: 1_000 + index, + ...overrides, + } +} + +describe("createGroupMemory", () => { + test("builds context from new messages since the cursor with overlap", () => { + const memory = createGroupMemory({ + storeMessages: 20, + storeChars: 500, + contextMessages: 6, + contextChars: 500, + overlap: 2, + }) + const records = [] + for (let index = 1; index <= 8; index += 1) { + records.push(memory.record(scope, entry(index))) + } + memory.markPromptCursor(scope, records[4].id) + + const context = memory.buildContext(scope, { currentMessageId: 8 }) + + expect(context.entries.map((item) => item.messageId)).toEqual([4, 5, 6, 7]) + expect(context.text).toContain("User 4: message 4") + expect(context.text).not.toContain("message 8") + }) + + test("prunes stored messages by count and total chars", () => { + const memory = createGroupMemory({ + storeMessages: 3, + storeChars: 28, + contextMessages: 10, + contextChars: 500, + overlap: 0, + }) + + memory.record(scope, entry(1, { text: "alpha" })) + memory.record(scope, entry(2, { text: "bravo" })) + memory.record(scope, entry(3, { text: "charlie" })) + memory.record(scope, entry(4, { text: "delta" })) + + expect(memory.snapshot(scope).map((item) => item.messageId)).toEqual([2, 3, 4]) + }) + + test("applies context char caps and per-message truncation", () => { + const memory = createGroupMemory({ + storeMessages: 10, + storeChars: 1_000, + contextMessages: 10, + contextChars: 80, + maxEntryChars: 20, + overlap: 0, + }) + + memory.record(scope, entry(1, { text: "short" })) + memory.record(scope, entry(2, { text: "x".repeat(100) })) + memory.record(scope, entry(3, { text: "last" })) + + const context = memory.buildContext(scope) + + expect(context.text.length).toBeLessThanOrEqual(80) + expect(context.text).toContain("xxxxxxxxxxxxxxxxxxxx...") + }) + + test("allows per-call context limits", () => { + const memory = createGroupMemory({ contextMessages: 10, contextChars: 500, overlap: 0 }) + for (let index = 1; index <= 5; index += 1) { + memory.record(scope, entry(index)) + } + + const context = memory.buildContext(scope, { contextMessages: 2 }) + + expect(context.entries.map((item) => item.messageId)).toEqual([4, 5]) + }) + + test("keeps topics and sessions isolated", () => { + const memory = createGroupMemory({ storeMessages: 20 }) + memory.record(scope, entry(1, { text: "topic seven" })) + memory.record({ ...scope, threadId: 8 }, entry(2, { text: "topic eight" })) + memory.record({ ...scope, sessionId: "ses_2" }, entry(3, { text: "session two" })) + + expect(memory.snapshot(scope).map((item) => item.text)).toEqual(["topic seven"]) + }) + + test("clears one scope or all memory", () => { + const memory = createGroupMemory({ storeMessages: 20 }) + memory.record(scope, entry(1)) + memory.record({ ...scope, threadId: 8 }, entry(2)) + + memory.clearScope(scope) + expect(memory.snapshot(scope)).toEqual([]) + expect(memory.snapshot({ ...scope, threadId: 8 })).toHaveLength(1) + + memory.clearAll() + expect(memory.snapshot({ ...scope, threadId: 8 })).toEqual([]) + }) + + test("clears every scope for one chat", () => { + const memory = createGroupMemory({ storeMessages: 20 }) + memory.record(scope, entry(1)) + memory.record({ ...scope, threadId: 8 }, entry(2)) + memory.record({ ...scope, chatId: -2002 }, entry(3)) + + memory.clearChat(-1001) + + expect(memory.snapshot(scope)).toEqual([]) + expect(memory.snapshot({ ...scope, threadId: 8 })).toEqual([]) + expect(memory.snapshot({ ...scope, chatId: -2002 })).toHaveLength(1) + }) +}) diff --git a/tests/adapters/telegramGroupMenu.test.js b/tests/adapters/telegramGroupMenu.test.js new file mode 100644 index 0000000..52de53a --- /dev/null +++ b/tests/adapters/telegramGroupMenu.test.js @@ -0,0 +1,107 @@ +import { describe, expect, test, vi } from "vitest" +import { createGroupMemory } from "../../src/adapters/telegram/groupMemory.js" +import { createTelegramGroupMenu } from "../../src/adapters/telegram/groupMenu.js" +import { createMemoryGroupStore } from "../../src/adapters/telegram/groupStore.js" + +describe("createTelegramGroupMenu", () => { + test("selects a group and updates reply policy through callback buttons", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await store.upsertKnownGroup({ chatId: -1001, title: "Build Room", type: "supergroup" }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const settingsCall = reply.mock.calls.at(-1) + expect(settingsCall[0]).toContain("Build Room settings") + const replyAllButton = settingsCall[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Reply: all") + + await menu.handleCallback({ + from: { id: 123 }, + match: [replyAllButton.callback_data, replyAllButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + + expect((await store.getSettings(-1001)).replyPolicy).toBe("all") + expect(reply.mock.calls.at(-1)[0]).toContain("Reply policy: all") + }) + + test("updates trigger, memory, and context settings through callback buttons", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const buttons = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard.flat() + + for (const label of ["Trigger name anywhere: off", "Memory: off", "Context messages: 50"]) { + const button = buttons.find((candidate) => candidate.text === label) + await menu.handleCallback({ + from: { id: 123 }, + match: [button.callback_data, button.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + } + + expect(await store.getSettings(-1001)).toEqual( + expect.objectContaining({ + triggers: expect.objectContaining({ nameAnywhere: true }), + memory: expect.objectContaining({ enabled: false }), + context: expect.objectContaining({ messages: 50 }), + }), + ) + }) + + test("rejects callback tokens used by another user", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + const answerCallbackQuery = vi.fn(async () => undefined) + + await menu.handleCallback({ + from: { id: 456 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery, + reply, + }) + + expect(answerCallbackQuery).toHaveBeenCalledWith({ text: "Group menu expired" }) + expect(reply).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/adapters/telegramGroupRegistry.test.js b/tests/adapters/telegramGroupRegistry.test.js new file mode 100644 index 0000000..bd3f7be --- /dev/null +++ b/tests/adapters/telegramGroupRegistry.test.js @@ -0,0 +1,81 @@ +import { describe, expect, test, vi } from "vitest" +import { createTelegramGroupRegistry } from "../../src/adapters/telegram/groupRegistry.js" +import { createMemoryGroupStore } from "../../src/adapters/telegram/groupStore.js" + +describe("createTelegramGroupRegistry", () => { + test("refreshes configured groups with getChat metadata", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001, -1002] }) + const api = { + getChat: vi.fn(async (chatId) => { + if (chatId === -1002) { + throw new Error("bot removed") + } + return { id: chatId, type: "supergroup", title: "Build Room", username: "build_room" } + }), + } + const registry = createTelegramGroupRegistry({ + telegram: { allowedChatIds: [-1001, -1002] }, + store, + api, + logger: { warn: vi.fn() }, + }) + + await registry.refreshAllowedGroups() + + expect(api.getChat).toHaveBeenCalledWith(-1001) + expect(api.getChat).toHaveBeenCalledWith(-1002) + expect(await store.listGroups()).toEqual([ + { + chatId: -1001, + title: "Build Room", + username: "build_room", + type: "supergroup", + status: "active", + }, + { + chatId: -1002, + title: "Group -1002", + username: null, + type: "supergroup", + status: "unavailable", + }, + ]) + }) + + test("records group messages and membership updates", async () => { + const store = createMemoryGroupStore() + const registry = createTelegramGroupRegistry({ + telegram: { allowedChatIds: [-1001] }, + store, + api: {}, + logger: { warn: vi.fn() }, + }) + + await registry.recordGroupMessage({ + chat: { id: -1001, type: "supergroup", title: "Seen Room" }, + }) + await registry.handleMyChatMember({ + chat: { id: -1001, type: "supergroup", title: "Seen Room" }, + new_chat_member: { status: "left" }, + }) + + expect(await store.listGroups()).toEqual([ + { + chatId: -1001, + title: "Seen Room", + username: null, + type: "supergroup", + status: "unavailable", + }, + ]) + + await registry.handleMyChatMember({ + chat: { id: -1001, type: "supergroup", title: "Seen Room" }, + new_chat_member: { status: "administrator" }, + }) + + expect(await store.listGroups()).toEqual([ + { chatId: -1001, title: "Seen Room", username: null, type: "supergroup", status: "active" }, + ]) + }) +}) diff --git a/tests/adapters/telegramGroupRouting.test.js b/tests/adapters/telegramGroupRouting.test.js new file mode 100644 index 0000000..4cd5a80 --- /dev/null +++ b/tests/adapters/telegramGroupRouting.test.js @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest" +import { + DEFAULT_GROUP_SETTINGS, + evaluateGroupMessageRouting, +} from "../../src/adapters/telegram/groupRouting.js" + +const botIdentity = { + id: 9001, + username: "OpenCodeRemoteBot", + firstName: "Khmara", + aliases: ["gateway"], +} + +function message(overrides = {}) { + return { + message_id: 10, + text: "hello", + chat: { id: -1001, type: "supergroup" }, + from: { id: 123, is_bot: false, first_name: "Ada" }, + ...overrides, + } +} + +describe("evaluateGroupMessageRouting", () => { + test("routes human messages that reply to the bot", () => { + const decision = evaluateGroupMessageRouting({ + message: message({ + text: "what do you mean?", + reply_to_message: { message_id: 9, from: { id: 9001, is_bot: true } }, + }), + settings: DEFAULT_GROUP_SETTINGS, + botIdentity, + }) + + expect(decision).toEqual({ route: true, trigger: "reply" }) + }) + + test("routes mention and name-prefix triggers", () => { + expect( + evaluateGroupMessageRouting({ + message: message({ text: "hey @OpenCodeRemoteBot can you check this?" }), + settings: DEFAULT_GROUP_SETTINGS, + botIdentity, + }), + ).toEqual({ route: true, trigger: "mention" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "Gateway, summarize the last part" }), + settings: DEFAULT_GROUP_SETTINGS, + botIdentity, + }), + ).toEqual({ route: true, trigger: "name_prefix" }) + }) + + test("does not route bot senders unless bot replies are enabled", () => { + const botMessage = message({ + text: "Khmara, compare our answers", + from: { id: 222, is_bot: true, first_name: "Other Bot" }, + }) + + expect( + evaluateGroupMessageRouting({ + message: botMessage, + settings: DEFAULT_GROUP_SETTINGS, + botIdentity, + }), + ).toEqual({ route: false, reason: "sender_policy" }) + + expect( + evaluateGroupMessageRouting({ + message: botMessage, + settings: { ...DEFAULT_GROUP_SETTINGS, replyPolicy: "all" }, + botIdentity, + }), + ).toEqual({ route: true, trigger: "name_prefix" }) + }) + + test("never routes this gateway bot's own messages", () => { + const decision = evaluateGroupMessageRouting({ + message: message({ + text: "Khmara, this should never loop", + from: { id: 9001, is_bot: true, first_name: "Khmara" }, + }), + settings: { ...DEFAULT_GROUP_SETTINGS, replyPolicy: "all" }, + botIdentity, + }) + + expect(decision).toEqual({ route: false, reason: "own_message" }) + }) + + test("keeps name-anywhere disabled by default", () => { + expect( + evaluateGroupMessageRouting({ + message: message({ text: "I wonder whether Khmara would like this" }), + settings: DEFAULT_GROUP_SETTINGS, + botIdentity, + }), + ).toEqual({ route: false, reason: "not_addressed" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "I wonder whether Khmara would like this" }), + settings: { + ...DEFAULT_GROUP_SETTINGS, + triggers: { ...DEFAULT_GROUP_SETTINGS.triggers, nameAnywhere: true }, + }, + botIdentity, + }), + ).toEqual({ route: true, trigger: "name_anywhere" }) + }) +}) diff --git a/tests/adapters/telegramGroupStore.test.js b/tests/adapters/telegramGroupStore.test.js new file mode 100644 index 0000000..e7550a8 --- /dev/null +++ b/tests/adapters/telegramGroupStore.test.js @@ -0,0 +1,104 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, test } from "vitest" +import { + createMemoryGroupStore, + DEFAULT_GROUP_CONFIG, + openTelegramGroupStore, +} from "../../src/adapters/telegram/groupStore.js" + +const tempDirs = [] + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 +}) + +describe("openTelegramGroupStore", () => { + test("persists known groups and settings", async () => { + const dbPath = await tempDbPath() + const store = openTelegramGroupStore(dbPath) + await store.upsertKnownGroup({ + chatId: -1001, + title: "Build Room", + username: "build_room", + type: "supergroup", + status: "active", + }) + await store.updateSettings(-1001, { + replyPolicy: "all", + triggers: { nameAnywhere: true }, + context: { messages: 50 }, + }) + store.close() + + const reopened = openTelegramGroupStore(dbPath) + expect(await reopened.listGroups()).toEqual([ + { + chatId: -1001, + title: "Build Room", + username: "build_room", + type: "supergroup", + status: "active", + }, + ]) + expect(await reopened.getSettings(-1001)).toEqual({ + ...DEFAULT_GROUP_CONFIG, + replyPolicy: "all", + triggers: { ...DEFAULT_GROUP_CONFIG.triggers, nameAnywhere: true }, + context: { ...DEFAULT_GROUP_CONFIG.context, messages: 50 }, + }) + reopened.close() + }) + + test("marks groups unavailable and resets settings", async () => { + const store = openTelegramGroupStore(await tempDbPath()) + await store.upsertKnownGroup({ chatId: -1002, title: "Old Room", type: "group" }) + await store.updateSettings(-1002, { replyPolicy: "bots" }) + + await store.markGroupUnavailable(-1002) + await store.resetSettings(-1002) + + expect(await store.listGroups()).toEqual([ + { + chatId: -1002, + title: "Old Room", + username: null, + type: "group", + status: "unavailable", + }, + ]) + expect(await store.getSettings(-1002)).toEqual(DEFAULT_GROUP_CONFIG) + store.close() + }) +}) + +describe("createMemoryGroupStore", () => { + test("seeds known groups from allowed chat IDs", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001, -1002] }) + + expect(await store.listGroups()).toEqual([ + { + chatId: -1001, + title: "Group -1001", + username: null, + type: "supergroup", + status: "configured", + }, + { + chatId: -1002, + title: "Group -1002", + username: null, + type: "supergroup", + status: "configured", + }, + ]) + }) +}) + +async function tempDbPath() { + const dir = await mkdtemp(join(tmpdir(), "opencode-remote-group-store-")) + tempDirs.push(dir) + return join(dir, "groups.db") +} diff --git a/tests/core/commands.test.js b/tests/core/commands.test.js index 555e9fd..788bf5f 100644 --- a/tests/core/commands.test.js +++ b/tests/core/commands.test.js @@ -11,6 +11,7 @@ describe("commands", () => { "progress", "voice", "stickers", + "group", "help", ]) }) @@ -23,5 +24,6 @@ describe("commands", () => { expect(help).toContain("/progress - Set tool progress visibility") expect(help).toContain("/voice - Show or set voice mode") expect(help).toContain("/stickers - Manage saved sticker packs") + expect(help).toContain("/group - Manage Telegram group behavior") }) }) diff --git a/tests/runtime/bootstrap.test.js b/tests/runtime/bootstrap.test.js index 53817cb..8e2fdbc 100644 --- a/tests/runtime/bootstrap.test.js +++ b/tests/runtime/bootstrap.test.js @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest" -import { botCommands } from "../../src/core/commands/commands.js" +import { privateBotCommands, publicBotCommands } from "../../src/core/commands/commands.js" import { runGateway } from "../../src/runtime/bootstrap.js" describe("runGateway", () => { @@ -104,7 +104,7 @@ describe("runGateway", () => { }), ) expect(bot.start).toHaveBeenCalledWith({ - allowed_updates: ["message", "callback_query", "message_reaction"], + allowed_updates: ["message", "callback_query", "message_reaction", "my_chat_member"], }) expect(processLike.once).toHaveBeenCalledWith("SIGINT", expect.any(Function)) expect(processLike.once).toHaveBeenCalledWith("SIGTERM", expect.any(Function)) @@ -143,8 +143,8 @@ describe("runGateway", () => { processLike: { once: vi.fn() }, }) - expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(1, botCommands) - expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(2, botCommands, { + expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(1, publicBotCommands) + expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(2, privateBotCommands, { scope: { type: "all_private_chats" }, }) expect(order).toEqual(["default", "all_private_chats", "start"]) @@ -181,12 +181,12 @@ describe("runGateway", () => { }) expect(logger.warn).toHaveBeenCalledWith({ error }, "Could not register Telegram commands") - expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(1, botCommands) - expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(2, botCommands, { + expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(1, publicBotCommands) + expect(bot.api.setMyCommands).toHaveBeenNthCalledWith(2, privateBotCommands, { scope: { type: "all_private_chats" }, }) expect(bot.start).toHaveBeenCalledWith({ - allowed_updates: ["message", "callback_query", "message_reaction"], + allowed_updates: ["message", "callback_query", "message_reaction", "my_chat_member"], }) }) @@ -264,6 +264,61 @@ describe("runGateway", () => { expect(createTelegramBot).toHaveBeenCalledWith(expect.objectContaining({ stickerStore })) }) + test("creates group store and refreshes known allowed groups before polling", async () => { + const order = [] + const server = { stop: vi.fn(async () => undefined) } + const bot = { + api: { setMyCommands: vi.fn(async () => undefined) }, + start: vi.fn(async () => { + order.push("start") + }), + stop: vi.fn(async () => undefined), + } + const groupStore = { close: vi.fn() } + const registry = { + setApi: vi.fn(), + refreshAllowedGroups: vi.fn(async () => { + order.push("refresh") + }), + } + const openTelegramGroupStore = vi.fn(() => groupStore) + const createTelegramGroupRegistry = vi.fn(() => registry) + const createTelegramBot = vi.fn(() => bot) + + await runGateway({ + config: testConfig(), + logger: testLogger(), + dependencies: { + ensureOpenCodeServer: vi.fn(async () => server), + createOpenCodeClient: vi.fn(() => ({})), + resolveProjectIdentity: vi.fn(async () => ({ + id: "project-1", + worktree: "/project", + vcs: "git", + })), + createProjectStateStore: vi.fn(() => ({})), + createGatewayController: vi.fn(() => ({})), + openTelegramGroupStore, + createTelegramGroupRegistry, + createTelegramBot, + }, + processLike: { once: vi.fn() }, + }) + + expect(openTelegramGroupStore).toHaveBeenCalledWith() + expect(createTelegramGroupRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + telegram: testConfig().telegram, + store: groupStore, + }), + ) + expect(registry.setApi).toHaveBeenCalledWith(bot.api) + expect(createTelegramBot).toHaveBeenCalledWith( + expect.objectContaining({ groupStore, groupRegistry: registry }), + ) + expect(order).toEqual(["refresh", "start"]) + }) + test("passes voice-aware gateway context to the controller", async () => { const logger = testLogger() const createGatewayController = vi.fn(() => ({})) @@ -369,6 +424,7 @@ describe("runGateway", () => { test("registered shutdown stops Telegram polling and owned server", async () => { const server = { stop: vi.fn(async () => undefined) } const stickerStore = { close: vi.fn() } + const groupStore = { close: vi.fn() } const bot = { api: { setMyCommands: vi.fn(async () => undefined) }, start: vi.fn(async () => undefined), @@ -391,6 +447,7 @@ describe("runGateway", () => { createProjectStateStore: vi.fn(() => ({})), createGatewayController: vi.fn(() => ({})), openTelegramStickerStore: vi.fn(() => stickerStore), + openTelegramGroupStore: vi.fn(() => groupStore), createTelegramBot: vi.fn(() => bot), }, processLike, @@ -401,6 +458,7 @@ describe("runGateway", () => { expect(bot.stop).toHaveBeenCalled() expect(server.stop).toHaveBeenCalled() expect(stickerStore.close).toHaveBeenCalled() + expect(groupStore.close).toHaveBeenCalled() }) }) From 698bc87d1c77539f482cc89caafbaed31810aa34 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:35:02 +0200 Subject: [PATCH 05/12] docs: design Telegram custom group triggers --- ...8-telegram-custom-group-triggers-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-telegram-custom-group-triggers-design.md diff --git a/docs/superpowers/specs/2026-05-28-telegram-custom-group-triggers-design.md b/docs/superpowers/specs/2026-05-28-telegram-custom-group-triggers-design.md new file mode 100644 index 0000000..81539cd --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-telegram-custom-group-triggers-design.md @@ -0,0 +1,105 @@ +# Telegram Custom Group Triggers Design + +## Overview + +Telegram group routing currently supports built-in addressing triggers: replies to the bot, bot username mentions, bot-name prefixes, and optional bot-name anywhere matching. Add per-group custom trigger phrases so users can route group messages with workspace-specific phrases such as `codex please` or `shipbot`. + +Custom triggers are configured from the existing DM-only `/group` menu. They are plain text, case-insensitive substring matches against the message text used for routing. They are not regular expressions. + +## Requirements + +- Store custom trigger phrases per Telegram group. +- Match custom triggers anywhere in text messages, photo captions, and voice transcripts. +- Keep trigger matching case-insensitive. +- Treat trigger phrases as plain text, not regex patterns. +- Normalize phrases by trimming and collapsing internal whitespace. +- Normalize candidate routing text by collapsing internal whitespace before matching. +- Deduplicate phrases case-insensitively. +- Cap each group at 20 custom triggers. +- Cap each phrase at 64 characters after normalization. +- Manage triggers from the existing DM `/group` menu. +- Do not persist group message text beyond the existing settings JSON. + +## Non-Goals + +- Regex triggers. +- Per-trigger match modes. +- Per-trigger reply policies. +- Group-chat configuration commands. +- Import/export of trigger lists. + +## Data Model + +Extend the existing group settings JSON with a `customTriggers` array: + +```json +{ + "customTriggers": ["codex please", "shipbot"] +} +``` + +This keeps the change aligned with existing per-group settings and avoids a new table for a small bounded list. Existing groups without this field normalize to an empty list. + +## Routing + +The group routing decision keeps the current order for built-in triggers: + +1. Reply to this gateway bot. +2. Mention this gateway bot. +3. Bot-name prefix. +4. Optional bot-name anywhere. +5. Custom trigger phrase anywhere. + +Custom triggers use the same text source already passed to routing: + +- Text messages use `message.text`. +- Photo routing uses combined captions. +- Voice routing uses the transcript. +- Stickers generally have no text, so custom triggers do not route sticker messages unless another trigger applies. + +When matched, routing returns `trigger: "custom"`. + +Matching uses normalized lower-case strings and plain substring checks. A phrase such as `codex please` matches `Codex please check this`; punctuation remains literal text. + +## Menu UX + +The existing DM-only `/group` menu gains a custom trigger section in the group settings view: + +- Show configured custom triggers in the settings summary. +- Provide an `Add custom trigger` button. +- Provide a `Remove custom trigger` button when at least one trigger exists. +- Provide a `Clear custom triggers` button when at least one trigger exists. + +Adding a phrase uses a short pending state tied to the requesting user and selected group. The next private text message from that user is treated as the phrase, normalized, validated, and saved. `/cancel` exits the pending state without changes. + +The Telegram bot checks this pending state before normal private text prompt handling so trigger setup messages are not forwarded to OpenCode. + +Removal shows one button per configured phrase, using bounded callback tokens rather than raw long phrases. + +## Validation + +When adding a trigger phrase: + +- Empty phrases are rejected. +- Phrases longer than 64 characters after normalization are rejected. +- Duplicate phrases, compared case-insensitively, are rejected. +- The 21st phrase is rejected with a clear message. + +All messages are safe user-facing strings and do not expose internal paths, stack traces, or raw provider data. + +## Testing + +Add focused tests for: + +- Routing matches custom triggers anywhere, case-insensitively. +- Routing treats custom trigger phrases as plain text. +- Routing does not match when no custom trigger is configured. +- Store normalization persists custom triggers and defaults missing values to `[]`. +- Menu add/remove/clear flows update group settings. +- Bot integration routes group text by custom trigger and includes passive context. + +Run the normal verification command after implementation: + +```bash +pnpm run check +``` From 0a16f1aea4b71af60ec2fe7e7496a35c7f7d391d Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:44:42 +0200 Subject: [PATCH 06/12] docs: plan Telegram custom group triggers --- ...26-05-28-telegram-custom-group-triggers.md | 743 ++++++++++++++++++ 1 file changed, 743 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md diff --git a/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md b/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md new file mode 100644 index 0000000..1a52654 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md @@ -0,0 +1,743 @@ +# Telegram Custom Group Triggers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-group Telegram custom trigger phrases that route group messages when a configured phrase appears anywhere in text, captions, or voice transcripts. + +**Architecture:** Store custom triggers in the existing per-group settings JSON as `customTriggers: string[]`. Keep routing logic in `src/adapters/telegram/groupRouting.js`, persistence normalization in `src/adapters/telegram/groupStore.js`, DM menu state in `src/adapters/telegram/groupMenu.js`, and Telegram wiring in `src/adapters/telegram/bot.js`. + +**Tech Stack:** Node.js ESM, grammY, SQLite via `node:sqlite`, Vitest, Biome. + +--- + +## File Map + +- Modify `src/adapters/telegram/groupRouting.js`: constants, trigger phrase normalization, custom trigger routing. +- Modify `src/adapters/telegram/groupStore.js`: settings normalization so persisted/missing `customTriggers` becomes a bounded array. +- Modify `src/adapters/telegram/groupMenu.js`: DM menu add/remove/clear flows and pending phrase capture. +- Modify `src/adapters/telegram/bot.js`: call the pending custom trigger handler before normal private text prompt handling. +- Modify `tests/adapters/telegramGroupRouting.test.js`: custom trigger routing tests. +- Modify `tests/adapters/telegramGroupStore.test.js`: persistence/default normalization tests. +- Modify `tests/adapters/telegramGroupMenu.test.js`: add/remove/clear menu flow tests. +- Modify `tests/adapters/telegramBot.test.js`: integration test for private setup plus group routing. +- Modify `README.md` and `FEATURES.md`: document custom group triggers. + +## Task 1: Routing And Store Normalization + +**Files:** +- Modify: `tests/adapters/telegramGroupRouting.test.js` +- Modify: `tests/adapters/telegramGroupStore.test.js` +- Modify: `src/adapters/telegram/groupRouting.js` +- Modify: `src/adapters/telegram/groupStore.js` + +- [ ] **Step 1: Add failing routing tests** + +Append these tests inside the `describe("evaluateGroupMessageRouting", () => { ... })` block in `tests/adapters/telegramGroupRouting.test.js`: + +```js + test("routes custom triggers anywhere case-insensitively", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["codex please"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "Can CODEX please check this?" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) + + test("treats custom trigger phrases as plain text", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["ship.bot"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "shipXbot should not match" }), + settings, + botIdentity, + }), + ).toEqual({ route: false, reason: "not_addressed" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "please ask ship.bot for help" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) +``` + +- [ ] **Step 2: Add failing store tests** + +In `tests/adapters/telegramGroupStore.test.js`, update the first persistence test's `updateSettings` call and expectation: + +```js + await store.updateSettings(-1001, { + replyPolicy: "all", + triggers: { nameAnywhere: true }, + context: { messages: 50 }, + customTriggers: [" Codex please ", "codex please", "shipbot"], + }) +``` + +Add this property to the expected settings object: + +```js + customTriggers: ["Codex please", "shipbot"], +``` + +Add this test to the `createMemoryGroupStore` describe block: + +```js + test("normalizes missing and oversized custom triggers", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await store.updateSettings(-1001, { + customTriggers: [ + " alpha trigger ", + "ALPHA trigger", + "x".repeat(65), + ...Array.from({ length: 25 }, (_, index) => `trigger ${index}`), + ], + }) + + const settings = await store.getSettings(-1001) + + expect(settings.customTriggers).toHaveLength(20) + expect(settings.customTriggers[0]).toBe("alpha trigger") + expect(settings.customTriggers).not.toContain("x".repeat(65)) + }) +``` + +- [ ] **Step 3: Run focused tests to verify failure** + +Run: `pnpm test -- tests/adapters/telegramGroupRouting.test.js tests/adapters/telegramGroupStore.test.js` + +Expected: FAIL because `customTriggers` routing and normalization are not implemented yet. + +- [ ] **Step 4: Implement custom trigger routing** + +In `src/adapters/telegram/groupRouting.js`, add constants and default settings: + +```js +export const CUSTOM_TRIGGER_MAX_COUNT = 20 +export const CUSTOM_TRIGGER_MAX_LENGTH = 64 + +export const DEFAULT_GROUP_SETTINGS = { + replyPolicy: "humans", + triggers: { + reply: true, + mention: true, + namePrefix: true, + nameAnywhere: false, + voiceName: false, + }, + customTriggers: [], +} +``` + +Add the custom trigger check after `nameAnywhere`: + +```js + if (matchesCustomTrigger(text, normalizedSettings.customTriggers)) { + return { route: true, trigger: "custom" } + } +``` + +Update `normalizeGroupSettings`: + +```js +export function normalizeGroupSettings(settings = {}) { + const triggers = { ...DEFAULT_GROUP_SETTINGS.triggers, ...(settings.triggers ?? {}) } + return { + ...DEFAULT_GROUP_SETTINGS, + ...settings, + triggers, + customTriggers: normalizeCustomTriggers(settings.customTriggers), + } +} +``` + +Add helpers near the bottom of the file: + +```js +export function normalizeCustomTriggerPhrase(value) { + const phrase = String(value ?? "") + .trim() + .replace(/\s+/g, " ") + if (!phrase || phrase.length > CUSTOM_TRIGGER_MAX_LENGTH) { + return null + } + return phrase +} + +export function normalizeCustomTriggers(values) { + const result = [] + const seen = new Set() + for (const value of Array.isArray(values) ? values : []) { + const phrase = normalizeCustomTriggerPhrase(value) + const key = phrase?.toLocaleLowerCase("en-US") + if (!phrase || seen.has(key)) { + continue + } + seen.add(key) + result.push(phrase) + if (result.length >= CUSTOM_TRIGGER_MAX_COUNT) { + break + } + } + return result +} + +function matchesCustomTrigger(text, triggers) { + const candidate = normalizeComparableText(text) + if (!candidate) { + return false + } + return triggers.some((trigger) => candidate.includes(normalizeComparableText(trigger))) +} + +function normalizeComparableText(value) { + return String(value ?? "") + .trim() + .replace(/\s+/g, " ") + .toLocaleLowerCase("en-US") +} +``` + +- [ ] **Step 5: Ensure store merge preserves normalized custom triggers** + +In `src/adapters/telegram/groupStore.js`, import `normalizeCustomTriggers`: + +```js +import { + DEFAULT_GROUP_SETTINGS, + normalizeCustomTriggers, + normalizeGroupSettings, +} from "./groupRouting.js" +``` + +Add `customTriggers` to `mergeGroupConfig` before `memory`: + +```js + customTriggers: normalizeCustomTriggers( + patch?.customTriggers ?? base?.customTriggers ?? DEFAULT_GROUP_CONFIG.customTriggers, + ), +``` + +- [ ] **Step 6: Run focused tests to verify pass** + +Run: `pnpm test -- tests/adapters/telegramGroupRouting.test.js tests/adapters/telegramGroupStore.test.js` + +Expected: PASS for both files. + +- [ ] **Step 7: Commit routing/store work** + +Run: + +```bash +git add src/adapters/telegram/groupRouting.js src/adapters/telegram/groupStore.js tests/adapters/telegramGroupRouting.test.js tests/adapters/telegramGroupStore.test.js +git commit -m "feat: route Telegram groups by custom triggers" +``` + +## Task 2: DM Group Menu Management + +**Files:** +- Modify: `tests/adapters/telegramGroupMenu.test.js` +- Modify: `src/adapters/telegram/groupMenu.js` + +- [ ] **Step 1: Add failing menu flow tests** + +Append this test to `tests/adapters/telegramGroupMenu.test.js`: + +```js + test("adds, removes, and clears custom triggers through DM menu", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + + const addButton = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Add custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect(reply.mock.calls.at(-1)[0]).toContain("Send the custom trigger phrase") + + expect( + await menu.handlePendingText({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { text: " Codex please " }, + reply, + }), + ).toBe(true) + expect((await store.getSettings(-1001)).customTriggers).toEqual(["Codex please"]) + + const removeButton = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Remove custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [removeButton.callback_data, removeButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const phraseButton = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Codex please") + await menu.handleCallback({ + from: { id: 123 }, + match: [phraseButton.callback_data, phraseButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect((await store.getSettings(-1001)).customTriggers).toEqual([]) + + await store.updateSettings(-1001, { customTriggers: ["shipbot"] }) + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectAgain = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectAgain, selectAgain.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const clearButton = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Clear custom triggers") + await menu.handleCallback({ + from: { id: 123 }, + match: [clearButton.callback_data, clearButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect((await store.getSettings(-1001)).customTriggers).toEqual([]) + }) +``` + +Append this validation test: + +```js + test("rejects invalid custom trigger phrases", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await store.updateSettings(-1001, { customTriggers: ["shipbot"] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.startCustomTriggerAddForTesting?.(123, -1001) + expect( + await menu.handlePendingText({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { text: "SHIPBOT" }, + reply, + }), + ).toBe(true) + + expect(reply).toHaveBeenCalledWith("That custom trigger is already configured.") + expect((await store.getSettings(-1001)).customTriggers).toEqual(["shipbot"]) + }) +``` + +- [ ] **Step 2: Run focused test to verify failure** + +Run: `pnpm test -- tests/adapters/telegramGroupMenu.test.js` + +Expected: FAIL because `handlePendingText` and custom trigger callbacks do not exist. + +- [ ] **Step 3: Implement menu state and callbacks** + +In `src/adapters/telegram/groupMenu.js`, import trigger constants: + +```js +import { + CUSTOM_TRIGGER_MAX_COUNT, + CUSTOM_TRIGGER_MAX_LENGTH, + normalizeCustomTriggerPhrase, +} from "./groupRouting.js" +``` + +Add pending state near `groupTokens`: + +```js + const pendingCustomTriggerAdds = new Map() +``` + +Expose `handlePendingText` and a testing helper in the returned object: + +```js + async handlePendingText(ctx) { + return handlePendingCustomTriggerText(ctx) + }, + + async startCustomTriggerAddForTesting(userId, chatId) { + pendingCustomTriggerAdds.set(userId, { chatId }) + }, +``` + +Add callback cases before the final `Group selected` branch: + +```js + if (selection.action === "add_custom_trigger") { + pendingCustomTriggerAdds.set(selection.userId, { chatId: selection.chatId }) + await ctx.answerCallbackQuery({ text: "Send trigger phrase" }) + await ctx.reply( + `Send the custom trigger phrase for this group. It can be up to ${CUSTOM_TRIGGER_MAX_LENGTH} characters. Send /cancel to stop.`, + ) + return + } + if (selection.action === "remove_custom_trigger") { + await ctx.answerCallbackQuery({ text: "Select trigger" }) + await replyWithCustomTriggerRemoveMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "remove_custom_trigger_phrase") { + const settings = await store.getSettings(selection.chatId) + const key = customTriggerKey(selection.phrase) + await store.updateSettings(selection.chatId, { + customTriggers: settings.customTriggers.filter( + (phrase) => customTriggerKey(phrase) !== key, + ), + }) + await ctx.answerCallbackQuery({ text: "Custom trigger removed" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "clear_custom_triggers") { + await store.updateSettings(selection.chatId, { customTriggers: [] }) + await ctx.answerCallbackQuery({ text: "Custom triggers cleared" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } +``` + +Add custom trigger buttons in `replyWithSettingsMenu` after trigger toggles: + +```js + const addTriggerToken = groupTokens.add({ action: "add_custom_trigger", chatId, userId }) + keyboard.text("Add custom trigger", `group:${addTriggerToken}`).row() + if (settings.customTriggers.length > 0) { + const removeTriggerToken = groupTokens.add({ + action: "remove_custom_trigger", + chatId, + userId, + }) + keyboard.text("Remove custom trigger", `group:${removeTriggerToken}`).row() + const clearTriggerToken = groupTokens.add({ + action: "clear_custom_triggers", + chatId, + userId, + }) + keyboard.text("Clear custom triggers", `group:${clearTriggerToken}`).row() + } +``` + +Add helper functions before `maybeSendGroupNotice`: + +```js + async function replyWithCustomTriggerRemoveMenu(ctx, chatId, userId) { + const settings = await store.getSettings(chatId) + if (settings.customTriggers.length === 0) { + await ctx.reply("No custom triggers are configured for this group.") + return + } + const keyboard = new InlineKeyboard() + for (const phrase of settings.customTriggers) { + const token = groupTokens.add({ + action: "remove_custom_trigger_phrase", + chatId, + userId, + phrase, + }) + keyboard.text(phrase, `group:${token}`).row() + } + await ctx.reply("Select a custom trigger to remove:", { reply_markup: keyboard }) + } + + async function handlePendingCustomTriggerText(ctx) { + if (!isPrivateChat(ctx)) { + return false + } + const userId = ctx.from?.id + const pending = pendingCustomTriggerAdds.get(userId) + if (!pending) { + return false + } + const text = String(ctx.message?.text ?? "") + if (text.trim() === "/cancel") { + pendingCustomTriggerAdds.delete(userId) + await ctx.reply("Custom trigger setup cancelled.") + return true + } + const rawPhrase = text.trim().replace(/\s+/g, " ") + if (!rawPhrase) { + await ctx.reply("Custom trigger cannot be empty. Send another phrase or /cancel.") + return true + } + if (rawPhrase.length > CUSTOM_TRIGGER_MAX_LENGTH) { + await ctx.reply(`Custom trigger must be ${CUSTOM_TRIGGER_MAX_LENGTH} characters or fewer.`) + return true + } + const settings = await store.getSettings(pending.chatId) + if (settings.customTriggers.length >= CUSTOM_TRIGGER_MAX_COUNT) { + pendingCustomTriggerAdds.delete(userId) + await ctx.reply(`This group already has ${CUSTOM_TRIGGER_MAX_COUNT} custom triggers.`) + return true + } + const phrase = normalizeCustomTriggerPhrase(rawPhrase) + if (settings.customTriggers.some((existing) => customTriggerKey(existing) === customTriggerKey(phrase))) { + await ctx.reply("That custom trigger is already configured.") + return true + } + pendingCustomTriggerAdds.delete(userId) + await store.updateSettings(pending.chatId, { + customTriggers: [...settings.customTriggers, phrase], + }) + await ctx.reply(`Added custom trigger: ${phrase}`) + await replyWithSettingsMenu(ctx, pending.chatId, userId) + return true + } +``` + +Update `formatGroupSettings` to include custom triggers: + +```js + `Custom triggers: ${formatCustomTriggers(settings.customTriggers)}`, +``` + +Add formatting helper: + +```js +function formatCustomTriggers(customTriggers = []) { + return customTriggers.length === 0 ? "none" : customTriggers.join(", ") +} + +function customTriggerKey(value) { + return String(value ?? "").toLocaleLowerCase("en-US") +} +``` + +- [ ] **Step 4: Run focused menu test to verify pass** + +Run: `pnpm test -- tests/adapters/telegramGroupMenu.test.js` + +Expected: PASS. + +- [ ] **Step 5: Commit menu work** + +Run: + +```bash +git add src/adapters/telegram/groupMenu.js tests/adapters/telegramGroupMenu.test.js +git commit -m "feat: manage Telegram custom group triggers" +``` + +## Task 3: Telegram Bot Integration + +**Files:** +- Modify: `tests/adapters/telegramBot.test.js` +- Modify: `src/adapters/telegram/bot.js` + +- [ ] **Step 1: Add failing bot integration test** + +Append this test near the other group routing tests in `tests/adapters/telegramBot.test.js`: + +```js + test("custom group triggers can be configured in DM and route group text", async () => { + const controller = { sendPrompt: vi.fn(async () => "custom answer") } + const groupStore = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await groupStore.upsertKnownGroup({ chatId: -1001, title: "Build Room", type: "supergroup" }) + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123], allowedChatIds: [-1001] }), + controller, + groupStore, + groupMemory: createGroupMemory({ contextMessages: 10, contextChars: 1_000 }), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const dmReply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await bot.commands.get("group")({ + from: { id: 123, is_bot: false }, + chat: { id: 123, type: "private" }, + message: { text: "/group", chat: { id: 123, type: "private" } }, + reply: dmReply, + }) + const selectData = dmReply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await bot.callbackHandlers[0].handler({ + from: { id: 123, is_bot: false }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply: dmReply, + }) + const addButton = dmReply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Add custom trigger") + await bot.callbackHandlers[0].handler({ + from: { id: 123, is_bot: false }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply: dmReply, + }) + + await bot.messageHandlers.get("message:text")({ + from: { id: 123, is_bot: false }, + chat: { id: 123, type: "private" }, + message: { message_id: 5, text: "codex please", chat: { id: 123, type: "private" } }, + api: { sendChatAction: vi.fn(async () => undefined), setMessageReaction: vi.fn(async () => true) }, + reply: dmReply, + }) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "we use sqlite here", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { sendChatAction: vi.fn(async () => undefined), setMessageReaction: vi.fn(async () => true) }, + reply: vi.fn(async () => undefined), + }) + const groupReply = vi.fn(async () => ({ message_id: 12, chat: { id: -1001 }, text: "custom answer" })) + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 11, + text: "Can CODEX please summarize?", + chat: { id: -1001, type: "supergroup" }, + from: { id: 778, is_bot: false, first_name: "Grace" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { sendChatAction: vi.fn(async () => undefined), setMessageReaction: vi.fn(async () => true) }, + reply: groupReply, + }) + + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Ada: we use sqlite here") + expect(groupReply).toHaveBeenCalledWith("custom answer") + }) +``` + +- [ ] **Step 2: Run focused bot test to verify failure** + +Run: `pnpm test -- tests/adapters/telegramBot.test.js` + +Expected: FAIL because private pending trigger text is still sent through normal prompt handling. + +- [ ] **Step 3: Wire pending text handling before normal text prompts** + +In `src/adapters/telegram/bot.js`, update the `bot.on("message:text", async (ctx) => {` handler start: + +```js + bot.on("message:text", async (ctx) => { + if (await groupMenu.handlePendingText?.(ctx)) { + return + } + if (ctx.message.text.startsWith("/")) { + return + } +``` + +- [ ] **Step 4: Run focused bot test to verify pass** + +Run: `pnpm test -- tests/adapters/telegramBot.test.js` + +Expected: PASS. + +- [ ] **Step 5: Commit bot integration** + +Run: + +```bash +git add src/adapters/telegram/bot.js tests/adapters/telegramBot.test.js +git commit -m "feat: wire Telegram custom group triggers" +``` + +## Task 4: Public Documentation + +**Files:** +- Modify: `README.md` +- Modify: `FEATURES.md` + +- [ ] **Step 1: Update README group routing docs** + +Find the Telegram group routing section in `README.md` and add: + +```md +- Custom trigger phrases are configured per group from `/group` in DM. They are plain text, case-insensitive, and match anywhere in text, captions, and voice transcripts. +``` + +- [ ] **Step 2: Update FEATURES group feature list** + +Find the Telegram group support bullets in `FEATURES.md` and add: + +```md +- Per-group custom trigger phrases managed from the DM `/group` menu. +``` + +- [ ] **Step 3: Run docs-adjacent checks** + +Run: `pnpm run lint` + +Expected: PASS with `Checked ... files ... No fixes applied.` + +- [ ] **Step 4: Commit docs** + +Run: + +```bash +git add README.md FEATURES.md +git commit -m "docs: document Telegram custom group triggers" +``` + +## Task 5: Full Verification + +**Files:** +- Verify all modified files. + +- [ ] **Step 1: Run full verification** + +Run: `pnpm run check` + +Expected: PASS. This runs Biome, Vitest coverage, package smoke, and workflow smoke. + +- [ ] **Step 2: Inspect final status** + +Run: `git status --short` + +Expected: no unstaged implementation files. If the plan file remains uncommitted, commit it with the final implementation or a docs commit before declaring completion. + +- [ ] **Step 3: Summarize commits and verification evidence** + +Report the final commit hashes and the exact verification command result, including test file and test counts from `pnpm run check`. From ad8f26e082f17666e951c2e172d95435eb0ec749 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:47:07 +0200 Subject: [PATCH 07/12] docs: refine custom trigger plan --- ...26-05-28-telegram-custom-group-triggers.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md b/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md index 1a52654..8bad75f 100644 --- a/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md +++ b/docs/superpowers/plans/2026-05-28-telegram-custom-group-triggers.md @@ -353,7 +353,29 @@ Append this validation test: const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) - await menu.startCustomTriggerAddForTesting?.(123, -1001) + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const addButton = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard + .flat() + .find((button) => button.text === "Add custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect( await menu.handlePendingText({ from: { id: 123 }, @@ -392,16 +414,12 @@ Add pending state near `groupTokens`: const pendingCustomTriggerAdds = new Map() ``` -Expose `handlePendingText` and a testing helper in the returned object: +Expose `handlePendingText` in the returned object: ```js async handlePendingText(ctx) { return handlePendingCustomTriggerText(ctx) }, - - async startCustomTriggerAddForTesting(userId, chatId) { - pendingCustomTriggerAdds.set(userId, { chatId }) - }, ``` Add callback cases before the final `Group selected` branch: From 5f883cb8f0f3bb83b5ae17aba3604c157e844273 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:49:23 +0200 Subject: [PATCH 08/12] feat: route Telegram groups by custom triggers --- src/adapters/telegram/groupRouting.js | 51 +++++++++++++++++++++ src/adapters/telegram/groupStore.js | 9 +++- tests/adapters/telegramGroupRouting.test.js | 38 +++++++++++++++ tests/adapters/telegramGroupStore.test.js | 20 ++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/adapters/telegram/groupRouting.js b/src/adapters/telegram/groupRouting.js index 1ce208a..12011cc 100644 --- a/src/adapters/telegram/groupRouting.js +++ b/src/adapters/telegram/groupRouting.js @@ -1,3 +1,6 @@ +export const CUSTOM_TRIGGER_MAX_COUNT = 20 +export const CUSTOM_TRIGGER_MAX_LENGTH = 64 + export const DEFAULT_GROUP_SETTINGS = { replyPolicy: "humans", triggers: { @@ -7,6 +10,7 @@ export const DEFAULT_GROUP_SETTINGS = { nameAnywhere: false, voiceName: false, }, + customTriggers: [], } export function evaluateGroupMessageRouting({ message, settings, botIdentity } = {}) { @@ -37,6 +41,9 @@ export function evaluateGroupMessageRouting({ message, settings, botIdentity } = if (normalizedSettings.triggers.nameAnywhere && containsBotName(text, normalizedIdentity.names)) { return { route: true, trigger: "name_anywhere" } } + if (matchesCustomTrigger(text, normalizedSettings.customTriggers)) { + return { route: true, trigger: "custom" } + } return { route: false, reason: "not_addressed" } } @@ -47,7 +54,36 @@ export function normalizeGroupSettings(settings = {}) { ...DEFAULT_GROUP_SETTINGS, ...settings, triggers, + customTriggers: normalizeCustomTriggers(settings.customTriggers), + } +} + +export function normalizeCustomTriggerPhrase(value) { + const phrase = String(value ?? "") + .trim() + .replace(/\s+/g, " ") + if (!phrase || phrase.length > CUSTOM_TRIGGER_MAX_LENGTH) { + return null } + return phrase +} + +export function normalizeCustomTriggers(values) { + const result = [] + const seen = new Set() + for (const value of Array.isArray(values) ? values : []) { + const phrase = normalizeCustomTriggerPhrase(value) + const key = phrase?.toLocaleLowerCase("en-US") + if (!phrase || seen.has(key)) { + continue + } + seen.add(key) + result.push(phrase) + if (result.length >= CUSTOM_TRIGGER_MAX_COUNT) { + break + } + } + return result } function senderAllowed(message, replyPolicy) { @@ -102,6 +138,21 @@ function containsBotName(text, names) { }) } +function matchesCustomTrigger(text, triggers) { + const candidate = normalizeComparableText(text) + if (!candidate) { + return false + } + return triggers.some((trigger) => candidate.includes(normalizeComparableText(trigger))) +} + +function normalizeComparableText(value) { + return String(value ?? "") + .trim() + .replace(/\s+/g, " ") + .toLocaleLowerCase("en-US") +} + function normalizeBotIdentity(identity = {}) { const username = normalizeUsername(identity.username) const names = uniqueStrings([ diff --git a/src/adapters/telegram/groupStore.js b/src/adapters/telegram/groupStore.js index f00c8d0..bffb979 100644 --- a/src/adapters/telegram/groupStore.js +++ b/src/adapters/telegram/groupStore.js @@ -2,7 +2,11 @@ import { mkdirSync } from "node:fs" import { dirname, posix, win32 } from "node:path" import { DatabaseSync } from "node:sqlite" import { getAppDataDir } from "../../core/state/appDataPath.js" -import { DEFAULT_GROUP_SETTINGS, normalizeGroupSettings } from "./groupRouting.js" +import { + DEFAULT_GROUP_SETTINGS, + normalizeCustomTriggers, + normalizeGroupSettings, +} from "./groupRouting.js" const GROUP_DB_FILE_NAME = "telegram-groups.db" @@ -288,6 +292,9 @@ function mergeGroupConfig(base, patch = {}) { ...(base?.triggers ?? {}), ...(patch?.triggers ?? {}), }, + customTriggers: normalizeCustomTriggers( + patch?.customTriggers ?? base?.customTriggers ?? DEFAULT_GROUP_CONFIG.customTriggers, + ), memory: { ...DEFAULT_GROUP_CONFIG.memory, ...(base?.memory ?? {}), diff --git a/tests/adapters/telegramGroupRouting.test.js b/tests/adapters/telegramGroupRouting.test.js index 4cd5a80..07852f4 100644 --- a/tests/adapters/telegramGroupRouting.test.js +++ b/tests/adapters/telegramGroupRouting.test.js @@ -109,4 +109,42 @@ describe("evaluateGroupMessageRouting", () => { }), ).toEqual({ route: true, trigger: "name_anywhere" }) }) + + test("routes custom triggers anywhere case-insensitively", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["codex please"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "Can CODEX please check this?" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) + + test("treats custom trigger phrases as plain text", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["ship.bot"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "shipXbot should not match" }), + settings, + botIdentity, + }), + ).toEqual({ route: false, reason: "not_addressed" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "please ask ship.bot for help" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) }) diff --git a/tests/adapters/telegramGroupStore.test.js b/tests/adapters/telegramGroupStore.test.js index e7550a8..7f5a8cf 100644 --- a/tests/adapters/telegramGroupStore.test.js +++ b/tests/adapters/telegramGroupStore.test.js @@ -30,6 +30,7 @@ describe("openTelegramGroupStore", () => { replyPolicy: "all", triggers: { nameAnywhere: true }, context: { messages: 50 }, + customTriggers: [" Codex please ", "codex please", "shipbot"], }) store.close() @@ -48,6 +49,7 @@ describe("openTelegramGroupStore", () => { replyPolicy: "all", triggers: { ...DEFAULT_GROUP_CONFIG.triggers, nameAnywhere: true }, context: { ...DEFAULT_GROUP_CONFIG.context, messages: 50 }, + customTriggers: ["Codex please", "shipbot"], }) reopened.close() }) @@ -95,6 +97,24 @@ describe("createMemoryGroupStore", () => { }, ]) }) + + test("normalizes missing and oversized custom triggers", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await store.updateSettings(-1001, { + customTriggers: [ + " alpha trigger ", + "ALPHA trigger", + "x".repeat(65), + ...Array.from({ length: 25 }, (_, index) => `trigger ${index}`), + ], + }) + + const settings = await store.getSettings(-1001) + + expect(settings.customTriggers).toHaveLength(20) + expect(settings.customTriggers[0]).toBe("alpha trigger") + expect(settings.customTriggers).not.toContain("x".repeat(65)) + }) }) async function tempDbPath() { From 4f0cf202a31c9a9841fb4d4d127352cea70644e4 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:53:53 +0200 Subject: [PATCH 09/12] feat: manage Telegram custom group triggers --- src/adapters/telegram/groupMenu.js | 133 +++++++++++++++++++++++ tests/adapters/telegramGroupMenu.test.js | 133 +++++++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/src/adapters/telegram/groupMenu.js b/src/adapters/telegram/groupMenu.js index 7d3f5a2..1614916 100644 --- a/src/adapters/telegram/groupMenu.js +++ b/src/adapters/telegram/groupMenu.js @@ -1,4 +1,9 @@ import { InlineKeyboard } from "grammy" +import { + CUSTOM_TRIGGER_MAX_COUNT, + CUSTOM_TRIGGER_MAX_LENGTH, + normalizeCustomTriggerPhrase, +} from "./groupRouting.js" const GROUP_NOTICE_TEXT = "Group settings are managed in DM. Message me and run /group." @@ -10,6 +15,7 @@ export function createTelegramGroupMenu({ } = {}) { const noticeTimes = new Map() const groupTokens = createTokenStore(200) + const pendingCustomTriggerAdds = new Map() return { async handleCommand(ctx) { @@ -36,6 +42,10 @@ export function createTelegramGroupMenu({ await ctx.reply("Select a Telegram group to configure:", { reply_markup: keyboard }) }, + async handlePendingText(ctx) { + return handlePendingCustomTriggerText(ctx) + }, + async handleCallback(ctx) { const token = ctx.match?.[1] const selection = groupTokens.get(token) @@ -85,6 +95,37 @@ export function createTelegramGroupMenu({ await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) return } + if (selection.action === "add_custom_trigger") { + pendingCustomTriggerAdds.set(selection.userId, { chatId: selection.chatId }) + await ctx.answerCallbackQuery({ text: "Send trigger phrase" }) + await ctx.reply( + `Send the custom trigger phrase for this group. It can be up to ${CUSTOM_TRIGGER_MAX_LENGTH} characters. Send /cancel to stop.`, + ) + return + } + if (selection.action === "remove_custom_trigger") { + await ctx.answerCallbackQuery({ text: "Select trigger" }) + await replyWithCustomTriggerRemoveMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "remove_custom_trigger_phrase") { + const settings = await store.getSettings(selection.chatId) + const key = customTriggerKey(selection.phrase) + await store.updateSettings(selection.chatId, { + customTriggers: settings.customTriggers.filter( + (phrase) => customTriggerKey(phrase) !== key, + ), + }) + await ctx.answerCallbackQuery({ text: "Custom trigger removed" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } + if (selection.action === "clear_custom_triggers") { + await store.updateSettings(selection.chatId, { customTriggers: [] }) + await ctx.answerCallbackQuery({ text: "Custom triggers cleared" }) + await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) + return + } await ctx.answerCallbackQuery({ text: "Group selected" }) await replyWithSettingsMenu(ctx, selection.chatId, selection.userId) @@ -109,6 +150,22 @@ export function createTelegramGroupMenu({ ) .row() } + const addTriggerToken = groupTokens.add({ action: "add_custom_trigger", chatId, userId }) + keyboard.text("Add custom trigger", `group:${addTriggerToken}`).row() + if (settings.customTriggers.length > 0) { + const removeTriggerToken = groupTokens.add({ + action: "remove_custom_trigger", + chatId, + userId, + }) + keyboard.text("Remove custom trigger", `group:${removeTriggerToken}`).row() + const clearTriggerToken = groupTokens.add({ + action: "clear_custom_triggers", + chatId, + userId, + }) + keyboard.text("Clear custom triggers", `group:${clearTriggerToken}`).row() + } const memoryToken = groupTokens.add({ action: "toggle_memory", chatId, userId }) keyboard.text(`Memory: ${settings.memory.enabled ? "off" : "on"}`, `group:${memoryToken}`).row() for (const messages of [10, 30, 50]) { @@ -126,6 +183,73 @@ export function createTelegramGroupMenu({ }) } + async function replyWithCustomTriggerRemoveMenu(ctx, chatId, userId) { + const settings = await store.getSettings(chatId) + if (settings.customTriggers.length === 0) { + await ctx.reply("No custom triggers are configured for this group.") + return + } + const keyboard = new InlineKeyboard() + for (const phrase of settings.customTriggers) { + const token = groupTokens.add({ + action: "remove_custom_trigger_phrase", + chatId, + userId, + phrase, + }) + keyboard.text(phrase, `group:${token}`).row() + } + await ctx.reply("Select a custom trigger to remove:", { reply_markup: keyboard }) + } + + async function handlePendingCustomTriggerText(ctx) { + if (!isPrivateChat(ctx)) { + return false + } + const userId = ctx.from?.id + const pending = pendingCustomTriggerAdds.get(userId) + if (!pending) { + return false + } + const text = String(ctx.message?.text ?? "") + if (text.trim() === "/cancel") { + pendingCustomTriggerAdds.delete(userId) + await ctx.reply("Custom trigger setup cancelled.") + return true + } + const rawPhrase = text.trim().replace(/\s+/g, " ") + if (!rawPhrase) { + await ctx.reply("Custom trigger cannot be empty. Send another phrase or /cancel.") + return true + } + if (rawPhrase.length > CUSTOM_TRIGGER_MAX_LENGTH) { + await ctx.reply(`Custom trigger must be ${CUSTOM_TRIGGER_MAX_LENGTH} characters or fewer.`) + return true + } + const settings = await store.getSettings(pending.chatId) + if (settings.customTriggers.length >= CUSTOM_TRIGGER_MAX_COUNT) { + pendingCustomTriggerAdds.delete(userId) + await ctx.reply(`This group already has ${CUSTOM_TRIGGER_MAX_COUNT} custom triggers.`) + return true + } + const phrase = normalizeCustomTriggerPhrase(rawPhrase) + if ( + settings.customTriggers.some( + (existing) => customTriggerKey(existing) === customTriggerKey(phrase), + ) + ) { + await ctx.reply("That custom trigger is already configured.") + return true + } + pendingCustomTriggerAdds.delete(userId) + await store.updateSettings(pending.chatId, { + customTriggers: [...settings.customTriggers, phrase], + }) + await ctx.reply(`Added custom trigger: ${phrase}`) + await replyWithSettingsMenu(ctx, pending.chatId, userId) + return true + } + async function maybeSendGroupNotice(ctx) { const chatId = ctx.chat?.id ?? ctx.message?.chat?.id const lastNoticeAt = noticeTimes.get(chatId) ?? 0 @@ -147,11 +271,20 @@ function formatGroupSettings(groupTitle, settings) { `${groupTitle} settings:`, `Reply policy: ${settings.replyPolicy}`, `Triggers: ${formatEnabledTriggers(settings.triggers)}`, + `Custom triggers: ${formatCustomTriggers(settings.customTriggers)}`, `Memory: ${settings.memory.enabled ? "on" : "off"}`, `Context: ${settings.context.messages} messages, ${settings.context.chars} chars, ${settings.context.overlap} overlap`, ].join("\n") } +function formatCustomTriggers(customTriggers = []) { + return customTriggers.length === 0 ? "none" : customTriggers.join(", ") +} + +function customTriggerKey(value) { + return String(value ?? "").toLocaleLowerCase("en-US") +} + function formatEnabledTriggers(triggers) { return Object.entries(triggers) .filter(([, enabled]) => enabled) diff --git a/tests/adapters/telegramGroupMenu.test.js b/tests/adapters/telegramGroupMenu.test.js index 52de53a..baad38b 100644 --- a/tests/adapters/telegramGroupMenu.test.js +++ b/tests/adapters/telegramGroupMenu.test.js @@ -104,4 +104,137 @@ describe("createTelegramGroupMenu", () => { expect(answerCallbackQuery).toHaveBeenCalledWith({ text: "Group menu expired" }) expect(reply).toHaveBeenCalledTimes(1) }) + + test("adds, removes, and clears custom triggers through DM menu", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + + const addButton = reply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Add custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect(reply.mock.calls.at(-1)[0]).toContain("Send the custom trigger phrase") + + expect( + await menu.handlePendingText({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { text: " Codex please " }, + reply, + }), + ).toBe(true) + expect((await store.getSettings(-1001)).customTriggers).toEqual(["Codex please"]) + + const removeButton = reply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Remove custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [removeButton.callback_data, removeButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const phraseButton = reply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Codex please") + await menu.handleCallback({ + from: { id: 123 }, + match: [phraseButton.callback_data, phraseButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect((await store.getSettings(-1001)).customTriggers).toEqual([]) + + await store.updateSettings(-1001, { customTriggers: ["shipbot"] }) + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectAgain = reply.mock.calls.at(-1)[1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectAgain, selectAgain.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const clearButton = reply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Clear custom triggers") + await menu.handleCallback({ + from: { id: 123 }, + match: [clearButton.callback_data, clearButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + expect((await store.getSettings(-1001)).customTriggers).toEqual([]) + }) + + test("rejects invalid custom trigger phrases", async () => { + const store = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await store.updateSettings(-1001, { customTriggers: ["shipbot"] }) + const menu = createTelegramGroupMenu({ store, memory: createGroupMemory() }) + const reply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await menu.handleCommand({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { chat: { id: 123, type: "private" } }, + reply, + }) + const selectData = reply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + await menu.handleCallback({ + from: { id: 123 }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + const addButton = reply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Add custom trigger") + await menu.handleCallback({ + from: { id: 123 }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply, + }) + + expect( + await menu.handlePendingText({ + from: { id: 123 }, + chat: { id: 123, type: "private" }, + message: { text: "SHIPBOT" }, + reply, + }), + ).toBe(true) + + expect(reply).toHaveBeenCalledWith("That custom trigger is already configured.") + expect((await store.getSettings(-1001)).customTriggers).toEqual(["shipbot"]) + }) }) From db1ecc8de64cc9f76db6a533bf6c562836686253 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:57:32 +0200 Subject: [PATCH 10/12] feat: wire Telegram custom group triggers --- src/adapters/telegram/bot.js | 3 + tests/adapters/telegramBot.test.js | 93 ++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/adapters/telegram/bot.js b/src/adapters/telegram/bot.js index d12eb76..b7a4984 100644 --- a/src/adapters/telegram/bot.js +++ b/src/adapters/telegram/bot.js @@ -351,6 +351,9 @@ export function createTelegramBot({ }) bot.on("message:text", async (ctx) => { + if (await groupMenu.handlePendingText?.(ctx)) { + return + } if (ctx.message.text.startsWith("/")) { return } diff --git a/tests/adapters/telegramBot.test.js b/tests/adapters/telegramBot.test.js index 040393e..dffb470 100644 --- a/tests/adapters/telegramBot.test.js +++ b/tests/adapters/telegramBot.test.js @@ -1293,6 +1293,99 @@ describe("createTelegramBot", () => { expect(setMessageReaction).toHaveBeenNthCalledWith(2, -1001, 11, []) }) + test("custom group triggers can be configured in DM and route group text", async () => { + const controller = { sendPrompt: vi.fn(async () => "custom answer") } + const groupStore = createMemoryGroupStore({ allowedChatIds: [-1001] }) + await groupStore.upsertKnownGroup({ chatId: -1001, title: "Build Room", type: "supergroup" }) + const bot = createTelegramBot({ + token: "token", + telegram: testTelegram({ allowedUserIds: [123], allowedChatIds: [-1001] }), + controller, + groupStore, + groupMemory: createGroupMemory({ contextMessages: 10, contextChars: 1_000 }), + botIdentity: { id: 9001, username: "OpenCodeRemoteBot", firstName: "Khmara" }, + logger: { warn: vi.fn(), error: vi.fn() }, + botFactory: FakeBot, + }) + const dmReply = vi.fn(async (_text, options) => ({ reply_markup: options?.reply_markup })) + + await bot.commands.get("group")({ + from: { id: 123, is_bot: false }, + chat: { id: 123, type: "private" }, + message: { text: "/group", chat: { id: 123, type: "private" } }, + reply: dmReply, + }) + const selectData = dmReply.mock.calls[0][1].reply_markup.inline_keyboard[0][0].callback_data + const groupCallback = bot.callbackHandlers.find(({ pattern }) => + pattern.test(selectData), + ).handler + await groupCallback({ + from: { id: 123, is_bot: false }, + match: [selectData, selectData.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply: dmReply, + }) + const addButton = dmReply.mock.calls + .at(-1)[1] + .reply_markup.inline_keyboard.flat() + .find((button) => button.text === "Add custom trigger") + await groupCallback({ + from: { id: 123, is_bot: false }, + match: [addButton.callback_data, addButton.callback_data.replace("group:", "")], + answerCallbackQuery: vi.fn(async () => undefined), + reply: dmReply, + }) + + await bot.messageHandlers.get("message:text")({ + from: { id: 123, is_bot: false }, + chat: { id: 123, type: "private" }, + message: { message_id: 5, text: "codex please", chat: { id: 123, type: "private" } }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: dmReply, + }) + + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 10, + text: "we use sqlite here", + chat: { id: -1001, type: "supergroup" }, + from: { id: 777, is_bot: false, first_name: "Ada" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: vi.fn(async () => undefined), + }) + const groupReply = vi.fn(async () => ({ + message_id: 12, + chat: { id: -1001 }, + text: "custom answer", + })) + await bot.messageHandlers.get("message:text")({ + message: { + message_id: 11, + text: "Can CODEX please summarize?", + chat: { id: -1001, type: "supergroup" }, + from: { id: 778, is_bot: false, first_name: "Grace" }, + }, + chat: { id: -1001, type: "supergroup" }, + api: { + sendChatAction: vi.fn(async () => undefined), + setMessageReaction: vi.fn(async () => true), + }, + reply: groupReply, + }) + + expect(controller.sendPrompt).toHaveBeenCalledTimes(1) + expect(controller.sendPrompt.mock.calls[0][0].text).toContain("Ada: we use sqlite here") + expect(groupReply).toHaveBeenCalledWith("custom answer") + }) + test("group routing can use grammY ctx.me as bot identity", async () => { const controller = { sendPrompt: vi.fn(async () => "answer") } const bot = createTelegramBot({ From 3496ea938537ca8395d84aa97e5b966f8e3b6f1f Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 15:59:11 +0200 Subject: [PATCH 11/12] docs: document Telegram custom group triggers --- FEATURES.md | 2 ++ README.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 296cc30..c871c8e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -32,9 +32,11 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - `/voice` shows and controls voice mode, lists voices by required short country/locale filter, sets the active Edge TTS voice, and sends a test voice note. - `/stickers` saves, lists, and forgets sticker packs for future sticker replies. - `/group` opens a private-chat management menu for known allowed groups. In groups, `/group` replies with a short DM-only notice. +- Per-group custom trigger phrases are managed from the DM `/group` menu. - `/help` shows the available bot commands. - The Telegram slash-command menu is refreshed on gateway startup. - Non-command text from an authorized private user is sent to OpenCode as a prompt. In allowed groups, text, photo, voice, and sticker messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. +- Custom group trigger phrases are plain text, case-insensitive, and match anywhere in text, captions, and voice transcripts. - Allowed groups keep bounded in-memory recent context while the gateway is running. Routed group prompts include capped recent context, but passive messages are not sent to OpenCode by themselves. - Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names. - The bot shows Telegram typing activity while a prompt is running. diff --git a/README.md b/README.md index 3d834ac..f8566d9 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ The config file is JSON: `progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message. -Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. +Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match anywhere in text, captions, and voice transcripts. `voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. When a voice-note reply succeeds, the bot does not also send the text reply; if speech generation or sending fails, it falls back to text. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled. @@ -179,7 +179,7 @@ The bot currently supports: /help Show available commands ``` -Any non-command text message from an authorized private Telegram user is sent to OpenCode as a prompt. In allowed group chats, messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. Defaults are conservative: human senders can trigger replies by replying to the bot, mentioning the bot username, or starting text with the bot name. Other bots are remembered as passive context by default but do not trigger replies unless group settings are changed in the DM `/group` menu. If no active session is selected, the gateway creates one automatically. +Any non-command text message from an authorized private Telegram user is sent to OpenCode as a prompt. In allowed group chats, messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. Defaults are conservative: human senders can trigger replies by replying to the bot, mentioning the bot username, or starting text with the bot name. Per-group custom trigger phrases can also route text, captions, and voice transcripts when the phrase appears anywhere in the message. Other bots are remembered as passive context by default but do not trigger replies unless group settings are changed in the DM `/group` menu. If no active session is selected, the gateway creates one automatically. Allowed group chats keep bounded in-memory recent context while the gateway process runs. When a group message is routed, the gateway sends OpenCode the addressed message plus a capped recent-context transcript. It does not persist group message text; memory is cleared on gateway restart and when the active OpenCode session changes. Passive stickers and photos are stored as lightweight metadata and are not downloaded for OpenCode unless routed. Group voice messages may be transcribed before routing when voice mode is enabled so the gateway can decide whether the transcript addresses the bot. From d5df49a573ffa530f4679744205cf9c18dd8ac20 Mon Sep 17 00:00:00 2001 From: crankshift Date: Thu, 28 May 2026 16:25:00 +0200 Subject: [PATCH 12/12] fix: bound Telegram custom trigger matches --- CHANGELOG.md | 11 +++++ FEATURES.md | 2 +- README.md | 2 +- package.json | 2 +- src/adapters/telegram/groupRouting.js | 13 +++++- src/bin/program.js | 2 +- tests/adapters/telegramGroupRouting.test.js | 46 +++++++++++++++++++++ 7 files changed, 73 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f2f81..102e5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ This project follows Semantic Versioning. ## Unreleased +## [0.7.0] - 2026-05-28 + +### Added + +- Added configured Telegram group chat allowlists with group-scoped routing, ephemeral context memory, and DM-based `/group` settings. +- Added per-group custom trigger phrases for routing text, captions, and voice transcripts to OpenCode. + +### Fixed + +- Fixed custom group triggers to match bounded words or phrases so short bot names such as `Рес` do not trigger on longer words such as `ресурси`. + ## [0.6.1] - 2026-05-28 ### Fixed diff --git a/FEATURES.md b/FEATURES.md index c871c8e..96e8d9b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -36,7 +36,7 @@ OpenCode Remote is currently a Telegram gateway for OpenCode with text, image, s - `/help` shows the available bot commands. - The Telegram slash-command menu is refreshed on gateway startup. - Non-command text from an authorized private user is sent to OpenCode as a prompt. In allowed groups, text, photo, voice, and sticker messages are sent to OpenCode only when group routing settings identify them as addressed to the bot. -- Custom group trigger phrases are plain text, case-insensitive, and match anywhere in text, captions, and voice transcripts. +- Custom group trigger phrases are plain text, case-insensitive, and match as bounded words or phrases anywhere in text, captions, and voice transcripts. - Allowed groups keep bounded in-memory recent context while the gateway is running. Routed group prompts include capped recent context, but passive messages are not sent to OpenCode by themselves. - Telegram text, photo, album, voice, and sticker prompts include safe author context, including forwarded original authors and messages sent by anonymous admins or on behalf of chats/channels when Telegram provides usable names. - The bot shows Telegram typing activity while a prompt is running. diff --git a/README.md b/README.md index f8566d9..c2c96fc 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ The config file is JSON: `progressVerbosity` controls the startup default for the prompt activity message in private chats. Supported values are `off`, `new`, `all`, and `verbose`. The default is `verbose`. The Telegram `/progress` command can change this at runtime in private chats. Group chats always suppress the `Activity` message. -Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match anywhere in text, captions, and voice transcripts. +Group behavior is managed from a private DM with the bot using `/group`. The DM menu lists known allowed groups, including groups from `telegram.allowedChatIds` and groups the bot has seen. Only configured `allowedUserIds` can use this menu. Running `/group` inside a group replies with a short notice to configure the bot in DM instead. Custom trigger phrases are configured per group from this DM menu; they are plain text, case-insensitive, and match as bounded words or phrases anywhere in text, captions, and voice transcripts. `voice` controls optional Telegram voice input and spoken replies. `mode="on"` sends voice-note replies only after voice prompts, `mode="all"` sends voice-note replies after text, photo, and voice prompts, and `mode="off"` disables voice. When a voice-note reply succeeds, the bot does not also send the text reply; if speech generation or sending fails, it falls back to text. Voice mode requires `voice.groqApiKey` and local `ffmpeg` when enabled. diff --git a/package.json b/package.json index b14a50e..0766092 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@crankshift/opencode-remote", "description": "A messenger-based chat interface for OpenCode, starting with Telegram.", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", "repository": { "type": "git", diff --git a/src/adapters/telegram/groupRouting.js b/src/adapters/telegram/groupRouting.js index 12011cc..5d89231 100644 --- a/src/adapters/telegram/groupRouting.js +++ b/src/adapters/telegram/groupRouting.js @@ -1,6 +1,8 @@ export const CUSTOM_TRIGGER_MAX_COUNT = 20 export const CUSTOM_TRIGGER_MAX_LENGTH = 64 +const WORD_CHARACTER_CLASS = "\\p{Letter}\\p{Number}_" + export const DEFAULT_GROUP_SETTINGS = { replyPolicy: "humans", triggers: { @@ -143,7 +145,16 @@ function matchesCustomTrigger(text, triggers) { if (!candidate) { return false } - return triggers.some((trigger) => candidate.includes(normalizeComparableText(trigger))) + return triggers.some((trigger) => { + const phrase = normalizeComparableText(trigger) + if (!phrase) { + return false + } + return new RegExp( + `(^|[^${WORD_CHARACTER_CLASS}])${escapeRegex(phrase)}($|[^${WORD_CHARACTER_CLASS}])`, + "u", + ).test(candidate) + }) } function normalizeComparableText(value) { diff --git a/src/bin/program.js b/src/bin/program.js index 9939241..1f25fa1 100644 --- a/src/bin/program.js +++ b/src/bin/program.js @@ -36,7 +36,7 @@ export function createGatewayProgram({ const program = new Command() const afterCreate = createStartupAfterConfigHook({ enableGatewayStartup, output }) - program.name("opencode-remote").description("OpenCode messaging gateway").version("0.6.1") + program.name("opencode-remote").description("OpenCode messaging gateway").version("0.7.0") program .command("setup") diff --git a/tests/adapters/telegramGroupRouting.test.js b/tests/adapters/telegramGroupRouting.test.js index 07852f4..c8edd9b 100644 --- a/tests/adapters/telegramGroupRouting.test.js +++ b/tests/adapters/telegramGroupRouting.test.js @@ -125,6 +125,52 @@ describe("evaluateGroupMessageRouting", () => { ).toEqual({ route: true, trigger: "custom" }) }) + test("routes custom triggers as bounded words", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["Рес"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "ресурси" }), + settings, + botIdentity, + }), + ).toEqual({ route: false, reason: "not_addressed" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "рес шось там шось там" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) + + test("routes custom trigger phrases as bounded words", () => { + const settings = { + ...DEFAULT_GROUP_SETTINGS, + customTriggers: ["codex please"], + } + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "codex pleased" }), + settings, + botIdentity, + }), + ).toEqual({ route: false, reason: "not_addressed" }) + + expect( + evaluateGroupMessageRouting({ + message: message({ text: "Can CODEX please check this?" }), + settings, + botIdentity, + }), + ).toEqual({ route: true, trigger: "custom" }) + }) + test("treats custom trigger phrases as plain text", () => { const settings = { ...DEFAULT_GROUP_SETTINGS,