From 498979a78ddee96147fa3d0d2935f5906f510e95 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 9 Aug 2026 09:54:05 +0000 Subject: [PATCH] feat: telegram listener pushes command list to bot menu via setMyCommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map entries may now carry a menu description (/cmd::desc=bash) shown in the bot's "/" menu; it falls back to the bash command (~40 chars) when omitted. The command list is pushed automatically after every map edit, on --enable, and at daemon start, plus a manual --sync-commands flag (wired into POS_FLAGS completion). Names are validated against Telegram's lowercase [a-z0-9_] rule — invalid ones are skipped from the menu with a warning but still resolve when typed; an empty map clears the menu. Fixes found by unit-testing the sync path: map_has always returned false (awk END{exit 1} overrode the match), map_set had a local cross-reference that broke under set -u, and warn() leaked into the generated JSON (now stderr). Docs + AGENT_TODO updated; make gen && make check pass. --- AGENT_TODO.md | 2 + DOC/AGENT_Context_Project.md | 2 +- DOC/POS.md | 3 + DOC/howto/communication.md | 8 ++ bin/pos-communication-telegram-listener | 125 +++++++++++++++++++----- completions/pos.bash | 2 +- 6 files changed, 113 insertions(+), 29 deletions(-) diff --git a/AGENT_TODO.md b/AGENT_TODO.md index c0b0557..bd57470 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -16,6 +16,8 @@ summary (newest last). ## Done +- **2026-08-09** — Telegram listener pushes its mapped `/commands` to the bot's `/` menu via `setMyCommands` (auto after every map edit, on `--enable`, and at daemon start; manual `--sync-commands` flag). Map lines may carry a menu description: `/cmd::short description=bash command` (falls back to the bash command, ~40 chars). Names are validated against Telegram's lowercase `[a-z0-9_]` rule — invalid ones are skipped from the menu with a warning but still resolve when typed; empty map clears the menu. Fixed latent bugs found by the sync work: `map_has` (awk `END{exit 1}` overrode the match), and `warn()` went to stdout so it leaked into the generated JSON (now stderr). + - **2026-08-09** — `pos config ` scope completion is now cached at gen time (`_pos_config_scopes` array emitted by `make gen` from the `# POS_CONFIG:` registry) instead of scanning ~40 tools per TAB — a per-keypress subshell storm that wedged interactive shells for minutes on the loaded homelab box. Two stuck `-bash` sessions (69%/38% CPU) killed. `plugin_marker`/`plugin_keys` hardened with `|| true` so `config_keys` no longer aborts mid-scan under `set -euo pipefail` on mixed lib/plugin dirs (installed layout) — fixes missing plugin keys in `pos config entertainment`. - **2026-08-09** — `pos config ` interactive config editor: reads the `# POS_CONFIG:` registry across tools into a single runtime config (`~/.config/linux_post_install/*.env`, one file per scope, chmod 600); secret masking with show/hide toggle, `digits:`/`num:`/`url:` validation, `-` to clear, blank keeps; `*plugins` marker expands plugin vars (entertainment) from `entertainment-lib.sh`; `desc::example` value-format hints shown in the editor; gen-docs now handles category-less tools (`pos-config`), fixed a `set -e`+`pipefail` bug that truncated the header registry. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 5662d7e..2255b63 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -553,7 +553,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) | | `bin/pos` | 270 | CLI dispatcher with smart arg matching + logging + category help | -| `bin/pos-communication-telegram-listener` | 437 | Telegram bot listener: map /command → bash, run them on chat messages | +| `bin/pos-communication-telegram-listener` | 508 | Telegram bot listener: map /command → bash, run them on chat messages | | `bin/pos-communication-telegram-sender` | 270 | Send Telegram messages/files/links/stickers via Bot API (send, test, config set) | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-docker-compose` | 366 | Docker Compose service manager (ls/up/down/restart/logs/update/config) | diff --git a/DOC/POS.md b/DOC/POS.md index 7dfeaf9..4dc7ea4 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -224,10 +224,13 @@ The bot token is a secret — it is stored only in `~/.config/linux_post_install | `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 --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) | 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". 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. `/status=@quiet pos system health --send`), avoiding 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. + ### entertainment **File:** `bin/pos-entertainment-send` diff --git a/DOC/howto/communication.md b/DOC/howto/communication.md index 8a4395d..e86151d 100644 --- a/DOC/howto/communication.md +++ b/DOC/howto/communication.md @@ -79,6 +79,14 @@ pos communication telegram listener --disable # remove it /temp=sensors | grep -i 'Tctl\|package id 0' /update=cd /path/to/repo && git pull ``` +- **Bot command menu:** the mapped commands are pushed to the bot's `/` menu + (`setMyCommands`) after every map edit, on `--enable`, and at daemon start + (force it anytime with `--sync-commands`). Add a short description with the + `/cmd::description=bash command` syntax — e.g. + `/backup::Encrypted nightly backup=@quiet pos system backup --send` — or it + falls back to the bash command. Telegram only registers lowercase `[a-z0-9_]` + names (1–32 chars); `/Status` or `/my-cmd` are skipped from the menu but still + work when typed. An empty map clears the menu. - **Owner-only:** the bot only reacts to `TELEGRAM_CHAT_ID` (your own chat); others are ignored. `/help` lists mapped commands; unknown → "Unknown command". - **Runs as you:** mapped commands execute as your user with a 60s timeout, diff --git a/bin/pos-communication-telegram-listener b/bin/pos-communication-telegram-listener index 1a7d1d6..3c010a5 100755 --- a/bin/pos-communication-telegram-listener +++ b/bin/pos-communication-telegram-listener @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail # POS: communication telegram-listener — Telegram bot listener: map /command → bash, run them on chat messages -# POS_FLAGS: --enable --disable --status --run +# POS_FLAGS: --enable --disable --status --sync-commands --run CONFIG_DIR="$HOME/.config/linux_post_install" CONFIG_FILE="$CONFIG_DIR/telegram.env" @@ -12,7 +12,7 @@ USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" err() { echo "ERROR: $*" >&2; exit 1; } log() { echo "[+] $*"; } -warn() { echo "[!] $*"; } +warn() { echo "[!] $*" >&2; } usage() { cat < "$tmp" - printf '%s=%s\n' "$cmd" "$value" >> "$tmp" + [ -n "$desc" ] && key="${cmd}::${desc}" + awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp" + printf '%s=%s\n' "$key" "$value" >> "$tmp" mv "$tmp" "$MAP_FILE" chmod 600 "$MAP_FILE" } @@ -104,20 +123,20 @@ map_del() { [ -f "$MAP_FILE" ] || return 0 local cmd="$1" tmp tmp="$(mktemp)" - awk -v k="$cmd" 'index($0, k "=") != 1 { print }' "$MAP_FILE" > "$tmp" + awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp" mv "$tmp" "$MAP_FILE" chmod 600 "$MAP_FILE" } -MAP_CMDS=(); MAP_VALS=(); MAP_N=0 +MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0 load_map() { - MAP_CMDS=(); MAP_VALS=(); MAP_N=0 + MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0 [ -f "$MAP_FILE" ] || return 0 - local i=0 line cmd value - while IFS='|' read -r cmd value; do + local i=0 line cmd desc value + while IFS='|' read -r cmd desc value; do [ -n "$cmd" ] || continue i=$((i + 1)) - MAP_CMDS[$i]="$cmd"; MAP_VALS[$i]="$value" + MAP_CMDS[$i]="$cmd"; MAP_DESCS[$i]="$desc"; MAP_VALS[$i]="$value" done <<< "$(map_entries)" MAP_N="$i" } @@ -132,6 +151,51 @@ map_cmds_list() { printf '%s' "${out:-none}" } +# ── Bot command menu sync (setMyCommands) ────────────────────── +# Push the mapped /commands to the bot's "/" menu so they show in the +# Telegram UI, not just resolve when typed. Runs after map edits, on +# --enable, and at daemon start; force it with --sync-commands. + +description_for() { + local value="$1" desc="${2:-}" + if [ -n "$desc" ]; then + printf '%s' "$desc" + return + fi + value="$(strip_quiet "$value")" + printf '%s' "${value:0:40}" +} + +build_commands_json() { + load_map + local i name desc + for ((i=1; i<=MAP_N; i++)); do + name="${MAP_CMDS[$i]#/}" + if ! [[ "$name" =~ ^[a-z][a-z0-9_]{0,31}$ ]]; then + warn "skip '$name' from bot menu — Telegram commands are lowercase [a-z0-9_], 1-32 chars" + continue + fi + desc="$(description_for "${MAP_VALS[$i]}" "${MAP_DESCS[$i]}")" + printf '%s\t%s\n' "$name" "$desc" + done | jq -Rr 'split("\t") | {command: .[0], description: .[1]}' | jq -sc '.' +} + +sync_bot_commands() { + load_config + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || { warn "no bot token — run 'pos communication telegram config set TELEGRAM_BOT_TOKEN=...' first"; return 1; } + command -v jq &>/dev/null || { warn "jq not found — cannot sync bot commands"; return 1; } + local json n + json="$(build_commands_json)" + n="$(printf '%s' "$json" | jq 'length')" + if curl -fsS -m 30 -X POST "${API}/bot${TELEGRAM_BOT_TOKEN}/setMyCommands" \ + --data-urlencode "commands=${json}" >/dev/null 2>&1; then + log "bot command menu updated ($n commands)" + return 0 + fi + warn "setMyCommands failed" + return 1 +} + check_syntax() { bash -n -c "$(strip_quiet "$1")" 2>&1 } @@ -146,7 +210,7 @@ ui_pick() { echo local i for ((i=1; i<=MAP_N; i++)); do - printf ' %2d) %-16s -> %s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" + printf ' %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" done local idx read -rp "Entry number: " idx @@ -188,7 +252,7 @@ ui_run_command() { } ui_add() { - local cmd value out + local cmd value desc out read -rp "/command name (e.g. /status): " cmd [ -n "$cmd" ] || { warn "empty command name"; return; } case "$cmd" in @@ -197,8 +261,10 @@ ui_add() { esac read -rp "bash command: " value [ -n "$value" ] || { warn "empty bash command"; return; } + read -rp "description (optional, shown in the bot menu): " desc if out="$(check_syntax "$value")"; then - map_set "$cmd" "$value" + map_set "$cmd" "$value" "$desc" + sync_bot_commands || true log "saved $cmd -> $value" else warn "syntax error — not saved:" @@ -213,14 +279,16 @@ ui_add() { } ui_edit() { - local idx cmd value out + local idx cmd value desc out idx="$(ui_pick)" || return cmd="${MAP_CMDS[$idx]}" echo "Editing: $cmd -> ${MAP_VALS[$idx]}" read -rp "bash command: " value [ -n "$value" ] || { warn "empty bash command"; return; } + read -rp "description (current: ${MAP_DESCS[$idx]:-none}): " desc if out="$(check_syntax "$value")"; then - map_set "$cmd" "$value" + map_set "$cmd" "$value" "$desc" + sync_bot_commands || true log "updated $cmd -> $value" else warn "syntax error — not saved:" @@ -234,7 +302,7 @@ ui_remove() { cmd="${MAP_CMDS[$idx]}" read -rp "Remove '$cmd'? [y/N] " yn case "$yn" in - y|Y) map_del "$cmd"; log "removed $cmd" ;; + y|Y) map_del "$cmd"; sync_bot_commands || true; log "removed $cmd" ;; *) warn "canceled" ;; esac } @@ -257,7 +325,7 @@ ui() { else local i for ((i=1; i<=MAP_N; i++)); do - printf ' %2d) %-16s -> %s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" + printf ' %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" done fi echo @@ -307,6 +375,7 @@ EOF systemctl --user daemon-reload systemctl --user enable --now "$SERVICE" log "listener service enabled: $SERVICE" + sync_bot_commands || true warn "commands run as $(id -un) — 'sudo' inside them needs a NOPASSWD rule" if command -v loginctl >/dev/null 2>&1; then if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then @@ -343,7 +412,7 @@ status() { echo "commands: $MAP_N mapped" local i for ((i=1; i<=MAP_N; i++)); do - printf ' %-16s -> %s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" + printf ' %-16s -> %s%s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" done } @@ -395,6 +464,7 @@ run_daemon() { load_config [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos communication telegram config set TELEGRAM_BOT_TOKEN=...'" [ -n "${TELEGRAM_CHAT_ID:-}" ] || err "No chat id — run 'pos communication telegram config set TELEGRAM_CHAT_ID=...'" + sync_bot_commands || true local offset=0 log "listener running (owner chat ${TELEGRAM_CHAT_ID}) — Ctrl+C to stop" @@ -431,6 +501,7 @@ case "${1:-}" in --enable) enable_service ;; --disable) disable_service ;; --status) status ;; + --sync-commands) sync_bot_commands ;; --run) run_daemon ;; "") ui ;; *) err "Unknown option '$1' (see --help)" ;; diff --git a/completions/pos.bash b/completions/pos.bash index 3c07a38..313ba12 100644 --- a/completions/pos.bash +++ b/completions/pos.bash @@ -3,7 +3,7 @@ # Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/ # GEN:START posflags declare -A _pos_flags -_pos_flags[communication-telegram-listener]="--enable --disable --status --run" +_pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run" _pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown" _pos_flags[entertainment-send]="--print --markdown" _pos_flags[network-hotspot]="--foreground"