mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 17:57:02 +01:00
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:
parent
0c4b259c17
commit
d5c63b423b
16
.env.sample
16
.env.sample
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
49
DOCS-liveness-and-trust.md
Normal file
49
DOCS-liveness-and-trust.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
@ -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"
|
||||
|
||||
22
scripts/signal-api-watchdog.service
Normal file
22
scripts/signal-api-watchdog.service
Normal 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
|
||||
15
scripts/signal-api-watchdog.timer
Normal file
15
scripts/signal-api-watchdog.timer
Normal 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
179
scripts/signal_api_watchdog.sh
Executable 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
229
scripts/test_watchdog.sh
Executable 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 "$@"
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
172
signal_bot/liveness.py
Normal 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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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
149
signal_bot/trust.py
Normal 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
|
||||
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
52
tests/test_healthz.py
Normal 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
177
tests/test_liveness.py
Normal 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
177
tests/test_trust.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user