Release n is pope n, so the version is a fact about the ordinal rather than a judgement about the size of a change. The constraint that fixes the scheme is Jan Pawel II at 2.13.7: eight patches per minor with majors opening at ordinals 33 and 153 gives (264 - 153) = 13 * 8 + 7 exactly, and both earlier majors happen to divide evenly by eight too, so no bracket needs a fudge factor. The anchors are not arbitrary -- 33 is Sylwester I, pope through the Edict of Milan and Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism. popes.json is scraped by tool/gen_popes.py rather than recalled, because antipopes, Benedykt IX's three separate reigns and the Stefan II question all shift the count and being wrong by one moves Jan Pawel II off 2.13.7 silently. The generator refuses to write a list that is not 267 contiguous entries with Jan Pawel II at 264. A second, independent parse of the same page agreed on every ordinal, which is the only reason to believe either. On startup each watched group is told about the releases it has not seen. A group with no record has seen none of them, so the first announcement starts at 0.0.0 "Swiety Piotr Apostol" rather than skipping to whatever is current -- watching the ladder start at Peter is the point of naming releases after popes. A group added years from now would otherwise be handed the whole backlog, so the message is capped at ten releases and says how many it left out. The version is recorded before the message is sent: with Restart=always and RestartSec=5, a crash between the two would re-announce on every restart, and a missed announcement is far cheaper than a looping one. DOCS-changelog.md is the single source -- the bot parses it, so there is no second machine-readable copy to drift. It is DOCS-, not CHANGELOG.md, because the md-naming gate allows four namespaces and that is not one of them. Deliberately NOT bumping SCHEMA_VERSION for the new group_meta table: _apply_schema drops every table on a bump, so bumping to add a table would have thrown away the live tallies to gain nothing. Verified against the real database -- group_meta appeared and all four counts survived the restart. The schema moves to its own module so store.py stays under the 250-line cap, and /healthz now names the build, because there was previously no way to ask a running bot which version it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
10 KiB
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.
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
scopingscenario asserts that DMs and other groups produce zero outbound messages, andscripts/e2e.shfails the build if that stops holding.
Run it offline, with no Signal account
Everything works against a local fake that speaks the real protocol:
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/:
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.
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:
./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
./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:
- 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.
- 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_profileandno_add_clone_profileon every user — killing Shelter, Island, Private Space and app cloning alike. Verified on-device:pm create-user --profileOf 0 --managedreturnsCannot 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.
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:
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
pytest # 359 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. Wire format:
DOCS-signal-envelopes.md. Releases are named after the popes, in order: DOCS-changelog.md.
Licence
MIT. See LICENSE.