signal-bot/README.md
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

251 lines
11 KiB
Markdown

# signal-bot
A Signal group bot: `!cat` / `!dog` / `!gekon` / `!pułapka` / `!sztuka` /
`!wiersz` / `!papież`, and group statistics. 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 }
```
It enforces a 40-second budget on itself. 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 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. And **it cannot be installed anyway**
on a phone with a Device Owner: Android then enables `no_add_managed_profile`
and its two siblings on every user, killing Shelter, Island, Private Space and
cloning alike. 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,
what the bot now answers to a `!word` nothing claims, 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`.
## Managing it
`./web.sh` serves one page on `127.0.0.1:8140`: health, the configuration
(editable) and the album's tagger. Started by hand and stopped again, because
it can rewrite `.env`: [DOCS-web-ui.md](DOCS-web-ui.md).
## Develop
```sh
pytest # 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). The page has its own gates: `pnpm --dir web lint` and `pnpm --dir web coverage`, both at 100% with no excludes.
## Licence
MIT. See [LICENSE](LICENSE).