Go to file
Krzysztof kuhy Rudnicki b48c945d89 feat: make the bot actually reply on Signal, and gate every way it went silent
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.

Four failures, each of which left the bot looking perfectly healthy:

- Invited is not joined. Signal only adds an account outright if the adder
  holds its profile key; with no profile the add becomes a pending invite,
  and signal-cli cannot accept one addressed to a phone number ("Cannot find
  service ID for self"). Group listed, websocket up, /healthz OK, zero
  messages. register_bot.sh now sets the profile *before* the add, and
  startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
  every attachment 400s with "Can't load library: awt". Both mode guards now
  demand exactly json-rpc, and start_container replaces a wrong-mode
  container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
  envelopes carry what that decodes to -- so the scoping gate rejected the
  bot's own group. e2e.sh now models both spellings; reverting the fix fails
  4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
  a .env. The bind address comes from Config now.

A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.

The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.

100% branch coverage, 230 tests, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 14:37:27 +02:00
.github/workflows feat: register the bot as its own Signal account, no phone app needed 2026-08-29 13:41:13 +02:00
bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051 feat: register the bot as its own Signal account, no phone app needed 2026-08-29 13:41:13 +02:00
fake_signal feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
scripts feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
signal_bot feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
tests feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
.env.sample feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
.gitignore feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
.pre-commit-config.yaml feat: register the bot as its own Signal account, no phone app needed 2026-08-29 13:41:13 +02:00
DOCS-signal-envelopes.md feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
LICENSE feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
pyproject.toml fix: pin setuptools to 84.0.0, closing the dependabot alert 2026-08-29 13:08:02 +02:00
README.md feat: make the bot actually reply on Signal, and gate every way it went silent 2026-08-29 14:37:27 +02:00
requirements.txt feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
run.sh feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
SECURITY.md feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00

signal-bot

A Signal group bot: !cat / !dog image commands and a daily message tally.

Runs as a linked device on an existing Signal account via signal-cli-rest-api.

Scoping, first

A linked device receives every direct message and every group on the account it is linked to. GROUP_ID is the only thing that keeps this bot inside one conversation, so:

  • the bot refuses to start without GROUP_ID and GROUP_ID_SEND;
  • 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:

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/:

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.

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.

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:

./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. StartLimitBurst=5 stops a config error — a missing GROUP_ID, a taken port — from hiding inside a permanent 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

./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. GROUP_ID is the only thing scoping it, which is why that scoping is a tested, CI-enforced invariant rather than a convention.

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.

Commands

Command Aliases
cat !kot !koty !kots !cat !cats !meow !miau + styled variants
dog !pies !psy !dog !dogs !woof !szczek + 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.

Daily tally

Every non-sticker, non-reaction message in the watched group increments its sender's count. At REPORT_HOUR:REPORT_MINUTE (default 21:37) the tally is posted to GROUP_ID_SEND and reset. Counts live in SQLite and survive a restart.

Develop

pytest                       # 189 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. Docs on the wire format are in DOCS-signal-envelopes.md.

Licence

MIT. See LICENSE.