mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
The leak this repository had was not carelessness with a secret. It was somebody reaching for a real name because it was the example in front of them -- a fixture built from a live database, an envelope pasted out of a running container, a help text written while looking at the actual group. Three different people-shaped mistakes, none of which a rule about "be careful" would have stopped. So it is a gate. The hard part is that the thing being checked must not contain the list of what to refuse, or the check *is* the leak. It reads the names at run time from outside: the photo pack's manifest, the message database, the local .env. It never prints the word it matched, because this output reaches CI logs. On a machine with none of that private data it says "shapes only" out loud rather than passing quietly, and CI enforces the half that needs nothing -- phone numbers, uuids and Signal group ids, compared against an allowlist of values invented for the fixtures. It runs on staged files, on the commit message -- where two of the leaks actually travelled -- and over the whole tree in CI. It paid for itself twice before it was wired up. The live test group's id was in tests/test_config.py in two spellings, and truncated in three more files, after four rounds of grepping by hand had pronounced the tree clean -- nobody thinks to grep for base64. Then it refused the commit that introduced it, because DOCS-privacy.md had used a real name as the example of a name. An allowlist line may be scoped to one file, which is how the given names that are also popes stay legal in popes.json and refused in every fixture that has no business carrying them. The threshold is three characters, because one of this group's given names is three characters long. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
251 lines
11 KiB
Markdown
251 lines
11 KiB
Markdown
# signal-bot
|
|
|
|
A Signal group bot: `!cat` / `!dog` / `!gekon` image commands, `!papież`, and
|
|
group message statistics. The group's own data stays out: `DOCS-privacy.md`.
|
|
|
|
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. A container that is not up yet is no longer fatal — the bot
|
|
serves a 503 `/healthz` and keeps retrying — so `StartLimitBurst=5` is left
|
|
doing only what it was for: catching a config error in a 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, and dead in
|
|
`failed` for eight after the 2026-08-30 reboot. `/healthz` now has three
|
|
answers — `ok`, `connecting`, `deaf` — a two-minute timer restarts the
|
|
container, and `TRUSTED_INVITERS` decides who may add this bot to a group:
|
|
[DOCS-liveness-and-trust.md](DOCS-liveness-and-trust.md).
|
|
|
|
## Commands
|
|
|
|
`!pomoc` (or `!help`) prints every command **in the group**, in Polish, one
|
|
line of description each. It is built from the registry at run time and
|
|
`CommandRegistry.register` takes that description as a required argument, so a
|
|
command cannot be added without appearing there. Every trigger that resolves,
|
|
and why the printed spelling is not one of them:
|
|
[DOCS-commands.md](DOCS-commands.md).
|
|
|
|
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.
|
|
|
|
## Statistics
|
|
|
|
One row of metadata per message — sender, Warsaw day, hour, media, length —
|
|
and **never the message body**. At `REPORT_HOUR:REPORT_MINUTE` (default 21:37)
|
|
each group gets its report for the previous full calendar day: silent members
|
|
at 0, up to three highlights. Sundays close a week, month ends a month. Ask it
|
|
things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona`
|
|
`!dni` `!ja`. Full behaviour: `DOCS-statistics.md`.
|
|
|
|
## Develop
|
|
|
|
```sh
|
|
pytest # 708 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. Never bump `SCHEMA_VERSION` to add a table —
|
|
read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while
|
|
`CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
|
|
Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
|
|
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md).
|
|
|
|
## Licence
|
|
|
|
MIT. See [LICENSE](LICENSE).
|