The build-system pin was setuptools==80.9.0, which GHSA flags for an sdist MANIFEST.in exclusion bypass via Unicode NFC/NFD collision on macOS APFS/HFS+ (fixed in 83.0.0). Build-backend requirements are a real dependency surface and the freshness sweep had not covered them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy |
||
|---|---|---|
| .github/workflows | ||
| fake_signal | ||
| scripts | ||
| signal_bot | ||
| tests | ||
| .env.sample | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| DOCS-signal-envelopes.md | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| run.sh | ||
| SECURITY.md | ||
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_IDandGROUP_ID_SEND; - direct messages are dropped explicitly, never by a falsy check;
- the
scopingscenario asserts that DMs and other groups produce zero outbound messages, andscripts/e2e.shfails 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
-
Start the container. Use
json-rpc-native—/v1/receiveis only a WebSocket in a json-rpc mode; innormal/nativeit 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.100Pin the stable tag. The dev series (
0.202-dev) counts separately, so an older0.167-devis not newer than0.100. If linking fails with a connection error, that is server-side client deprecation: try a recent*-citag, which tracks a newer signal-cli. -
Link the device and pick a group — this does both, and only asks you to scan a QR code:
./scripts/live_smoke.sh -
Or do it by hand: open
http://localhost:9922/v1/qrcodelink?device_name=signal-api, scan it, copy.env.sampleto.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.