Go to file
Krzysztof kuhy Rudnicki c9061b27a8 fix: make shellcheck agree between local and CI, and fix the group picker
CI failed on shellcheck where the local hook passed: the runner's shellcheck
reports SC2015 (`A && B || C` is not if-then-else) and mine did not. Both now
run with --severity=style so a version skew cannot pass locally and fail in
CI. The cleanup traps are plain ifs, which is what the finding was about --
`kill` on an already-dead pid makes B fail and C run while A is true.

live_smoke.sh choose_group had two bugs that only surface when it is actually
run, neither reachable from the offline suite:
- it fetched /v1/groups twice, once to display and once to resolve the picked
  index, so an unstable order would hand back a different group than the one
  shown. Fetched once into a variable now.
- `read -r -p` wrote its prompt to stdout, which the caller captures with
  $(...), so the prompt text would have been swallowed into the group id.
  Prompt and listing both go to stderr; stdout carries only the id.
Also validates that the pick is a number and in range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:07:28 +02:00
.github/workflows fix: make shellcheck agree between local and CI, and fix the group picker 2026-08-29 13:07:28 +02:00
fake_signal feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
scripts fix: make shellcheck agree between local and CI, and fix the group picker 2026-08-29 13:07:28 +02:00
signal_bot feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
tests feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
.env.sample feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
.gitignore feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
.pre-commit-config.yaml fix: make shellcheck agree between local and CI, and fix the group picker 2026-08-29 13:07:28 +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 feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
README.md feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +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

  1. Start the container. Use json-rpc-native — /v1/receive is only a WebSocket in a json-rpc mode; in normal/native it silently degrades to a polling GET and the bot will never receive anything.

    docker run -d --name signal-api --restart=always -p 9922:8080 \
        -v "$HOME/signal-api:/home/.local/share/signal-cli" \
        -e MODE=json-rpc-native \
        bbernhard/signal-cli-rest-api:0.100
    

    Pin the stable tag. The dev series (0.202-dev) counts separately, so an older 0.167-dev is not newer than 0.100. If linking fails with a connection error, that is server-side client deprecation: try a recent *-ci tag, which tracks a newer signal-cli.

  2. Link the device and pick a group — this does both, and only asks you to scan a QR code:

    ./scripts/live_smoke.sh
    
  3. Or do it by hand: open http://localhost:9922/v1/qrcodelink?device_name=signal-api, scan it, copy .env.sample to .env, fill it in, then ./run.sh.

Send !cat in the group. Because this is a linked device, the reply arrives from your own account.

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.