signal-bot/README.md
Krzysztof kuhy Rudnicki d5c63b423b 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
2026-08-29 19:01:55 +02:00

251 lines
10 KiB
Markdown

# signal-bot
A Signal group bot: `!cat` / `!dog` image commands and a daily message tally.
Runs on its **own registered Signal number** (or, if you prefer, as a linked
device on an existing account) via
[signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api).
## Scoping, first
How wide the bot's reach is depends entirely on **whose account it runs as**,
and the setup scripts pick the safe answer for you rather than leaving it to
be remembered:
| Run as | Written to `.env` | Reach |
|---|---|---|
| its own number (`register_bot.sh`) | no `GROUP_IDS` | every group somebody adds it to — and nothing else, because that account has no other chats |
| a linked device (`live_smoke.sh`) | `GROUP_IDS=<one group>` | only that group. A linked device receives every DM and group on the **personal** account it rides, so it is pinned |
In both cases:
- direct messages are dropped explicitly, never by a falsy check;
- the `scoping` scenario asserts that DMs and other groups produce **zero**
outbound messages, and `scripts/e2e.sh` fails the build if that stops holding.
## Run it offline, with no Signal account
Everything works against a local fake that speaks the real protocol:
```sh
uv venv .venv && uv pip install -e '.[dev]'
./scripts/e2e.sh
```
That boots the fake server, boots the unmodified bot pointed at it, replays
every scenario in `fake_signal/scenarios/`, and **exits non-zero on mismatch**.
It needs no container, no network and no phone.
```
== booting the fake Signal server on :8099
== booting the bot on :8098, pointed at the fake
ok !cat in the watched group posts an image to the reply group
ok one user's cooldown does not silence anybody else
ok direct messages and other groups are never answered
...
```
Add a case by dropping a YAML file in `fake_signal/scenarios/`:
```yaml
name: "what this proves"
inject:
- { text: "!cat", uuid: uuid-alice, name: Alice } # group: watched|other|null
expect:
- { kind: image, to: reply }
```
Give each scenario its own `uuid`: one bot process serves them all and the
cooldown lives in SQLite, so a shared actor leaks state between files.
## Run it for real
Two ways to give the bot a Signal identity. **Prefer the first.**
### A dedicated bot account (recommended)
The bot registers its own number as its **own primary device**. It is a
separate identity: it cannot read your personal DMs and never posts as you.
No phone app, no QR scan, no work profile.
You need a number that can receive one SMS or voice call. A real mobile
SIM/eSIM is the only reliable kind — Signal itself has no VoIP blocklist, but
SMS carriers reject many VoIP numbers, so a VoIP number is a gamble and
registration attempts are rate-limited.
```sh
docker rm -f signal-api
IMAGE=bbernhard/signal-cli-rest-api:1787779383-ci \
./scripts/register_bot.sh +48123456789 # add --voice for a landline
```
The image override is not optional noise. Stable `0.100` bundles signal-cli
**0.14.5**; registration needs **≥ 0.14.7**, which carries "Update to new
registration/provisioning API". On 2026-03-06 Signal made the post-quantum
ratchet mandatory and **de-registered every account whose client lacked it**,
so a stale client is how a bot dies silently months later. The script checks
the bundled version and refuses rather than warning.
The container runs `MODE=json-rpc`, not `json-rpc-native`. Both expose the
receive WebSocket, but `-native` is a GraalVM native-image build with no AWT,
so every attachment send fails with `Can't load library: awt` — text replies
work and `!cat` returns nothing, which reads as a bug in this repo and is not.
The script solves everything except two steps only a human can do: pasting a
captcha token (the captcha exists to stop exactly this automation) and reading
the SMS code. Then add the bot's number to your private group from your
personal Signal, and it picks the group, verifies it really joined, installs
the service and tails the log.
#### Adding the bot to a group: invited is not joined
Signal adds an account to a group outright **only if the person adding it
already holds that account's profile key**. With no profile there is no key,
so the add silently becomes a pending *invitation* — and signal-cli cannot
accept one addressed to a phone number:
```
POST /v1/groups/<number>/<group>/join → 400
{"error":"Cannot find service ID for self to accept invite"}
```
This is why `register_bot.sh` sets the bot's profile **before** telling you to
add it. A group in that state is indistinguishable from a working one from the
outside — `/v1/groups` lists it, the WebSocket connects, `/healthz` answers,
and not one message arrives — so the bot checks `member` at startup and
refuses to run rather than looking healthy and staying deaf:
```
TransportError: the account is invited to 'testbot' but has not joined it,
so it will receive nothing.
```
If you hit it on an existing group: add the bot's number again from Signal.
Verified on 2026-08-29 against a group that already held an unacceptable
invite — re-adding turned `member: false` into `member: true, members: 2`,
and the bot answered `!cat` in that same group. (Signal keeps the dead
invite listed in `pending_invites`; `member` is the field that matters,
which is what the startup gate reads.)
### It runs as a service, not in a terminal
`register_bot.sh` finishes by installing a **user** systemd unit, so the bot
outlives the terminal that started it:
```sh
./scripts/install_service.sh # install, start, verify
./scripts/install_service.sh --verify-only # re-check without reinstalling
systemctl --user status signal-bot.service
journalctl --user -u signal-bot.service -f
```
`Restart=always` brings it back from a crash; lingering (checked by the
installer, because without it the promise is false) starts it at boot with
nobody logged in. `StartLimitBurst=5` stops a config error — a missing
a pinned group it cannot join, a taken port — from hiding inside a
permanent restart loop.
The health probe binds `127.0.0.1:8137` (`BIND_PORT`). Not 8000: that port
attracts unrelated dev servers, and 8137 sits below the ephemeral range so
the kernel cannot hand it out. If it is taken anyway the bot names the
offender and exits non-zero, because uvicorn logs the collision and exits
**0**, which every supervisor reads as success:
```
cannot bind 127.0.0.1:8000 -- held by pid 82385: /usr/bin/python -m ...
```
### A linked device on your own account
```sh
./scripts/live_smoke.sh
```
Starts the container, opens the QR code, waits for the link, then picks a
group. **Caveat:** a linked device rides your account — it sends *as you* and
receives every DM and group you are in, so `signal_write_env` pins it to the
single group you picked. Prefer `scripts/register_bot.sh`, which gives the bot
its own number and no access to your chats at all.
### Why not Shelter / a second Signal app on the phone
Two independent reasons, both verified rather than assumed:
1. **A second app instance is not a second account.** Every Signal account
needs its own phone number, so Shelter never removed the actual blocker.
Signal's phone-number-free "Signal Login" is announced but not shipped.
2. **It cannot be installed anyway** on a phone with a Device Owner. Setting a
Device Owner makes Android enable `no_add_managed_profile`,
`no_add_private_profile` and `no_add_clone_profile` on every user — killing
Shelter, Island, Private Space and app cloning alike. Verified on-device:
`pm create-user --profileOf 0 --managed` returns
`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 |
|---|---|
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
Aliases live in `signal_bot/aliases.json`, not in source. Each command is
rate-limited per user, per command — the original bot shared one 10-second
window across everybody.
`signal_bot/decorators.py` also ships `daily_limit` and `vote_gated` (run only
once N distinct people ask, within a window). Both are generic and tested;
attach them to any command.
## Watching more than one group
`GROUP_IDS` takes a comma-separated list, so one process can serve a test
group and the production group at once:
```sh
GROUP_IDS=group.<base64-of-test>,group.<base64-of-prod>
```
Everything downstream is keyed by group. A command is answered **in the group
that asked and nowhere else**; cooldowns, daily limits, votes and the tally
are per group, so a `!cat` in the test group neither throttles nor counts
against the production one. There is no reply-target setting to get wrong —
`GROUP_ID_SEND` was removed, and setting it now fails startup rather than
quietly posting the day's report into one group's chat.
The `per_group_isolation` scenario is the gate: one person asks in both
groups inside a single cooldown window and must get two images, one in each.
## Daily tally
Every non-sticker, non-reaction message in a watched group increments its
sender's count **for that group**. At `REPORT_HOUR:REPORT_MINUTE` (default
21:37) each group is posted its own tally, in its own chat, and reset. A group
where nobody spoke is logged and **not** posted into — otherwise every quiet
group the bot has ever been added to would get a nightly ping. A group that
refuses the report keeps its counts and does not cost the others theirs.
Counts live in SQLite and survive a restart.
## Develop
```sh
pytest # 189 tests, 100% branch coverage, no network
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
./scripts/e2e.sh # the real bot against the fake server
```
No `# noqa`, no `# type: ignore`, no coverage omits. Docs on the wire format
are in [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
## Licence
MIT. See [LICENSE](LICENSE).