fix: Telegram listener — async command execution + singleton guard
gates / consistency-and-conventions (push) Successful in 23s
gates / consistency-and-conventions (push) Successful in 23s
Root cause: run_and_reply() blocked the entire listener synchronously. FFmpeg hung because child processes inherited stdin (waiting for 'q'). Long-running commands froze the listener for up to 120s. Fix: - Commands run in background with stdin=/dev/null, output to temp file - reap_commands() collects output non-blocking after each getUpdates cycle - SIGCHLD handler pre-caches exit codes via wait -n - TERM/INT trap kills background processes and cleans temp files - Singleton guard (flock) prevents duplicate listeners racing getUpdates Tests: - t-telegram-listener-exec.sh: 12 hermetic checks (echo, pipes, stderr, compound commands, long-running, quiet mode) - t-telegram-listener-singleton.sh: 8 checks (lock acquire/release/status) Architect verdict: accepted as-is, no re-architecture needed.
This commit is contained in:
@@ -42,6 +42,8 @@ summary (newest last).
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
- **2026-09-09** — Telegram listener single-instance guard (Toolsmith): `bin/pos-communication-telegram-listener --run` now takes a `flock(1)` on `${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock` inside `run_daemon()` (before config load/sync/poll loop) — a second `--run` on the same token fails fast (exit 1, `ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener`), never racing getUpdates (Telegram 409/command stealing). Kernel auto-release → no stale-lock bookkeeping, systemd `Restart=always` restarts clean. `--status` first line now reports `listener: running (single instance lock held)` / `listener: not running` via the same `lock_held()` probe. `flock` dep guard added (`util-linux`). New regression `tests/t-telegram-listener-singleton.sh` (7 checks: first acquires+loops, second exits 1 with exact message, lock releases → third starts clean, status reports both states; stubbed curl/systemctl, sandboxed XDG_RUNTIME_DIR — hermetic, no network). Verified: `bash -n`, `make gen` ×2 byte-idempotent, `make check` OK, `make lint` 0 FAIL / 0 WARN, `make test` green, `git diff --check` clean.
|
||||||
|
|
||||||
- **2026-09-09** — Unified YouTube tools into `pos media yt` + new `subtitles` (Architect POS--9). New `bin/pos-media-yt` dispatcher (mp3/mp4/grab/ytsync/subtitles) + `bin/pos-media-yt-{mp3,mp4,grab,subtitles,ytsync}`; the ytsync file is a forwarder to the existing `pos media ytsync`; legacy `bin/pos-media-{mp3,mp4,grab}` became thin forwarders to the `yt` forms. New `lib/yt-lib.sh` (deps/URL-validation/echo/classify helpers; `classify_url` migrated from grab, `yt_validate_url` is a return-1 checker — never exits, so callers can prefix errors). `bin/pos` INTERACTIVE_CMDS += `media-yt-mp4` (interactive format pick reads stdin). `pos-media-yt-subtitles` extracts captions via `--write-subs --write-auto-subs --sub-langs best`, `--lang en,ar` (one `--sub-langs` arg), `srt|vtt|txt` (txt = srt→txt conversion stripping timestamps/HTML), `--auto-only`, `--list-subs` probe, `--output`, no-ffmpeg dep (yt-dlp only; dry-run skips deps entirely). Docs: DOC/POS.md media section rewritten (yt group + forwarder rows), DOC/howto/media.md `yt` commands + subtitles section, AGENT_Context hand-maintained `lib/yt-lib.sh` row, tests/README row. New `tests/t-pos-media-yt.sh` (72 checks: dispatcher/forwarder resolution, full `pos media mp3` dispatch chain, yt-lib helpers, per-tool flags/dry-run/`YT_OUT_DIR` seam/`GRAB_DEFAULT` config, 3 mandated negative controls — unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping, unavailable-subs detection). `tests/t-config-precedence.sh` Part D config-consumer list updated `pos-media-grab` → `pos-media-yt-grab`. Verified: `bash -n` all; `make gen` ×2 byte-idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` 21 files / 533 checks / 0 fail / 0 skip; `git diff --check` clean; smokes — `pos media yt --help`, `yt mp3/mp4/subtitles --help`, `yt ytsync --help` (reaches `pos media ytsync`), `pos media mp3 --help` forwarder, `pos tree` shows the `yt` subtree (with repo-first PATH; system `/usr/local/bin` has a stale pre-POS--9 install that shadows it otherwise).
|
- **2026-09-09** — Unified YouTube tools into `pos media yt` + new `subtitles` (Architect POS--9). New `bin/pos-media-yt` dispatcher (mp3/mp4/grab/ytsync/subtitles) + `bin/pos-media-yt-{mp3,mp4,grab,subtitles,ytsync}`; the ytsync file is a forwarder to the existing `pos media ytsync`; legacy `bin/pos-media-{mp3,mp4,grab}` became thin forwarders to the `yt` forms. New `lib/yt-lib.sh` (deps/URL-validation/echo/classify helpers; `classify_url` migrated from grab, `yt_validate_url` is a return-1 checker — never exits, so callers can prefix errors). `bin/pos` INTERACTIVE_CMDS += `media-yt-mp4` (interactive format pick reads stdin). `pos-media-yt-subtitles` extracts captions via `--write-subs --write-auto-subs --sub-langs best`, `--lang en,ar` (one `--sub-langs` arg), `srt|vtt|txt` (txt = srt→txt conversion stripping timestamps/HTML), `--auto-only`, `--list-subs` probe, `--output`, no-ffmpeg dep (yt-dlp only; dry-run skips deps entirely). Docs: DOC/POS.md media section rewritten (yt group + forwarder rows), DOC/howto/media.md `yt` commands + subtitles section, AGENT_Context hand-maintained `lib/yt-lib.sh` row, tests/README row. New `tests/t-pos-media-yt.sh` (72 checks: dispatcher/forwarder resolution, full `pos media mp3` dispatch chain, yt-lib helpers, per-tool flags/dry-run/`YT_OUT_DIR` seam/`GRAB_DEFAULT` config, 3 mandated negative controls — unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping, unavailable-subs detection). `tests/t-config-precedence.sh` Part D config-consumer list updated `pos-media-grab` → `pos-media-yt-grab`. Verified: `bash -n` all; `make gen` ×2 byte-idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` 21 files / 533 checks / 0 fail / 0 skip; `git diff --check` clean; smokes — `pos media yt --help`, `yt mp3/mp4/subtitles --help`, `yt ytsync --help` (reaches `pos media ytsync`), `pos media mp3 --help` forwarder, `pos tree` shows the `yt` subtree (with repo-first PATH; system `/usr/local/bin` has a stale pre-POS--9 install that shadows it otherwise).
|
||||||
|
|
||||||
- **2026-09-08** — `install.sh` version gate (Architect→Builder): skip+abort when installed version == current version, `--force` to bypass, version scheme `0.0c<git commit count>` (auto-bumps per commit). `install_version()` derives `0.0c$(git rev-list --count HEAD)`; empty when `.git` absent → gate skipped (silently); `INSTALL_VERSION_OVERRIDE` env var (presence-check) = test seam. Gate after arg-parse, before phases, numeric comparison (strip `0.0c`, `-eq`); `log "Already installed ($CURRENT_VERSION). Use --force to re-install."` / `--dry-run` → `(dry-run) Would skip install: already at version $CURRENT_VERSION`, both exit 0. `FORCE=0` init, `--force` parse + usage. `flag_set installed_version "$CURRENT_VERSION"` after "Bootstrap complete" banner (only when DRY_RUN≠1 and version non-empty; even under --force). New `tests/t-install-version.sh` (21 checks / 9 cases). Docs: README/SCRIPTS/AGENT_Context (flags, flow, line count 248→301, tests/README row). Verified: `bash -n` clean; `make gen` idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` suite green.
|
- **2026-09-08** — `install.sh` version gate (Architect→Builder): skip+abort when installed version == current version, `--force` to bypass, version scheme `0.0c<git commit count>` (auto-bumps per commit). `install_version()` derives `0.0c$(git rev-list --count HEAD)`; empty when `.git` absent → gate skipped (silently); `INSTALL_VERSION_OVERRIDE` env var (presence-check) = test seam. Gate after arg-parse, before phases, numeric comparison (strip `0.0c`, `-eq`); `log "Already installed ($CURRENT_VERSION). Use --force to re-install."` / `--dry-run` → `(dry-run) Would skip install: already at version $CURRENT_VERSION`, both exit 0. `FORCE=0` init, `--force` parse + usage. `flag_set installed_version "$CURRENT_VERSION"` after "Bootstrap complete" banner (only when DRY_RUN≠1 and version non-empty; even under --force). New `tests/t-install-version.sh` (21 checks / 9 cases). Docs: README/SCRIPTS/AGENT_Context (flags, flow, line count 248→301, tests/README row). Verified: `bash -n` clean; `make gen` idempotent; `make check` OK; `make lint` 0 FAIL / 0 WARN; `make test` suite green.
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
|||||||
| `bin/pos-communication-matrix-listener` | 582 | Matrix listener: map /command → bash, run them on room messages |
|
| `bin/pos-communication-matrix-listener` | 582 | Matrix listener: map /command → bash, run them on room messages |
|
||||||
| `bin/pos-communication-matrix-sender` | 215 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
| `bin/pos-communication-matrix-sender` | 215 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||||
| `bin/pos-communication-scrcpy` | 245 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
| `bin/pos-communication-scrcpy` | 245 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||||
| `bin/pos-communication-telegram-listener` | 815 | Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages |
|
| `bin/pos-communication-telegram-listener` | 889 | Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages |
|
||||||
| `bin/pos-communication-telegram-sender` | 212 | Send Telegram messages/files/links/stickers via Bot API (send, test) |
|
| `bin/pos-communication-telegram-sender` | 212 | 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-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) |
|
| `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ Commands:
|
|||||||
(none) Interactive editor for the /command → bash map
|
(none) Interactive editor for the /command → bash map
|
||||||
--enable Install + start the systemd user service (autostarts on login)
|
--enable Install + start the systemd user service (autostarts on login)
|
||||||
--disable Stop + disable + remove the service
|
--disable Stop + disable + remove the service
|
||||||
--status Show service state and the command map
|
--status Show service state (single-instance lock) and the command map
|
||||||
--sync-commands
|
--sync-commands
|
||||||
Push the mapped /commands to the bot's "/" menu (setMyCommands)
|
Push the mapped /commands to the bot's "/" menu (setMyCommands)
|
||||||
--run Run the polling loop in the foreground (used by the service)
|
--run Run the polling loop in the foreground (used by the service).
|
||||||
|
Single instance: only one --run may poll the bot token at a
|
||||||
|
time — a second --run exits immediately with an error.
|
||||||
prefix [word [command...]]
|
prefix [word [command...]]
|
||||||
Manage the text-prefix map (telegram_prefixes.env): any
|
Manage the text-prefix map (telegram_prefixes.env): any
|
||||||
non-command message '<prefix> <text>' runs the mapped command
|
non-command message '<prefix> <text>' runs the mapped command
|
||||||
@@ -337,24 +339,67 @@ prefix_map_show() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run a mapped command line and reply with its output: empty output → "OK",
|
# ── async command execution ─────────────────────────────────────
|
||||||
# non-zero exit → "exit <rc>" + output; quiet=1 suppresses the reply (for
|
# Commands run in the background so the listener never blocks. stdin is
|
||||||
# '@quiet ' entries that self-notify). Used by the /command map (60s cap)
|
# /dev/null (prevents interactive hangs — FFmpeg reading 'q', scripts
|
||||||
# and the text-prefix bridge (120s cap for app calls).
|
# waiting for prompts); stdout+stderr go to a temp file; output is collected
|
||||||
|
# and replied asynchronously from the main loop.
|
||||||
|
#
|
||||||
|
# PID → metadata arrays (populated by run_and_reply, drained by reap_commands)
|
||||||
|
declare -A _CMD_OUT _CMD_MSG _CMD_QUIET
|
||||||
|
# Exit codes stored by the SIGCHLD handler (wait -n) so reap_commands can
|
||||||
|
# retrieve them without calling blocking wait.
|
||||||
|
declare -A _EXIT_CODES
|
||||||
|
|
||||||
|
# run_and_reply <cmdline> <msg_id> [timeout] [quiet]
|
||||||
|
# Starts the command in the background and returns immediately. The main
|
||||||
|
# loop calls reap_commands after each getUpdates cycle to collect output
|
||||||
|
# and send replies.
|
||||||
run_and_reply() {
|
run_and_reply() {
|
||||||
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}" output rc
|
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}"
|
||||||
if output="$(timeout "$tmo" bash -c "$cmdline" 2>&1)"; then
|
local out_file
|
||||||
rc=0
|
out_file="$(mktemp /tmp/pos-cmd.XXXXXX)"
|
||||||
else
|
# stdin=/dev/null: prevents interactive hangs (FFmpeg 'q', read prompts).
|
||||||
rc=$?
|
# The child inherits nothing from the listener's own stdin.
|
||||||
fi
|
timeout "$tmo" bash -c "$cmdline" </dev/null >"$out_file" 2>&1 &
|
||||||
[ "$quiet" -eq 1 ] && return
|
local pid=$!
|
||||||
[ -n "$output" ] || output="OK"
|
_CMD_OUT[$pid]="$out_file"
|
||||||
if [ "$rc" -ne 0 ]; then
|
_CMD_MSG[$pid]="$msg_id"
|
||||||
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
|
_CMD_QUIET[$pid]="$quiet"
|
||||||
else
|
}
|
||||||
reply "$output" "$msg_id"
|
|
||||||
fi
|
# reap_commands — called from the main loop after each getUpdates cycle.
|
||||||
|
# Checks every tracked PID with kill -0 (non-blocking); when a process has
|
||||||
|
# exited, reads its output file and sends the reply. Never blocks the loop.
|
||||||
|
reap_commands() {
|
||||||
|
local pid
|
||||||
|
for pid in "${!_CMD_OUT[@]}"; do
|
||||||
|
# Non-blocking: has the process exited?
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
# Retrieve exit code (SIGCHLD handler stores it; fallback to wait).
|
||||||
|
local rc="${_EXIT_CODES[$pid]:-}"
|
||||||
|
if [ -n "$rc" ]; then
|
||||||
|
unset _EXIT_CODES[$pid]
|
||||||
|
else
|
||||||
|
wait "$pid" 2>/dev/null; rc=$?
|
||||||
|
fi
|
||||||
|
local out_file="${_CMD_OUT[$pid]}"
|
||||||
|
local msg_id="${_CMD_MSG[$pid]}"
|
||||||
|
local quiet="${_CMD_QUIET[$pid]}"
|
||||||
|
local output=""
|
||||||
|
[ -s "$out_file" ] && output="$(cat "$out_file" 2>/dev/null)"
|
||||||
|
rm -f "$out_file"
|
||||||
|
if [ "$quiet" -ne 1 ]; then
|
||||||
|
[ -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
|
||||||
|
fi
|
||||||
|
unset _CMD_OUT[$pid] _CMD_MSG[$pid] _CMD_QUIET[$pid]
|
||||||
|
fi
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
ui_run_command() {
|
ui_run_command() {
|
||||||
@@ -521,8 +566,8 @@ disable_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then
|
if lock_held; then
|
||||||
echo "listener: running"
|
echo "listener: running (single instance lock held)"
|
||||||
else
|
else
|
||||||
echo "listener: not running"
|
echo "listener: not running"
|
||||||
fi
|
fi
|
||||||
@@ -750,7 +795,31 @@ handle_message() {
|
|||||||
run_and_reply "$value" "$msg_id" 60 "$quiet"
|
run_and_reply "$value" "$msg_id" 60 "$quiet"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── single-instance guard ──────────────────────────────────────
|
||||||
|
# flock(1) on a runtime lockfile — the kernel drops the lock when the process
|
||||||
|
# dies, so there is no stale-lock/pidfile bookkeeping and the systemd
|
||||||
|
# Restart=always unit restarts cleanly. Two getUpdates loops on one bot token
|
||||||
|
# cause Telegram 409 conflicts and command stealing, so a second --run fails
|
||||||
|
# closed instead of racing the active listener.
|
||||||
|
LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock"
|
||||||
|
|
||||||
|
acquire_lock() {
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
flock -n 9 || err "listener already running (single instance) — check: systemctl --user status pos-telegram-listener"
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_held() {
|
||||||
|
# Non-blocking probe: acquiring then dropping the flock in a subshell
|
||||||
|
# succeeds only when nobody else holds it. Returns 0 when held.
|
||||||
|
if ( flock -n 9 ) 9>"$LOCK_FILE" 2>/dev/null; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
run_daemon() {
|
run_daemon() {
|
||||||
|
command -v flock &>/dev/null || err "flock not found (install util-linux)"
|
||||||
|
acquire_lock
|
||||||
command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)"
|
command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)"
|
||||||
load_config
|
load_config
|
||||||
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
|
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] || err "No bot token — run 'pos config telegram'"
|
||||||
@@ -764,7 +833,10 @@ run_daemon() {
|
|||||||
|
|
||||||
local offset=0
|
local offset=0
|
||||||
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}) — Ctrl+C to stop"
|
log "listener running (chat ${TELEGRAM_CHAT_ID}, owner ${TELEGRAM_OWNER_ID:-unset}) — Ctrl+C to stop"
|
||||||
trap 'kill $(jobs -p) 2>/dev/null; exit 0' TERM INT
|
# SIGCHLD: reap finished children and store their exit codes so
|
||||||
|
# reap_commands can retrieve them without blocking.
|
||||||
|
trap 'local _p; while _p=$(wait -n 2>/dev/null); do _EXIT_CODES[$_p]=$?; done' CHLD
|
||||||
|
trap 'kill $(jobs -p) 2>/dev/null; rm -f /tmp/pos-cmd.* 2>/dev/null; wait 2>/dev/null; exit 0' TERM INT
|
||||||
while true; do
|
while true; do
|
||||||
local resp n i
|
local resp n i
|
||||||
resp="$(curl -fsS -m 45 "${API}/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
|
resp="$(curl -fsS -m 45 "${API}/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
|
||||||
@@ -799,6 +871,8 @@ run_daemon() {
|
|||||||
fi
|
fi
|
||||||
handle_message "$text" "$msg_id" "$reply_text"
|
handle_message "$text" "$msg_id" "$reply_text"
|
||||||
done
|
done
|
||||||
|
# Collect output from finished background commands and send replies.
|
||||||
|
reap_commands
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,4 @@ silently.
|
|||||||
| `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison |
|
| `t-install-version.sh` | install.sh version gate: match→skip, mismatch→proceed, --force bypass, dry-run variant, flag write, numeric comparison |
|
||||||
| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards |
|
| `t-share-mountpoint.sh` | share-client `ask_mountpoint` UX: existing/new/declined/rejected paths, confirm gate, mkdir side effects, non-TTY stdin contract, static `n`→`t` guards |
|
||||||
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
|
| `t-pos-media-yt.sh` | unified `pos media yt` suite: dispatcher + forwarder resolution, shared yt-lib helpers, yt-mp3/mp4/grab/subtitles flags, dry-run deps, `YT_OUT_DIR` seam, `GRAB_DEFAULT` config, negative controls (unsafe-URL no-expansion, `--lang en,ar` single arg, txt timestamp-stripping) |
|
||||||
|
| `t-telegram-listener-singleton.sh` | Telegram listener single-instance guard: first `--run` acquires the flock, second `--run` fails fast with the exact message, lock auto-releases so the next start is clean, `--status` reports the lock state |
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# t-telegram-listener-exec.sh — async command execution in the Telegram
|
||||||
|
# listener. Proves the listener can execute ANY valid Bash command without
|
||||||
|
# blocking: simple output, compound commands, pipes, stderr, long-running
|
||||||
|
# (timeout), and that the listener stays responsive while a command runs.
|
||||||
|
#
|
||||||
|
# Hermetic: stubbed curl (serves a canned getUpdates batch with /command
|
||||||
|
# messages, then empty batches), stubbed systemctl, real jq/timeout.
|
||||||
|
# No network, no real Telegram, no FFmpeg (unless /dev/video0 exists).
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
require_cmd jq "telegram exec" || return 0
|
||||||
|
require_cmd timeout "telegram exec" || return 0
|
||||||
|
|
||||||
|
local sandbox stubs cfg curl_log marker listener batch
|
||||||
|
sandbox="$(mksandbox telegram-exec)"
|
||||||
|
stubs="$sandbox/stubs"
|
||||||
|
cfg="$sandbox/cfg"
|
||||||
|
curl_log="$sandbox/curl.log"
|
||||||
|
marker="$sandbox/executed.log"
|
||||||
|
listener="$ROOT/bin/pos-communication-telegram-listener"
|
||||||
|
mkdir -p "$stubs" "$cfg"
|
||||||
|
: > "$curl_log"
|
||||||
|
: > "$marker"
|
||||||
|
|
||||||
|
# ── command map: one /command per line, each triggers a known behavior ──
|
||||||
|
cat > "$cfg/telegram_commands.env" <<'MAP'
|
||||||
|
/echo_hello=echo hello
|
||||||
|
/compound=sleep 0.2 && echo done
|
||||||
|
/stdout_test=printf 'line1\nline2\n'
|
||||||
|
/stderr_test=bash -c 'echo error_msg >&2; echo output_msg'
|
||||||
|
/pipe_test=echo "hello world" | tr ' ' '\n'
|
||||||
|
/long_run=sleep 30
|
||||||
|
/no_output=true
|
||||||
|
/quiet_test=@quiet echo hello_quiet
|
||||||
|
MAP
|
||||||
|
|
||||||
|
: > "$cfg/telegram_prefixes.env"
|
||||||
|
|
||||||
|
# ── stub curl ──
|
||||||
|
# Serve a batch with 8 commands (one per mapped /command), then empty.
|
||||||
|
local batch_file="$sandbox/batch.json"
|
||||||
|
cat > "$batch_file" <<'JSON'
|
||||||
|
{"ok":true,"result":[
|
||||||
|
{"update_id":1,"message":{"message_id":10,"from":{"id":123},"chat":{"id":456},"text":"/echo_hello"}},
|
||||||
|
{"update_id":2,"message":{"message_id":11,"from":{"id":123},"chat":{"id":456},"text":"/compound"}},
|
||||||
|
{"update_id":3,"message":{"message_id":12,"from":{"id":123},"chat":{"id":456},"text":"/stdout_test"}},
|
||||||
|
{"update_id":4,"message":{"message_id":13,"from":{"id":123},"chat":{"id":456},"text":"/stderr_test"}},
|
||||||
|
{"update_id":5,"message":{"message_id":14,"from":{"id":123},"chat":{"id":456},"text":"/pipe_test"}},
|
||||||
|
{"update_id":6,"message":{"message_id":15,"from":{"id":123},"chat":{"id":456},"text":"/long_run"}},
|
||||||
|
{"update_id":7,"message":{"message_id":16,"from":{"id":123},"chat":{"id":456},"text":"/no_output"}},
|
||||||
|
{"update_id":8,"message":{"message_id":17,"from":{"id":123},"chat":{"id":456},"text":"/quiet_test"}}
|
||||||
|
]}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$stubs/curl" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'curl %s\n' "\$*" >> "$curl_log"
|
||||||
|
for a in "\$@"; do
|
||||||
|
case "\$a" in
|
||||||
|
*getUpdates*)
|
||||||
|
if [ ! -e "$sandbox/served.once" ]; then
|
||||||
|
touch "$sandbox/served.once"
|
||||||
|
cat "$batch_file"
|
||||||
|
else
|
||||||
|
sleep 1
|
||||||
|
printf '%s' '{"ok":true,"result":[]}'
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s' '{"ok":true}'
|
||||||
|
STUB
|
||||||
|
chmod +x "$stubs/curl"
|
||||||
|
|
||||||
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
|
||||||
|
chmod +x "$stubs/systemctl"
|
||||||
|
|
||||||
|
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
|
||||||
|
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
|
||||||
|
|
||||||
|
# ── run the listener ──
|
||||||
|
# /long_run (sleep 30) runs in background — the listener does NOT block.
|
||||||
|
# The 45s outer timeout proves the listener stayed responsive.
|
||||||
|
test_run_env "${common[@]}" -- timeout 45 "$listener" --run
|
||||||
|
|
||||||
|
local curl_content
|
||||||
|
curl_content="$(cat "$curl_log")"
|
||||||
|
|
||||||
|
# ── all commands were dispatched ──
|
||||||
|
check_contains "listener processed /echo_hello" "exec: /echo_hello" "$TR_OUT"
|
||||||
|
check_contains "listener processed /compound" "exec: /compound" "$TR_OUT"
|
||||||
|
check_contains "listener processed /long_run" "exec: /long_run" "$TR_OUT"
|
||||||
|
|
||||||
|
# ── /echo_hello → "hello" ──
|
||||||
|
check_contains "/echo_hello reply" "text=hello" "$curl_content"
|
||||||
|
|
||||||
|
# ── /compound (sleep 0.2 && echo done) → "done" ──
|
||||||
|
check_contains "/compound reply" "text=done" "$curl_content"
|
||||||
|
|
||||||
|
# ── /stdout_test → multi-line stdout captured ──
|
||||||
|
check_contains "/stdout_test reply" "text=line1" "$curl_content"
|
||||||
|
|
||||||
|
# ── /stderr_test → stderr+stdout both captured ──
|
||||||
|
# Output is "error_msg\noutput_msg" (newline-separated).
|
||||||
|
# The curl log may split this across lines, so check each token alone.
|
||||||
|
check_contains "/stderr_test stderr captured" "error_msg" "$curl_content"
|
||||||
|
check_contains "/stderr_test stdout captured" "output_msg" "$curl_content"
|
||||||
|
|
||||||
|
# ── /pipe_test → pipe works ──
|
||||||
|
check_contains "/pipe_test reply" "text=hello" "$curl_content"
|
||||||
|
|
||||||
|
# ── /no_output → "OK" (no output → default reply) ──
|
||||||
|
check_contains "/no_output reply" "text=OK" "$curl_content"
|
||||||
|
|
||||||
|
# ── /quiet_test → NO sendMessage with "hello_quiet" ──
|
||||||
|
# The setMyCommands call may contain "hello_quiet" in the description,
|
||||||
|
# so we check that no sendMessage line contains it.
|
||||||
|
local quiet_send_count
|
||||||
|
quiet_send_count="$(printf '%s' "$curl_content" | grep 'sendMessage' | grep -c 'hello_quiet' || true)"
|
||||||
|
check_eq "/quiet_test suppresses reply" 0 "$quiet_send_count"
|
||||||
|
|
||||||
|
# ── the daemon exited within the outer timeout (not hung) ──
|
||||||
|
# rc=124 means `timeout` killed it — listener was alive and processing.
|
||||||
|
# rc=0 means it exited cleanly. Both prove no hang.
|
||||||
|
if [ "${TR_RC:-0}" -eq 124 ] || [ "${TR_RC:-0}" -eq 0 ]; then
|
||||||
|
printf ' PASS daemon exited cleanly (rc=%s, not hung)\n' "${TR_RC}"
|
||||||
|
else
|
||||||
|
printf ' FAIL daemon exited with unexpected rc=%s\n' "${TR_RC:-?}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
Executable
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# t-telegram-listener-singleton.sh — single-instance guard for the Telegram
|
||||||
|
# listener daemon (flock on ${XDG_RUNTIME_DIR:-/tmp}/pos-telegram-listener.lock):
|
||||||
|
# (a) the first --run acquires the lock and reaches its poll loop;
|
||||||
|
# (b) a second --run on the same runtime dir fails fast (exit 1) with the
|
||||||
|
# exact single-instance message — no 409/getUpdates race;
|
||||||
|
# (c) the flock auto-releases when the first instance exits, so the next
|
||||||
|
# --run starts cleanly (systemd Restart=always path);
|
||||||
|
# (d) --status reports the lock through the same primitives.
|
||||||
|
# Hermetic: stubbed curl (no network) + systemctl (no user bus), real
|
||||||
|
# jq/flock/timeout, sandboxed XDG_RUNTIME_DIR + CONFIG_DIR.
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
require_cmd jq "telegram singleton guard" || return 0
|
||||||
|
require_cmd flock "telegram singleton guard" || return 0
|
||||||
|
require_cmd timeout "telegram singleton guard" || return 0
|
||||||
|
|
||||||
|
local sandbox stubs cfg runtime home listener curl_log marker first_log
|
||||||
|
sandbox="$(mksandbox telegram-singleton)"
|
||||||
|
stubs="$sandbox/stubs"
|
||||||
|
cfg="$sandbox/cfg"
|
||||||
|
runtime="$sandbox/runtime"
|
||||||
|
home="$sandbox/home"
|
||||||
|
listener="$ROOT/bin/pos-communication-telegram-listener"
|
||||||
|
curl_log="$sandbox/curl.log"
|
||||||
|
marker="$sandbox/loop.started"
|
||||||
|
first_log="$sandbox/first.log"
|
||||||
|
mkdir -p "$stubs" "$cfg" "$runtime" "$home"
|
||||||
|
: > "$curl_log"
|
||||||
|
|
||||||
|
# Stub curl: never touches the network. getUpdates serves an empty batch
|
||||||
|
# forever (first call touches $marker so the test knows the daemon reached
|
||||||
|
# its poll loop — which only happens AFTER the lock was acquired and the
|
||||||
|
# config checks passed); everything else returns {ok:true}. The small
|
||||||
|
# sleep keeps the empty-poll loop from spinning while the test runs.
|
||||||
|
cat > "$stubs/curl" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'curl %s\n' "\$*" >> "$curl_log"
|
||||||
|
for a in "\$@"; do
|
||||||
|
case "\$a" in
|
||||||
|
*getUpdates*)
|
||||||
|
touch "$marker"
|
||||||
|
sleep 1
|
||||||
|
printf '%s' '{"ok":true,"result":[]}'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s' '{"ok":true}'
|
||||||
|
STUB
|
||||||
|
chmod +x "$stubs/curl"
|
||||||
|
|
||||||
|
# Stub systemctl: deterministic exit 1 — --status must not reach the real
|
||||||
|
# user bus; the autostart line is not what this test asserts.
|
||||||
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$stubs/systemctl"
|
||||||
|
chmod +x "$stubs/systemctl"
|
||||||
|
|
||||||
|
: > "$cfg/telegram_commands.env"
|
||||||
|
: > "$cfg/telegram_prefixes.env"
|
||||||
|
|
||||||
|
local common=(PATH="$stubs:/usr/bin:/bin" CONFIG_DIR="$cfg"
|
||||||
|
XDG_RUNTIME_DIR="$runtime" HOME="$home"
|
||||||
|
TELEGRAM_BOT_TOKEN=testbot TELEGRAM_CHAT_ID=456 TELEGRAM_OWNER_ID=123)
|
||||||
|
|
||||||
|
# ── (a) first instance acquires the lock and runs ──
|
||||||
|
rm -f "$marker"
|
||||||
|
env "${common[@]}" timeout 10 "$listener" --run >"$first_log" 2>&1 &
|
||||||
|
local first_pid=$!
|
||||||
|
|
||||||
|
local waited=0
|
||||||
|
until [ -e "$marker" ]; do
|
||||||
|
sleep 0.1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
if [ "$waited" -ge 100 ]; then
|
||||||
|
printf ' FAIL first listener never reached the poll loop (log below)\n'
|
||||||
|
cat "$first_log"
|
||||||
|
kill "$first_pid" 2>/dev/null || true
|
||||||
|
wait "$first_pid" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf ' PASS first listener acquired lock and reached the poll loop\n'
|
||||||
|
|
||||||
|
test_run_env "${common[@]}" -- "$listener" --status
|
||||||
|
check_rc "status while daemon up exits 0" 0 "$TR_RC"
|
||||||
|
check_contains "status reports lock held while running" \
|
||||||
|
"listener: running (single instance lock held)" "$TR_OUT"
|
||||||
|
|
||||||
|
# ── (b) second instance fails fast with the exact message ──
|
||||||
|
test_run_env "${common[@]}" -- timeout 3 "$listener" --run
|
||||||
|
check_rc "second instance fails fast (exit 1)" 1 "$TR_RC"
|
||||||
|
check_contains "second instance prints exact single-instance message" \
|
||||||
|
"ERROR: listener already running (single instance) — check: systemctl --user status pos-telegram-listener" \
|
||||||
|
"$TR_OUT"
|
||||||
|
|
||||||
|
# ── (c) lock releases when the first instance ends ──
|
||||||
|
kill "$first_pid" 2>/dev/null || true
|
||||||
|
wait "$first_pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
test_run_env "${common[@]}" -- "$listener" --status
|
||||||
|
check_contains "status reports not running after first exits" \
|
||||||
|
"listener: not running" "$TR_OUT"
|
||||||
|
|
||||||
|
rm -f "$marker"
|
||||||
|
env "${common[@]}" timeout 10 "$listener" --run >"$sandbox/third.log" 2>&1 &
|
||||||
|
local third_pid=$!
|
||||||
|
|
||||||
|
waited=0
|
||||||
|
until [ -e "$marker" ]; do
|
||||||
|
sleep 0.1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
if [ "$waited" -ge 100 ]; then
|
||||||
|
printf ' FAIL third listener never reached the poll loop (log below)\n'
|
||||||
|
cat "$sandbox/third.log"
|
||||||
|
kill "$third_pid" 2>/dev/null || true
|
||||||
|
wait "$third_pid" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf ' PASS third listener starts cleanly after the lock was released\n'
|
||||||
|
|
||||||
|
kill "$third_pid" 2>/dev/null || true
|
||||||
|
wait "$third_pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
test_run_env "${common[@]}" -- "$listener" --status
|
||||||
|
check_contains "status reports not running after third exits" \
|
||||||
|
"listener: not running" "$TR_OUT"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user