feat: telegram listener text-prefix map — <word> <text> routes to a mapped app
gates / consistency-and-conventions (push) Successful in 1m57s

This commit is contained in:
Your Name
2026-08-29 11:01:35 -04:00
parent e6fa0a4ee9
commit 99f87e6042
6 changed files with 249 additions and 62 deletions
+1
View File
@@ -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/<name>` 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 <names>` 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=<tag>` 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 60120 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 `<prefix> ` (case-insensitive, literal match) are forwarded to Gemini. New `prefix` verb: `pos communication telegram listener prefix` shows the current word, `prefix <word>` 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 `<word> <text>` to a mapped command with `<text>` 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 <rc>` reply, syntax-checked on save, first-file-match wins, case-insensitive, word must be space-delimited so bare `<word>` 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 <word> <command...>` = map (validated `[A-Za-z0-9][A-Za-z0-9_-]*`, `bash -n` via check_syntax); `prefix <word>` = show one; `prefix -r <word>` = 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.
+3 -3
View File
@@ -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 <prefix> → 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 <prefix> → 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 <prefix> → 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) |
+9 -5
View File
@@ -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 `<prefix>` → 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` (`<word>=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 <word>` | Set the AI-bridge trigger word (one word of letters/digits/`-`/`_`); non-command messages starting with `<word>` + 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`: `<word>=command` lines) plus the built-in Gemini bridge word |
| `pos communication telegram listener prefix <word>` | Show one mapping, or map `<word>` to a command with `prefix <word> <command...>` — non-command messages `<word> <text>` run the command with `<text>` appended as ONE quoted argument (e.g. `prefix opencode opencode` → "opencode check cpu" runs `opencode "check cpu"`). `prefix -r <word>` 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 `<prefix> ` — default `ai `, configurable via `pos communication telegram listener prefix <word>` 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 `<word> <text>` runs the mapped command with `<text>` 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 `<word>` with no trailing space replies Unknown command). Values are syntax-checked on save; `@quiet ` values suppress the reply; the 120s timeout + empty→`OK` + `exit <rc>` 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 `<word> ` — 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 (132 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed.
+12 -4
View File
@@ -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 <word>` (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 <word> <command...>`)
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.
+15
View File
@@ -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 `<word>=command` per line — a non-command message
`<word> <text>` runs the app with `<text>` 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 <word> <command...>`
(bare `prefix` lists, `prefix <word>` shows one, `prefix -r <word>` 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
+209 -50
View File
@@ -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 <prefix> → 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 "<prefix> " 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 <word>' 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 '<word> ' 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 '<prefix> <text>' runs the mapped command
with <text> as ONE argument. Bare: list; <word>: show one;
<word> <command...>: map (e.g. 'prefix opencode opencode' →
"opencode check cpu" runs 'opencode "check cpu"');
-r <word>: 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 — '<word>=command' per line: a non-command
message starting with '<word> <text>' runs the command with <text>
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: <word>=command. Unlike the /command map (exact match on the whole
# message), a matching <word> 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
# <word> 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 <rc>" + 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: ('<prefix> <text>' 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 '<prefix> ' are answered by Gemini."
echo "Set it with: pos communication telegram listener prefix <word> (default: ai)"
echo "Text-prefix map ($PREFIX_FILE): messages like '<prefix> <text>' run the"
echo "mapped command with <text> passed as ONE argument. First match wins"
echo "(file order), case-insensitive; a mapped word shadows the built-in"
echo "Gemini bridge. A bare <prefix> 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 <word> show one mapping"
echo " prefix <word> <command...> map <word> to a command"
echo " prefix -r <word> 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 <word>"
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 <word> <command...> (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> <text>' runs: $rest \"<text>\" (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: '<prefix> <text>' runs the mapped command with
# <text> 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 <prefix> 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 "<prefix> " (default "ai",
# case-insensitive, configurable via 'pos communication telegram listener
# prefix <word>' / TELEGRAM_AI_PREFIX) is forwarded to Gemini; the model's
# answer is replied verbatim. Each chat gets its own persistent memory
# session ("telegram-<chat_id>"); the exact prompt "<prefix> /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-<chat_id>"); the exact prompt "<prefix> /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