# 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](https://github.com/bbernhard/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: ```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 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. ```sh 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: ```sh ./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 ```sh 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](DOCS-signal-envelopes.md). ## Licence MIT. See [LICENSE](LICENSE).