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:
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)" ;;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user