mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
./web.sh serves health, the configuration and the photo album on 127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8, TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked + unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics. No coverage exclude list, including for main.tsx. This repository treats a coverage omit as a suppression, and "the DOM bootstrap is awkward to assert" is the same sentence as "this code is untested" -- so the bootstrap is a shim thin enough to test in jsdom, and the album view holds its file list apart from the album so the update path stopped carrying a "what if it is null" branch that cannot happen. An untestable branch is an untested one. TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the compiler waits for the linter. The freshness gate then caught two pins the reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a floating node-version: "24" in CI -- both bumped rather than allowlisted. Traps the gates found, all of them real: - .gitignore has a bare lib/, which matches at any depth, so web/src/lib/ would have been silently untracked. The source lives in src/api/. - CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in CI for its whole life and web.sh would have inherited that. Now ./*.sh. - tsconfig.json is strict JSON, not JSONC: check-json is generic and tsconfig being commentable is a convention, not a requirement. - The personal-data gate refused an invented phone number in a test, which is exactly what it is for. The album's warning that names a command moved with the command, and every path that pointed at tool/tagger.py now points at ./web.sh. The historical mentions stay: they explain why the code is shaped as it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
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 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,
|
|
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).
|