feat: telegram listener pushes command list to bot menu via setMyCommands

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.
This commit is contained in:
Your Name
2026-08-09 09:54:05 +00:00
parent 95917607cf
commit 498979a78d
6 changed files with 113 additions and 29 deletions
+2
View File
@@ -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 <TAB>` 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 <scope>` 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.
+1 -1
View File
@@ -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) |
<!-- GEN:START filetable -->
| `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) |
+3
View File
@@ -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 (132 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`
+8
View File
@@ -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 (132 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,
+98 -27
View File
@@ -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 <<EOF
@@ -25,10 +25,13 @@ Commands:
--enable Install + start the systemd user service (autostarts on login)
--disable Stop + disable + remove the service
--status Show service state and the command map
--sync-commands
Push the mapped /commands to the bot's "/" menu (setMyCommands)
--run Run the polling loop in the foreground (used by the service)
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID)
Map: $MAP_FILE — one '/cmd=bash command' per line
Map: $MAP_FILE — '/cmd=bash command' per line (optional
'/cmd::short description=bash command' shown in the bot menu)
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
@@ -42,6 +45,11 @@ The listener only reacts to the owner chat (TELEGRAM_CHAT_ID). Commands run
as your user, so 'sudo' inside them needs a NOPASSWD rule. The interactive
editor runs 'bash -n' to syntax-check commands before saving.
The command list is pushed to the bot's "/" menu (setMyCommands) after every
map edit, on --enable, and at daemon start; force it anytime with
--sync-commands. Telegram only accepts lowercase [a-z0-9_] names — any other
command is skipped from the menu but still resolves when typed.
Examples:
pos communication telegram listener
pos communication telegram listener --enable
@@ -67,35 +75,46 @@ load_config() {
}
# ── command map (MAP_FILE) ──────────────────────────────────────
# Lines: /cmd=bash command. Keys keep the leading slash; read via awk so
# values may contain '='. The map is re-read per message — edits apply
# without restarting the listener.
# Lines: /cmd=bash command, or /cmd::description=bash command. Keys keep the
# leading slash; read via awk so values may contain '='. The map is re-read
# per message — edits apply without restarting the listener.
map_entries() {
[ -f "$MAP_FILE" ] || return 0
grep -E '^/[A-Za-z0-9_.-]+=' "$MAP_FILE" | while IFS= read -r line; do
printf '%s|%s\n' "${line%%=*}" "${line#*=}"
grep -E '^/[A-Za-z0-9_.-]+(::.*)?=' "$MAP_FILE" | while IFS= read -r line; do
local left="${line%%=*}" cmd desc
if [[ "$left" == *::* ]]; then
cmd="${left%%::*}"
desc="${left#*::}"
else
cmd="$left"
desc=""
fi
printf '%s|%s|%s\n' "$cmd" "$desc" "${line#*=}"
done
}
map_has() {
[ -f "$MAP_FILE" ] && awk -F= -v k="$1" '$1==k{exit 0} END{exit 1}' "$MAP_FILE"
[ -f "$MAP_FILE" ] || return 1
awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {found=1} END{exit !found}' "$MAP_FILE"
}
map_get() {
[ -f "$MAP_FILE" ] || return 0
awk -F= -v k="$1" '$1==k{sub(/^[^=]*=/,""); print}' "$MAP_FILE"
awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {sub(/^[^=]*=/,""); print}' "$MAP_FILE"
}
map_set() {
local cmd="$1" value="$2"
local cmd="$1" value="$2" desc="${3:-}"
local key="$cmd"
mkdir -p "$CONFIG_DIR"
touch "$MAP_FILE"
chmod 600 "$MAP_FILE"
local tmp
tmp="$(mktemp)"
awk -v k="$cmd" 'index($0, k "=") != 1 { print }' "$MAP_FILE" > "$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)" ;;
+1 -1
View File
@@ -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"