signal-bot/scripts/live_smoke.sh
Krzysztof kuhy Rudnicki 6bde3493a4 feat: rewrite the bot so it runs, and gate it like the other repos
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.

Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.

Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
  the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
  2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read

Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.

Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.

Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.

Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:05:42 +02:00

153 lines
4.9 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Live verification against a real Signal account.
#
# Starts the signal-cli-rest-api container, opens the linking QR code, waits
# for the link, lists your groups so you can pick a private one, writes .env
# and starts the bot. The only manual step is scanning the QR code.
#
# Run scripts/e2e.sh first: this costs a device link, that costs nothing.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
readonly CONTAINER="${CONTAINER:-signal-api}"
readonly PORT="${PORT:-9922}"
readonly API="http://localhost:${PORT}"
readonly DATA_DIR="${DATA_DIR:-$HOME/signal-api}"
# Stable tag. The dev series counts separately, so 0.167-dev is OLDER than this.
readonly IMAGE="${IMAGE:-bbernhard/signal-cli-rest-api:0.100}"
# Fallback when linking fails on client deprecation: tracks a newer signal-cli.
readonly FALLBACK_IMAGE="${FALLBACK_IMAGE:-bbernhard/signal-cli-rest-api:1787779383-ci}"
TEMP_DIR=""
cleanup() {
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
return 0
}
trap cleanup EXIT
validate_requirements() {
command -v docker >/dev/null || { echo "Error: docker is required" >&2; exit 1; }
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
command -v jq >/dev/null || { echo "Error: jq is required" >&2; exit 1; }
[[ -x "$PYTHON" ]] || {
echo "Error: no interpreter at $PYTHON" >&2
echo " Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
exit 1
}
}
start_container() {
local image="$1"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
mkdir -p "$DATA_DIR"
echo "== starting $image on :${PORT} (MODE=json-rpc-native)"
docker run -d --name "$CONTAINER" --restart=always -p "${PORT}:8080" \
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
-e 'MODE=json-rpc-native' "$image" >/dev/null
local attempt=0
until curl -sf "${API}/v1/about" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge 150 ]]; then
echo "Error: container never became ready. Logs:" >&2
docker logs --tail 30 "$CONTAINER" >&2
return 1
fi
sleep 0.4
done
}
verify_mode() {
local mode
mode="$(curl -sf "${API}/v1/about" | jq -r '.mode // "unknown"')"
if [[ "$mode" != *json-rpc* ]]; then
echo "Error: container mode is '$mode'." >&2
echo " /v1/receive is only a WebSocket in a json-rpc mode." >&2
exit 1
fi
echo "== container mode: $mode"
}
show_qr_code() {
local png="${TEMP_DIR}/link.png"
curl -sf "${API}/v1/qrcodelink?device_name=${CONTAINER}" -o "$png"
echo "== scan this QR code: Signal > Settings > Linked devices > +"
# The endpoint returns a PNG, so it has to be opened, not printed.
if command -v xdg-open >/dev/null; then
xdg-open "$png" >/dev/null 2>&1 &
else
echo " open this file: $png"
fi
}
wait_for_link() {
echo "== waiting for the link (Ctrl-C to abort)"
local attempt=0
until [[ "$(curl -sf "${API}/v1/accounts" | jq -r 'length // 0')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 300 ]]; then
echo "Error: no account linked after 5 minutes." >&2
echo " If linking failed with a connection error, retry with:" >&2
echo " IMAGE=$FALLBACK_IMAGE $0" >&2
exit 1
fi
sleep 1
done
}
choose_group() {
local number="$1"
echo "== groups on this account:" >&2
curl -sf "${API}/v1/groups/${number}" \
| jq -r '.[] | "\(.id)\t\(.name // "(unnamed)")"' \
| nl -w3 -s' ' >&2
echo >&2
read -r -p "Pick the number of the PRIVATE group to use: " choice
curl -sf "${API}/v1/groups/${number}" \
| jq -r --argjson n "$choice" '.[$n - 1].id'
}
write_env() {
local number="$1" group="$2"
cat > "${REPO_ROOT}/.env" <<ENV
PHONE_NUMBER=${number}
GROUP_ID=${group}
GROUP_ID_SEND=${group}
API_BASE_URL=${API}
DATABASE_PATH=${REPO_ROOT}/signal_bot.sqlite3
ENV
echo "== wrote ${REPO_ROOT}/.env (GROUP_ID == GROUP_ID_SEND, replies land in the same group)"
}
main() {
validate_requirements
TEMP_DIR="$(mktemp -d)"
start_container "$IMAGE"
verify_mode
show_qr_code
wait_for_link
local number group
number="$(curl -sf "${API}/v1/accounts" | jq -r '.[0]')"
echo "== linked as ${number}"
group="$(choose_group "$number")"
[[ -n "$group" && "$group" != "null" ]] || { echo "Error: no group chosen" >&2; exit 1; }
write_env "$number" "$group"
echo "== starting the bot. Send '!cat' in that group; you should get a cat."
exec "$REPO_ROOT/run.sh"
}
main "$@"