mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:03:48 +01:00
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=+48516501330 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
251 lines
10 KiB
Markdown
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).
|