mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
The registry used to be built once, inline in build_bot, so every change to the data behind it -- a person tagged in the web UI, an alias, soon a command switched off from the management page -- meant restarting the unit. That is cheap and lossless, but it is a deploy step somebody has to remember, and a 21:37 report can land in the middle of it. Now the registration sequence lives in wiring.build_registry, and RegistryHolder runs it again on POST /_reload: the new table is built whole and swapped in with one assignment, never patched in place, so a duplicate trigger is found before anything the dispatcher reads has changed. Bad data (ConfigError, DuplicateTriggerError) keeps the previous table and answers 409 with the reason; a programming error still surfaces as a 500. scripts/reload.sh wraps it, with --code for the restart a Python change needs, and an optional command to type in testbot so "reloaded" means "answered". No Python hot-reload on purpose. The loader change is what makes deploying this safe: two rows in the live tags.json name files that are no longer in media/, and load_pack refused them, so the next restart would have looped on ConfigError. A stale tag is drift in a file a page edits mid-session, not a broken album -- it is now skipped with a warning naming it. A tag that reaches outside the album is still refused. pack_load.py split at the 250-line cap; pack_tags.py is the half whose checks are not all fatal. Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is tagged into the synthetic pack, scripts/reload.sh runs, and the same process posts delta's photo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
11 KiB
Markdown
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).
|
|
A tagged person goes live with `scripts/reload.sh`, no restart; code with `--code`: [DOCS-reload.md](DOCS-reload.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).
|