From a4761df3f6936f8616708426d4850f377e7fd439 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 11:01:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20telegram=20listener=20text-prefix=20map?= =?UTF-8?q?=20=E2=80=94=20=20=20routes=20to=20a=20mapped=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the Telegram listener with a configurable text-prefix map (telegram_prefixes.env): any non-command message ' ' runs the mapped command with appended as ONE quoted argument — e.g. opencode=opencode turns 'opencode check cpu' into opencode "check cpu". Routing order per message: text-prefix map → built-in Gemini ai bridge → /command map → Unknown command. A mapped word shadows the Gemini bridge. The prefix verb is reworked: bare = list map + bridge word; 'prefix ' = map; 'prefix ' = show; 'prefix -r ' = remove. The Gemini trigger word itself is now set via 'pos config telegram' (TELEGRAM_AI_PREFIX). Also extracted run_and_reply() to share the /command-map (60s) and prefix (120s) execution semantics; fixed a latent set -e abort on invalid templates in prefix_map_set's check_syntax call. Verified: 27/27 routing-harness assertions, full CLI verb suite, dispatch smoke, pos config telegram render, bash -n, make gen && make check, make lint 0 FAIL / 0 WARN, shellcheck -S style (0 new findings). --- AGENT_TODO.md | 1 + DOC/AGENT_Context_Project.md | 6 +- DOC/POS.md | 14 +- DOC/howto/ai.md | 16 +- DOC/howto/communication.md | 15 ++ bin/pos-communication-telegram-listener | 259 +++++++++++++++++++----- 6 files changed, 249 insertions(+), 62 deletions(-) diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 0d51d1f..77cad2a 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -137,3 +137,4 @@ summary (newest last). - **2026-08-26** — `pos ai alias` activation rework (Option B) + `pos config` listing readability, per the 2026-08-26 Architect/Designer specs (`AgentsReport/{architect,designer}/2026-08-26-*.md`). **Alias activation:** the stale sourced-snapshot mechanism is gone — every `pos ai alias` invocation runs `_alias_sync()` (two-way reconciliation: render-diff-install of one executable wrapper per ENV record at `~/.local/bin/` chmod 755 via mktemp+mv with a `bash -n` pre-commit guard; marker-guarded deletion of owned wrappers missing from ENV; legacy `ai-aliases.sh` generation stopped and generator-marker-guarded auto-removal with an `unalias ` remediation hint; loud PATH guidance when `~/.local/bin` is off PATH). Edits are live on next invocation with no shell reload (kills the reported stale-gemini-alias bug class); create refuses foreign-file and PATH-binary collisions; `show` gains the wrapper path; `pos-system-uninstall` sweeps the wrappers by their line-2 marker in discovery+removal. Dup-table menu bug fixed with a single `_alias_table` renderer (menu option 4 returns to the loop whose pre-render already shows fresh state). **Config readability** (`lib/config-ui.sh`, fully generic): new optional `# POS_CONFIG:` field types — `@Caption` / `@[KEY=v1|v2] Caption` group captions (condition evaluated per render via `cfg_value`; inactive groups dimmed with a textual reason, never hidden → numbering stable; empty-alt segment = unset-as-default) and `*providers=` adapter filtering (zero match warns once + suppresses its caption); uniform typography tier for ALL scopes (bold title/keys, CYAN rule, dim numbers/placeholders/examples/captions, hanging-indent wrap clamped 60–120 cols, whole render block → stderr per menu-lib house pattern, honest prompt `Number to edit [r=refresh, q=quit]:`); masking/edit flow byte-compatible, no per-scope branches. `bin/pos-ai` line-6 header adopted to the caption/tag syntax (single-line change). Verified: stub-PATH harness (`HOME=/tmp/…`, `CONFIG_DIR` seam, argv-capturing `pos` shim) covering %q quoting round-trips (quotes/backticks/`$()`/%/unicode), staleness kill-test, orphan retraction, collision-refusal matrix, legacy migration (marker + foreign), PATH-absent warning, non-tty guard, idempotent double-sync; rendered-output diffs vs Designer mockups for `ai` AND old-format `system`; gates `make gen && make check && make lint` 0 FAIL / 0 WARN. - **2026-08-27** — Critical fix: paste injection + multiline paste in `pos ai alias`'s Insert Prompt (root cause: `menu_ask_value` → plain line-oriented `read -rp`; a multi-line Ctrl+V paste floods the tty queue, `read` consumes only line one and the rest execute as commands later or get eaten by the next prompt — user-verified `$(whoami)`/`; ls`/`sudo apt update` behavior). New `menu_read_value()` in `lib/menu-lib.sh` (169→362): raw-mode (`stty -icanon -echo -isig min 1 time 0`) bracketed-paste-aware value reader — `\e[?2004h/l` markers, text inside `[200~…[201~` inserted LITERALLY (embedded newlines/CR are data), Enter submits only outside a paste, Backspace/DEL/Left/Right/Home/End/Delete/Ctrl-U edit, Ctrl-D-on-empty + Ctrl-C/Z/\ cancel (terminal restored first); bytes read chunk-wise via `dd bs=4096|od -tx1|tr` — NOT bash's `read` builtin, which self-interrupts on an ETX byte from a tty even with ISIG disabled (SIGINTs the whole script on Ctrl-C); confirmed `read -erp` (readline) atomically consumes a paste but returns only its first line, so a custom reader was required. `bin/pos-ai-alias` (712→760): `_alias_prompt_encode/_decode` (backslash→`\\`, newline→`\n`; literal `[ = ]` comparisons — bash `case` patterns don't match a single backslash), `_alias_prompt_truncate` newline-safe + max-length arg; load/save encode/decode the prompt field; edit wizard shows a truncated display default but Enter restores the FULL original prompt (fixes pre-existing silent truncation of >80-char prompts), empty-original Enter continues. Verified: pty harnesses (`/tmp/pty_{menulib,cancel,e2e_alias}.py`, `/tmp/roundtrip_test.sh`) — bracketed multiline paste captured verbatim incl. `C:\temp\note`/`$(whoami)`/`; ls`/`echo test`/`sudo apt update`, nothing executed, clean exit; single-line paste; Ctrl-D and Ctrl-C both cancel cleanly (CANCELLED→DONE, terminal restored); full create→list→show→edit E2E with decode round-trip and Enter-keeps-full; `bash -n` ×2, `make gen && make check`, `make lint` 0 FAIL / 0 WARN. - **2026-08-27** — Configurable AI-bridge trigger word for the Telegram listener: the hard-coded `ai ` prefix in `pos-communication-telegram-listener` became `TELEGRAM_AI_PREFIX` (default `ai`) — messages starting with ` ` (case-insensitive, literal match) are forwarded to Gemini. New `prefix` verb: `pos communication telegram listener prefix` shows the current word, `prefix ` sets it (validated `[A-Za-z0-9][A-Za-z0-9_-]*`, writes `TELEGRAM_AI_PREFIX` to `telegram.env` chmod 600); also editable via `pos config telegram` (field added to the sender's `# POS_CONFIG:` telegram scope — registry-driven, no code in config-ui). Matching is per-message hot-reloaded (like the command map — no daemon restart), via scoped `shopt -s nocasematch` + quoted-literal `=~` prefix (bash `case` patterns can't do literal-then-whitespace + case-insensitivity in one test); `ai_bridge_prefix()` precedence: telegram.env > env from load_config > default `ai`. `--status` shows the current prefix; usage + `# POS_SUBCMDS: prefix` added (completions regenerate). Preserved edge: bare `ai` (no trailing space) never matched the old regex, so it still falls through to "Unknown command". Docs: POS.md listener rows/paragraph, howto/ai.md Telegram section + troubleshooting (also corrected a stale claim that AI errors reply with a `pos config ai` hint — code replies `AI error: …` only). Verified: function-level routing harness (`/tmp/ai_prefix_routing_test.sh` — extraction of the real listener functions + PATH stub `pos`): default `ai`/`AI` routes, bare-prefix and unknown-command fallthrough, `ai /reset` and custom-`bot` `/reset` reset the session, custom `bot`/`BOT` routes and old `ai` no longer routes, per-message hot-reload after removing the var; CLI verb tests (show/set/invalid rc 1/leading-digit/`--status`); dispatch smoke `pos communication telegram listener prefix` + flat form; `pos config telegram` render shows the field; `bash -n` ×2, `make gen && make check`, `make lint` 0 FAIL / 0 WARN. +- **2026-08-29** — Generic text-prefix map for the Telegram listener (user's clarification superseding the scalar `TELEGRAM_AI_PREFIX` setter): `bin/pos-communication-telegram-listener` (623→782) now routes any non-command message ` ` to a mapped command with `` appended as ONE quoted argument — `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`. New map file `telegram_prefixes.env` (chmod 600, re-read per message, `@quiet` values, 120s cap, empty→`OK`, `exit ` reply, syntax-checked on save, first-file-match wins, case-insensitive, word must be space-delimited so bare `` still falls through). Routing order: text-prefix map → built-in Gemini `ai` bridge → `/command` map → Unknown (a mapped `ai` shadows the bridge). `prefix` verb reworked: bare = list map + bridge word; `prefix ` = map (validated `[A-Za-z0-9][A-Za-z0-9_-]*`, `bash -n` via check_syntax); `prefix ` = show one; `prefix -r ` = remove; the AI-bridge word itself is now set ONLY via `pos config telegram` (`TELEGRAM_AI_PREFIX`, default `ai` — `--status` + bare `prefix` still display it). `run_and_reply()` extracted to share `/command`-map (60s) and prefix (120s) execution semantics; dispatch passes `"${@:2}"`. Docs: POS.md listener rows/paragraph, howto/communication.md bullet, howto/ai.md (prefix-map + shadowing), usage(), `# POS:` header, AGENT_Context regen. Verified: routing harness `/tmp/prefix_map_routing_test.sh` 27/27 (ai-bridge regression incl. `/reset`, opencode remainder=ONE arg, case-insensitivity, bare/trailing-space fallthrough, shadowing, no partial-prefix false match, exit/OK/@quiet/env-expansion, /command-map regression via run_and_reply); CLI verb suite (set/show/remove/missing rc 1/invalid word rc 1/invalid cmd rc 1 — fixed latent `set -e` cmdsubst abort on syntax errors); dispatch smoke nested + flat + `--status`; `pos config telegram` render; `bash -n`, `make gen && make check`, `make lint` 0 FAIL / 0 WARN. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 88d6180..020bf75 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -67,7 +67,7 @@ Linux_post_install/ │ ├── pos-communication-matrix-listener # Matrix listener: map /command → bash, run them on room messages │ ├── pos-communication-matrix-sender # Send messages to a Matrix room via the client-server API (send, test, login) │ ├── pos-communication-scrcpy # Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) -│ ├── pos-communication-telegram-listener # Telegram bot listener: map /command → bash, run them on chat messages +│ ├── pos-communication-telegram-listener # Telegram bot listener: map /command → bash and → app, run them on chat messages │ ├── pos-communication-telegram-sender # Send Telegram messages/files/links/stickers via Bot API (send, test) │ ├── pos-docker-compose # Docker Compose service manager (ls/up/down/restart/logs/update/config) │ ├── pos-docker-health # One-glance container health dashboard (exits 1 if unhealthy) @@ -281,7 +281,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst | communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages | | | | communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) | | | | communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) | | | -| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash, run them on chat messages | | | +| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash and → app, run them on chat messages | | | | communication | telegram-sender | `pos-communication-telegram-sender` | Send Telegram messages/files/links/stickers via Bot API (send, test) | | | | docker | compose | `pos-docker-compose` | Docker Compose service manager (ls/up/down/restart/logs/update/config) | | | | docker | health | `pos-docker-health` | One-glance container health dashboard (exits 1 if unhealthy) | | | @@ -609,7 +609,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-communication-matrix-listener` | 568 | Matrix listener: map /command → bash, run them on room messages | | `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) | | `bin/pos-communication-scrcpy` | 254 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) | -| `bin/pos-communication-telegram-listener` | 623 | Telegram bot listener: map /command → bash, run them on chat messages | +| `bin/pos-communication-telegram-listener` | 782 | Telegram bot listener: map /command → bash and → app, run them on chat messages | | `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) | | `bin/pos-docker-compose` | 487 | Docker Compose service manager (ls/up/down/restart/logs/update/config) | | `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) | diff --git a/DOC/POS.md b/DOC/POS.md index 072e704..41a99b3 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -307,7 +307,7 @@ Subcommands that need input prompt interactively when args are omitted. Bare inv | Command | File | Purpose | Configuration | |---------|------|---------|---------------| | `pos communication telegram sender send "text"` | `bin/pos-communication-telegram-sender` | Send a message, link, or media file (auto-detects the type) to a Telegram chat via the Bot API | Token + chat ID from `~/.config/linux_post_install/telegram.env` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, chmod 600). Precedence: `--token`/`--chat-id` flags > env > config file | -| `pos communication telegram listener` | `bin/pos-communication-telegram-listener` | Telegram bot listener: map `/command` → bash commands and run them from chat; interactive editor for the map | Same `telegram.env` (the bot is the owner, `TELEGRAM_CHAT_ID`). Map lives in `~/.config/linux_post_install/telegram_commands.env` (`/cmd=bash command` lines, chmod 600) | +| `pos communication telegram listener` | `bin/pos-communication-telegram-listener` | Telegram bot listener: map `/command` → bash commands and `` → apps, run them from chat; interactive editor for the map | Same `telegram.env` (the bot is the owner, `TELEGRAM_CHAT_ID`). Map lives in `~/.config/linux_post_install/telegram_commands.env` (`/cmd=bash command` lines); text-prefix app map in `telegram_prefixes.env` (`=command` lines) — both chmod 600 | | `pos communication matrix sender send "text"` | `bin/pos-communication-matrix-sender` | Send a text message (plain or `--markdown`) to a Matrix room via the client-server API; also `login` (password → access token) and `test` | Homeserver + room from `~/.config/linux_post_install/matrix.env` (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_ROOM_ID`, chmod 600, secrets masked by `pos config matrix`). Precedence: `--room` flag > env > config file | | `pos communication matrix listener` | `bin/pos-communication-matrix-listener` | Matrix listener: map `/command` → bash commands and run them from room messages; interactive editor for the map | Same `matrix.env` (reacts to `MATRIX_USER_ID`'s own messages; watches `MATRIX_ROOM_ID` or all joined rooms). Map lives in `~/.config/linux_post_install/matrix_commands.env` (`/cmd=bash command` lines, chmod 600) | | `pos communication scrcpy [cmd]` | `bin/pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb: `devices`, `record`, `tcpip`, `connect`, `push`, `pull`, `screenshot`, `info` (bare = mirror) | `scrcpy.env` (`SCRCPY_SERIAL`, `SCRCPY_MAX_SIZE`, `SCRCPY_MAX_FPS`, `SCRCPY_BIT_RATE`, `SCRCPY_FULLSCREEN`, `SCRCPY_RECORD_DIR`, `SCRCPY_PUSH_TARGET`, `SCRCPY_EXTRA_FLAGS`) via `pos config scrcpy` | @@ -335,14 +335,18 @@ The bot token is a secret — it is stored only in `~/.config/linux_post_install |---------|----------| | `pos communication telegram listener` | Interactive editor for the `/command` → bash map (`a`dd / `e`dit / `r`emove / `t`est / `q`uit); test-runs run `bash -n` first and may execute the command live | | `pos communication telegram listener --status` | Shows service state (running/autostart), config + map file paths, and the mapped commands | -| `pos communication telegram listener --enable` | Installs + starts a systemd **user** service (`pos-telegram-listener.service`); the daemon polls `getUpdates` and runs mapped commands | +| `pos communication telegram listener --enable` | Installs + starts a systemd **user** service (`pos-telegram-listener.service`); the daemon polls `getUpdates` and runs mapped commands + text-prefix apps | | `pos communication telegram listener --disable` | Stops, disables, and removes the service | | `pos communication telegram listener --sync-commands` | Push the mapped `/commands` to the bot's `/` menu (`setMyCommands`) — also run automatically after every map edit, on `--enable`, and at daemon start | | `pos communication telegram listener --run` | Run the polling loop in the foreground (what the service executes) | -| `pos communication telegram listener prefix` | Show the AI-bridge trigger word (default `ai`) | -| `pos communication telegram listener prefix ` | Set the AI-bridge trigger word (one word of letters/digits/`-`/`_`); non-command messages starting with `` + space are answered by Gemini. Also editable via `pos config telegram` (`TELEGRAM_AI_PREFIX`); takes effect immediately, no daemon restart | +| `pos communication telegram listener prefix` | List the text-prefix map (`telegram_prefixes.env`: `=command` lines) plus the built-in Gemini bridge word | +| `pos communication telegram listener prefix ` | Show one mapping, or map `` to a command with `prefix ` — non-command messages ` ` run the command with `` appended as ONE quoted argument (e.g. `prefix opencode opencode` → "opencode check cpu" runs `opencode "check cpu"`). `prefix -r ` removes. Mapped words shadow the Gemini bridge; the bridge word itself (`TELEGRAM_AI_PREFIX`, default `ai`) is set via `pos config telegram` | -The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with ` ` — default `ai `, configurable via `pos communication telegram listener prefix ` or `pos config telegram` — is forwarded to Gemini via `pos ai gemini ask` (case-insensitive, e.g. `ai what is Nvidia` or `BOT what is Nvidia` with prefix `bot`) and the answer is replied verbatim; an AI failure replies the error. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. +The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command". + +**Text-prefix map** (`telegram_prefixes.env`, managed via the `prefix` verb): for apps, not bash snippets — a non-command message ` ` runs the mapped command with `` appended as ONE quoted argument, e.g. `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`. First match wins (file order), matching is case-insensitive and the word must be space-delimited (bare `` with no trailing space replies Unknown command). Values are syntax-checked on save; `@quiet ` values suppress the reply; the 120s timeout + empty→`OK` + `exit ` reply mirror the command map. Routing order on every non-command message: text-prefix map → AI bridge → `/command` map → "Unknown command". + +**AI bridge**: non-command text starting with ` ` — default `ai `, configurable via `pos config telegram` → `TELEGRAM_AI_PREFIX` (a text-prefix entry with the same word shadows it) — is forwarded to Gemini via `pos ai gemini ask` (case-insensitive, e.g. `ai what is Nvidia` or `BOT what is Nvidia` with prefix `bot`) and the answer is replied verbatim; an AI failure replies the error. Commands run as your user via `timeout bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. Map entries may carry an optional **description** shown in the bot's `/` menu: `/cmd::short description=bash command` (the description falls back to the bash command, truncated to ~40 chars, when omitted). After every add/edit/remove the command list is pushed to the bot via `setMyCommands`, so the menu stays in sync; an empty map clears the menu. Telegram only registers lowercase `[a-z0-9_]` names (1–32 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed. diff --git a/DOC/howto/ai.md b/DOC/howto/ai.md index bbd3565..e8b89b8 100644 --- a/DOC/howto/ai.md +++ b/DOC/howto/ai.md @@ -150,10 +150,18 @@ you: ai what is Nvidia bot: NVIDIA is a company best known for GPUs... ``` -The trigger word is configurable via `pos communication telegram listener -prefix ` (or `pos config telegram` → `TELEGRAM_AI_PREFIX`); it takes -effect immediately, so with prefix `bot` you'd message `bot what is Nvidia`. -`pos communication telegram listener prefix` shows the current value. +The trigger word is configurable via `pos config telegram` → +`TELEGRAM_AI_PREFIX` (default `ai`); it takes effect immediately, so with +prefix `bot` you'd message `bot what is Nvidia`. `pos communication telegram +listener prefix` shows the current value. + +The listener also has a generic **text-prefix map** (`telegram_prefixes.env`, +managed with `pos communication telegram listener prefix `) +that runs any app with the rest of the message as one argument — e.g. +`prefix opencode opencode` turns the message `opencode check cpu` into +`opencode "check cpu"`. Routing order per non-command message: text-prefix +map → AI bridge → `/command` map, so mapping a word in the prefix map +shadows the Gemini bridge for that word. The bridge lives in the Telegram listener's `handle_message` (it calls `pos ai ask`); only the owner chat is served, so your key stays private. diff --git a/DOC/howto/communication.md b/DOC/howto/communication.md index fbb19d5..02e0345 100644 --- a/DOC/howto/communication.md +++ b/DOC/howto/communication.md @@ -97,6 +97,21 @@ pos communication telegram listener --disable # remove it - **Runs as you:** mapped commands execute as your user with a 60s timeout, stdout + stderr are replied to the chat (truncated ~3800 chars; empty → `OK`). `sudo` inside a command needs a NOPASSWD rule. +- **Text-prefix map (apps):** `~/.config/linux_post_install/telegram_prefixes.env` + (chmod 600), one `=command` per line — a non-command message + ` ` runs the app with `` appended as ONE quoted argument, + e.g. `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`. + First match wins (file order), case-insensitive, word must be space-delimited. + Manage it with `pos communication telegram listener prefix ` + (bare `prefix` lists, `prefix ` shows one, `prefix -r ` removes): + ```bash + pos communication telegram listener prefix opencode opencode + # then message: opencode check cpu → runs opencode "check cpu" + pos communication telegram listener prefix ai "pos ai gemini ask --session telegram-\$TELEGRAM_CHAT_ID" + # overrides the built-in Gemini bridge for the word 'ai' + ``` + Routing order per non-command message: text-prefix map → AI bridge → + `/command` map → "Unknown command". - **`@quiet` prefix:** a map value starting with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification, so you don't get it twice. `pos system backup` self-notifies, so diff --git a/bin/pos-communication-telegram-listener b/bin/pos-communication-telegram-listener index 1ed4e36..1af62dc 100755 --- a/bin/pos-communication-telegram-listener +++ b/bin/pos-communication-telegram-listener @@ -1,12 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -# POS: communication telegram-listener — Telegram bot listener: map /command → bash, run them on chat messages +# POS: communication telegram-listener — Telegram bot listener: map /command → bash and → app, run them on chat messages # POS_FLAGS: --enable --disable --status --sync-commands --run # POS_SUBCMDS: prefix CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}" CONFIG_FILE="$CONFIG_DIR/telegram.env" MAP_FILE="$CONFIG_DIR/telegram_commands.env" +PREFIX_FILE="$CONFIG_DIR/telegram_prefixes.env" API="https://api.telegram.org" SERVICE="pos-telegram-listener.service" USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" @@ -14,8 +15,8 @@ USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" # System prompt for the " " AI bridge (default prefix: "ai"): replies # are posted straight into the chat, so ask for concise, emoji-friendly # Telegram-style answers. The trigger word is configurable via -# TELEGRAM_AI_PREFIX in telegram.env ('pos communication telegram listener -# prefix ' or 'pos config telegram'). +# TELEGRAM_AI_PREFIX in telegram.env ('pos config telegram', default 'ai'); +# the text-prefix map checked before it can override any word. AI_SYSTEM="You are a friendly assistant chatting in a Telegram chat. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself." err() { echo "ERROR: $*" >&2; exit 1; } @@ -36,15 +37,25 @@ Commands: --sync-commands Push the mapped /commands to the bot's "/" menu (setMyCommands) --run Run the polling loop in the foreground (used by the service) - prefix [word] - Show (no argument) or set the AI-bridge trigger word — any - non-command message starting with ' ' is answered by - Gemini (default 'ai'). Restore the default with 'prefix ai'. + prefix [word [command...]] + Manage the text-prefix map (telegram_prefixes.env): any + non-command message ' ' runs the mapped command + with as ONE argument. Bare: list; : show one; + : map (e.g. 'prefix opencode opencode' → + "opencode check cpu" runs 'opencode "check cpu"'); + -r : remove. Built-in Gemini bridge word (default 'ai') + is set via 'pos config telegram' (TELEGRAM_AI_PREFIX). Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_AI_PREFIX — edit with 'pos config telegram') Map: $MAP_FILE — '/cmd=bash command' per line (optional '/cmd::short description=bash command' shown in the bot menu) +Prefix map: $PREFIX_FILE — '=command' per line: a non-command + message starting with ' ' runs the command with + appended as ONE quoted argument. First match wins (file order), + case-insensitive; a mapped word shadows the built-in Gemini 'ai' + bridge. Routing order: text-prefix map → AI bridge → /command map → + 'Unknown command'. Manage it with the 'prefix' verb. Prefix a map value with '@quiet ' to run the command without replying — for commands that already send their own notification, so you don't get it @@ -250,6 +261,105 @@ strip_quiet() { fi } +# ── text-prefix map (PREFIX_FILE) ─────────────────────────────── +# Lines: =command. Unlike the /command map (exact match on the whole +# message), a matching at the START of a non-command message routes +# the REST of the message to the mapped command as ONE quoted argument: +# 'opencode=opencode' + message "opencode check cpu" → 'opencode "check cpu"' +# The map is re-read per message (edits apply without restarting), matching +# is case-insensitive, the FIRST matching line wins (file order), a bare +# with no trailing space does NOT match, and a mapped word shadows the +# built-in Gemini bridge in handle_message. + +prefix_map_find() { + [ -f "$PREFIX_FILE" ] || return 1 + local text="$1" line word cmd rem + shopt -s nocasematch + while IFS= read -r line; do + case "$line" in \#*|'') continue ;; esac + word="${line%%=*}" + cmd="${line#*=}" + word="${word# }" + cmd="${cmd# }" + [[ -n "$word" && -n "$cmd" ]] || continue + if [[ "$text" =~ ^"$word"[[:space:]](.*)$ ]]; then + rem="${BASH_REMATCH[1]}" + [ -n "$rem" ] || continue + shopt -u nocasematch + printf '%s\x1f%s\n' "$cmd" "$rem" + return 0 + fi + done < "$PREFIX_FILE" + shopt -u nocasematch + return 1 +} + +prefix_map_set() { + local word="$1" cmd="$2" + if ! [[ "$word" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then + err "invalid prefix '$word' — use one word of letters, digits, '-' or '_' (no spaces)" + fi + local errs + errs="$(check_syntax "$cmd" 2>&1)" || err "invalid command for '$word': $errs" + mkdir -p "$CONFIG_DIR" + touch "$PREFIX_FILE" + chmod 600 "$PREFIX_FILE" + local tmp + tmp="$(mktemp)" + awk -v k="$word" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp" + printf '%s=%s\n' "$word" "$cmd" >> "$tmp" + mv "$tmp" "$PREFIX_FILE" + chmod 600 "$PREFIX_FILE" +} + +prefix_map_del() { + [ -f "$PREFIX_FILE" ] || return 1 + local tmp + tmp="$(mktemp)" + awk -v k="$1" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp" + if cmp -s "$tmp" "$PREFIX_FILE"; then + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$PREFIX_FILE" + chmod 600 "$PREFIX_FILE" + return 0 +} + +prefix_map_show() { + local word="$1" line key + [ -f "$PREFIX_FILE" ] || return 1 + while IFS= read -r line; do + case "$line" in \#*|'') continue ;; esac + key="${line%%=*}" + if [ "$key" = "$word" ]; then + printf '%s -> %s\n' "$key" "${line#*=}" + return 0 + fi + done < "$PREFIX_FILE" + return 1 +} + +# Run a mapped command line and reply with its output: empty output → "OK", +# non-zero exit → "exit " + output; quiet=1 suppresses the reply (for +# '@quiet ' entries that self-notify). Used by the /command map (60s cap) +# and the text-prefix bridge (120s cap for app calls). +run_and_reply() { + local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}" output rc + if output="$(timeout "$tmo" bash -c "$cmdline" 2>&1)"; then + rc=0 + else + rc=$? + fi + [ "$quiet" -eq 1 ] && return + [ -n "$output" ] || output="OK" + if [ "$rc" -ne 0 ]; then + reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc" + else + reply "$output" "$msg_id" + fi +} + ui_run_command() { local value="$1" output rc value="$(strip_quiet "$value")" @@ -426,6 +536,7 @@ status() { fi echo "config: $CONFIG_FILE" echo "map file: $MAP_FILE" + echo "prefix map: $PREFIX_FILE" load_config echo "ai prefix: ${TELEGRAM_AI_PREFIX:-ai}" load_map @@ -434,34 +545,76 @@ status() { for ((i=1; i<=MAP_N; i++)); do printf ' %-16s -> %s%s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" done + if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then + local pline pword pcmd pn=0 + echo "prefixes: (' ' routes to the mapped app)" + while IFS= read -r pline; do + case "$pline" in \#*|'') continue ;; esac + pword="${pline%%=*}" pcmd="${pline#*=}" + pn=$((pn + 1)) + printf ' %-16s -> %s\n' "$pword" "$pcmd" + done < "$PREFIX_FILE" + [ "$pn" -gt 0 ] || echo " (none)" + else + echo "prefixes: (none)" + fi } -# ── AI-bridge trigger word (TELEGRAM_AI_PREFIX) ───────────────── -# Show or set the word that starts the AI bridge in handle_message. -# Default 'ai'; a word is [A-Za-z0-9][A-Za-z0-9_-]* (no spaces, no pattern -# metacharacters — the match is a literal, case-insensitive prefix). +# ── text-prefix map + built-in Gemini bridge word (prefix verb) ── +# 'prefix' manages PREFIX_FILE (word → command). The built-in Gemini bridge +# word (TELEGRAM_AI_PREFIX, default 'ai') is set via 'pos config telegram'; +# it is only reached when no text-prefix entry matches first. prefix_cmd() { - local val="${1:-}" - if [ -z "$val" ]; then + local arg="${1:-}" rest="${*:2}" + if [ -z "$arg" ]; then load_config - echo "AI bridge prefix: ${TELEGRAM_AI_PREFIX:-ai}" - echo "Non-command messages starting with ' ' are answered by Gemini." - echo "Set it with: pos communication telegram listener prefix (default: ai)" + echo "Text-prefix map ($PREFIX_FILE): messages like ' ' run the" + echo "mapped command with passed as ONE argument. First match wins" + echo "(file order), case-insensitive; a mapped word shadows the built-in" + echo "Gemini bridge. A bare with no trailing space does not match." + echo + if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then + local line word cmd + while IFS= read -r line; do + case "$line" in \#*|'') continue ;; esac + word="${line%%=*}" cmd="${line#*=}" + printf ' %-16s -> %s\n' "$word" "$cmd" + done < "$PREFIX_FILE" + else + echo " (none)" + fi + echo + echo "Built-in Gemini bridge word: ${TELEGRAM_AI_PREFIX:-ai} (set via 'pos config telegram')" + echo + echo "Usage:" + echo " prefix list this map + the Gemini bridge word" + echo " prefix show one mapping" + echo " prefix map to a command" + echo " prefix -r remove a mapping" + echo "Examples:" + echo " pos communication telegram listener prefix opencode opencode" + echo " # then sending 'opencode check cpu' runs: opencode \"check cpu\"" return 0 fi - if ! [[ "$val" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then - err "invalid prefix '$val' — use one word of letters, digits, '-' or '_' (no spaces)" + case "$arg" in + -r|--remove) + [ -n "$rest" ] || err "usage: prefix -r " + if prefix_map_del "$rest"; then + log "removed prefix mapping '$rest'" + else + warn "no prefix mapping for '$rest'" + fi + return 0 ;; + esac + if [ -z "$rest" ]; then + prefix_map_show "$arg" && return 0 + warn "no prefix mapping for '$arg'" + echo "Map one with: prefix (e.g. prefix opencode opencode)" + echo "The built-in Gemini bridge word is set via 'pos config telegram' (TELEGRAM_AI_PREFIX)." + return 1 fi - mkdir -p "$CONFIG_DIR" - touch "$CONFIG_FILE" - chmod 600 "$CONFIG_FILE" - local tmp - tmp="$(mktemp)" - grep -vE '^TELEGRAM_AI_PREFIX=' "$CONFIG_FILE" > "$tmp" || true - printf 'TELEGRAM_AI_PREFIX=%s\n' "$val" >> "$tmp" - mv "$tmp" "$CONFIG_FILE" - chmod 600 "$CONFIG_FILE" - log "AI bridge prefix set to '$val' — messages starting with '$val ' go to Gemini (takes effect immediately, no restart)" + prefix_map_set "$arg" "$rest" + log "prefix '$arg' -> '$rest' — sending '<$arg> ' runs: $rest \"\" (takes effect immediately, no restart)" } # Current AI-bridge trigger word. Like the command map, re-read per message so @@ -503,19 +656,38 @@ strip_markdown() { } handle_message() { - local text="$1" msg_id="$2" reply_text="${3:-}" value output rc quiet=0 + local text="$1" msg_id="$2" reply_text="${3:-}" value quiet=0 case "$text" in /help|/start) reply "Mapped commands: $(map_cmds_list)" "$msg_id" return ;; esac + # Text-prefix bridge: ' ' runs the mapped command with + # passed as ONE quoted argument (telegram_prefixes.env), e.g. + # 'opencode=opencode' → sending "opencode check cpu" runs + # 'opencode "check cpu"'. Re-read per message, first match wins (file + # order), case-insensitive; a mapped word shadows the built-in Gemini + # bridge below. A bare with no trailing space does not match. + local pv cmd t qtext + if pv="$(prefix_map_find "$text")"; then + cmd="${pv%%$'\x1f'*}" + t="${pv#*$'\x1f'}" + if [ "${cmd#"$QUIET_PREFIX "}" != "$cmd" ]; then + quiet=1 + cmd="${cmd#"$QUIET_PREFIX "}" + fi + qtext="$(printf '%q' "$t")" + log "prefix: $text" + run_and_reply "$cmd $qtext" "$msg_id" 120 "$quiet" + return + fi # AI bridge: non-command text starting with " " (default "ai", - # case-insensitive, configurable via 'pos communication telegram listener - # prefix ' / TELEGRAM_AI_PREFIX) is forwarded to Gemini; the model's - # answer is replied verbatim. Each chat gets its own persistent memory - # session ("telegram-"); the exact prompt " /reset" - # clears it. Future non-command intents (e.g. reminders) slot in as more - # case arms here. + # case-insensitive, configurable via TELEGRAM_AI_PREFIX in 'pos config + # telegram') is forwarded to Gemini; the model's answer is replied + # verbatim. Each chat gets its own persistent memory session + # ("telegram-"); the exact prompt " /reset" clears it. + # Future non-command intents (e.g. reminders) slot in as more case arms + # here. local prefix prefix="$(ai_bridge_prefix)" shopt -s nocasematch @@ -555,20 +727,7 @@ handle_message() { value="${value#"$QUIET_PREFIX "}" fi log "exec: $text" - if output="$(timeout 60 bash -c "$value" 2>&1)"; then - rc=0 - else - rc=$? - fi - [ "$quiet" -eq 1 ] && return - if [ -z "$output" ]; then - output="OK" - fi - if [ "$rc" -ne 0 ]; then - reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc" - else - reply "$output" "$msg_id" - fi + run_and_reply "$value" "$msg_id" 60 "$quiet" } run_daemon() { @@ -617,7 +776,7 @@ case "${1:-}" in --status) status ;; --sync-commands) sync_bot_commands ;; --run) run_daemon ;; - prefix) prefix_cmd "${2:-}" ;; + prefix) prefix_cmd "${@:2}" ;; "") ui ;; *) err "Unknown option '$1' (see --help)" ;; esac