mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:17:02 +01:00
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.
Four failures, each of which left the bot looking perfectly healthy:
- Invited is not joined. Signal only adds an account outright if the adder
holds its profile key; with no profile the add becomes a pending invite,
and signal-cli cannot accept one addressed to a phone number ("Cannot find
service ID for self"). Group listed, websocket up, /healthz OK, zero
messages. register_bot.sh now sets the profile *before* the add, and
startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
every attachment 400s with "Can't load library: awt". Both mode guards now
demand exactly json-rpc, and start_container replaces a wrong-mode
container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
envelopes carry what that decodes to -- so the scoping gate rejected the
bot's own group. e2e.sh now models both spellings; reverting the fix fails
4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
a .env. The bind address comes from Config now.
A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.
The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.
100% branch coverage, 230 tests, all gates green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
129 lines
4.3 KiB
Bash
Executable File
129 lines
4.3 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# ============================================================================
|
|
# End-to-end test: the real bot against the fake Signal server.
|
|
#
|
|
# Boots the protocol-faithful fake, boots the unmodified bot pointed at it,
|
|
# replays every scenario and exits non-zero on the first mismatch. No Signal
|
|
# account, no network, no container.
|
|
# ============================================================================
|
|
|
|
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 FAKE_PORT="${FAKE_PORT:-8099}"
|
|
readonly BOT_PORT="${BOT_PORT:-8098}"
|
|
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
|
|
|
|
# Both spellings of one group, exactly as the real API reports them:
|
|
# /v1/groups returns the `group.<base64>` form (what .env holds), while an
|
|
# incoming envelope carries the id that base64 decodes to. Modelling only
|
|
# one is why the offline suite once passed against a bot that ignored every
|
|
# message in its own group.
|
|
readonly WATCHED_GROUP="group.WlRKbExYZGhkR05vWldRdFozSnZkWEF0YVdRdE16SXRZbmwwWlhNaElTRT0="
|
|
readonly WATCHED_GROUP_INTERNAL="ZTJlLXdhdGNoZWQtZ3JvdXAtaWQtMzItYnl0ZXMhISE="
|
|
readonly REPLY_GROUP="group.e2e.reply=="
|
|
readonly OTHER_GROUP="group.e2e.other=="
|
|
|
|
FAKE_PID=""
|
|
BOT_PID=""
|
|
TEMP_DIR=""
|
|
|
|
cleanup() {
|
|
# Plain ifs, not `A && B || C`: that is not if-then-else, and C runs when
|
|
# A is true but B fails -- which is exactly the case here (kill on an
|
|
# already-dead pid).
|
|
if [[ -n "$BOT_PID" ]]; then
|
|
kill "$BOT_PID" 2>/dev/null || true
|
|
fi
|
|
if [[ -n "$FAKE_PID" ]]; then
|
|
kill "$FAKE_PID" 2>/dev/null || true
|
|
fi
|
|
wait 2>/dev/null || true
|
|
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
|
|
rm -rf "$TEMP_DIR"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
wait_for_url() {
|
|
local url="$1" name="$2" attempt=0
|
|
until curl -sf "$url" >/dev/null 2>&1; do
|
|
attempt=$((attempt + 1))
|
|
if [[ $attempt -ge 100 ]]; then
|
|
echo "Error: $name did not come up at $url" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
}
|
|
|
|
wait_for_subscription() {
|
|
local attempt=0
|
|
until [[ "$(curl -sf "${FAKE_URL}/_control/ready" | tr -dc '0-9')" != "0" ]]; do
|
|
attempt=$((attempt + 1))
|
|
if [[ $attempt -ge 100 ]]; then
|
|
echo "Error: the bot never subscribed to the receive websocket" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
}
|
|
|
|
validate_requirements() {
|
|
if [[ ! -x "$PYTHON" ]]; then
|
|
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
|
exit 1
|
|
fi
|
|
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
|
|
}
|
|
|
|
main() {
|
|
validate_requirements
|
|
TEMP_DIR="$(mktemp -d)"
|
|
|
|
echo "== booting the fake Signal server on :${FAKE_PORT}"
|
|
export FAKE_PORT
|
|
"$PYTHON" -m uvicorn fake_signal.server:app \
|
|
--host 127.0.0.1 --port "$FAKE_PORT" --log-level warning &
|
|
FAKE_PID=$!
|
|
wait_for_url "${FAKE_URL}/v1/about" "fake signal server"
|
|
|
|
echo "== seeding the fake with a registered, joined account"
|
|
curl -sf -X POST "${FAKE_URL}/_control/accounts" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '["+15550000000"]' >/dev/null
|
|
curl -sf -X POST "${FAKE_URL}/_control/groups" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "[{\"id\":\"${WATCHED_GROUP}\",\"internal_id\":\"${WATCHED_GROUP_INTERNAL}\",\"name\":\"e2e\",\"member\":true}]" >/dev/null
|
|
|
|
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
|
env -u CAT_API \
|
|
PHONE_NUMBER="+15550000000" \
|
|
GROUP_ID="$WATCHED_GROUP" \
|
|
GROUP_ID_SEND="$REPLY_GROUP" \
|
|
API_BASE_URL="$FAKE_URL" \
|
|
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
|
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
|
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
|
BIND_PORT="$BOT_PORT" \
|
|
"$PYTHON" -m signal_bot &
|
|
BOT_PID=$!
|
|
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
|
wait_for_subscription
|
|
|
|
echo "== replaying scenarios"
|
|
"$PYTHON" -m fake_signal.cli \
|
|
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
|
|
--base-url "$FAKE_URL" \
|
|
--group-id "$WATCHED_GROUP_INTERNAL" \
|
|
--group-id-send "$REPLY_GROUP" \
|
|
--other-group "$OTHER_GROUP"
|
|
}
|
|
|
|
main "$@"
|