feat: telegram listener text-prefix map — <word> <text> routes to a mapped app
gates / consistency-and-conventions (push) Successful in 2m3s
gates / consistency-and-conventions (push) Successful in 2m3s
Generalizes the Telegram listener with a configurable text-prefix map (telegram_prefixes.env): any 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". 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 <word> <cmd...>' = map; 'prefix <word>' = show; 'prefix -r <word>' = 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).
This commit is contained in:
@@ -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 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 `<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.
|
||||
|
||||
@@ -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
@@ -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 (1–32 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed.
|
||||
|
||||
|
||||
+12
-4
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user