mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 21:27:16 +01:00
Per-person photo commands, where the mechanism is public and the album is not. Nothing identifying is in this repository: who the people are, what fires their photos and which file shows whom all live in one directory outside it, and the tests, the docs and the offline e2e run use three invented people instead. Several names in one message intersect -- "!alfa beta" is a photo with both -- and where an entry offers a choice the bot picks among the readings that actually have a photo, so a question the album could answer is not refused. A combination nobody photographed gets one line rather than silence, which from the group's side is indistinguishable from a broken bot. Scoping is enforced in the dispatcher, not in the handler: a command the group is not allowed resolves to nothing, so it is not answered, not counted as a command, and never named in a log line outside its own groups. A handler that quietly returned would leak all three. Commands are named by pseudonym (pack:p1), so the journal and command_uses say somebody asked for a photo and nothing about whose. Video works: the attachment goes out as data:video/mp4;filename=...;base64, which is the container's own spelling, read off its /swagger/doc.json. Told it is a photo, Signal renders a clip as an unplayable file. tool/tagger.py serves one page on loopback to fill the album in -- names are typed into the page, never as arguments, and it prints how many photos each command would have when it stops, because a command with zero is dead on arrival. Also renames the real people who were still sitting in the stats test fixtures. They remain in the history; the point is that nothing new is added to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
203 lines
8.0 KiB
Bash
Executable File
203 lines
8.0 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="
|
|
# A second watched group, so the suite proves replies and rate limits stay
|
|
# in the group that asked instead of leaking into the other one.
|
|
readonly SECOND_GROUP="group.WlRKbExYTmxZMjl1WkMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0="
|
|
readonly SECOND_GROUP_INTERNAL="ZTJlLXNlY29uZC1ncm91cC1pZC0zMi1ieXRlcyEhISE="
|
|
# Not seeded at boot: these arrive mid-run, which is the point.
|
|
readonly THIRD_GROUP="group.WlRKbExYUm9hWEprTFdkeWIzVndMV2xrTFRNeUxXSjVkR1Z6SVNFaElRPT0="
|
|
readonly THIRD_GROUP_INTERNAL="ZTJlLXRoaXJkLWdyb3VwLWlkLTMyLWJ5dGVzISEhIQ=="
|
|
readonly FOURTH_GROUP="group.WlRKbExXWnZkWEowYUMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0="
|
|
readonly FOURTH_GROUP_INTERNAL="ZTJlLWZvdXJ0aC1ncm91cC1pZC0zMi1ieXRlcyEhISE="
|
|
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
|
|
|
|
die() {
|
|
echo "Error: $1" >&2
|
|
exit 1
|
|
}
|
|
|
|
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,\"joinable\":true},{\"id\":\"${SECOND_GROUP}\",\"internal_id\":\"${SECOND_GROUP_INTERNAL}\",\"name\":\"e2e-second\",\"member\":true,\"joinable\":true}]" >/dev/null
|
|
|
|
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers
|
|
# its groups from the account, which is the configuration real
|
|
# deployments use. Set rather than merely unset, because load_dotenv()
|
|
# would otherwise let the developer's own .env decide what this run
|
|
# watches -- and a TRUSTED_INVITERS there would refuse every fixture
|
|
# group. The gate itself is covered in tests/test_trust.py.
|
|
# The album is private and lives outside this repository, so the run
|
|
# builds a synthetic one: three invented people, a combination and a
|
|
# video, scoped to the watched group so the scoping scenario can prove
|
|
# it does not exist in the second one.
|
|
echo "== writing a synthetic photo pack"
|
|
"$PYTHON" "$REPO_ROOT/tool/e2e_pack.py" --dir "${TEMP_DIR}/pack" --group "$WATCHED_GROUP"
|
|
|
|
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
|
env -u CAT_API \
|
|
PHONE_NUMBER="+15550000000" \
|
|
GROUP_IDS="" \
|
|
TRUSTED_INVITERS="" \
|
|
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" \
|
|
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
|
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
|
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
|
PHOTO_PACK_DIR="${TEMP_DIR}/pack" \
|
|
BIND_PORT="$BOT_PORT" \
|
|
SILENCE_SECONDS=0 \
|
|
"$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 "watched=${WATCHED_GROUP_INTERNAL}" \
|
|
--group "reply=${WATCHED_GROUP}" \
|
|
--group "second=${SECOND_GROUP_INTERNAL}" \
|
|
--group "second_reply=${SECOND_GROUP}" \
|
|
--group "third=${THIRD_GROUP_INTERNAL}" \
|
|
--group "third_reply=${THIRD_GROUP}" \
|
|
--group "fourth=${FOURTH_GROUP_INTERNAL}" \
|
|
--group "fourth_reply=${FOURTH_GROUP}" \
|
|
--group "other=${OTHER_GROUP}"
|
|
|
|
prove_the_deafness_gate
|
|
}
|
|
|
|
# The gate that did not exist on 2026-08-29, proven against the real process:
|
|
# with signal-cli unable to reach Signal, /healthz must go red rather than
|
|
# keep answering 200 while nothing arrives.
|
|
prove_the_deafness_gate() {
|
|
local health="http://127.0.0.1:${BOT_PORT}/healthz"
|
|
echo "== cutting the fake's connection to Signal"
|
|
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
|
-H 'Content-Type: application/json' -d '{"alive": false}' >/dev/null
|
|
|
|
local code="" waited=0
|
|
while ((waited < 150)); do
|
|
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
|
[[ "$code" == "503" ]] && break
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
[[ "$code" == "503" ]] || die "/healthz still answers ${code} with the connection cut"
|
|
curl -s "$health" | grep -q "Closed unexpectedly" ||
|
|
die "/healthz went red without saying why"
|
|
echo "== /healthz turned 503 and named the reason after ${waited}s"
|
|
|
|
echo "== restoring the connection"
|
|
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
|
-H 'Content-Type: application/json' -d '{"alive": true}' >/dev/null
|
|
waited=0
|
|
while ((waited < 150)); do
|
|
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
|
[[ "$code" == "200" ]] && break
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
[[ "$code" == "200" ]] || die "/healthz stayed ${code} after the connection came back"
|
|
echo "== /healthz recovered on its own after ${waited}s"
|
|
}
|
|
|
|
main "$@"
|