mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 21:14:04 +01:00
The README still described a counter that increments and resets, which stopped being true two commits ago. The behaviour that replaced it does not fit in a README section, so it moves to DOCS-statistics.md: what is recorded and what deliberately is not, why a day is a stored Warsaw date string and what breaks if it stops being one, what happens to counts and to reports across an outage, why 2026-08-30 is marked and excluded from every average, and how to read a wire format with LOG_LEVEL=DEBUG. README keeps eight lines and a pointer, which puts it at exactly 250. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
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.
|
|
|
|
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
|
|
|
|
| Command | Aliases |
|
|
|---|---|
|
|
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
|
|
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
|
|
| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` |
|
|
| pope | `!papież` `!papiez` `!papa` `!pope` `!popes` `!pontifex` + 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.
|
|
|
|
## 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 # 377 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 a bump publishes to the group: [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).
|