mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 13:47:48 +01:00
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.
Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.
Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read
Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.
Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.
Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.
Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
121 lines
4.0 KiB
Markdown
121 lines
4.0 KiB
Markdown
# 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).
|