feat: refuse to look healthy while deaf, and heal the container by itself

Today the bot was up, healthy and answering nothing for three hours. A host
firewall change (fixed in testsAndMisc 334c0cd9) cut every Docker container
off the internet; signal-cli lost Signal and reconnected in a loop from
15:32:50 onward. Nothing noticed:

  - the container's own healthcheck answered 204
  - /v1/groups answered 200 out of local state, so discovery saw no change
  - the bot's WebSocket to the container stayed OPEN, so the transport's
    reconnect logic never fired
  - /healthz answered {"status": "ok"} the entire time

The only symptom was silence, which is also what a quiet evening looks like.

signal_bot/liveness.py asks the one question that cannot be answered from a
cache -- GET /v1/devices, which needs a live round trip -- but only after
SILENCE_SECONDS (120) with no frame of any kind, so a busy group costs
nothing and an idle one costs one request per window. Three consecutive
failures and /healthz answers 503 with the reason. Verified end to end in
scripts/e2e.sh against the real process: 503 after 84s with the fake's
connection cut, back to 200 on its own 30s after restoring it.

scripts/signal_api_watchdog.sh + a two-minute user timer do the recovery the
bot cannot do for itself, since a wedged bot cannot restart the container it
depends on. Two tiers, so the steady state costs no Signal round trip at
all: the bot's own /healthz first, the authenticated probe only when that is
not 200. It stops after 3 restarts in 6 hours and escalates instead -- a
deregistered account or a blocked network is not fixable by restarting, and
a restart loop would hide it. Its own bash suite stubs curl and docker and
drives every branch including the give-up cap.

signal_bot/trust.py answers the other half: TRUSTED_INVITERS decides who may
put this bot in a group. The gate sits in GroupDirectory._permitted, which
both _join_pending and _adopt already route through -- gating only invites
would gate the door nobody uses, since prime_profile.sh exists precisely so
that an add becomes a direct join. Evidence is either the group-update
envelope, which is the only frame naming an inviter, or a trusted identity
being a full member in the listing. A refusal is logged once, with that
group's members, so the operator can see which string to add.

verify_allowlist moved to membership.py to keep discovery.py under the
250-line gate; it was already the module that owns MembershipError.

Verified live, by stopping the container by hand and touching nothing else:

  18:54:35  docker stop signal-api
  18:56:36  liveness: failure 1 of 3 after 123s of silence
  18:57:36  failure 3 of 3 -> /healthz answers 503 from 18:57:45
  18:58:18  watchdog: probe failed (1/2)
  19:00:28  watchdog: probe failed (2/2) -> docker restart signal-api
  19:00:36  liveness: receive path recovered after 8 failed probe(s)
  19:01:13  watchdog: signal-api is back

Six minutes, unattended, against three hours of silence this afternoon. All
four real groups are still adopted with TRUSTED_INVITERS=+48123456789 set,
and no group was refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 19:01:55 +02:00
parent 0c4b259c17
commit d5c63b423b
30 changed files with 1622 additions and 42 deletions

View File

@ -38,6 +38,22 @@ REPORT_MINUTE=
# Optional. Per-user, per-command cooldown in seconds. Default: 10
COOLDOWN_SECONDS=
# Optional but recommended. Comma-separated +E.164 numbers -- or member
# UUIDs, which is how /v1/groups spells anybody the account has never
# messaged -- allowed to add this bot to a group.
#
# Empty means anyone who knows the number can pull the bot into a group and
# have it answer there. With it set, "add the bot" is still the whole setup
# for you, and a stranger's group is refused with one WARNING naming that
# group's members, so you can see exactly which string to add here.
TRUSTED_INVITERS=
# Optional. How long the bot may hear nothing at all -- no message, no
# receipt, no typing indicator -- before it probes /v1/devices to find out
# whether it has gone deaf. Three failed probes and /healthz turns 503.
# Default: 120. scripts/e2e.sh sets it to 0 to prove the gate really fires.
SILENCE_SECONDS=
# Optional. Image APIs. Overridden by scripts/e2e.sh to serve local fixtures.
CAT_API_URL=
DOG_API_URL=

View File

@ -98,6 +98,15 @@ repos:
language: system
types: [shell]
# The watchdog is bash, so pytest cannot reach it. Its own suite stubs
# curl and docker and drives every branch, including the give-up cap.
- id: watchdog-tests
name: watchdog behaviour (bash)
entry: scripts/test_watchdog.sh
language: system
pass_filenames: false
files: ^scripts/(signal_api_watchdog|test_watchdog)\.sh$
# 100% branch coverage, no omits. Runs on every commit: coverage is the
# whole point of the suite, and a partial run cannot prove it.
- id: pytest

View File

@ -0,0 +1,49 @@
# Going deaf, and who may add the bot
Two gates that exist because of one afternoon. Both are referenced from
the README; this is the detail.
## When it cannot hear
On 2026-08-29 the bot was up, healthy and deaf for three hours. A host
firewall change cut every Docker container off the internet, `signal-cli`
lost its connection to Signal and reconnected in a loop, and **every** health
signal stayed green: the container's own healthcheck answered 204,
`/v1/groups` answered 200 from local state, the bot's WebSocket to the
container stayed open, and `/healthz` answered `{"status": "ok"}`. The only
symptom was silence, which is also what a quiet evening looks like.
Two things now make that impossible to miss:
- **`signal_bot/liveness.py`** — after `SILENCE_SECONDS` (120 by default)
with no frame of any kind, the bot probes `/v1/devices/<number>`, the one
endpoint that needs a live round trip to Signal. Three consecutive
failures and `/healthz` answers **503** with the reason, and every failure
is a `WARNING` in the journal. Worst case from the last envelope to a red
`/healthz` is 120 + 3×30 ≈ 3.5 minutes.
- **`scripts/signal_api_watchdog.sh`**, on a two-minute user timer — probes
the same endpoint from outside the process, restarts the container after
two consecutive failures, and **stops after 3 restarts in 6 hours**,
escalating with `notify-send` and a journald `ERROR` instead. A
deregistered account or a blocked network is not fixable by restarting,
and a forever-restart loop would hide it. `scripts/install_service.sh`
installs and arms it beside the bot's own unit.
```bash
systemctl --user list-timers signal-api-watchdog.timer
journalctl --user -u signal-api-watchdog.service -n 20
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8137/healthz
```
## Who may add the bot to a group
`TRUSTED_INVITERS` in `.env` is a comma-separated list of `+E.164` numbers,
or the member UUIDs `/v1/groups` uses for anybody the account has never
messaged. Empty means anyone who knows the number can pull the bot into a
group and be answered there, and the bot says so in a `WARNING` at startup.
With it set, adding the bot is still the whole setup for you: a group is
adopted when a group-update envelope says a trusted identity added the bot,
or when a trusted identity is a full member of it in the listing. Anything
else is refused once, loudly, with that group's member list in the log so
you can see which string to add.

View File

@ -28,6 +28,37 @@ Reference for the two JSON frames `signal-cli-rest-api` pushes down
}
```
## Somebody changed the membership: `groupInfo.type` is `UPDATE`
The only frame that names *who* added the bot to a group, which is what
`signal_bot/trust.py` needs. It carries no `message` at all, so it counts
toward nothing and answers nothing; `source`/`sourceNumber` is the person
who made the change. Captured live on 2026-08-29:
```json
{
"envelope": {
"source": "+48123456789",
"sourceNumber": "+48123456789",
"sourceUuid": "b1c2d3e4-…",
"sourceName": "kuhy",
"sourceDevice": 1,
"timestamp": 1788006851503,
"dataMessage": {
"timestamp": 1788006851503,
"message": null,
"groupInfo": { "groupId": "Y29uZmlnLWdy/…", "groupName": "testbot", "type": "UPDATE" }
}
},
"account": "+420111222333"
}
```
Note `source` here is a **phone number**, not the display name this document
used to show for `dataMessage`. Both spellings have been observed, which is
why `models.py` reads the sender's number from `sourceNumber` and nothing
trusts `source`.
## From the bot's own account: `syncMessage.sentMessage`
Because the bot runs as a *linked device*, the account's own outgoing messages

View File

@ -182,6 +182,14 @@ Two independent reasons, both verified rather than assumed:
`Cannot add user: no_add_managed_profile is enabled`. This is the Android 11
COMP deprecation and is unchanged through Android 16.
## When it cannot hear, and who may add it
The bot was up, healthy and deaf for three hours on 2026-08-29. `/healthz`
now turns **503** when silence plus three failed `/v1/devices` probes say the
connection to Signal is dead, a two-minute timer restarts the container, and
`TRUSTED_INVITERS` decides who may put this bot in a group:
[DOCS-liveness-and-trust.md](DOCS-liveness-and-trust.md).
## Commands
| Command | Aliases |

View File

@ -35,6 +35,23 @@ def build_api_router() -> APIRouter:
headers={"X-Device-Name": device_name},
)
@router.get("/v1/devices/{number}")
async def devices(request: Request, number: str) -> JSONResponse:
"""The endpoint the deafness gate probes.
Answers from Signal, never from local state, which is exactly why
the bot uses it to tell "quiet" apart from "deaf".
"""
state = _state(request)
if number not in state.accounts:
return JSONResponse({"error": f"{number} is not registered"}, status_code=400)
if not state.connection_alive:
return JSONResponse(
{"error": "Failed to get linked devices: Closed unexpectedly"},
status_code=400,
)
return JSONResponse([{"id": 1, "name": "", "created": 0, "last_seen": 0}])
@router.post("/v2/send")
async def send(request: Request, payload: dict[str, Any]) -> JSONResponse:
_state(request).record_send(payload)

View File

@ -29,6 +29,13 @@ def build_control_router() -> APIRouter:
delivered += 1
return {"delivered": delivered}
@router.post("/connection")
async def set_connection(request: Request, body: dict[str, Any]) -> dict[str, bool]:
"""Cut or restore signal-cli's connection to Signal."""
state = _state(request)
state.connection_alive = bool(body.get("alive", True))
return {"alive": state.connection_alive}
@router.post("/groups")
async def set_groups(request: Request, groups: list[dict[str, Any]]) -> dict[str, int]:
"""Replace the group list, so the group picker can be rehearsed offline."""

View File

@ -31,6 +31,9 @@ class State:
accounts: list[str] = field(default_factory=list)
pending: dict[str, str] = field(default_factory=dict)
groups: list[dict[str, Any]] = field(default_factory=list)
# Whether signal-cli can reach Signal. False reproduces the 2026-08-29
# outage: local state still answers, anything needing a round trip 400s.
connection_alive: bool = True
def reset(self) -> None:
"""Forget captured sends and attachments."""

View File

@ -58,6 +58,11 @@ cleanup() {
}
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
@ -109,19 +114,23 @@ main() {
-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 deliberately empty: the bot discovers its groups from the
# account, which is the configuration real deployments now use. Set
# rather than merely unset, because load_dotenv() would otherwise let
# the developer's own .env decide what this test run watches.
# 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.
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" \
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"
@ -140,6 +149,43 @@ main() {
--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 "$@"

View File

@ -18,6 +18,8 @@ readonly SCRIPT_NAME
readonly UNIT_NAME="signal-bot.service"
readonly UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
readonly TEMPLATE="${REPO_ROOT}/scripts/${UNIT_NAME}"
readonly WATCHDOG_UNITS=("signal-api-watchdog.service" "signal-api-watchdog.timer")
readonly WATCHDOG_TIMER="signal-api-watchdog.timer"
readonly DEFAULT_PORT=8137
VERIFY_ONLY="false"
@ -51,6 +53,12 @@ validate_requirements() {
die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first"
[[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh"
[[ -f "$TEMPLATE" ]] || die "no unit template at $TEMPLATE"
[[ -x "${REPO_ROOT}/scripts/signal_api_watchdog.sh" ]] ||
die "no executable ${REPO_ROOT}/scripts/signal_api_watchdog.sh"
local unit
for unit in "${WATCHDOG_UNITS[@]}"; do
[[ -f "${REPO_ROOT}/scripts/${unit}" ]] || die "no unit template for ${unit}"
done
}
# Without linger, a user unit dies at logout and never starts at boot.
@ -79,6 +87,18 @@ install_unit() {
echo "== installed and started ${UNIT_DIR}/${UNIT_NAME}"
}
# The bot cannot restart the container it depends on from inside a wedged
# event loop, so recovery from a dead Signal connection lives in a timer.
install_watchdog() {
local unit
for unit in "${WATCHDOG_UNITS[@]}"; do
sed "s|@REPO_ROOT@|${REPO_ROOT}|g" "${REPO_ROOT}/scripts/${unit}" >"${UNIT_DIR}/${unit}"
done
systemctl --user daemon-reload
systemctl --user enable --now "$WATCHDOG_TIMER" >/dev/null
echo "== installed and armed ${UNIT_DIR}/${WATCHDOG_TIMER}"
}
# Report what actually happened, not that the commands returned 0.
verify() {
local port="$1"
@ -90,6 +110,12 @@ verify() {
signal_wait_for_url "http://127.0.0.1:${port}/healthz" "signal-bot" 60
echo "== active, and answering on http://127.0.0.1:${port}/healthz"
echo "== enabled at boot: $(systemctl --user is-enabled "$UNIT_NAME")"
if ! systemctl --user is-active --quiet "$WATCHDOG_TIMER"; then
echo "Error: ${WATCHDOG_TIMER} is not active; nothing will recover a dead" >&2
echo " connection to Signal automatically." >&2
return 1
fi
systemctl --user list-timers --no-pager "$WATCHDOG_TIMER" | sed -n '2p'
}
main() {
@ -100,6 +126,7 @@ main() {
if [[ "$VERIFY_ONLY" != "true" ]]; then
require_linger
install_unit
install_watchdog
fi
verify "$port"

View File

@ -0,0 +1,22 @@
# Installed to ~/.config/systemd/user/ by scripts/install_service.sh, which
# substitutes @REPO_ROOT@. Edit this template, never the installed copy.
[Unit]
Description=Restart signal-cli-rest-api when it can no longer reach Signal
Documentation=file://@REPO_ROOT@/README.md
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
WorkingDirectory=@REPO_ROOT@
# PHONE_NUMBER, API_BASE_URL and BIND_PORT come from the same file the bot
# reads, so the watchdog can never probe a different account than the bot.
EnvironmentFile=@REPO_ROOT@/.env
ExecStart=@REPO_ROOT@/scripts/signal_api_watchdog.sh
# A oneshot under a timer has no memory of its own: the failure counter and
# the restart history have to live somewhere that survives the exit.
StateDirectory=signal-api-watchdog
# Deliberately NOT ProtectHome: StateDirectory for a user unit writes under
# $HOME/.local/state, which a read-only home would make fail.
NoNewPrivileges=true

View File

@ -0,0 +1,15 @@
# Installed to ~/.config/systemd/user/ by scripts/install_service.sh.
[Unit]
Description=Probe signal-cli's connection to Signal every two minutes
[Timer]
# Two minutes: the bot's own gate needs ~2 min of silence plus three probes
# to admit it is deaf, so checking faster would only ask before it knows.
OnBootSec=2min
OnUnitActiveSec=2min
AccuracySec=10s
Unit=signal-api-watchdog.service
[Install]
WantedBy=timers.target

179
scripts/signal_api_watchdog.sh Executable file
View File

@ -0,0 +1,179 @@
#!/bin/bash
# ============================================================================
# Restart signal-cli-rest-api when it can no longer reach Signal.
#
# Nothing else notices this failure. On 2026-08-29 the container's own
# healthcheck answered 204, /v1/groups answered 200 out of local state, and
# the bot's WebSocket to it stayed open, while signal-cli reconnected to
# Signal in a loop for three hours and not one message arrived. Docker cannot
# see it, so a timer asks the one question that needs a real round trip.
#
# Runs from signal-api-watchdog.timer as a oneshot, so every counter lives on
# disk: the process has no memory of the previous firing.
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly API="${API_BASE_URL:-http://localhost:9922}"
readonly BOT="http://127.0.0.1:${BIND_PORT:-8137}/healthz"
readonly CONTAINER="${SIGNAL_CONTAINER:-signal-api}"
readonly STATE_DIR="${STATE_DIRECTORY:-${XDG_STATE_HOME:-$HOME/.local/state}/signal-api-watchdog}"
# Overridable so the tests can drive the whole ladder without waiting.
readonly PROBE_TIMEOUT="${PROBE_TIMEOUT:-15}"
readonly FAILURES_BEFORE_RESTART="${FAILURES_BEFORE_RESTART:-2}"
readonly MAX_RESTARTS="${MAX_RESTARTS:-3}"
readonly RESTART_WINDOW="${RESTART_WINDOW:-21600}" # 6 hours
readonly SETTLE_SECONDS="${SETTLE_SECONDS:-45}"
readonly FAILURE_FILE="${STATE_DIR}/consecutive_failures"
readonly RESTART_FILE="${STATE_DIR}/restarts"
usage() {
echo "Usage: $SCRIPT_NAME"
echo " Probes signal-cli's connection to Signal and restarts ${CONTAINER}"
echo " when it has been dead for ${FAILURES_BEFORE_RESTART} consecutive checks."
echo " Reads PHONE_NUMBER, API_BASE_URL and BIND_PORT from the environment."
exit 0
}
die() {
echo "Error: $1" >&2
exit 1
}
# journald and the desktop, always both: the broken path IS Signal, so the
# bot cannot tell anybody about this itself.
alarm() {
logger --priority user.warning --tag signal-api-watchdog -- "$1" || true
echo "== $1"
notify-send --urgency=critical --app-name=signal-bot "signal-bot" "$1" 2>/dev/null || true
}
validate_requirements() {
command -v curl >/dev/null || die "curl not found"
command -v docker >/dev/null || die "docker not found"
[[ -n "${PHONE_NUMBER:-}" ]] ||
die "PHONE_NUMBER is unset -- the unit must load the repo's .env"
mkdir -p "$STATE_DIR"
}
read_count() {
local value=0
[[ -f "$FAILURE_FILE" ]] && read -r value <"$FAILURE_FILE"
printf '%s' "${value:-0}"
}
# --max-time on every probe: an unbounded curl would outlive the timer
# interval and overlap the next firing.
http_code() {
curl -s -o /dev/null -m "$2" -w '%{http_code}' "$1" || true
}
# Two tiers. The bot's own /healthz is free and already knows whether it can
# hear; the authenticated probe is what confirms it independently, and what
# still works when the bot process itself is down.
connection_is_alive() {
if [[ "$(http_code "$BOT" 5)" == "200" ]]; then
return 0
fi
local code
code="$(http_code "${API}/v1/devices/${PHONE_NUMBER}" "$PROBE_TIMEOUT")"
if [[ "$code" == "200" ]]; then
return 0
fi
echo "== ${API}/v1/devices answered ${code}"
return 1
}
record_success() {
local previous
previous="$(read_count)"
printf '0\n' >"$FAILURE_FILE"
if ((previous >= FAILURES_BEFORE_RESTART)); then
alarm "signal-api is reachable again after ${previous} failed probe(s)."
fi
}
# Restart history is durable and pruned to the window, so a fault that
# recurs every few hours cannot be papered over by a counter that resets.
recent_restarts() {
local now cutoff stamp count=0 pruned
now="$(date +%s)"
cutoff=$((now - RESTART_WINDOW))
if [[ ! -f "$RESTART_FILE" ]]; then
printf '0'
return 0
fi
pruned="${RESTART_FILE}.pruned"
: >"$pruned"
while read -r stamp; do
[[ "$stamp" =~ ^[0-9]+$ ]] || continue
((stamp > cutoff)) || continue
printf '%s\n' "$stamp" >>"$pruned"
count=$((count + 1))
done <"$RESTART_FILE"
mv "$pruned" "$RESTART_FILE"
printf '%s' "$count"
}
give_up() {
alarm "signal-api has been restarted ${MAX_RESTARTS} time(s) and is still deaf. \
This is probably not transient: the account may be deregistered, or the host \
firewall may be blocking the container. Check 'docker exec ${CONTAINER} head -5 \
/var/log/signal-cli-json-rpc-1/out.log' and 'docker exec ${CONTAINER} getent hosts \
chat.signal.org'. No further restarts until the window passes."
}
restart_container() {
date +%s >>"$RESTART_FILE"
alarm "signal-api cannot reach Signal; restarting ${CONTAINER}."
docker restart "$CONTAINER" >/dev/null || die "docker restart ${CONTAINER} failed"
sleep "$SETTLE_SECONDS"
if connection_is_alive; then
printf '0\n' >"$FAILURE_FILE"
alarm "signal-api is back: the connection to Signal is alive again."
return 0
fi
alarm "signal-api restarted but is still deaf; will retry on the next check."
}
main() {
validate_requirements
if connection_is_alive; then
record_success
exit 0
fi
local failures
failures=$(($(read_count) + 1))
printf '%s\n' "$failures" >"$FAILURE_FILE"
logger --priority user.warning --tag signal-api-watchdog -- \
"signal-api probe failed (${failures}/${FAILURES_BEFORE_RESTART})" || true
echo "== probe failed (${failures}/${FAILURES_BEFORE_RESTART})"
if ((failures < FAILURES_BEFORE_RESTART)); then
exit 0
fi
if (($(recent_restarts) >= MAX_RESTARTS)); then
give_up
exit 0
fi
restart_container
}
while [[ $# -gt 0 ]]; do
case $1 in
-h | --help) usage ;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
main

229
scripts/test_watchdog.sh Executable file
View File

@ -0,0 +1,229 @@
#!/bin/bash
# ============================================================================
# Tests for scripts/signal_api_watchdog.sh.
#
# Hermetic: curl and docker are both stubbed on PATH, so nothing here talks to
# a network or a container. The stub docker can "fix" the stub curl's answers,
# which is how the restart-then-recover path is exercised without a restart.
# ============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly REPO_ROOT
readonly WATCHDOG="${REPO_ROOT}/scripts/signal_api_watchdog.sh"
WORK=""
FAILURES=0
cleanup() {
if [[ -n "$WORK" && -d "$WORK" ]]; then
rm -rf "$WORK"
fi
}
trap cleanup EXIT
fail() {
echo " FAIL: $1" >&2
FAILURES=$((FAILURES + 1))
}
ok() {
echo " ok: $1"
}
# Rebuild a pristine sandbox: stub binaries, stub answers, empty state.
setup() {
rm -rf "$WORK"
mkdir -p "$WORK/bin" "$WORK/state"
printf '200' >"$WORK/bot_code"
printf '200' >"$WORK/api_code"
printf '0' >"$WORK/fix_on_restart"
cat >"$WORK/bin/curl" <<EOF
#!/bin/bash
url="\${*: -1}"
case "\$url" in
*/healthz) cat "$WORK/bot_code" ;;
*) cat "$WORK/api_code" ;;
esac
EOF
cat >"$WORK/bin/docker" <<EOF
#!/bin/bash
echo "\$@" >> "$WORK/docker.log"
if [[ "\$(cat "$WORK/fix_on_restart")" == "1" ]]; then
printf '200' > "$WORK/bot_code"
printf '200' > "$WORK/api_code"
fi
EOF
chmod +x "$WORK/bin/curl" "$WORK/bin/docker"
}
run_watchdog() {
PATH="$WORK/bin:$PATH" \
PHONE_NUMBER="+15550000000" \
STATE_DIRECTORY="$WORK/state" \
SETTLE_SECONDS=0 \
FAILURES_BEFORE_RESTART=2 \
MAX_RESTARTS=2 \
RESTART_WINDOW=3600 \
"$WATCHDOG" 2>&1
}
expect_contains() {
if [[ "$1" == *"$2"* ]]; then
ok "$3"
else
fail "$3 -- got: $1"
fi
}
expect_missing() {
if [[ "$1" != *"$2"* ]]; then
ok "$3"
else
fail "$3 -- unexpectedly found $2"
fi
}
expect_file() {
local actual
actual="$(cat "$1" 2>/dev/null || echo MISSING)"
if [[ "$actual" == "$2" ]]; then
ok "$3"
else
fail "$3 -- $1 is '$actual', expected '$2'"
fi
}
test_healthy_is_free() {
echo "== a healthy bot needs no restart and no authenticated probe"
setup
run_watchdog >/dev/null
expect_file "$WORK/state/consecutive_failures" "0" "failure counter stays at zero"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was touched while everything was healthy"
else
ok "docker was never called"
fi
}
test_one_failure_waits() {
echo "== one failed probe is not enough to restart anything"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
local out
out="$(run_watchdog)"
expect_contains "$out" "probe failed (1/2)" "the failure is counted and logged"
expect_file "$WORK/state/consecutive_failures" "1" "counter persisted for the next firing"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was restarted after a single failure"
else
ok "docker was not restarted"
fi
}
test_confirmed_failure_restarts_and_recovers() {
echo "== two failures restart the container, and recovery is announced"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
printf '1' >"$WORK/fix_on_restart"
run_watchdog >/dev/null
local out
out="$(run_watchdog)"
expect_contains "$(cat "$WORK/docker.log")" "restart signal-api" "the container was restarted"
expect_contains "$out" "is back" "the recovery is announced"
expect_file "$WORK/state/consecutive_failures" "0" "the counter was cleared"
}
test_restart_that_does_not_help() {
echo "== a restart that does not help says so instead of claiming success"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
run_watchdog >/dev/null
local out
out="$(run_watchdog)"
expect_contains "$out" "still deaf" "the failed recovery is reported"
}
test_gives_up_instead_of_looping() {
echo "== it gives up loudly rather than restarting forever"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
local out="" attempt=0
while ((attempt < 6)); do
out="$(run_watchdog)"
attempt=$((attempt + 1))
done
local restarts
restarts="$(grep -c "restart signal-api" "$WORK/docker.log")"
if [[ "$restarts" == "2" ]]; then
ok "restarts stopped at the cap (2)"
else
fail "expected 2 restarts, got $restarts"
fi
expect_contains "$out" "still deaf" "the give-up message explains what to check"
expect_contains "$out" "out.log" "the give-up message names the log to read"
}
test_requires_a_number() {
echo "== it refuses to run without PHONE_NUMBER"
setup
local out status=0
out="$(PATH="$WORK/bin:$PATH" PHONE_NUMBER="" STATE_DIRECTORY="$WORK/state" \
"$WATCHDOG" 2>&1)" || status=$?
if [[ "$status" == "1" ]]; then
ok "exited non-zero"
else
fail "expected exit 1, got $status"
fi
expect_contains "$out" "PHONE_NUMBER is unset" "it says which value is missing"
}
test_bot_down_but_account_alive() {
echo "== the bot being down is not the container's fault"
setup
printf '000' >"$WORK/bot_code"
local out
out="$(run_watchdog)"
expect_missing "$out" "probe failed" "the authenticated probe carried the check"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was restarted although the account was reachable"
else
ok "docker was not restarted"
fi
}
main() {
WORK="$(mktemp -d)"
[[ -d "$WORK" ]] || {
echo "Error: failed to create a temporary directory" >&2
exit 1
}
echo "============================================================"
echo "signal_api_watchdog.sh"
echo "============================================================"
test_healthy_is_free
test_bot_down_but_account_alive
test_one_failure_waits
test_confirmed_failure_restarts_and_recovers
test_restart_that_does_not_help
test_gives_up_instead_of_looping
test_requires_a_number
echo "============================================================"
if ((FAILURES > 0)); then
echo "FAILED: $FAILURES check(s)"
exit 1
fi
echo "all checks passed"
}
main "$@"

View File

@ -16,7 +16,7 @@ if TYPE_CHECKING:
from signal_bot.transport_base import Transport
import httpx
from fastapi import FastAPI
from fastapi import FastAPI, Response, status
from signal_bot.animals import register_animal_commands
from signal_bot.config import Config, load_config
@ -25,10 +25,12 @@ from signal_bot.decorators import cooldown
from signal_bot.discovery import GroupDirectory
from signal_bot.dispatcher import Dispatcher
from signal_bot.http_client import ImageFetcher
from signal_bot.liveness import Liveness
from signal_bot.registry import CommandRegistry
from signal_bot.scheduler import DailyScheduler
from signal_bot.store import Store
from signal_bot.transport_rest import RestTransport
from signal_bot.trust import InviteTrust
logger = logging.getLogger(__name__)
@ -50,6 +52,7 @@ class Bot:
transport: Transport
directory: GroupDirectory
dispatcher: Dispatcher
liveness: Liveness
async def publish_report(self) -> None:
"""Post each group's own tally into that group, and reset it.
@ -78,6 +81,10 @@ class Bot:
async def listen(self) -> None:
"""Consume frames forever, handing each to the dispatcher."""
async for frame in self.transport.receive():
# Every frame counts, including receipts and typing indicators:
# the question this answers is "can anything still reach us?",
# not "did anybody address the bot?".
self.liveness.note_envelope()
await self.dispatcher.handle(frame)
@ -87,6 +94,7 @@ def build_bot(
store: Store,
transport: Transport,
directory: GroupDirectory,
liveness: Liveness,
) -> Bot:
"""Assemble the object graph from its injected dependencies."""
registry = CommandRegistry()
@ -110,6 +118,7 @@ def build_bot(
transport=transport,
directory=directory,
dispatcher=dispatcher,
liveness=liveness,
)
@ -137,6 +146,9 @@ class Runtime:
# Third loop: without it, a group added later is only noticed
# when somebody happens to speak in it.
asyncio.create_task(self.bot.directory.run()),
# Fourth loop: without it, a dead connection to Signal is
# indistinguishable from a quiet evening, for hours.
asyncio.create_task(self.bot.liveness.run()),
]
async def stop(self) -> None:
@ -155,11 +167,15 @@ async def create_runtime(config: Config, client: httpx.AsyncClient) -> Runtime:
store = Store(config.database_path)
transport = RestTransport(config, client)
await transport.verify_mode()
directory = GroupDirectory(config, client, store)
directory = GroupDirectory(config, client, store, InviteTrust(config.trusted_inviters))
await directory.refresh()
await directory.verify_allowlist()
# No probe here: /v1/devices has been seen to hang past 20s, and a
# startup that can hang is a startup that can burn the unit's restart
# limit. The gate starts healthy and earns its verdict at runtime.
liveness = Liveness(config, client)
return Runtime(
bot=build_bot(config, client, store, transport, directory),
bot=build_bot(config, client, store, transport, directory, liveness),
client=client,
store=store,
)
@ -194,6 +210,15 @@ app = FastAPI(lifespan=lifespan)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
"""Liveness probe. The old app defined no routes at all."""
return {"status": "ok"}
async def healthz(response: Response) -> dict[str, str]:
"""Liveness probe, and the only place the bot admits it has gone deaf.
Answering 200 while nothing can reach Signal is what let a three-hour
outage look like a quiet evening, so this must go red before anybody
thinks to look at the logs.
"""
liveness: Liveness = app.state.runtime.bot.liveness
if liveness.healthy:
return {"status": "ok"}
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "deaf", "reason": liveness.reason}

View File

@ -20,6 +20,7 @@ from signal_bot.groups import (
group_id_aliases,
parse_group_ids,
)
from signal_bot.trust import parse_trusted_inviters
__all__ = [
"Config",
@ -27,6 +28,7 @@ __all__ = [
"canonical_group_id",
"group_id_aliases",
"load_config",
"parse_trusted_inviters",
]
DEFAULT_API_BASE_URL: Final = "http://localhost:9922"
@ -46,6 +48,10 @@ MAX_HOUR: Final = 23
MAX_MINUTE: Final = 59
MAX_PORT: Final = 65535
DEFAULT_LOG_LEVEL: Final = "INFO"
# How long the bot may hear nothing at all before it stops believing its own
# health. Silence is normal -- a group can be quiet all evening -- so this is
# only the point at which silence becomes worth *asking* about.
DEFAULT_SILENCE_SECONDS: Final = 120
_WS_SCHEMES: Final = {"http": "ws", "https": "wss"}
@ -77,6 +83,11 @@ class Config:
# unreachable in production, which is how a scoping mismatch turns
# into a bot that looks healthy and answers nothing.
log_level: str = DEFAULT_LOG_LEVEL
# Who may add this bot to a group. Empty means anyone. See signal_bot.trust.
trusted_inviters: tuple[str, ...] = ()
# Lowered in the e2e run so the deafness gate can be proven against the
# real process rather than only in unit tests.
silence_seconds: int = DEFAULT_SILENCE_SECONDS
@property
def is_pinned(self) -> bool:
@ -125,6 +136,19 @@ class Config:
"""REST endpoint reporting the container build and its mode."""
return f"{self.api_base_url}/v1/about"
@property
def devices_url(self) -> str:
"""The one endpoint that fails when signal-cli cannot reach Signal.
Verified on 2026-08-29, in both directions: 200 with a device list
while healthy, and 400 "Failed to get linked devices: Closed
unexpectedly" during the outage -- while /v1/groups and /v1/about
kept answering 200 from local state and the container's own
healthcheck stayed green. Local state is why the bot could look
perfectly healthy for three hours with a dead connection.
"""
return f"{self.api_base_url}/v1/devices/{self.phone_number}"
@property
def groups_url(self) -> str:
"""REST endpoint listing the groups this account knows about."""
@ -200,4 +224,6 @@ def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -
bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST,
bind_port=bind_port,
log_level=log_level,
trusted_inviters=parse_trusted_inviters(env),
silence_seconds=_positive_int(env, "SILENCE_SECONDS", DEFAULT_SILENCE_SECONDS),
)

View File

@ -23,11 +23,13 @@ from typing import TYPE_CHECKING, Any, Final
import httpx
from signal_bot.groups import build_alias_map, canonical_group_id
from signal_bot.membership import JoinAttempts, MembershipError, attempt_join
from signal_bot.membership import JoinAttempts, attempt_join, verify_allowlist
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
from signal_bot.store import Store
from signal_bot.trust import InviteTrust
logger = logging.getLogger(__name__)
@ -44,6 +46,7 @@ class GroupDirectory:
config: Config,
client: httpx.AsyncClient,
store: Store,
trust: InviteTrust,
*,
sleeper: Sleeper | None = None,
) -> None:
@ -54,11 +57,14 @@ class GroupDirectory:
client: HTTP client for the REST API.
store: Where per-group state lives, so a group the bot is
removed from can have its rows dropped.
trust: Decides whether a discovered group is one the operator
asked for.
sleeper: Async sleep used by run(). Injected by the tests.
"""
self._config = config
self._client = client
self._store = store
self._trust = trust
self._sleep = sleeper if sleeper is not None else asyncio.sleep
self._aliases: dict[str, str] = {}
self._groups: tuple[str, ...] = ()
@ -92,6 +98,20 @@ class GroupDirectory:
return None
return self._aliases.get(group_id)
def note_group_update(self, message: IncomingMessage) -> None:
"""Record who changed a group's membership.
Called before the scoping gate on purpose: the frame that names the
inviter arrives while the group it describes is still unknown, and
it is the only authoritative answer to "who added this bot?".
Args:
message: Any parsed inbound message.
"""
if message.group_id is None or not message.is_group_update:
return
self._trust.note_update(message.group_id, message.source_number, message.source_uuid)
async def refresh(self) -> None:
"""Rebuild the watched set from the account's own group listing.
@ -125,28 +145,8 @@ class GroupDirectory:
return self.resolve(group_id)
async def verify_allowlist(self) -> None:
"""Refuse to start when GROUP_IDS names a group the bot cannot use.
Only applies when the operator pinned the bot to specific groups. An
unpinned bot watches whatever it has been added to, including
nothing at all, and that is not an error -- it is a bot waiting to
be added.
Raises:
MembershipError: A pinned group is missing or unjoinable.
"""
if not self._config.is_pinned:
logger.info("watching every group this account is a member of")
return
missing = [group_id for group_id in self._config.group_ids if group_id not in self._groups]
if missing:
details = ", ".join(f"{gid} ({self._joins.reason(gid)})" for gid in missing)
msg = (
f"GROUP_IDS pins the bot to {len(self._config.group_ids)} group(s) but it is "
f"not a joined member of: {details}. Have an admin add the bot again now that "
"it has a profile, or drop the id from GROUP_IDS."
)
raise MembershipError(msg)
"""Refuse to start when GROUP_IDS names a group the bot cannot use."""
verify_allowlist(self._config, self._groups, self._joins)
async def run(self, iterations: int | None = None) -> None:
"""Refresh forever, so a group added later needs no restart.
@ -189,7 +189,15 @@ class GroupDirectory:
if not isinstance(raw, str) or not raw:
return None
group_id = canonical_group_id(raw)
return group_id if self._config.allows(group_id) else None
if not self._config.allows(group_id):
return None
# A pinned id was named by the operator in .env, which is already the
# strongest possible vouch; running it past the trust gate as well
# would make verify_allowlist fail with a message about joins that
# says nothing about trust.
if self._config.is_pinned or self._trust.allows(group_id, entry):
return group_id
return None
async def _join_pending(self, entries: list[dict[str, Any]]) -> bool:
"""Try to accept every invitation worth retrying.

View File

@ -41,6 +41,10 @@ class Dispatcher:
if message is None:
return
# Before the scoping gate: a group-update frame arrives while its
# group is still unknown, and it is what vouches for that group.
self.directory.note_group_update(message)
# The scoping gate. Direct messages and every other group the account
# belongs to stop here, before counting and before any handler runs.
# The canonical id it yields is the reply address and the key for

172
signal_bot/liveness.py Normal file
View File

@ -0,0 +1,172 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Whether the bot can still hear, and the gate that stops it saying yes anyway.
On 2026-08-29 signal-cli's connection to Signal died and reconnected in a
loop for three hours. Every surface said the bot was fine: the container's
own healthcheck answered 204, ``/v1/groups`` answered 200 out of local
state, ``/healthz`` answered 200, and the bot's WebSocket to the container
stayed *open* -- the container keeps client sockets alive even when its own
upstream is dead, so the transport's reconnect logic never fired. The only
symptom was silence, and silence is also what a quiet evening looks like.
So this module answers "is the silence real?" with a question nobody can
answer from a cache: ``/v1/devices``, which needs a live round trip to
Signal. It is asked only after the silence has gone on long enough to be
worth asking about, so a busy group costs nothing and an idle one costs one
request per silence window.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Final
import httpx
if TYPE_CHECKING:
from signal_bot.config import Config
logger = logging.getLogger(__name__)
TICK_SECONDS: Final = 30.0
# Measured against the real failure: the broken endpoint answered in 3.9s
# once and hung past 20s another time. 15s is above the observed failure
# latency and below TICK_SECONDS, so probes can never pile up.
PROBE_TIMEOUT_SECONDS: Final = 15.0
# signal-cli's own reconnect loop backs off in 100..3200ms, so the failure is
# stable within a second. Three probes 30s apart is not "waiting it out", it
# is refusing to trip on one bad round trip.
FAILURES_BEFORE_DEAF: Final = 3
Ticker = Callable[[], float]
Sleeper = Callable[[float], Awaitable[None]]
class Liveness:
"""Tracks proof that messages can still arrive, and reports when they cannot."""
def __init__(
self,
config: Config,
client: httpx.AsyncClient,
*,
ticker: Ticker | None = None,
sleeper: Sleeper | None = None,
failures_before_deaf: int = FAILURES_BEFORE_DEAF,
) -> None:
"""Wire the gate to its config and its injectable I/O.
Args:
config: Resolved runtime configuration.
client: HTTP client used for the probe.
ticker: Monotonic clock. Injected by the tests.
sleeper: Async sleep used by run(). Injected by the tests.
failures_before_deaf: Consecutive failed probes before the bot
stops calling itself healthy.
"""
self._config = config
self._client = client
self._now = ticker if ticker is not None else time.monotonic
self._sleep = sleeper if sleeper is not None else asyncio.sleep
self._limit = failures_before_deaf
# Healthy until proven otherwise: the installer waits on /healthz
# with a 60s budget, and a cold "unknown means 503" would fail the
# install and burn the unit's restart limit for no reason.
self._last_alive = self._now()
self._failures = 0
self._detail = ""
@property
def healthy(self) -> bool:
"""True while the bot has no evidence that it has gone deaf."""
return self._failures < self._limit
@property
def silent_for(self) -> float:
"""Seconds since the last envelope or successful probe."""
return self._now() - self._last_alive
@property
def reason(self) -> str:
"""Why the bot believes it is deaf, in one line for /healthz."""
return (
f"nothing received for {self.silent_for:.0f}s and {self._failures} "
f"consecutive failed probes of {self._config.devices_url}: {self._detail}"
)
def note_envelope(self) -> None:
"""Record proof of life: any frame at all, or a successful probe."""
self._last_alive = self._now()
if self._failures:
logger.warning("receive path recovered after %d failed probe(s)", self._failures)
self._failures = 0
self._detail = ""
async def probe(self) -> bool:
"""Ask signal-cli something it cannot answer from local state.
Returns:
True when the account's connection to Signal is alive.
"""
try:
response = await self._client.get(
self._config.devices_url, timeout=PROBE_TIMEOUT_SECONDS
)
except httpx.HTTPError as exc:
self._detail = f"{type(exc).__name__}: {exc}"
return False
if response.status_code != httpx.codes.OK:
self._detail = f"HTTP {response.status_code}: {response.text.strip()[:200]}"
return False
return True
async def run(self, iterations: int | None = None) -> None:
"""Check for deafness forever.
Args:
iterations: Stop after this many ticks. None means forever.
"""
completed = 0
while iterations is None or completed < iterations:
await self._sleep(TICK_SECONDS)
await self.check()
completed += 1
async def check(self) -> None:
"""Probe once, but only when the silence has lasted long enough.
Public because it is the whole gate in one call: the tests, and any
future hand-run diagnostic, must be able to ask the same question
the loop asks without reaching into private state.
"""
silent = self.silent_for
if silent < self._config.silence_seconds:
return
if await self.probe():
# A live probe is proof of life exactly like an envelope, and
# resetting the clock here is what bounds the cost: an idle
# account probes once per silence window, not once per tick.
self.note_envelope()
return
self._failures += 1
if self._failures <= self._limit:
logger.warning(
"signal-cli cannot reach Signal (failure %d of %d) after %.0fs of silence: %s",
self._failures,
self._limit,
silent,
self._detail,
)
return
# Past the limit the verdict is already in, and "failure 8 of 3"
# reads like a bug in the counter rather than a bot that is still
# deaf. Keep saying it, but say it truthfully.
logger.warning(
"still deaf: %d failed probe(s) and %.0fs of silence: %s",
self._failures,
silent,
self._detail,
)

View File

@ -104,3 +104,32 @@ async def attempt_join(client: httpx.AsyncClient, config: Config, group_id: str)
except httpx.HTTPError as exc:
return f"join request failed: {exc}"
return f"join returned HTTP {response.status_code}: {response.text.strip()[:200]}"
def verify_allowlist(config: Config, groups: tuple[str, ...], joins: JoinAttempts) -> None:
"""Refuse to start when GROUP_IDS names a group the bot is not in.
Only applies when the operator pinned the bot to specific groups. An
unpinned bot watches whatever it has been added to, including nothing at
all, and that is not an error -- it is a bot waiting to be added.
Args:
config: Resolved runtime configuration.
groups: The groups the bot actually discovered.
joins: Why a join failed, for the error message.
Raises:
MembershipError: A pinned group is missing or unjoinable.
"""
if not config.is_pinned:
logger.info("watching every group this account is a member of")
return
missing = [group_id for group_id in config.group_ids if group_id not in groups]
if missing:
details = ", ".join(f"{gid} ({joins.reason(gid)})" for gid in missing)
msg = (
f"GROUP_IDS pins the bot to {len(config.group_ids)} group(s) but it is "
f"not a joined member of: {details}. Have an admin add the bot again now that "
"it has a profile, or drop the id from GROUP_IDS."
)
raise MembershipError(msg)

View File

@ -24,6 +24,13 @@ class IncomingMessage:
text: str | None
has_sticker: bool
is_reaction: bool
# The sender's +E.164 number. "source" carries it too, but only
# sometimes: live envelopes have shown a display name there and the
# number here, so this is the field a trust decision may rely on.
source_number: str = ""
# A group membership change rather than a message: this is the only
# frame that names who added the bot to a group.
is_group_update: bool = False
@property
def is_direct_message(self) -> bool:
@ -59,6 +66,15 @@ def _group_id(payload: dict[str, Any]) -> str | None:
return group_id
def _group_type(payload: dict[str, Any]) -> str:
"""Return the groupInfo type ("DELIVER", "UPDATE", ...), or ""."""
info = payload.get("groupInfo")
if not isinstance(info, dict):
return ""
group_type = info.get("type")
return group_type if isinstance(group_type, str) else ""
def _text(payload: dict[str, Any]) -> str | None:
"""Return the message body, or None when there is no text."""
message = payload.get("message")
@ -102,4 +118,6 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
# filtered. Reading it off the resolved payload fixes both shapes.
has_sticker=isinstance(payload.get("sticker"), dict),
is_reaction=isinstance(payload.get("reaction"), dict),
source_number=str(envelope.get("sourceNumber", "")),
is_group_update=_group_type(payload) == "UPDATE",
)

149
signal_bot/trust.py Normal file
View File

@ -0,0 +1,149 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Who is allowed to put this bot in a group.
The bot adopts every group its account is a member of, which is what makes
"add the bot" the whole setup. The cost is that *anybody* who knows the
number can pull it into a group and have it answer there. TRUSTED_INVITERS
puts a name on the door without taking the convenience away: a group still
needs no config edit and no restart, it just has to involve somebody the
operator listed.
Two independent pieces of evidence, because neither covers the other:
- the **group-update envelope**, which is the only frame that names who
changed the membership. It is authoritative, and it arrives before the
first message in a new group -- but only if the bot was running.
- the **listing row**, used when the bot was down when the group appeared.
``/v1/groups`` never says who invited anyone, so the proxy is "a trusted
identity is a full member of this group". Deliberately a proxy: it stops
a stranger's private group, and it does not stop a stranger's group that
a trusted person genuinely joined.
Identities are matched as the listing spells them. A row lists a phone
number for contacts the account knows and a bare UUID for everybody else,
so TRUSTED_INVITERS accepts either form and a refusal logs the row's own
members, which is what tells the operator which string to add.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Final
from signal_bot.groups import ConfigError, canonical_group_id
logger = logging.getLogger(__name__)
TRUSTED_INVITERS_KEY: Final = "TRUSTED_INVITERS"
# +E.164, or the UUID the listing uses for a member the account has never
# exchanged messages with.
_E164: Final = re.compile(r"^\+[1-9]\d{6,14}$")
_UUID: Final = re.compile(r"^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
def parse_trusted_inviters(env: dict[str, str]) -> tuple[str, ...]:
"""Return the identities allowed to add this bot to a group.
Args:
env: The environment mapping to read.
Returns:
The listed identities, de-duplicated and in order. Empty means the
gate is off and any group the account is in will be watched.
Raises:
ConfigError: An entry is neither +E.164 nor a UUID.
"""
raw = env.get(TRUSTED_INVITERS_KEY, "").strip()
if not raw:
return ()
trusted: list[str] = []
for item in raw.split(","):
identity = item.strip()
if not identity:
continue
if not _E164.match(identity) and not _UUID.match(identity):
msg = (
f"{TRUSTED_INVITERS_KEY} entries must be +E.164 numbers or member "
f"UUIDs, got {identity!r}"
)
raise ConfigError(msg)
if identity not in trusted:
trusted.append(identity)
return tuple(trusted)
def _identities(entry: dict[str, Any], key: str) -> set[str]:
"""Return one listing field as a set of strings, tolerating anything."""
values = entry.get(key)
if not isinstance(values, list):
return set()
return {value for value in values if isinstance(value, str)}
class InviteTrust:
"""Decides whether a group in the listing is one the operator asked for."""
def __init__(self, trusted: tuple[str, ...]) -> None:
"""Wire the gate to its allowlist.
Args:
trusted: Identities permitted to add the bot to a group. Empty
turns the gate off, which is said out loud rather than
assumed, because the difference is who can make this bot
talk in their group.
"""
self._trusted = frozenset(trusted)
self._vouched: set[str] = set()
self._refused: set[str] = set()
if not self._trusted:
logger.warning(
"%s is unset: every group this account is added to will be watched, "
"whoever added it",
TRUSTED_INVITERS_KEY,
)
def note_update(self, group_id: str, source_number: str, source_uuid: str) -> None:
"""Record a membership change, vouching for the group when it is trusted.
Args:
group_id: The group id as the envelope spelled it.
source_number: The +E.164 number that made the change, if known.
source_uuid: The uuid that made the change.
"""
if source_number not in self._trusted and source_uuid not in self._trusted:
return
canonical = canonical_group_id(group_id)
if canonical not in self._vouched:
self._vouched.add(canonical)
self._refused.discard(canonical)
logger.info("a trusted inviter added the bot to %s", canonical)
def allows(self, group_id: str, entry: dict[str, Any]) -> bool:
"""True when this group may be watched.
Args:
group_id: The canonical group id.
entry: The group's row from ``/v1/groups``.
Returns:
True when the gate is off, the group was vouched for by an
update envelope, or a trusted identity is a full member.
"""
if not self._trusted or group_id in self._vouched:
return True
members = _identities(entry, "members") - _identities(entry, "pending_invites")
if self._trusted & members:
return True
if group_id not in self._refused:
self._refused.add(group_id)
logger.warning(
"refusing group %r (%s): no trusted inviter. Its members are %s; "
"add one of them to %s if this group is yours.",
entry.get("name"),
group_id,
sorted(members),
TRUSTED_INVITERS_KEY,
)
return False

View File

@ -14,6 +14,7 @@ from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
from signal_bot.trust import InviteTrust
# Canonical ``group.<base64>`` ids, the spelling /v1/groups reports and the
# only one /v2/send accepts. The *_INTERNAL forms are what an envelope
@ -24,6 +25,10 @@ SECOND_GROUP = "group.YzJWamIyNWtMV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaA=="
SECOND_INTERNAL = "c2Vjb25kLWdyb3VwLWlkLTMyLWJ5dGVzISEhISEh"
OTHER_GROUP = "group.YjNSb1pYSXRaM0p2ZFhBdGFXUXRNekl0WW5sMFpYTWhJU0VoSVNFPQ=="
# The operator, and somebody who is not.
TRUSTED_NUMBER = "+48123456789"
STRANGER_NUMBER = "+15555550123"
@pytest.fixture
def config() -> Config:
@ -52,7 +57,7 @@ def listing_client(entries: list[dict[str, Any]]) -> httpx.AsyncClient:
async def directory(config: Config, store: Store) -> GroupDirectory:
"""A directory that has already discovered both test groups."""
client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)])
found = GroupDirectory(config, client, store)
found = GroupDirectory(config, client, store, InviteTrust(()))
await found.refresh()
return found
@ -96,6 +101,7 @@ def envelope(
body: dict[str, Any] = {
"source": name,
"sourceNumber": STRANGER_NUMBER,
"sourceUuid": uuid,
"sourceName": name,
"sourceDevice": 69,
@ -106,3 +112,33 @@ def envelope(
else:
body["dataMessage"] = payload
return json.dumps({"envelope": body, "account": "+15550000000"})
def update_envelope(
*,
group_id: str = WATCHED_GROUP,
number: str = TRUSTED_NUMBER,
uuid: str = "uuid-operator",
) -> str:
"""Build a group-membership-change frame: no text, groupInfo type UPDATE.
This is the shape signal-cli emits when somebody adds the bot to a
group, and the only frame that names who did it.
"""
return json.dumps(
{
"envelope": {
"source": number,
"sourceNumber": number,
"sourceUuid": uuid,
"sourceName": "kuhy",
"timestamp": 1719177917717,
"dataMessage": {
"timestamp": 1719177917717,
"message": None,
"groupInfo": {"groupId": group_id, "type": "UPDATE"},
},
},
"account": "+15550000000",
}
)

View File

@ -11,6 +11,7 @@ import pytest
from signal_bot.discovery import GroupDirectory
from signal_bot.membership import MembershipError
from signal_bot.trust import InviteTrust
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, group_entry
if TYPE_CHECKING:
@ -19,7 +20,12 @@ if TYPE_CHECKING:
def _directory(config: Config, store: Store, handler: object) -> GroupDirectory:
return GroupDirectory(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)), store)
return GroupDirectory(
config,
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
store,
InviteTrust(()),
)
async def test_the_allowlist_restricts_what_is_adopted(config: Config, store: Store) -> None:
@ -85,6 +91,7 @@ async def test_run_refreshes_on_a_schedule(config: Config, store: Store) -> None
transport=httpx.MockTransport(lambda r: httpx.Response(200, json=next(listings)))
),
store,
InviteTrust(()),
sleeper=sleeper,
)
await found.run(iterations=2)

View File

@ -14,9 +14,11 @@ from fastapi.testclient import TestClient
from signal_bot import app as app_module
from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local
from signal_bot.discovery import GroupDirectory
from signal_bot.liveness import Liveness
from signal_bot.membership import MembershipError
from signal_bot.transport_fake import FakeTransport
from signal_bot.transport_rest import TransportError
from signal_bot.trust import InviteTrust
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope, group_entry, listing_client
if TYPE_CHECKING:
@ -52,7 +54,8 @@ def _signal(*group_ids: str, mode: str = "json-rpc", member: bool = True) -> Han
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
return build_bot(config, _client(lambda r: httpx.Response(200)), store, transport, directory)
client = _client(lambda r: httpx.Response(200))
return build_bot(config, client, store, transport, directory, Liveness(config, client))
def test_now_local_is_timezone_aware() -> None:
@ -118,8 +121,8 @@ async def test_runtime_start_and_stop_cancels_tasks(
client = _client(lambda r: httpx.Response(200))
runtime = Runtime(bot=_bot(config, store, transport, directory), client=client, store=store)
runtime.start()
# listen, the daily report, and the group refresh loop.
assert len(runtime.tasks) == 3
# listen, the daily report, the group refresh loop, the deafness gate.
assert len(runtime.tasks) == 4
await asyncio.sleep(0)
await runtime.stop()
assert all(task.done() for task in runtime.tasks)
@ -214,7 +217,7 @@ async def test_the_report_follows_the_live_group_set(
) -> None:
"""Groups come from the directory, not from .env, so a new one is included."""
client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)])
directory = GroupDirectory(config, client, store)
directory = GroupDirectory(config, client, store, InviteTrust(()))
await directory.refresh()
store.increment(WATCHED_GROUP, "u1", "Alice")
store.increment(SECOND_GROUP, "u2", "Bob")

View File

@ -9,6 +9,7 @@ import httpx
import pytest
from signal_bot.discovery import GroupDirectory
from signal_bot.trust import InviteTrust
from tests.conftest import (
OTHER_GROUP,
SECOND_GROUP,
@ -26,13 +27,21 @@ Listing = list[dict[str, Any]]
def _directory(config: Config, store: Store, handler: object) -> GroupDirectory:
return GroupDirectory(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)), store)
return GroupDirectory(
config,
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
store,
InviteTrust(()),
)
async def test_discovers_every_group_the_account_is_in(config: Config, store: Store) -> None:
"""No GROUP_IDS at all: whatever the account is a member of is watched."""
found = GroupDirectory(
config, listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]), store
config,
listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]),
store,
InviteTrust(()),
)
await found.refresh()
assert found.groups == (WATCHED_GROUP, SECOND_GROUP)

View File

@ -111,3 +111,33 @@ def test_control_replaces_accounts_rather_than_appending(client: TestClient) ->
client.post("/_control/accounts", json=["+15550000001"])
client.post("/_control/accounts", json=["+15550000002"])
assert client.get("/v1/accounts").json() == ["+15550000002"]
def test_devices_needs_a_registered_account(client: TestClient) -> None:
response = client.get(f"/v1/devices/{NUMBER}")
assert response.status_code == 400
assert "not registered" in response.json()["error"]
def test_devices_answers_while_the_connection_is_alive(client: TestClient, state: State) -> None:
state.accounts.append(NUMBER)
response = client.get(f"/v1/devices/{NUMBER}")
assert response.status_code == 200
assert response.json()[0]["id"] == 1
def test_devices_reproduces_the_outage_when_the_connection_is_cut(
client: TestClient, state: State
) -> None:
"""The 2026-08-29 shape: local state fine, anything upstream 400s."""
state.accounts.append(NUMBER)
assert client.post("/_control/connection", json={"alive": False}).json() == {"alive": False}
response = client.get(f"/v1/devices/{NUMBER}")
assert response.status_code == 400
assert response.json()["error"] == "Failed to get linked devices: Closed unexpectedly"
# Local state keeps answering, which is what made this invisible.
assert client.get("/v1/accounts").status_code == 200
assert client.post("/_control/connection", json={"alive": True}).json() == {"alive": True}
assert client.get(f"/v1/devices/{NUMBER}").status_code == 200

52
tests/test_healthz.py Normal file
View File

@ -0,0 +1,52 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""/healthz must go red when the bot cannot hear, not just when it is down."""
from __future__ import annotations
from dataclasses import replace
from types import SimpleNamespace
from typing import TYPE_CHECKING
import httpx
from fastapi import Response
from signal_bot import app as app_module
from signal_bot.app import healthz
from signal_bot.liveness import Liveness
if TYPE_CHECKING:
from signal_bot.config import Config
DEAD_BODY = {"error": "Failed to get linked devices: Closed unexpectedly"}
def _gate(config: Config, response: httpx.Response) -> Liveness:
"""A liveness gate whose probe always gets this answer."""
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: response))
return Liveness(config, client)
def _install(liveness: Liveness) -> None:
"""Point the app at a runtime carrying this gate."""
app_module.app.state.runtime = SimpleNamespace(bot=SimpleNamespace(liveness=liveness))
async def test_a_healthy_bot_answers_ok(config: Config) -> None:
_install(_gate(config, httpx.Response(200, json=[])))
response = Response()
assert await healthz(response) == {"status": "ok"}
assert response.status_code == httpx.codes.OK
async def test_a_deaf_bot_answers_503_and_says_why(config: Config) -> None:
"""The failure this exists for: up, listening, and hearing nothing."""
live = _gate(replace(config, silence_seconds=0), httpx.Response(400, json=DEAD_BODY))
for _ in range(3):
await live.check()
_install(live)
response = Response()
body = await healthz(response)
assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE
assert body["status"] == "deaf"
assert "Closed unexpectedly" in body["reason"]

177
tests/test_liveness.py Normal file
View File

@ -0,0 +1,177 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The deafness gate: silence plus a failing probe is not health."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import httpx
from signal_bot.liveness import Liveness
if TYPE_CHECKING:
from collections.abc import Callable
import pytest
from signal_bot.config import Config
DEAD_BODY = {"error": "Failed to get linked devices: Closed unexpectedly"}
class Clock:
"""A monotonic clock the test moves by hand."""
def __init__(self) -> None:
self.seconds = 0.0
def __call__(self) -> float:
return self.seconds
def gate(
config: Config,
handler: Callable[[httpx.Request], httpx.Response],
clock: Clock,
*,
slept: list[float] | None = None,
) -> Liveness:
"""A Liveness wired to a mock API and a hand-cranked clock."""
async def sleeper(seconds: float) -> None:
if slept is not None:
slept.append(seconds)
return Liveness(
config,
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
ticker=clock,
sleeper=sleeper,
)
async def test_it_starts_healthy_without_asking_anybody(config: Config) -> None:
"""A cold start must not 503: the installer waits on /healthz."""
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
return httpx.Response(200, json=[])
assert gate(config, handler, Clock()).healthy is True
assert calls == []
async def test_a_live_account_answers_the_probe(config: Config) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/v1/devices/+15550000000"
return httpx.Response(200, json=[{"id": 1}])
assert await gate(config, handler, Clock()).probe() is True
async def test_the_real_outage_shape_fails_the_probe(config: Config) -> None:
live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), Clock())
assert await live.probe() is False
assert "Closed unexpectedly" in live.reason
async def test_a_hanging_api_fails_the_probe_rather_than_the_loop(config: Config) -> None:
"""/v1/devices was seen to hang past 20s during the outage."""
timed_out = "timed out"
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.TimeoutException(timed_out, request=request)
live = gate(config, handler, Clock())
assert await live.probe() is False
assert "TimeoutException" in live.reason
async def test_silence_alone_never_probes(config: Config) -> None:
"""A quiet group is normal; only long silence is worth a round trip."""
calls: list[str] = []
clock = Clock()
slept: list[float] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
return httpx.Response(200, json=[])
live = gate(config, handler, clock, slept=slept)
await live.run(iterations=2)
assert slept == [30.0, 30.0]
assert calls == []
assert live.healthy is True
async def test_a_successful_probe_resets_the_silence_clock(config: Config) -> None:
"""One probe per silence window, not one per tick."""
calls: list[str] = []
clock = Clock()
def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
clock.seconds += 30.0
return httpx.Response(200, json=[])
live = gate(config, handler, clock)
async def tick() -> None:
clock.seconds += 30.0
await live.run(iterations=1)
for _ in range(8):
await tick()
assert len(calls) == 2
assert live.healthy is True
async def test_three_failures_make_the_bot_admit_it_is_deaf(
config: Config, caplog: pytest.LogCaptureFixture
) -> None:
clock = Clock()
live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock)
with caplog.at_level(logging.WARNING):
for _ in range(3):
clock.seconds += 200.0
await live.run(iterations=1)
if live.healthy:
assert "cannot reach Signal" in caplog.text
assert live.healthy is False
assert "nothing received for" in live.reason
assert caplog.text.count("cannot reach Signal") == 3
# A fourth failure must not say "failure 4 of 3": the verdict is in.
with caplog.at_level(logging.WARNING):
clock.seconds += 200.0
await live.run(iterations=1)
assert caplog.text.count("cannot reach Signal") == 3
assert "still deaf: 4 failed probe(s)" in caplog.text
async def test_one_frame_clears_the_alarm(config: Config, caplog: pytest.LogCaptureFixture) -> None:
clock = Clock()
live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock)
for _ in range(3):
clock.seconds += 200.0
await live.run(iterations=1)
assert live.healthy is False
with caplog.at_level(logging.WARNING):
live.note_envelope()
assert live.healthy is True
assert "recovered after 3 failed probe(s)" in caplog.text
assert live.silent_for == 0.0
async def test_a_frame_while_healthy_says_nothing(
config: Config, caplog: pytest.LogCaptureFixture
) -> None:
"""The recovery line must mean something, so it cannot fire per frame."""
live = gate(config, lambda r: httpx.Response(200, json=[]), Clock())
with caplog.at_level(logging.WARNING):
live.note_envelope()
assert caplog.text == ""

177
tests/test_trust.py Normal file
View File

@ -0,0 +1,177 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Who may add this bot to a group, and what happens to everybody else."""
from __future__ import annotations
import logging
from dataclasses import replace
from typing import TYPE_CHECKING, Any
import pytest
from signal_bot.discovery import GroupDirectory
from signal_bot.groups import ConfigError
from signal_bot.models import parse_envelope
from signal_bot.trust import InviteTrust, parse_trusted_inviters
from tests.conftest import (
SECOND_GROUP,
STRANGER_NUMBER,
TRUSTED_NUMBER,
WATCHED_GROUP,
WATCHED_INTERNAL,
envelope,
group_entry,
listing_client,
update_envelope,
)
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
OPERATOR_UUID = "b1c2d3e4-0000-4a00-8a00-000000000001"
def row(group_id: str, *, members: list[str], pending: list[str] | None = None) -> dict[str, Any]:
"""A /v1/groups row with the membership fields the gate reads."""
entry = group_entry(group_id)
entry["members"] = members
entry["pending_invites"] = pending if pending is not None else []
return entry
def test_parsing_accepts_numbers_uuids_and_nothing() -> None:
assert parse_trusted_inviters({}) == ()
assert parse_trusted_inviters({"TRUSTED_INVITERS": " "}) == ()
assert parse_trusted_inviters({"TRUSTED_INVITERS": f" {TRUSTED_NUMBER} , ,"}) == (
TRUSTED_NUMBER,
)
assert parse_trusted_inviters({"TRUSTED_INVITERS": f"{TRUSTED_NUMBER},{OPERATOR_UUID}"}) == (
TRUSTED_NUMBER,
OPERATOR_UUID,
)
def test_parsing_de_duplicates_and_rejects_junk() -> None:
same = f"{TRUSTED_NUMBER},{TRUSTED_NUMBER}"
assert parse_trusted_inviters({"TRUSTED_INVITERS": same}) == (TRUSTED_NUMBER,)
with pytest.raises(ConfigError, match="must be"):
parse_trusted_inviters({"TRUSTED_INVITERS": "kuhy"})
def test_an_empty_allowlist_trusts_everyone_but_says_so(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
trust = InviteTrust(())
assert "will be watched" in caplog.text
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True
def test_a_trusted_member_vouches_for_the_group() -> None:
trust = InviteTrust((TRUSTED_NUMBER,))
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[TRUSTED_NUMBER])) is True
def test_a_trusted_uuid_vouches_too() -> None:
"""The listing spells an unknown contact as a uuid, not a number."""
trust = InviteTrust((OPERATOR_UUID,))
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[OPERATOR_UUID])) is True
def test_a_trusted_number_that_is_only_invited_does_not_vouch() -> None:
"""Bait: a stranger adds the operator, who has not accepted."""
trust = InviteTrust((TRUSTED_NUMBER,))
entry = row(WATCHED_GROUP, members=[STRANGER_NUMBER, TRUSTED_NUMBER], pending=[TRUSTED_NUMBER])
assert trust.allows(WATCHED_GROUP, entry) is False
def test_a_refusal_is_logged_once_and_names_the_members(
caplog: pytest.LogCaptureFixture,
) -> None:
trust = InviteTrust((TRUSTED_NUMBER,))
entry = row(WATCHED_GROUP, members=[STRANGER_NUMBER])
with caplog.at_level(logging.WARNING):
assert trust.allows(WATCHED_GROUP, entry) is False
assert trust.allows(WATCHED_GROUP, entry) is False
assert caplog.text.count("refusing group") == 1
assert STRANGER_NUMBER in caplog.text
def test_a_malformed_membership_field_refuses_rather_than_crashes() -> None:
trust = InviteTrust((TRUSTED_NUMBER,))
entry = group_entry(WATCHED_GROUP)
entry["members"] = "not-a-list"
assert trust.allows(WATCHED_GROUP, entry) is False
def test_an_update_from_a_trusted_number_vouches_in_either_spelling(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The envelope carries the internal id; the listing carries group.<b64>."""
trust = InviteTrust((TRUSTED_NUMBER,))
with caplog.at_level(logging.INFO):
trust.note_update(WATCHED_INTERNAL, TRUSTED_NUMBER, "uuid-operator")
trust.note_update(WATCHED_INTERNAL, TRUSTED_NUMBER, "uuid-operator")
assert caplog.text.count("trusted inviter added the bot") == 1
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True
def test_an_update_from_a_stranger_vouches_for_nothing() -> None:
trust = InviteTrust((TRUSTED_NUMBER,))
trust.note_update(WATCHED_INTERNAL, STRANGER_NUMBER, "uuid-stranger")
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is False
def test_an_update_by_a_trusted_uuid_vouches() -> None:
trust = InviteTrust((OPERATOR_UUID,))
trust.note_update(WATCHED_INTERNAL, "", OPERATOR_UUID)
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True
async def test_the_directory_adopts_only_the_trusted_group(config: Config, store: Store) -> None:
client = listing_client(
[
row(WATCHED_GROUP, members=[TRUSTED_NUMBER]),
row(SECOND_GROUP, members=[STRANGER_NUMBER]),
]
)
found = GroupDirectory(config, client, store, InviteTrust((TRUSTED_NUMBER,)))
await found.refresh()
assert found.groups == (WATCHED_GROUP,)
async def test_a_pinned_group_bypasses_the_trust_gate(config: Config, store: Store) -> None:
"""GROUP_IDS is the operator naming a group by hand: vouch enough."""
pinned = replace(config, group_ids=(WATCHED_GROUP,))
client = listing_client([row(WATCHED_GROUP, members=[STRANGER_NUMBER])])
found = GroupDirectory(pinned, client, store, InviteTrust((TRUSTED_NUMBER,)))
await found.refresh()
assert found.groups == (WATCHED_GROUP,)
async def test_an_update_frame_lets_the_next_refresh_adopt_the_group(
config: Config, store: Store
) -> None:
"""The whole point: being added by the operator is still the whole setup."""
client = listing_client([row(WATCHED_GROUP, members=[STRANGER_NUMBER])])
found = GroupDirectory(config, client, store, InviteTrust((TRUSTED_NUMBER,)))
await found.refresh()
assert found.groups == ()
message = parse_envelope(update_envelope(group_id=WATCHED_INTERNAL))
assert message is not None
found.note_group_update(message)
await found.refresh()
assert found.groups == (WATCHED_GROUP,)
def test_ordinary_frames_vouch_for_nothing(config: Config, store: Store) -> None:
"""Only an UPDATE names an inviter; a chat message and a DM must not."""
trust = InviteTrust((TRUSTED_NUMBER,))
found = GroupDirectory(config, listing_client([]), store, trust)
for frame in (envelope(text="!cat"), envelope(text="hi", group_id=None)):
message = parse_envelope(frame)
assert message is not None
found.note_group_update(message)
assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is False