feat: rewrite the bot so it runs, and gate it like the other repos

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 13:05:42 +02:00
commit 6bde3493a4
69 changed files with 4844 additions and 0 deletions

39
.env.sample Normal file
View File

@ -0,0 +1,39 @@
# Copy to .env and fill in. Every value here is read by signal_bot/config.py.
# The bot refuses to start if any REQUIRED value is missing or blank.
# REQUIRED. The account signal-cli is linked to, in +E.164 form.
PHONE_NUMBER=
# REQUIRED. The group the bot listens to.
#
# A linked Signal device receives every direct message and every group on the
# account. This value is the ONLY thing scoping the bot to one conversation,
# which is why it has no default and startup fails without it.
# Find it with: curl -s localhost:9922/v1/groups/$PHONE_NUMBER | jq
GROUP_ID=
# REQUIRED. Where replies and the daily report are posted. Set it to the same
# value as GROUP_ID to reply in the group that triggered the command.
GROUP_ID_SEND=
# Optional. The signal-cli-rest-api container. Point this at the fake server
# to run entirely offline. Default: http://localhost:9922
API_BASE_URL=
# Optional. Tally database. Survives restarts. Default: signal_bot.sqlite3
DATABASE_PATH=
# Optional. When the daily tally is posted. Default: 21:37
REPORT_HOUR=
REPORT_MINUTE=
# Optional. Per-user, per-command cooldown in seconds. Default: 10
COOLDOWN_SECONDS=
# Optional. Image APIs. Overridden by scripts/e2e.sh to serve local fixtures.
CAT_API_URL=
DOG_API_URL=
# Optional. Where the /healthz probe binds. Default: 127.0.0.1:8000
BIND_HOST=
BIND_PORT=

46
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
run: uv python install 3.14
- name: Install dependencies
run: |
uv venv .venv
uv pip install --python .venv/bin/python -e '.[dev]'
- name: ruff format
run: .venv/bin/ruff format --check .
- name: ruff check (ALL rules)
run: .venv/bin/ruff check .
- name: mypy --strict
run: .venv/bin/mypy signal_bot fake_signal
- name: shellcheck
run: shellcheck run.sh scripts/*.sh
- name: pytest (100% branch coverage)
run: .venv/bin/python -m pytest -q
# The real bot against the fake Signal server. No account, no network.
- name: end-to-end
run: ./scripts/e2e.sh

165
.gitignore vendored Normal file
View File

@ -0,0 +1,165 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
*.jpg
*.gif
nohup.out

91
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,91 @@
# Pre-commit hooks for signal-bot.
#
# Install with: pre-commit install
# Run manually: pre-commit run --all-files
#
# Every hook is `language: system` because the tools already live in .venv;
# pre-commit must not build isolated environments for them. fail_fast so a
# later check cannot run after an earlier one has already rejected the commit.
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- id: debug-statements
- id: check-ast
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
# The shared 250-line cap. Checks only the files in the commit, so an
# unrelated commit never breaks. No baseline, no allowlist -- those are
# suppressions. The exemption list lives with the checker in ~/utils so
# this repo's idea of the rule cannot drift from every other repo's.
- id: file-length
name: file length <= 250 lines
entry: scripts/check_file_length.sh
language: system
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
- id: md-naming
name: markdown naming convention
entry: scripts/check_md_naming.sh
language: system
files: \.md$
# Exact pins at newest stable. Runs repo-wide: a pin goes stale because
# upstream released, not because this commit touched the manifest.
- id: dependency-freshness
name: dependencies are newest stable
entry: scripts/check_dependency_freshness.sh --all
language: system
pass_filenames: false
always_run: true
- id: ruff-format
name: ruff format
entry: .venv/bin/ruff format --check
language: system
types_or: [python, pyi]
- id: ruff
name: ruff (ALL rules)
entry: .venv/bin/ruff check --force-exclude
language: system
types_or: [python, pyi]
- id: mypy
name: mypy --strict
entry: .venv/bin/mypy
language: system
pass_filenames: false
args: [signal_bot, fake_signal]
- id: shellcheck
name: shellcheck
entry: shellcheck
language: system
types: [shell]
# 100% branch coverage, no omits. Runs on every commit: coverage is the
# whole point of the suite, and a partial run cannot prove it.
- id: pytest
name: pytest (100% branch coverage)
entry: .venv/bin/python -m pytest -q
language: system
pass_filenames: false
always_run: true

78
DOCS-signal-envelopes.md Normal file
View File

@ -0,0 +1,78 @@
# Signal envelope shapes
Reference for the two JSON frames `signal-cli-rest-api` pushes down
`ws://…/v1/receive/{number}`. `signal_bot/models.py` flattens both into one
`IncomingMessage`; these are the shapes it flattens.
## From another account: `dataMessage`
```json
{
"envelope": {
"source": "NAME",
"sourceNumber": "PHONE_NUMBER",
"sourceUuid": "SOURCE_ID",
"sourceName": "USER_DEFINED_NAME",
"sourceDevice": 69,
"timestamp": 1719177917717,
"dataMessage": {
"timestamp": 1719177917717,
"message": "MESSAGE_CONTENT",
"expiresInSeconds": 0,
"viewOnce": false,
"sticker": { "packId": "166dd1b5…", "stickerId": 6 },
"groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
}
},
"account": "BOT_ACCOUNT_PHONE_NUMBER"
}
```
## From the bot's own account: `syncMessage.sentMessage`
Because the bot runs as a *linked device*, the account's own outgoing messages
come back as sync messages. Same fields, one level deeper.
```json
{
"envelope": {
"sourceUuid": "SOURCE_ID",
"sourceName": "USER_DEFINED_NAME",
"timestamp": 1719177728639,
"syncMessage": {
"sentMessage": {
"destination": "DEST_NAME",
"destinationNumber": "DEST_PHONE_NUMBER",
"timestamp": 1719177728639,
"message": "MESSAGE_CONTENT",
"sticker": { "packId": "166dd1b5…", "stickerId": 6 },
"groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
}
}
},
"account": "BOT_ACCOUNT_PHONE_NUMBER"
}
```
## The trap this caused
The original bot read `envelope.dataMessage.sticker` even when the frame was a
`syncMessage`. Its own stickers therefore never matched the sticker filter and
were counted as messages. Four consecutive commits (`f1931b8`, `b325126`,
`62352e5`, `d35aa59`) fought the symptom.
`models.py` resolves the payload *first* — `dataMessage` or
`syncMessage.sentMessage` — and reads `sticker`, `reaction`, `message` and
`groupInfo` off whichever it found. `tests/test_models.py` asserts both shapes.
## No `groupInfo` means a direct message
A frame without `groupInfo` arrived in a one-to-one conversation. A linked
device receives every one of those on the account, so `counting.is_watched_group`
rejects them explicitly rather than relying on a falsy comparison. See the
`scoping` scenario in `fake_signal/scenarios/`.
## Frames the bot ignores
Receipts, typing indicators and anything else with no `dataMessage` and no
`syncMessage.sentMessage` parse to `None` and are dropped.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 kuhyx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

120
README.md Normal file
View File

@ -0,0 +1,120 @@
# 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).

36
SECURITY.md Normal file
View File

@ -0,0 +1,36 @@
# Security Policy
## Scope of a linked device
This bot runs as a **linked device** on an existing Signal account. That means
it can read every direct message and every group on that account, and anything
it sends is sent **as the account owner**. It is not a separate identity.
`GROUP_ID` is the only thing that narrows this. The bot therefore:
- refuses to start when `GROUP_ID` or `GROUP_ID_SEND` is missing or blank,
rather than defaulting to an empty string that matches nothing predictably;
- drops messages with no `groupInfo` (direct messages) explicitly, in
`counting.is_watched_group`, rather than relying on a falsy comparison;
- ships a `scoping` scenario asserting that direct messages and other groups
produce zero outbound messages, run by `scripts/e2e.sh` in CI.
If you widen that boundary, the scoping scenario is what should fail.
## Secrets
`.env` is gitignored and never committed. `.env.sample` contains no real
values. The bot reads no credentials of its own: authentication is the
signal-cli data directory mounted into the container, which stays on the host.
## Reporting a vulnerability
Open an issue at https://github.com/kuhyx/signal-bot/issues. If the issue
would expose message content or account access, say so without a working
proof of concept in the public issue.
## Dependencies
Every dependency is exact-pinned at its newest stable release and checked by
`scripts/check_dependency_freshness.sh` in pre-commit and CI. There are no
pre-release pins.

2
fake_signal/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A protocol-faithful fake of signal-cli-rest-api, for offline testing."""

81
fake_signal/cli.py Normal file
View File

@ -0,0 +1,81 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Drive a running fake server through a scenario, and adjudicate the result.
Exits non-zero on any mismatch, so scripts/e2e.sh is a real gate rather than
a log to read.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
import httpx
from fake_signal.scenario import Scenario, check, load_scenario
SETTLE_SECONDS = 0.35
def _emit(line: str) -> None:
"""Write one report line to stdout."""
sys.stdout.write(f"{line}\n")
def _groups(args: argparse.Namespace) -> dict[str, str]:
"""Map the scenario's symbolic group names onto real ids."""
return {"watched": args.group_id, "reply": args.group_id_send, "other": args.other_group}
async def run_scenario(
base_url: str, scenario: Scenario, groups: dict[str, str], *, settle: float
) -> list[str]:
"""Inject every frame, wait for the bot to react, then compare."""
async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
await client.post("/_control/reset")
for injection in scenario.inject:
await client.post("/_control/inject", json={"raw": injection.to_frame(groups)})
await asyncio.sleep(settle)
response = await client.get("/_control/sent")
response.raise_for_status()
return check(scenario, response.json(), groups)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the injector's command line."""
parser = argparse.ArgumentParser(description="Replay a scenario against the bot.")
parser.add_argument("scenarios", nargs="+", type=Path)
parser.add_argument("--base-url", default="http://127.0.0.1:8099")
parser.add_argument("--group-id", required=True)
parser.add_argument("--group-id-send", required=True)
parser.add_argument("--other-group", default="group-other==")
parser.add_argument("--settle", type=float, default=SETTLE_SECONDS)
return parser.parse_args(argv)
async def _main_async(args: argparse.Namespace) -> int:
"""Run every scenario, reporting failures as they happen."""
groups = _groups(args)
failed = 0
for path in args.scenarios:
scenario = load_scenario(path)
failures = await run_scenario(args.base_url, scenario, groups, settle=args.settle)
if failures:
failed += 1
_emit(f"FAIL {scenario.name}")
for line in failures:
_emit(f" {line}")
else:
_emit(f"ok {scenario.name}")
return 1 if failed else 0
def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns a process exit code."""
return asyncio.run(_main_async(parse_args(argv)))
if __name__ == "__main__":
sys.exit(main())

79
fake_signal/routes_api.py Normal file
View File

@ -0,0 +1,79 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The endpoints the real signal-cli-rest-api exposes."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, Response
from fake_signal.state import PIXEL_PNG, State
def _state(request: Request) -> State:
"""Return the State attached to the running app."""
state: State = request.app.state.fake
return state
def build_api_router() -> APIRouter:
"""Build the router mirroring the real container's API."""
router = APIRouter()
@router.get("/v1/about")
async def about(request: Request) -> dict[str, Any]:
return {"build": 2, "mode": _state(request).mode, "versions": ["v1", "v2"]}
@router.get("/v1/qrcodelink")
async def qrcodelink(device_name: str = "signal-api") -> Response:
# The real endpoint returns a PNG, which is why the live runbook has
# to open an image rather than print a string.
return Response(
content=PIXEL_PNG,
media_type="image/png",
headers={"X-Device-Name": device_name},
)
@router.post("/v2/send")
async def send(request: Request, payload: dict[str, Any]) -> JSONResponse:
_state(request).record_send(payload)
return JSONResponse({"timestamp": 1719177917717}, status_code=201)
@router.get("/v1/attachments")
async def list_attachments(request: Request) -> list[str]:
return list(_state(request).attachments)
@router.delete("/v1/attachments/{attachment_id}")
async def delete_attachment(request: Request, attachment_id: str) -> Response:
attachments = _state(request).attachments
if attachment_id in attachments:
attachments.remove(attachment_id)
return Response(status_code=204)
_add_receive_route(router)
return router
def _add_receive_route(router: APIRouter) -> None:
"""Attach the WebSocket receive endpoint.
Split out so the router builder stays under the complexity cap.
"""
@router.websocket("/v1/receive/{number}")
async def receive(websocket: WebSocket, number: str) -> None:
state: State = websocket.app.state.fake
await websocket.accept()
await websocket.send_json({"envelope": {"subscribed": number}})
state.sockets.append(websocket)
try:
while True:
# Blocks until the client goes away. The bot never sends.
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
# Branchless: a conditional remove leaves an arc no test can reach.
state.sockets[:] = [s for s in state.sockets if s is not websocket]

View File

@ -0,0 +1,45 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The injector's control plane. Not part of the real API."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter, Request
if TYPE_CHECKING:
from fake_signal.state import State
def _state(request: Request) -> State:
"""Return the State attached to the running app."""
state: State = request.app.state.fake
return state
def build_control_router() -> APIRouter:
"""Build the router the scenario injector drives."""
router = APIRouter(prefix="/_control")
@router.post("/inject")
async def inject(request: Request, frame: dict[str, Any]) -> dict[str, int]:
delivered = 0
for socket in list(_state(request).sockets):
await socket.send_text(str(frame["raw"]))
delivered += 1
return {"delivered": delivered}
@router.get("/sent")
async def captured(request: Request) -> list[dict[str, Any]]:
return list(_state(request).sent)
@router.post("/reset")
async def reset(request: Request) -> dict[str, str]:
_state(request).reset()
return {"status": "ok"}
@router.get("/ready")
async def ready(request: Request) -> dict[str, int]:
return {"sockets": len(_state(request).sockets)}
return router

View File

@ -0,0 +1,34 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Local stand-ins for thecatapi and dog.ceo, shaped like the real responses."""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import Response
from fake_signal.state import PIXEL_PNG, State
def _image_url(request: Request) -> str:
"""Absolute URL of the fixture image, as this server sees itself."""
state: State = request.app.state.fake
return f"{state.base_url}/_fixtures/image.png"
def build_fixture_router() -> APIRouter:
"""Build the router serving fixture images and image-API responses."""
router = APIRouter(prefix="/_fixtures")
@router.get("/cat-api")
async def cat_api(request: Request) -> list[dict[str, str]]:
return [{"url": _image_url(request)}]
@router.get("/dog-api")
async def dog_api(request: Request) -> dict[str, str]:
return {"message": _image_url(request)}
@router.get("/image.png")
async def image() -> Response:
return Response(content=PIXEL_PNG, media_type="image/png")
return router

130
fake_signal/scenario.py Normal file
View File

@ -0,0 +1,130 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Scenario files: what to inject, and what the bot must do in response."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from pathlib import Path
class ScenarioError(ValueError):
"""Raised when a scenario file is malformed."""
@dataclass(frozen=True, slots=True)
class Injection:
"""One inbound message to push down the websocket."""
text: str | None = None
uuid: str = "uuid-alice"
name: str = "Alice"
group: str | None = "watched"
sticker: bool = False
reaction: bool = False
sync: bool = False
def to_frame(self, groups: dict[str, str]) -> str:
"""Render this injection as a raw signal-cli envelope."""
payload: dict[str, Any] = {"timestamp": 1719177917717}
if self.text is not None:
payload["message"] = self.text
if self.group is not None:
if self.group not in groups:
msg = f"unknown group alias {self.group!r}; known: {sorted(groups)}"
raise ScenarioError(msg)
payload["groupInfo"] = {"groupId": groups[self.group], "type": "DELIVER"}
if self.sticker:
payload["sticker"] = {"packId": "abc", "stickerId": 6}
if self.reaction:
payload["reaction"] = {"emoji": "\U0001f44d", "targetAuthor": "x"}
body: dict[str, Any] = {"sourceUuid": self.uuid, "sourceName": self.name}
if self.sync:
body["syncMessage"] = {"sentMessage": payload}
else:
body["dataMessage"] = payload
return json.dumps({"envelope": body, "account": "+15550000000"})
@dataclass(frozen=True, slots=True)
class Expectation:
"""One message the bot is expected to have sent."""
kind: str = "text"
to: str = "reply"
contains: str | None = None
@dataclass(frozen=True, slots=True)
class Scenario:
"""A named list of injections and the sends they must produce."""
name: str
inject: list[Injection] = field(default_factory=list)
expect: list[Expectation] = field(default_factory=list)
def _build[T](cls: type[T], entry: object, what: str) -> T:
"""Instantiate a scenario dataclass, rejecting unknown keys."""
if not isinstance(entry, dict):
msg = f"each {what} entry must be a mapping, got {type(entry).__name__}"
raise ScenarioError(msg)
allowed = set(cls.__annotations__)
unknown = set(entry) - allowed
if unknown:
msg = f"unknown {what} keys {sorted(unknown)}; allowed: {sorted(allowed)}"
raise ScenarioError(msg)
return cls(**entry)
def load_scenario(path: Path) -> Scenario:
"""Load and validate one scenario file.
Raises:
ScenarioError: The file is not a mapping or carries unknown keys.
"""
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
msg = f"{path} must contain a mapping"
raise ScenarioError(msg)
if "name" not in document:
msg = f"{path} is missing a name"
raise ScenarioError(msg)
return Scenario(
name=str(document["name"]),
inject=[_build(Injection, e, "inject") for e in document.get("inject", [])],
expect=[_build(Expectation, e, "expect") for e in document.get("expect", [])],
)
def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
"""Reduce a captured send to (kind, recipient, body) for comparison."""
kind = "image" if payload.get("base64_attachments") else "text"
recipients = payload.get("recipients") or [""]
return kind, str(recipients[0]), str(payload.get("message", ""))
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:
"""Compare captured sends against expectations, returning failure lines."""
failures: list[str] = []
if len(sent) != len(scenario.expect):
failures.append(f"expected {len(scenario.expect)} send(s), captured {len(sent)}")
for index, expected in enumerate(scenario.expect):
if index >= len(sent):
failures.append(f"[{index}] missing: expected a {expected.kind}")
continue
kind, recipient, body = describe(sent[index])
want_to = groups.get(expected.to, expected.to)
if kind != expected.kind:
failures.append(f"[{index}] kind: expected {expected.kind}, got {kind}")
if recipient != want_to:
failures.append(f"[{index}] recipient: expected {want_to}, got {recipient}")
if expected.contains is not None and expected.contains not in body:
failures.append(f"[{index}] body: {expected.contains!r} not in {body!r}")
return failures

View File

@ -0,0 +1,5 @@
name: "!cat in the watched group posts an image to the reply group"
inject:
- { text: "!cat", uuid: uuid-alice, name: Alice }
expect:
- { kind: image, to: reply }

View File

@ -0,0 +1,9 @@
# Distinct actor per scenario: one bot process serves them all, and the
# cooldown lives in SQLite, so sharing a uuid would leak state between files.
name: "a second !cat inside the cooldown window is refused, not served"
inject:
- { text: "!cat", uuid: uuid-carol, name: Carol }
- { text: "!cat", uuid: uuid-carol, name: Carol }
expect:
- { kind: image, to: reply }
- { kind: text, to: reply, contains: "BEEP BOOP" }

View File

@ -0,0 +1,9 @@
# The original bot shared one 10-second window across every user and every
# command, so one person's cat picture silenced the whole group.
name: "one user's cooldown does not silence anybody else"
inject:
- { text: "!cat", uuid: uuid-erin, name: Erin }
- { text: "!cat", uuid: uuid-frank, name: Frank }
expect:
- { kind: image, to: reply }
- { kind: image, to: reply }

View File

@ -0,0 +1,5 @@
name: "!dog resolves through a styled alias and posts an image"
inject:
- { text: "!szczek", uuid: uuid-bob, name: Bob }
expect:
- { kind: image, to: reply }

View File

@ -0,0 +1,5 @@
name: "stickers and reactions produce no reply at all"
inject:
- { sticker: true, sync: true, uuid: uuid-dave, name: Dave }
- { reaction: true, uuid: uuid-dave, name: Dave }
expect: []

View File

@ -0,0 +1,9 @@
# The security invariant. A linked Signal device receives every direct
# conversation and every group on the account; GROUP_ID is the only thing
# scoping the bot. Nothing here may produce a single outbound message.
name: "direct messages and other groups are never answered"
inject:
- { text: "!cat", group: null, uuid: uuid-stranger, name: Stranger }
- { text: "!cat", group: other, uuid: uuid-stranger, name: Stranger }
- { text: "hello there", group: null, uuid: uuid-stranger, name: Stranger }
expect: []

View File

@ -0,0 +1,5 @@
name: "chatter and unknown commands produce nothing"
inject:
- { text: "just chatting", uuid: uuid-alice, name: Alice }
- { text: "!nosuchcommand", uuid: uuid-alice, name: Alice }
expect: []

40
fake_signal/server.py Normal file
View File

@ -0,0 +1,40 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A protocol-faithful fake of signal-cli-rest-api.
Serves the exact surface the bot uses, so the unmodified bot connects to it
by pointing API_BASE_URL at this process. A real local Signal server is not
achievable -- signal-cli compiles its endpoints and trust store in, and
Signal-Server needs FoundationDB, DynamoDB and Redis -- so faking this
boundary is the only way to exercise every feature without a real account.
"""
from __future__ import annotations
import os
from fastapi import FastAPI
from fake_signal.routes_api import build_api_router
from fake_signal.routes_control import build_control_router
from fake_signal.routes_fixtures import build_fixture_router
from fake_signal.state import State
def create_app(state: State | None = None) -> FastAPI:
"""Build the fake server, optionally sharing an existing State."""
app = FastAPI(title="fake-signal-cli-rest-api")
app.state.fake = state if state is not None else State()
app.include_router(build_api_router())
app.include_router(build_fixture_router())
app.include_router(build_control_router())
return app
def default_state() -> State:
"""State whose self-URL matches the port the e2e script binds."""
host = os.getenv("FAKE_HOST", "127.0.0.1")
port = os.getenv("FAKE_PORT", "8099")
return State(base_url=f"http://{host}:{port}")
app = create_app(default_state())

40
fake_signal/state.py Normal file
View File

@ -0,0 +1,40 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Shared mutable state for the fake server."""
from __future__ import annotations
import base64
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from fastapi import WebSocket
# A 1x1 PNG, inline so no binary file enters the repo.
PIXEL_PNG: Final = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQ"
"GAhKmMIQAAAABJRU5ErkJggg=="
)
@dataclass
class State:
"""Everything the fake remembers between requests."""
sockets: list[WebSocket] = field(default_factory=list)
sent: list[dict[str, Any]] = field(default_factory=list)
attachments: list[str] = field(default_factory=list)
mode: str = "json-rpc-native"
base_url: str = "http://127.0.0.1:8099"
def reset(self) -> None:
"""Forget captured sends and attachments."""
self.sent.clear()
self.attachments.clear()
def record_send(self, payload: dict[str, Any]) -> None:
"""Capture one outgoing message and mint ids for its attachments."""
self.sent.append(payload)
attachments = payload.get("base64_attachments") or []
for index in range(len(attachments)):
self.attachments.append(f"attach-{len(self.sent)}-{index}")

124
pyproject.toml Normal file
View File

@ -0,0 +1,124 @@
[project]
name = "signal-bot"
version = "1.0.0"
description = "A Signal group bot: image commands and daily message tallies."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.14"
# Exact pins, newest stable, per the dependency-freshness rule. A caret range
# is "newest-compatible" by definition and gives the freshness gate nothing to
# compare against.
dependencies = [
"fastapi==0.141.1",
"httpx==0.28.1",
"python-dotenv==1.2.3",
"PyYAML==6.0.3",
"uvicorn==0.52.4",
"websockets==17.1",
]
[project.optional-dependencies]
dev = [
"mypy==2.3.1",
"pre-commit==4.6.2",
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
"pytest-cov==7.1.0",
"ruff==0.16.5",
"types-PyYAML==6.0.12.20260815",
]
[project.scripts]
signal-bot = "signal_bot.__main__:main"
[build-system]
requires = ["setuptools==80.9.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["signal_bot", "fake_signal"]
# ============================================================================
# RUFF
# ============================================================================
[tool.ruff]
target-version = "py314"
line-length = 100
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"D203", # conflicts with D211, we use D211
"D213", # conflicts with D212, we use D212
"COM812", # formatter owns trailing commas
"ISC001", # formatter may create implicit string concatenation
]
fixable = ["ALL"]
# Verified empirically, not copied: with this block removed, ruff reports
# exactly these four rules across tests/ and nothing else. SLF001 was in
# the original list and fired zero times, so it is gone.
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"ARG", # fixture and mock params exist for their side effect
"D", # test names are the documentation
"PLR2004", # `assert count == 2` beats a named constant
"S101", # assert IS what tests do
]
[tool.ruff.lint.pylint]
# The test envelope builder takes 7 keyword-only fields, one per envelope
# attribute. Splitting it into a dataclass would only move the count.
max-args = 7
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"
[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.format]
quote-style = "double"
docstring-code-format = true
# ============================================================================
# MYPY
# ============================================================================
[tool.mypy]
python_version = "3.14"
strict = true
warn_unreachable = true
disallow_any_explicit = false
# ============================================================================
# PYTEST / COVERAGE
# ============================================================================
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = [
"--cov=signal_bot",
"--cov=fake_signal",
"--cov-branch",
"--cov-report=term-missing",
"--cov-fail-under=100",
"--strict-markers",
]
[tool.coverage.run]
branch = true
# No omits. Every line of both packages is covered by tests.
[tool.coverage.report]
# No "pragma: no cover" escape hatch: nothing in this repo uses one, and
# leaving the pattern configured is an invitation to hide code from the
# 100% gate later. TYPE_CHECKING bodies are genuinely never executed.
exclude_lines = ["if TYPE_CHECKING:"]
show_missing = true

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
# Runtime dependencies, exact-pinned at newest stable.
# Dev/test dependencies live in pyproject.toml under [project.optional-dependencies].
fastapi==0.141.1
httpx==0.28.1
python-dotenv==1.2.3
PyYAML==6.0.3
uvicorn==0.52.4
websockets==17.1

25
run.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# ============================================================================
# Run the Signal bot.
#
# The readme used to say `python main.py`, which did nothing at all: there was
# no __main__ block and no uvicorn.run() call anywhere in the project.
# ============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
main() {
if [[ ! -x "$PYTHON" ]]; then
echo "Error: no interpreter at $PYTHON" >&2
echo " Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
exit 1
fi
exec "$PYTHON" -m signal_bot
}
main "$@"

26
scripts/check_coverage.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Thin delegate to the shared gate in ~/utils.
#
# Copying the logic here is what lets one repo's idea of the rule drift from
# every other repo's, so this script only locates the shared checker and
# forwards its arguments.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_coverage.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Thin delegate to the shared gate in ~/utils.
#
# Copying the logic here is what lets one repo's idea of the rule drift from
# every other repo's, so this script only locates the shared checker and
# forwards its arguments.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_dependency_freshness.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

26
scripts/check_file_length.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Thin delegate to the shared gate in ~/utils.
#
# Copying the logic here is what lets one repo's idea of the rule drift from
# every other repo's, so this script only locates the shared checker and
# forwards its arguments.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_file_length.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

26
scripts/check_md_naming.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# ============================================================================
# Thin delegate to the shared gate in ~/utils.
#
# Copying the logic here is what lets one repo's idea of the rule drift from
# every other repo's, so this script only locates the shared checker and
# forwards its arguments.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_md_naming.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

106
scripts/e2e.sh Executable file
View File

@ -0,0 +1,106 @@
#!/bin/bash
# ============================================================================
# End-to-end test: the real bot against the fake Signal server.
#
# Boots the protocol-faithful fake, boots the unmodified bot pointed at it,
# replays every scenario and exits non-zero on the first mismatch. No Signal
# account, no network, no container.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
readonly FAKE_PORT="${FAKE_PORT:-8099}"
readonly BOT_PORT="${BOT_PORT:-8098}"
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
readonly WATCHED_GROUP="group.e2e.watched=="
readonly REPLY_GROUP="group.e2e.reply=="
readonly OTHER_GROUP="group.e2e.other=="
FAKE_PID=""
BOT_PID=""
TEMP_DIR=""
cleanup() {
[[ -n "$BOT_PID" ]] && kill "$BOT_PID" 2>/dev/null || true
[[ -n "$FAKE_PID" ]] && kill "$FAKE_PID" 2>/dev/null || true
wait 2>/dev/null || true
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
return 0
}
trap cleanup EXIT
wait_for_url() {
local url="$1" name="$2" attempt=0
until curl -sf "$url" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge 100 ]]; then
echo "Error: $name did not come up at $url" >&2
exit 1
fi
sleep 0.1
done
}
wait_for_subscription() {
local attempt=0
until [[ "$(curl -sf "${FAKE_URL}/_control/ready" | tr -dc '0-9')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 100 ]]; then
echo "Error: the bot never subscribed to the receive websocket" >&2
exit 1
fi
sleep 0.1
done
}
validate_requirements() {
if [[ ! -x "$PYTHON" ]]; then
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
exit 1
fi
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
}
main() {
validate_requirements
TEMP_DIR="$(mktemp -d)"
echo "== booting the fake Signal server on :${FAKE_PORT}"
export FAKE_PORT
"$PYTHON" -m uvicorn fake_signal.server:app \
--host 127.0.0.1 --port "$FAKE_PORT" --log-level warning &
FAKE_PID=$!
wait_for_url "${FAKE_URL}/v1/about" "fake signal server"
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
env -u CAT_API \
PHONE_NUMBER="+15550000000" \
GROUP_ID="$WATCHED_GROUP" \
GROUP_ID_SEND="$REPLY_GROUP" \
API_BASE_URL="$FAKE_URL" \
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
BIND_PORT="$BOT_PORT" \
"$PYTHON" -m signal_bot &
BOT_PID=$!
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
wait_for_subscription
echo "== replaying scenarios"
"$PYTHON" -m fake_signal.cli \
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
--base-url "$FAKE_URL" \
--group-id "$WATCHED_GROUP" \
--group-id-send "$REPLY_GROUP" \
--other-group "$OTHER_GROUP"
}
main "$@"

152
scripts/live_smoke.sh Executable file
View File

@ -0,0 +1,152 @@
#!/bin/bash
# ============================================================================
# Live verification against a real Signal account.
#
# Starts the signal-cli-rest-api container, opens the linking QR code, waits
# for the link, lists your groups so you can pick a private one, writes .env
# and starts the bot. The only manual step is scanning the QR code.
#
# Run scripts/e2e.sh first: this costs a device link, that costs nothing.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
readonly CONTAINER="${CONTAINER:-signal-api}"
readonly PORT="${PORT:-9922}"
readonly API="http://localhost:${PORT}"
readonly DATA_DIR="${DATA_DIR:-$HOME/signal-api}"
# Stable tag. The dev series counts separately, so 0.167-dev is OLDER than this.
readonly IMAGE="${IMAGE:-bbernhard/signal-cli-rest-api:0.100}"
# Fallback when linking fails on client deprecation: tracks a newer signal-cli.
readonly FALLBACK_IMAGE="${FALLBACK_IMAGE:-bbernhard/signal-cli-rest-api:1787779383-ci}"
TEMP_DIR=""
cleanup() {
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
return 0
}
trap cleanup EXIT
validate_requirements() {
command -v docker >/dev/null || { echo "Error: docker is required" >&2; exit 1; }
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
command -v jq >/dev/null || { echo "Error: jq is required" >&2; exit 1; }
[[ -x "$PYTHON" ]] || {
echo "Error: no interpreter at $PYTHON" >&2
echo " Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
exit 1
}
}
start_container() {
local image="$1"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
mkdir -p "$DATA_DIR"
echo "== starting $image on :${PORT} (MODE=json-rpc-native)"
docker run -d --name "$CONTAINER" --restart=always -p "${PORT}:8080" \
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
-e 'MODE=json-rpc-native' "$image" >/dev/null
local attempt=0
until curl -sf "${API}/v1/about" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge 150 ]]; then
echo "Error: container never became ready. Logs:" >&2
docker logs --tail 30 "$CONTAINER" >&2
return 1
fi
sleep 0.4
done
}
verify_mode() {
local mode
mode="$(curl -sf "${API}/v1/about" | jq -r '.mode // "unknown"')"
if [[ "$mode" != *json-rpc* ]]; then
echo "Error: container mode is '$mode'." >&2
echo " /v1/receive is only a WebSocket in a json-rpc mode." >&2
exit 1
fi
echo "== container mode: $mode"
}
show_qr_code() {
local png="${TEMP_DIR}/link.png"
curl -sf "${API}/v1/qrcodelink?device_name=${CONTAINER}" -o "$png"
echo "== scan this QR code: Signal > Settings > Linked devices > +"
# The endpoint returns a PNG, so it has to be opened, not printed.
if command -v xdg-open >/dev/null; then
xdg-open "$png" >/dev/null 2>&1 &
else
echo " open this file: $png"
fi
}
wait_for_link() {
echo "== waiting for the link (Ctrl-C to abort)"
local attempt=0
until [[ "$(curl -sf "${API}/v1/accounts" | jq -r 'length // 0')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 300 ]]; then
echo "Error: no account linked after 5 minutes." >&2
echo " If linking failed with a connection error, retry with:" >&2
echo " IMAGE=$FALLBACK_IMAGE $0" >&2
exit 1
fi
sleep 1
done
}
choose_group() {
local number="$1"
echo "== groups on this account:" >&2
curl -sf "${API}/v1/groups/${number}" \
| jq -r '.[] | "\(.id)\t\(.name // "(unnamed)")"' \
| nl -w3 -s' ' >&2
echo >&2
read -r -p "Pick the number of the PRIVATE group to use: " choice
curl -sf "${API}/v1/groups/${number}" \
| jq -r --argjson n "$choice" '.[$n - 1].id'
}
write_env() {
local number="$1" group="$2"
cat > "${REPO_ROOT}/.env" <<ENV
PHONE_NUMBER=${number}
GROUP_ID=${group}
GROUP_ID_SEND=${group}
API_BASE_URL=${API}
DATABASE_PATH=${REPO_ROOT}/signal_bot.sqlite3
ENV
echo "== wrote ${REPO_ROOT}/.env (GROUP_ID == GROUP_ID_SEND, replies land in the same group)"
}
main() {
validate_requirements
TEMP_DIR="$(mktemp -d)"
start_container "$IMAGE"
verify_mode
show_qr_code
wait_for_link
local number group
number="$(curl -sf "${API}/v1/accounts" | jq -r '.[0]')"
echo "== linked as ${number}"
group="$(choose_group "$number")"
[[ -n "$group" && "$group" != "null" ]] || { echo "Error: no group chosen" >&2; exit 1; }
write_env "$number" "$group"
echo "== starting the bot. Send '!cat' in that group; you should get a cat."
exec "$REPO_ROOT/run.sh"
}
main "$@"

2
signal_bot/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A Signal group bot: image commands and daily message tallies."""

30
signal_bot/__main__.py Normal file
View File

@ -0,0 +1,30 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Console entrypoint, so `python -m signal_bot` actually starts the bot.
The old readme told people to run `python main.py`, which had no __main__
block and no uvicorn.run() call, so it silently did nothing at all.
"""
from __future__ import annotations
import os
import uvicorn
# Loopback by default: the only inbound surface is /healthz, and the link to
# signal-cli-rest-api is outbound. Override with BIND_HOST when containerised.
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
def main() -> None:
"""Run the ASGI app under uvicorn."""
uvicorn.run(
"signal_bot.app:app",
host=os.getenv("BIND_HOST", DEFAULT_HOST),
port=int(os.getenv("BIND_PORT", str(DEFAULT_PORT))),
)
if __name__ == "__main__":
main()

11
signal_bot/aliases.json Normal file
View File

@ -0,0 +1,11 @@
{
"_comment": "Command trigger words, including the styled Unicode variants people actually type. These live as data, not source, because they are data: adding an alias should never mean touching Python.",
"cat": [
"!kot", "!koty", "!kots", "!cat", "!cats", "!meow", "!miau",
"!ᴋᴏᴛ", "!𝓴𝓸𝓽", "!𝗸𝗼𝘁"
],
"dog": [
"!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek",
"!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽"
]
}

75
signal_bot/animals.py Normal file
View File

@ -0,0 +1,75 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The image commands, and the alias tables people actually type."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.decorators import Decorator
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandContext, CommandRegistry, Handler
CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
CAT_URL_PATH: Final[tuple[str | int, ...]] = (0, "url")
DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
DOG_URL_PATH: Final[tuple[str | int, ...]] = ("message",)
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
def load_aliases(path: Path = ALIAS_FILE) -> dict[str, list[str]]:
"""Load the trigger table.
Aliases live in JSON rather than in source so that the styled Unicode
variants are data the linter never has to reason about.
"""
document = json.loads(path.read_text(encoding="utf-8"))
return {name: list(triggers) for name, triggers in document.items() if not name.startswith("_")}
def image_handler(fetcher: ImageFetcher, api_url: str, url_path: Sequence[str | int]) -> Handler:
"""Build a handler that posts one random image from a JSON image API."""
async def handler(ctx: CommandContext) -> None:
encoded = await fetcher.fetch_base64(api_url, url_path)
await ctx.transport.send_image(encoded, ctx.recipient)
return handler
def register_animal_commands(
registry: CommandRegistry,
fetcher: ImageFetcher,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
endpoints: dict[str, str] | None = None,
) -> None:
"""Register the cat and dog commands on a registry.
Args:
registry: Registry to populate.
fetcher: Injected image fetcher.
decorate: Optional callable applied to each handler, used to attach
the cooldown gate without hard-wiring policy into the commands.
aliases: Trigger table. Defaults to the shipped aliases.json.
endpoints: Command name to image-API URL. Defaults to the real
public APIs; the offline environment points these at itself.
"""
table = load_aliases() if aliases is None else aliases
urls = {"cat": CAT_API_URL, "dog": DOG_API_URL} | (endpoints or {})
paths: dict[str, tuple[str | int, ...]] = {
"cat": CAT_URL_PATH,
"dog": DOG_URL_PATH,
}
for name, api_url in urls.items():
url_path = paths[name]
handler = image_handler(fetcher, api_url, url_path)
if decorate is not None:
handler = decorate(handler)
registry.register(name, table[name], handler)

150
signal_bot/app.py Normal file
View File

@ -0,0 +1,150 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Application wiring and the ASGI entrypoint."""
from __future__ import annotations
import asyncio
import contextlib
import datetime as dt
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from signal_bot.transport_base import Transport
import httpx
from fastapi import FastAPI
from signal_bot.animals import register_animal_commands
from signal_bot.config import Config, load_config
from signal_bot.counting import format_tally
from signal_bot.decorators import cooldown
from signal_bot.dispatcher import Dispatcher
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandRegistry
from signal_bot.scheduler import DailyScheduler
from signal_bot.store import Store
from signal_bot.transport_rest import RestTransport
logger = logging.getLogger(__name__)
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND."
HTTP_TIMEOUT_SECONDS = 30.0
def now_local() -> dt.datetime:
"""Current local time, timezone-aware."""
return dt.datetime.now().astimezone()
@dataclass
class Bot:
"""The wired-up bot and its two long-running loops."""
config: Config
store: Store
transport: Transport
dispatcher: Dispatcher
async def publish_report(self) -> None:
"""Post the tally to the reply group and reset it."""
rows = [(t.display_name or t.uuid, t.count) for t in self.store.tallies()]
await self.transport.send_text(format_tally(rows), self.config.group_id_send)
self.store.reset_tallies()
async def listen(self) -> None:
"""Consume frames forever, handing each to the dispatcher."""
async for frame in self.transport.receive():
await self.dispatcher.handle(frame)
def build_bot(config: Config, client: httpx.AsyncClient, store: Store, transport: Transport) -> Bot:
"""Assemble the object graph from its injected dependencies."""
registry = CommandRegistry()
register_animal_commands(
registry,
ImageFetcher(client),
decorate=cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE),
endpoints={"cat": config.cat_api_url, "dog": config.dog_api_url},
)
dispatcher = Dispatcher(
config=config,
registry=registry,
store=store,
transport=transport,
clock=now_local,
)
return Bot(config=config, store=store, transport=transport, dispatcher=dispatcher)
@dataclass
class Runtime:
"""Owns everything that must be torn down when the app stops."""
bot: Bot
client: httpx.AsyncClient
store: Store
tasks: list[asyncio.Task[None]] = field(default_factory=list)
def start(self) -> None:
"""Spawn the listener and the daily scheduler as background tasks."""
scheduler = DailyScheduler(
self.bot.config.report_hour,
self.bot.config.report_minute,
self.bot.publish_report,
now_local,
asyncio.sleep,
)
self.tasks = [
asyncio.create_task(self.bot.listen()),
asyncio.create_task(scheduler.run()),
]
async def stop(self) -> None:
"""Cancel the background tasks and close every resource."""
for task in self.tasks:
task.cancel()
for task in self.tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
await self.client.aclose()
self.store.close()
async def create_runtime(config: Config, client: httpx.AsyncClient) -> Runtime:
"""Build a Runtime, verifying the container exposes a WebSocket first."""
store = Store(config.database_path)
transport = RestTransport(config, client)
await transport.verify_mode()
return Runtime(bot=build_bot(config, client, store, transport), client=client, store=store)
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Start the background tasks without blocking ASGI startup.
The previous version awaited asyncio.gather() on two infinite tasks inside
an on_event("startup") handler, so uvicorn never finished starting up and
the HTTP app never began serving.
"""
config = load_config()
client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS)
runtime = await create_runtime(config, client)
runtime.start()
app.state.runtime = runtime
try:
yield
finally:
await runtime.stop()
app = FastAPI(lifespan=lifespan)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
"""Liveness probe. The old app defined no routes at all."""
return {"status": "ok"}

133
signal_bot/config.py Normal file
View File

@ -0,0 +1,133 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Runtime configuration, read once from the environment and validated eagerly."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Final
from urllib.parse import urlsplit, urlunsplit
from dotenv import load_dotenv
DEFAULT_API_BASE_URL: Final = "http://localhost:9922"
DEFAULT_DATABASE_PATH: Final = "signal_bot.sqlite3"
DEFAULT_REPORT_HOUR: Final = 21
DEFAULT_REPORT_MINUTE: Final = 37
DEFAULT_COOLDOWN_SECONDS: Final = 10
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
MAX_HOUR: Final = 23
MAX_MINUTE: Final = 59
_WS_SCHEMES: Final = {"http": "ws", "https": "wss"}
class ConfigError(RuntimeError):
"""Raised when the environment does not describe a runnable bot."""
@dataclass(frozen=True, slots=True)
class Config:
"""Everything the bot needs to know, resolved and validated."""
phone_number: str
group_id: str
group_id_send: str
api_base_url: str = DEFAULT_API_BASE_URL
database_path: str = DEFAULT_DATABASE_PATH
report_hour: int = DEFAULT_REPORT_HOUR
report_minute: int = DEFAULT_REPORT_MINUTE
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS
# Overridable so the offline environment can serve fixture images
# without the bot knowing it is not talking to the real APIs.
cat_api_url: str = DEFAULT_CAT_API_URL
dog_api_url: str = DEFAULT_DOG_API_URL
@property
def receive_url(self) -> str:
"""WebSocket URL the bot subscribes to for incoming envelopes."""
parts = urlsplit(self.api_base_url)
scheme = _WS_SCHEMES.get(parts.scheme, parts.scheme)
path = f"/v1/receive/{self.phone_number}"
query = "send_read_receipts=false"
return urlunsplit((scheme, parts.netloc, path, query, ""))
@property
def send_url(self) -> str:
"""REST endpoint for outgoing messages and attachments."""
return f"{self.api_base_url}/v2/send"
@property
def attachments_url(self) -> str:
"""REST endpoint listing server-side stored attachments."""
return f"{self.api_base_url}/v1/attachments"
@property
def about_url(self) -> str:
"""REST endpoint reporting the container build and its mode."""
return f"{self.api_base_url}/v1/about"
def _require(env: dict[str, str], key: str) -> str:
"""Return a non-empty environment value or raise ConfigError."""
value = env.get(key, "").strip()
if not value:
msg = (
f"{key} is required and must not be empty. "
"A linked Signal device sees every chat on the account, so the bot "
"refuses to start without an explicit group to scope itself to."
)
raise ConfigError(msg)
return value
def _positive_int(env: dict[str, str], key: str, default: int) -> int:
"""Return an int environment value, falling back to default when unset."""
raw = env.get(key, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
msg = f"{key} must be an integer, got {raw!r}"
raise ConfigError(msg) from None
if value < 0:
msg = f"{key} must not be negative, got {value}"
raise ConfigError(msg)
return value
def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config:
"""Build a Config from the environment, failing loudly on anything missing.
Args:
env: Mapping to read instead of os.environ. Used by the tests.
use_dotenv: Load a local .env file first. Disabled in tests.
Returns:
A validated, immutable Config.
"""
if env is None:
if use_dotenv:
load_dotenv()
env = dict(os.environ)
hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR)
minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE)
if hour > MAX_HOUR or minute > MAX_MINUTE:
msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}"
raise ConfigError(msg)
return Config(
phone_number=_require(env, "PHONE_NUMBER"),
group_id=_require(env, "GROUP_ID"),
group_id_send=_require(env, "GROUP_ID_SEND"),
api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL,
database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH,
report_hour=hour,
report_minute=minute,
cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS),
cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL,
dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL,
)

44
signal_bot/counting.py Normal file
View File

@ -0,0 +1,44 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Which messages count toward the daily tally, and which chats we listen to."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
def is_watched_group(message: IncomingMessage, config: Config) -> bool:
"""True only for messages from the one group the bot is scoped to.
A linked Signal device receives every direct conversation and every group
on the account. This predicate is the entire boundary between "a bot in
one group" and "a bot reading all of your chats", so it is deliberately
written as an explicit group-id equality rather than a falsy check: a
direct message has no group id at all and must never reach a handler.
"""
if message.group_id is None:
return False
return message.group_id == config.group_id
def should_count(message: IncomingMessage) -> bool:
"""True when a message should add to its sender's tally.
Reactions and stickers are not messages for tally purposes, and a frame
with no text body carries nothing to count.
"""
if message.is_reaction or message.has_sticker:
return False
return bool(message.text)
def format_tally(rows: list[tuple[str, int]]) -> str:
"""Render the daily report, or a placeholder when nobody spoke."""
if not rows:
return "Brak wiadomosci dzisiaj."
width = max(len(name) for name, _ in rows)
lines = [f"{name.ljust(width)} {count}" for name, count in rows]
return "\n".join(lines)

79
signal_bot/decorators.py Normal file
View File

@ -0,0 +1,79 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Reusable gates that wrap a command handler.
These are the mechanisms salvaged from the unmerged vote and rate-limit
branches, detached from the NSFW command they were originally written for.
They only decide whether a handler may run; recording a successful use is the
dispatcher's job, so stacking two gates cannot double-count one invocation.
"""
from __future__ import annotations
import functools
from collections.abc import Callable
from signal_bot.registry import CommandContext, Handler
Decorator = Callable[[Handler], Handler]
def cooldown(seconds: float, message: str) -> Decorator:
"""Refuse a command until `seconds` have passed since that user's last use.
The original bot shared one cooldown window across every user and every
command, so one person's cat picture silenced everybody.
"""
def decorate(handler: Handler) -> Handler:
@functools.wraps(handler)
async def wrapper(ctx: CommandContext) -> None:
last = ctx.store.last_used(ctx.message.source_uuid, ctx.command_name)
if last is not None and ctx.now - last < seconds:
remaining = seconds - (ctx.now - last)
await ctx.reply(message.format(seconds=int(remaining) + 1))
return
await handler(ctx)
return wrapper
return decorate
def daily_limit(max_uses: int, message: str) -> Decorator:
"""Allow a command at most `max_uses` times per user per calendar day."""
def decorate(handler: Handler) -> Handler:
@functools.wraps(handler)
async def wrapper(ctx: CommandContext) -> None:
used = ctx.store.uses_today(ctx.message.source_uuid, ctx.command_name, ctx.day)
if used >= max_uses:
await ctx.reply(message.format(max_uses=max_uses, used=used))
return
await handler(ctx)
return wrapper
return decorate
def vote_gated(required: int, window_seconds: float, message: str) -> Decorator:
"""Run a command only once `required` distinct people ask within a window.
A repeat vote from the same person is ignored, and the tally is cleared
once the command fires so the next run starts from zero.
"""
def decorate(handler: Handler) -> Handler:
@functools.wraps(handler)
async def wrapper(ctx: CommandContext) -> None:
ctx.store.add_vote(ctx.command_name, ctx.message.source_uuid, ctx.now)
votes = ctx.store.count_votes(ctx.command_name, ctx.now - window_seconds)
if votes < required:
await ctx.reply(message.format(votes=votes, required=required))
return
ctx.store.clear_votes(ctx.command_name)
await handler(ctx)
return wrapper
return decorate

72
signal_bot/dispatcher.py Normal file
View File

@ -0,0 +1,72 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Routes one inbound frame: scope it, count it, maybe run a command."""
from __future__ import annotations
import datetime as dt
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.counting import is_watched_group, should_count
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandContext, CommandRegistry
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_base import Transport
logger = logging.getLogger(__name__)
Clock = Callable[[], dt.datetime]
@dataclass(frozen=True, slots=True)
class Dispatcher:
"""Applies the scoping rules and dispatches commands."""
config: Config
registry: CommandRegistry
store: Store
transport: Transport
clock: Clock
async def handle(self, frame: str) -> None:
"""Process one raw websocket frame end to end."""
message = parse_envelope(frame)
if message is None:
return
# The scoping gate. Direct messages and every other group the account
# belongs to stop here, before counting and before any handler runs.
if not is_watched_group(message, self.config):
logger.debug("ignoring message outside the watched group")
return
if should_count(message):
self.store.increment(message.source_uuid, message.source_name)
command = self.registry.resolve(message.text)
if command is None:
return
now = self.clock()
ctx = CommandContext(
message=message,
recipient=self.config.group_id_send,
transport=self.transport,
store=self.store,
config=self.config,
command_name=command.name,
now=now.timestamp(),
day=now.date().isoformat(),
)
try:
await command.handler(ctx)
except Exception:
logger.exception("command %s failed", command.name)
return
# Recorded once, here, so stacked gates cannot double-count a use.
self.store.record_use(message.source_uuid, command.name, ctx.now, ctx.day)

87
signal_bot/http_client.py Normal file
View File

@ -0,0 +1,87 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Outbound HTTP for the image commands.
Every outbound request goes through this one injectable client, so the tests
and the offline environment can serve fixtures without the bot knowing.
"""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Sequence
import httpx
class ImageFetchError(RuntimeError):
"""Raised when an upstream image API did not yield a usable image."""
class ImageFetcher:
"""Resolves an image URL from a JSON API and returns it base64-encoded."""
def __init__(self, client: httpx.AsyncClient) -> None:
"""Store the injected HTTP client."""
self._client = client
async def fetch_base64(self, api_url: str, url_path: Sequence[str | int]) -> str:
"""Fetch an image and return it as base64.
Args:
api_url: JSON endpoint that names an image.
url_path: Keys/indices to walk in the JSON to reach the image URL.
Returns:
The image bytes, base64-encoded.
Raises:
ImageFetchError: The JSON did not contain a usable URL.
"""
response = await self._client.get(api_url)
response.raise_for_status()
image_url = _walk(response.json(), url_path)
# Never written to disk: the original bot named a local file after the
# remote URL's last path segment and wrote it into the process CWD.
image = await self._client.get(image_url)
image.raise_for_status()
return base64.b64encode(image.content).decode("ascii")
_MISSING: Final = object()
def _in_range(items: list[object], index: int) -> bool:
"""True when index addresses a real element, counting from either end."""
return -len(items) <= index < len(items)
def _step(node: object, key: str | int, path: Sequence[str | int]) -> object:
"""Descend one level into a JSON document.
Narrows explicitly instead of indexing blindly, so a malformed upstream
response raises ImageFetchError rather than a TypeError from deeper in.
"""
found: object = _MISSING
if isinstance(key, str) and isinstance(node, dict):
found = node.get(key, _MISSING)
elif isinstance(key, int) and isinstance(node, list) and _in_range(node, key):
found = node[key]
if found is _MISSING:
msg = f"image API response has no element at {path!r}"
raise ImageFetchError(msg)
return found
def _walk(document: object, path: Sequence[str | int]) -> str:
"""Walk a JSON document along path and return the string found there."""
node: object = document
for key in path:
node = _step(node, key, path)
if not isinstance(node, str) or not node:
msg = f"image API element at {path!r} is not a URL: {node!r}"
raise ImageFetchError(msg)
return node

105
signal_bot/models.py Normal file
View File

@ -0,0 +1,105 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Parsing of signal-cli-rest-api envelopes into a flat, typed message."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class IncomingMessage:
"""One inbound Signal message, normalised across envelope shapes.
signal-cli reports messages from other people under ``dataMessage`` and
the account's own outgoing messages under ``syncMessage.sentMessage``.
The two carry the same fields at different depths; everything downstream
works on this flattened form instead of re-deriving the nesting.
"""
source_uuid: str
source_name: str
group_id: str | None
text: str | None
has_sticker: bool
is_reaction: bool
@property
def is_direct_message(self) -> bool:
"""True when the message did not arrive via a group.
A linked device receives every direct conversation on the account, so
this is the discriminator that keeps the bot scoped to its group.
"""
return self.group_id is None
def _payload(envelope: dict[str, Any]) -> dict[str, Any] | None:
"""Return the dataMessage or sentMessage body, whichever is present."""
data = envelope.get("dataMessage")
if isinstance(data, dict):
return data
sync = envelope.get("syncMessage")
if isinstance(sync, dict):
sent = sync.get("sentMessage")
if isinstance(sent, dict):
return sent
return None
def _group_id(payload: dict[str, Any]) -> str | None:
"""Return the group id, or None for a direct message."""
info = payload.get("groupInfo")
if not isinstance(info, dict):
return None
group_id = info.get("groupId")
if not isinstance(group_id, str) or not group_id:
return None
return group_id
def _text(payload: dict[str, Any]) -> str | None:
"""Return the message body, or None when there is no text."""
message = payload.get("message")
if not isinstance(message, str):
return None
return message
def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
"""Parse one raw websocket frame.
Args:
raw: The JSON frame exactly as received.
Returns:
The normalised message, or None when the frame carries no message
body at all (receipts, typing indicators, malformed JSON).
"""
try:
decoded = json.loads(raw)
except json.JSONDecodeError, UnicodeDecodeError:
return None
if not isinstance(decoded, dict):
return None
envelope = decoded.get("envelope")
if not isinstance(envelope, dict):
return None
payload = _payload(envelope)
if payload is None:
return None
return IncomingMessage(
source_uuid=str(envelope.get("sourceUuid", "")),
source_name=str(envelope.get("sourceName", "")),
group_id=_group_id(payload),
text=_text(payload),
# The original bot looked for the sticker under dataMessage even when
# the envelope was a syncMessage, so its own stickers were never
# filtered. Reading it off the resolved payload fixes both shapes.
has_sticker=isinstance(payload.get("sticker"), dict),
is_reaction=isinstance(payload.get("reaction"), dict),
)

95
signal_bot/registry.py Normal file
View File

@ -0,0 +1,95 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Command lookup: trigger words to handlers.
Reimplements the idea behind the unmerged ``rules.py`` branch: triggers are
data, not a chain of if-statements, so aliases (including the Unicode-styled
ones people actually type) cost one tuple entry each.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
from signal_bot.store import Store
from signal_bot.transport_base import Transport
COMMAND_PREFIX = "!"
@dataclass(frozen=True, slots=True)
class CommandContext:
"""Everything a command handler is allowed to touch."""
message: IncomingMessage
recipient: str
transport: Transport
store: Store
config: Config
command_name: str
now: float
day: str
async def reply(self, text: str) -> None:
"""Send a text reply to the configured reply target."""
await self.transport.send_text(text, self.recipient)
Handler = Callable[[CommandContext], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class Command:
"""A named command and every phrase that triggers it."""
name: str
triggers: frozenset[str]
handler: Handler
class DuplicateTriggerError(ValueError):
"""Raised when two commands claim the same trigger word."""
class CommandRegistry:
"""Maps trigger words to commands."""
def __init__(self) -> None:
"""Start with no commands registered."""
self._by_trigger: dict[str, Command] = {}
self._commands: list[Command] = []
def register(self, name: str, triggers: Iterable[str], handler: Handler) -> Command:
"""Register one command under every one of its triggers.
Raises:
DuplicateTriggerError: A trigger is already claimed.
"""
normalised = frozenset(t.strip().lower() for t in triggers)
for trigger in sorted(normalised):
if trigger in self._by_trigger:
msg = f"trigger {trigger!r} is already registered"
raise DuplicateTriggerError(msg)
command = Command(name=name, triggers=normalised, handler=handler)
for trigger in normalised:
self._by_trigger[trigger] = command
self._commands.append(command)
return command
def resolve(self, text: str | None) -> Command | None:
"""Return the command a message body triggers, or None."""
if not text:
return None
candidate = text.strip().lower()
if not candidate.startswith(COMMAND_PREFIX):
return None
return self._by_trigger.get(candidate)
@property
def commands(self) -> list[Command]:
"""Every registered command, in registration order."""
return list(self._commands)

66
signal_bot/scheduler.py Normal file
View File

@ -0,0 +1,66 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The daily tally report.
Time is injected so the tests assert on the computed interval instead of
sleeping through it.
"""
from __future__ import annotations
import datetime as dt
import logging
from collections.abc import Awaitable, Callable
logger = logging.getLogger(__name__)
Clock = Callable[[], dt.datetime]
Sleeper = Callable[[float], Awaitable[None]]
Action = Callable[[], Awaitable[None]]
SECONDS_PER_DAY = 24 * 60 * 60
class DailyScheduler:
"""Runs one action at a fixed local wall-clock time, every day."""
def __init__(
self,
hour: int,
minute: int,
action: Action,
clock: Clock,
sleeper: Sleeper,
) -> None:
"""Store the target time, the action, and the injected time sources."""
self._hour = hour
self._minute = minute
self._action = action
self._clock = clock
self._sleep = sleeper
def seconds_until_next(self, now: dt.datetime) -> float:
"""Seconds from now until the next occurrence of the target time.
Rolls over to tomorrow when today's target has already passed, so the
result is always strictly positive.
"""
target = now.replace(hour=self._hour, minute=self._minute, second=0, microsecond=0)
if target <= now:
target += dt.timedelta(days=1)
return (target - now).total_seconds()
async def run(self, iterations: int | None = None) -> None:
"""Sleep until the target time and run the action, forever by default.
Args:
iterations: Stop after this many runs. None means run forever.
The tests use a finite count; production passes None.
"""
completed = 0
while iterations is None or completed < iterations:
await self._sleep(self.seconds_until_next(self._clock()))
try:
await self._action()
except Exception:
logger.exception("daily report failed")
completed += 1

179
signal_bot/store.py Normal file
View File

@ -0,0 +1,179 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""SQLite persistence for tallies, cooldowns, daily limits and votes.
Everything the bot remembers lives here so a restart before the daily report
does not lose the day. The original bot kept all of it in module globals.
"""
from __future__ import annotations
import contextlib
import sqlite3
from dataclasses import dataclass
from typing import TYPE_CHECKING, Self
if TYPE_CHECKING:
from collections.abc import Iterator
from types import TracebackType
_SCHEMA = """
CREATE TABLE IF NOT EXISTS message_counts (
uuid TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS command_uses (
user_uuid TEXT NOT NULL,
command TEXT NOT NULL,
used_at REAL NOT NULL,
PRIMARY KEY (user_uuid, command)
);
CREATE TABLE IF NOT EXISTS daily_uses (
user_uuid TEXT NOT NULL,
command TEXT NOT NULL,
day TEXT NOT NULL,
uses INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_uuid, command, day)
);
CREATE TABLE IF NOT EXISTS votes (
command TEXT NOT NULL,
voter_uuid TEXT NOT NULL,
voted_at REAL NOT NULL,
PRIMARY KEY (command, voter_uuid)
);
"""
@dataclass(frozen=True, slots=True)
class Tally:
"""One person's message count for the current reporting period."""
uuid: str
display_name: str
count: int
class Store:
"""Thin, synchronous SQLite wrapper. All state the bot keeps lives here."""
def __init__(self, path: str) -> None:
"""Open (or create) the database at path and apply the schema."""
self._conn = sqlite3.connect(path)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_SCHEMA)
self._conn.commit()
def __enter__(self) -> Self:
"""Enter a context manager that closes the connection on exit."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
"""Close the underlying connection."""
self.close()
def close(self) -> None:
"""Close the underlying connection."""
self._conn.close()
# -- message tallies ---------------------------------------------------
def increment(self, uuid: str, display_name: str) -> None:
"""Add one to a person's tally, creating the row when new."""
self._conn.execute(
"""
INSERT INTO message_counts (uuid, display_name, count)
VALUES (?, ?, 1)
ON CONFLICT(uuid) DO UPDATE SET
count = count + 1,
display_name = excluded.display_name
""",
(uuid, display_name),
)
self._conn.commit()
def tallies(self) -> list[Tally]:
"""Return every tally, highest count first."""
rows = self._conn.execute(
"SELECT uuid, display_name, count FROM message_counts "
"ORDER BY count DESC, display_name ASC"
).fetchall()
return [Tally(r["uuid"], r["display_name"], r["count"]) for r in rows]
def reset_tallies(self) -> None:
"""Clear every tally, as the daily report does after publishing."""
self._conn.execute("DELETE FROM message_counts")
self._conn.commit()
# -- per-user, per-command rate limiting -------------------------------
def record_use(self, user_uuid: str, command: str, now: float, day: str) -> None:
"""Record one successful use for both cooldown and daily-limit checks."""
self._conn.execute(
"INSERT INTO command_uses (user_uuid, command, used_at) VALUES (?, ?, ?) "
"ON CONFLICT(user_uuid, command) DO UPDATE SET used_at = excluded.used_at",
(user_uuid, command, now),
)
self._conn.execute(
"""
INSERT INTO daily_uses (user_uuid, command, day, uses)
VALUES (?, ?, ?, 1)
ON CONFLICT(user_uuid, command, day) DO UPDATE SET uses = uses + 1
""",
(user_uuid, command, day),
)
self._conn.commit()
def last_used(self, user_uuid: str, command: str) -> float | None:
"""Return when this user last ran this command, or None."""
row = self._conn.execute(
"SELECT used_at FROM command_uses WHERE user_uuid = ? AND command = ?",
(user_uuid, command),
).fetchone()
return None if row is None else float(row["used_at"])
def uses_today(self, user_uuid: str, command: str, day: str) -> int:
"""Return how many times this user ran this command on the given day."""
row = self._conn.execute(
"SELECT uses FROM daily_uses WHERE user_uuid = ? AND command = ? AND day = ?",
(user_uuid, command, day),
).fetchone()
return 0 if row is None else int(row["uses"])
# -- vote gating -------------------------------------------------------
def add_vote(self, command: str, voter_uuid: str, now: float) -> None:
"""Record one vote, ignoring a repeat vote from the same person."""
self._conn.execute(
"INSERT INTO votes (command, voter_uuid, voted_at) VALUES (?, ?, ?) "
"ON CONFLICT(command, voter_uuid) DO NOTHING",
(command, voter_uuid, now),
)
self._conn.commit()
def count_votes(self, command: str, not_before: float) -> int:
"""Return how many votes for command are still inside the window."""
row = self._conn.execute(
"SELECT COUNT(*) AS n FROM votes WHERE command = ? AND voted_at >= ?",
(command, not_before),
).fetchone()
return int(row["n"])
def clear_votes(self, command: str) -> None:
"""Drop every vote for a command, after it fires or expires."""
self._conn.execute("DELETE FROM votes WHERE command = ?", (command,))
self._conn.commit()
@contextlib.contextmanager
def open_store(path: str) -> Iterator[Store]:
"""Open a Store as a context manager."""
store = Store(path)
try:
yield store
finally:
store.close()

View File

@ -0,0 +1,34 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The seam between the bot and Signal.
Everything above this interface is transport-agnostic, which is what lets the
same bot code run against the real signal-cli-rest-api container and against
the local fake used by the tests and the end-to-end script.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@runtime_checkable
class Transport(Protocol):
"""What the bot needs from a Signal backend."""
def receive(self) -> AsyncIterator[str]:
"""Yield raw envelope frames until the transport is closed."""
async def send_text(self, text: str, recipient: str) -> None:
"""Send a plain-text message to a group id or phone number."""
async def send_image(self, image_base64: str, recipient: str) -> None:
"""Send a base64-encoded image attachment to a recipient."""
async def list_attachments(self) -> list[str]:
"""Return the ids of attachments stored server-side."""
async def delete_attachment(self, attachment_id: str) -> None:
"""Delete one stored attachment by id."""

View File

@ -0,0 +1,64 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""In-process transport double used by the unit tests.
This is the fast path: no sockets, no server, no event loop scheduling games.
The protocol-faithful HTTP/WebSocket fake lives in the ``fake_signal`` package
and is what the end-to-end script drives.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
@dataclass(frozen=True, slots=True)
class SentMessage:
"""One outbound call captured by the fake."""
recipient: str
text: str | None = None
image_base64: str | None = None
@dataclass
class FakeTransport:
"""Replays canned frames and records everything the bot sends."""
frames: list[str] = field(default_factory=list)
sent: list[SentMessage] = field(default_factory=list)
attachments: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
def queue(self, frames: Iterable[str]) -> None:
"""Add frames to be yielded by the next receive() iteration."""
self.frames.extend(frames)
async def receive(self) -> AsyncIterator[str]:
"""Yield every queued frame once, then stop."""
for frame in list(self.frames):
yield frame
async def send_text(self, text: str, recipient: str) -> None:
"""Record a text send."""
self.sent.append(SentMessage(recipient=recipient, text=text))
async def send_image(self, image_base64: str, recipient: str) -> None:
"""Record an image send."""
self.sent.append(SentMessage(recipient=recipient, image_base64=image_base64))
async def list_attachments(self) -> list[str]:
"""Return the configured attachment ids."""
return list(self.attachments)
async def delete_attachment(self, attachment_id: str) -> None:
"""Record an attachment deletion."""
self.deleted.append(attachment_id)
@property
def texts(self) -> list[str]:
"""Every text body sent so far, in order."""
return [m.text for m in self.sent if m.text is not None]

View File

@ -0,0 +1,145 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Transport adapter for bbernhard/signal-cli-rest-api.
Receive is a WebSocket, which the container only exposes when it runs in one
of the json-rpc modes; in normal/native mode the same path silently degrades
to a polling GET, so the mode is verified at startup rather than assumed.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import TYPE_CHECKING, Any, Final
from websockets.asyncio.client import connect as ws_connect
from websockets.exceptions import WebSocketException
if TYPE_CHECKING:
import httpx
from signal_bot.config import Config
logger = logging.getLogger(__name__)
INITIAL_BACKOFF: Final = 1.0
MAX_BACKOFF: Final = 30.0
BACKOFF_FACTOR: Final = 2.0
_OK_STATUSES: Final = frozenset({200, 201, 204})
Connector = Callable[[str], Any]
Sleeper = Callable[[float], Awaitable[None]]
class TransportError(RuntimeError):
"""Raised when the Signal REST API rejects a request."""
class RestTransport:
"""Talks to the signal-cli-rest-api container over REST plus a WebSocket."""
def __init__(
self,
config: Config,
client: httpx.AsyncClient,
*,
connector: Connector | None = None,
sleeper: Sleeper | None = None,
max_reconnects: int | None = None,
) -> None:
"""Wire the adapter to its config and its injectable I/O.
Args:
config: Resolved runtime configuration.
client: HTTP client used for every REST call.
connector: WebSocket connect callable. Injected by the tests.
sleeper: Async sleep used for reconnect backoff.
max_reconnects: Stop after this many reconnects. None means forever.
"""
self._config = config
self._client = client
self._connect = connector if connector is not None else ws_connect
self._sleep = sleeper if sleeper is not None else asyncio.sleep
self._max_reconnects = max_reconnects
async def verify_mode(self) -> str:
"""Return the container's mode, raising unless it exposes a WebSocket.
Raises:
TransportError: The container is not running a json-rpc mode.
"""
response = await self._client.get(self._config.about_url)
response.raise_for_status()
body = response.json()
mode = str(body.get("mode", "")) if isinstance(body, dict) else ""
if "json-rpc" not in mode:
msg = (
f"signal-cli-rest-api is running in mode {mode!r}; the receive "
"endpoint is only a WebSocket in a json-rpc mode. Start the "
"container with MODE=json-rpc-native."
)
raise TransportError(msg)
return mode
async def receive(self) -> AsyncIterator[str]:
"""Yield raw envelope frames, reconnecting with backoff when dropped."""
backoff = INITIAL_BACKOFF
reconnects = 0
while True:
try:
async with self._connect(self._config.receive_url) as socket:
backoff = INITIAL_BACKOFF
async for frame in socket:
yield frame if isinstance(frame, str) else frame.decode()
except (WebSocketException, OSError) as exc:
logger.warning("signal websocket dropped: %s", exc)
if self._max_reconnects is not None and reconnects >= self._max_reconnects:
return
reconnects += 1
await self._sleep(backoff)
backoff = min(backoff * BACKOFF_FACTOR, MAX_BACKOFF)
async def _post_send(self, payload: dict[str, Any]) -> None:
"""POST one send payload and raise unless the API accepted it."""
response = await self._client.post(self._config.send_url, json=payload)
if response.status_code not in _OK_STATUSES:
msg = f"send failed with HTTP {response.status_code}: {response.text}"
raise TransportError(msg)
async def send_text(self, text: str, recipient: str) -> None:
"""Send a plain-text message to a group id or phone number."""
await self._post_send(
{
"message": text,
"number": self._config.phone_number,
"recipients": [recipient],
}
)
async def send_image(self, image_base64: str, recipient: str) -> None:
"""Send a base64-encoded image attachment to a recipient."""
await self._post_send(
{
"message": "",
"base64_attachments": [image_base64],
"number": self._config.phone_number,
"recipients": [recipient],
}
)
async def list_attachments(self) -> list[str]:
"""Return the ids of attachments stored server-side."""
response = await self._client.get(self._config.attachments_url)
response.raise_for_status()
body = response.json()
if not isinstance(body, list):
return []
return [str(item) for item in body]
async def delete_attachment(self, attachment_id: str) -> None:
"""Delete one stored attachment by id."""
response = await self._client.delete(f"{self._config.attachments_url}/{attachment_id}")
if response.status_code not in _OK_STATUSES:
msg = f"attachment delete failed with HTTP {response.status_code}"
raise TransportError(msg)

2
tests/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Test suite."""

81
tests/conftest.py Normal file
View File

@ -0,0 +1,81 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Shared fixtures and envelope builders."""
from __future__ import annotations
import datetime as dt
import json
from typing import Any
import pytest
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
WATCHED_GROUP = "group-watched=="
REPLY_GROUP = "group-reply=="
OTHER_GROUP = "group-other=="
@pytest.fixture
def config() -> Config:
return Config(
phone_number="+15550000000",
group_id=WATCHED_GROUP,
group_id_send=REPLY_GROUP,
api_base_url="http://localhost:9922",
database_path=":memory:",
cooldown_seconds=10,
)
@pytest.fixture
def store() -> Store:
with Store(":memory:") as opened:
yield opened
@pytest.fixture
def transport() -> FakeTransport:
return FakeTransport()
@pytest.fixture
def clock() -> dt.datetime:
return dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC)
def envelope(
*,
text: str | None = "hello",
group_id: str | None = WATCHED_GROUP,
uuid: str = "uuid-alice",
name: str = "Alice",
sticker: bool = False,
reaction: bool = False,
sync: bool = False,
) -> str:
"""Build one raw websocket frame in either envelope shape."""
payload: dict[str, Any] = {"timestamp": 1719177917717}
if text is not None:
payload["message"] = text
if group_id is not None:
payload["groupInfo"] = {"groupId": group_id, "type": "DELIVER"}
if sticker:
payload["sticker"] = {"packId": "abc", "stickerId": 6}
if reaction:
payload["reaction"] = {"emoji": "👍", "targetAuthor": "x"}
body: dict[str, Any] = {
"source": name,
"sourceUuid": uuid,
"sourceName": name,
"sourceDevice": 69,
"timestamp": 1719177917717,
}
if sync:
body["syncMessage"] = {"sentMessage": payload}
else:
body["dataMessage"] = payload
return json.dumps({"envelope": body, "account": "+15550000000"})

111
tests/test_animals.py Normal file
View File

@ -0,0 +1,111 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The cat and dog commands, and their data-driven aliases."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
import pytest
from signal_bot.animals import (
ALIAS_FILE,
load_aliases,
register_animal_commands,
)
from signal_bot.decorators import cooldown
from signal_bot.http_client import ImageFetcher
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandContext, CommandRegistry
from tests.conftest import REPLY_GROUP, envelope
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
IMAGE = b"png-bytes"
def _fetcher() -> ImageFetcher:
def handler(request: httpx.Request) -> httpx.Response:
if "thecatapi" in str(request.url):
return httpx.Response(200, json=[{"url": "https://cdn/cat.png"}])
if "dog.ceo" in str(request.url):
return httpx.Response(200, json={"message": "https://cdn/dog.png"})
return httpx.Response(200, content=IMAGE)
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
def _ctx(
transport: FakeTransport, store: Store, config: Config, name: str, text: str
) -> CommandContext:
message = parse_envelope(envelope(text=text))
assert message is not None
return CommandContext(
message=message,
recipient=REPLY_GROUP,
transport=transport,
store=store,
config=config,
command_name=name,
now=100.0,
day="2026-08-29",
)
def test_alias_file_ships_with_the_package() -> None:
assert ALIAS_FILE.is_file()
aliases = load_aliases()
assert "!cat" in aliases["cat"]
assert "!pies" in aliases["dog"]
def test_comment_keys_are_skipped(tmp_path: Path) -> None:
path = tmp_path / "a.json"
path.write_text(json.dumps({"_comment": "x", "cat": ["!cat"]}), encoding="utf-8")
assert load_aliases(path) == {"cat": ["!cat"]}
def test_styled_unicode_aliases_resolve() -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher())
assert registry.resolve("!\U0001d5f8\U0001d5fc\U0001d601") is not None
assert registry.resolve("!miau").name == "cat"
assert registry.resolve("!szczek").name == "dog"
@pytest.mark.parametrize(("trigger", "name"), [("!cat", "cat"), ("!dog", "dog")])
async def test_commands_post_an_image(
transport: FakeTransport, store: Store, config: Config, trigger: str, name: str
) -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher())
command = registry.resolve(trigger)
assert command is not None
await command.handler(_ctx(transport, store, config, name, trigger))
assert transport.sent[0].image_base64 is not None
assert transport.sent[0].recipient == REPLY_GROUP
async def test_decorator_is_applied_when_given(
transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher(), decorate=cooldown(10, "wait {seconds}"))
store.record_use("uuid-alice", "cat", 100.0, "2026-08-29")
command = registry.resolve("!cat")
assert command is not None
await command.handler(_ctx(transport, store, config, "cat", "!cat"))
assert transport.texts == ["wait 11"]
def test_custom_alias_table_is_honoured() -> None:
registry = CommandRegistry()
register_animal_commands(registry, _fetcher(), aliases={"cat": ["!c"], "dog": ["!d"]})
assert registry.resolve("!c").name == "cat"
assert registry.resolve("!cat") is None

134
tests/test_app.py Normal file
View File

@ -0,0 +1,134 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Wiring, the background loops, and ASGI startup."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import httpx
import pytest
from fastapi.testclient import TestClient
from signal_bot import app as app_module
from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local
from signal_bot.transport_rest import TransportError
from tests.conftest import REPLY_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.registry import Handler
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
BASE_ENV = {
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "watched==",
"GROUP_ID_SEND": "reply==",
"DATABASE_PATH": ":memory:",
}
# Captured before any monkeypatching, so a patched httpx.AsyncClient cannot
# recurse into this helper.
_REAL_ASYNC_CLIENT = httpx.AsyncClient
def _client(handler: Handler) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler))
def _bot(config: Config, store: Store, transport: FakeTransport) -> Bot:
return build_bot(config, _client(lambda r: httpx.Response(200)), store, transport)
def test_now_local_is_timezone_aware() -> None:
assert now_local().tzinfo is not None
def test_build_bot_registers_the_animal_commands(
config: Config, store: Store, transport: FakeTransport
) -> None:
bot = _bot(config, store, transport)
assert bot.dispatcher.registry.resolve("!cat") is not None
assert bot.dispatcher.registry.resolve("!pies") is not None
async def test_publish_report_posts_and_resets(
config: Config, store: Store, transport: FakeTransport
) -> None:
store.increment("u1", "Alice")
await _bot(config, store, transport).publish_report()
assert transport.texts == ["Alice 1"]
assert transport.sent[0].recipient == REPLY_GROUP
assert store.tallies() == []
async def test_publish_report_falls_back_to_uuid_when_unnamed(
config: Config, store: Store, transport: FakeTransport
) -> None:
store.increment("u1", "")
await _bot(config, store, transport).publish_report()
assert transport.texts == ["u1 1"]
async def test_listen_dispatches_every_frame(
config: Config, store: Store, transport: FakeTransport
) -> None:
transport.queue([envelope(text="hello", group_id=config.group_id)])
await _bot(config, store, transport).listen()
assert store.tallies()[0].count == 1
async def test_create_runtime_verifies_the_container_mode(config: Config) -> None:
runtime = await create_runtime(
config, _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
)
assert isinstance(runtime, Runtime)
await runtime.stop()
async def test_create_runtime_refuses_a_polling_container(config: Config) -> None:
with pytest.raises(TransportError):
await create_runtime(
config, _client(lambda r: httpx.Response(200, json={"mode": "normal"}))
)
async def test_runtime_start_and_stop_cancels_tasks(
config: Config, store: Store, transport: FakeTransport
) -> None:
client = _client(lambda r: httpx.Response(200))
runtime = Runtime(bot=_bot(config, store, transport), client=client, store=store)
runtime.start()
assert len(runtime.tasks) == 2
await asyncio.sleep(0)
await runtime.stop()
assert all(task.done() for task in runtime.tasks)
def test_healthz_serves_and_startup_completes(monkeypatch: pytest.MonkeyPatch) -> None:
"""The old app never finished startup, so no route was ever reachable."""
for key, value in BASE_ENV.items():
monkeypatch.setenv(key, value)
def fake_client(**kwargs: object) -> httpx.AsyncClient:
return _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
monkeypatch.setattr(app_module.httpx, "AsyncClient", fake_client)
with TestClient(app_module.app) as client:
assert client.get("/healthz").json() == {"status": "ok"}
async def test_lifespan_is_an_async_context_manager(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for key, value in BASE_ENV.items():
monkeypatch.setenv(key, value)
monkeypatch.setattr(
app_module.httpx,
"AsyncClient",
lambda **kwargs: _client(lambda r: httpx.Response(200, json={"mode": "json-rpc"})),
)
async with lifespan(app_module.app):
assert app_module.app.state.runtime is not None

118
tests/test_cli.py Normal file
View File

@ -0,0 +1,118 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The scenario injector CLI, including its exit codes."""
from __future__ import annotations
import runpy
import sys
from pathlib import Path
import httpx
import pytest
from fake_signal import cli
from fake_signal.scenario import Expectation, Injection, Scenario
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
class Recorder:
"""Stands in for the fake server's control plane."""
def __init__(self, sent: list[dict]) -> None:
self.sent = sent
self.injected: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/_control/inject":
self.injected.append(request.content.decode())
return httpx.Response(200, json={"delivered": 1})
if path == "/_control/sent":
return httpx.Response(200, json=self.sent)
return httpx.Response(200, json={"status": "ok"})
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: Recorder) -> None:
real = httpx.AsyncClient
def factory(**kwargs: object) -> httpx.AsyncClient:
# base_url must survive: run_scenario uses relative paths.
return real(
transport=httpx.MockTransport(recorder),
base_url=str(kwargs.get("base_url", "http://x")),
)
monkeypatch.setattr(cli.httpx, "AsyncClient", factory)
async def test_run_scenario_injects_and_passes(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = Recorder([{"base64_attachments": ["A"], "recipients": ["R=="]}])
_patch_client(monkeypatch, recorder)
scenario = Scenario("s", inject=[Injection(text="!cat")], expect=[Expectation(kind="image")])
failures = await cli.run_scenario("http://x", scenario, GROUPS, settle=0)
assert failures == []
assert "!cat" in recorder.injected[0]
async def test_run_scenario_reports_a_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_client(monkeypatch, Recorder([]))
scenario = Scenario("s", expect=[Expectation(kind="image")])
assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0)
def test_parse_args_requires_the_group_ids() -> None:
args = cli.parse_args(["a.yaml", "--group-id", "W==", "--group-id-send", "R=="])
assert args.scenarios == [Path("a.yaml")]
assert args.group_id == "W=="
def test_parse_args_rejects_missing_groups() -> None:
with pytest.raises(SystemExit):
cli.parse_args(["a.yaml"])
def _scenario_file(tmp_path: Path, expect: str) -> Path:
path = tmp_path / "s.yaml"
path.write_text(f"name: demo\ninject:\n - {{text: '!cat'}}\n{expect}", encoding="utf-8")
return path
def test_main_exits_zero_when_every_scenario_passes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_patch_client(monkeypatch, Recorder([{"base64_attachments": ["A"], "recipients": ["R=="]}]))
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
code = cli.main([str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"])
assert code == 0
assert "ok demo" in capsys.readouterr().out
def test_main_exits_non_zero_on_a_mismatch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""The e2e script is a gate only because this returns 1."""
_patch_client(monkeypatch, Recorder([]))
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
code = cli.main([str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"])
assert code == 1
out = capsys.readouterr().out
assert "FAIL demo" in out
def test_module_runs_as_a_script(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`python -m fake_signal.cli` must exit with the adjudication code.
runpy re-imports into a fresh namespace, so the patch has to land on the
shared httpx module rather than on fake_signal.cli.
"""
_patch_client(monkeypatch, Recorder([]))
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
monkeypatch.setattr(
sys,
"argv",
["cli", str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"],
)
with pytest.raises(SystemExit) as exit_info:
runpy.run_module("fake_signal.cli", run_name="__main__", alter_sys=True)
assert exit_info.value.code == 1

105
tests/test_config.py Normal file
View File

@ -0,0 +1,105 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Configuration loading, validation and URL derivation."""
from __future__ import annotations
import pytest
from signal_bot.config import Config, ConfigError, load_config
BASE_ENV = {
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "watched==",
"GROUP_ID_SEND": "reply==",
}
def test_loads_minimal_environment() -> None:
config = load_config(dict(BASE_ENV))
assert config.phone_number == "+15550000000"
assert config.api_base_url == "http://localhost:9922"
assert config.report_hour == 21
assert config.report_minute == 37
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
def test_refuses_to_start_without_required_value(missing: str) -> None:
env = dict(BASE_ENV)
del env[missing]
with pytest.raises(ConfigError, match=missing):
load_config(env)
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
def test_refuses_to_start_on_blank_value(missing: str) -> None:
"""A linked device sees every chat, so an unscoped bot must not start."""
env = dict(BASE_ENV) | {missing: " "}
with pytest.raises(ConfigError, match=missing):
load_config(env)
def test_overrides_are_honoured() -> None:
env = dict(BASE_ENV) | {
"API_BASE_URL": "https://signal.example:8443",
"DATABASE_PATH": "/var/lib/bot/bot.sqlite3",
"REPORT_HOUR": "6",
"REPORT_MINUTE": "5",
"COOLDOWN_SECONDS": "0",
}
config = load_config(env)
assert config.api_base_url == "https://signal.example:8443"
assert config.database_path == "/var/lib/bot/bot.sqlite3"
assert config.report_hour == 6
assert config.cooldown_seconds == 0
def test_blank_optionals_fall_back_to_defaults() -> None:
env = dict(BASE_ENV) | {"API_BASE_URL": " ", "DATABASE_PATH": "", "REPORT_HOUR": ""}
config = load_config(env)
assert config.api_base_url == "http://localhost:9922"
assert config.database_path == "signal_bot.sqlite3"
assert config.report_hour == 21
def test_rejects_non_integer_option() -> None:
with pytest.raises(ConfigError, match="must be an integer"):
load_config(dict(BASE_ENV) | {"REPORT_HOUR": "quarter past"})
def test_rejects_negative_option() -> None:
with pytest.raises(ConfigError, match="must not be negative"):
load_config(dict(BASE_ENV) | {"COOLDOWN_SECONDS": "-1"})
@pytest.mark.parametrize(("hour", "minute"), [("24", "0"), ("0", "60")])
def test_rejects_out_of_range_clock(hour: str, minute: str) -> None:
env = dict(BASE_ENV) | {"REPORT_HOUR": hour, "REPORT_MINUTE": minute}
with pytest.raises(ConfigError, match="out of range"):
load_config(env)
def test_reads_os_environ_when_no_mapping_given(monkeypatch: pytest.MonkeyPatch) -> None:
for key, value in BASE_ENV.items():
monkeypatch.setenv(key, value)
config = load_config(use_dotenv=False)
assert config.group_id == "watched=="
def test_derived_urls() -> None:
config = load_config(dict(BASE_ENV))
assert config.receive_url == (
"ws://localhost:9922/v1/receive/+15550000000?send_read_receipts=false"
)
assert config.send_url == "http://localhost:9922/v2/send"
assert config.attachments_url == "http://localhost:9922/v1/attachments"
assert config.about_url == "http://localhost:9922/v1/about"
def test_https_base_url_yields_wss() -> None:
config = Config(
phone_number="+1",
group_id="g",
group_id_send="r",
api_base_url="https://signal.example",
)
assert config.receive_url.startswith("wss://signal.example/")

57
tests/test_counting.py Normal file
View File

@ -0,0 +1,57 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Scoping and tally predicates."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.counting import format_tally, is_watched_group, should_count
from signal_bot.models import parse_envelope
from tests.conftest import OTHER_GROUP, WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
def _message(**kwargs: object) -> object:
parsed = parse_envelope(envelope(**kwargs))
assert parsed is not None
return parsed
def test_watched_group_is_accepted(config: Config) -> None:
assert is_watched_group(_message(group_id=WATCHED_GROUP), config)
def test_other_group_is_rejected(config: Config) -> None:
assert not is_watched_group(_message(group_id=OTHER_GROUP), config)
def test_direct_message_is_rejected(config: Config) -> None:
"""The dangerous case: a linked device receives every DM on the account."""
assert not is_watched_group(_message(group_id=None), config)
def test_plain_text_counts() -> None:
assert should_count(_message(text="hello"))
@pytest.mark.parametrize("kwargs", [{"sticker": True}, {"reaction": True}])
def test_stickers_and_reactions_do_not_count(kwargs: dict[str, bool]) -> None:
assert not should_count(_message(**kwargs))
@pytest.mark.parametrize("text", [None, ""])
def test_empty_text_does_not_count(text: str | None) -> None:
assert not should_count(_message(text=text))
def test_format_tally_pads_names() -> None:
rendered = format_tally([("Alice", 5), ("Bo", 2)])
assert rendered == "Alice 5\nBo 2"
def test_format_tally_when_nobody_spoke() -> None:
assert format_tally([]) == "Brak wiadomosci dzisiaj."

159
tests/test_decorators.py Normal file
View File

@ -0,0 +1,159 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The reusable gates, tested against a harmless command."""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING
import pytest
from signal_bot.decorators import cooldown, daily_limit, vote_gated
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandContext, Handler
from tests.conftest import REPLY_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
DAY = "2026-08-29"
def _context(
store: Store, transport: FakeTransport, config: Config, *, uuid: str = "u1", now: float = 100.0
) -> CommandContext:
message = parse_envelope(envelope(text="!ping", uuid=uuid))
assert message is not None
return CommandContext(
message=message,
recipient=REPLY_GROUP,
transport=transport,
store=store,
config=config,
command_name="ping",
now=now,
day=DAY,
)
@pytest.fixture
def runs() -> list[str]:
return []
@pytest.fixture
def handler(runs: list[str]) -> Handler:
async def _handler(ctx: CommandContext) -> None:
runs.append(ctx.message.source_uuid)
return _handler
async def test_cooldown_allows_first_use(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
await cooldown(10, "wait {seconds}")(handler)(_context(store, transport, config))
assert runs == ["u1"]
async def test_cooldown_blocks_and_reports_remaining(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
store.record_use("u1", "ping", 100.0, DAY)
ctx = _context(store, transport, config, now=104.0)
await cooldown(10, "wait {seconds}")(handler)(ctx)
assert runs == []
assert transport.texts == ["wait 7"]
async def test_cooldown_expires(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
store.record_use("u1", "ping", 100.0, DAY)
await cooldown(10, "wait {seconds}")(handler)(_context(store, transport, config, now=111.0))
assert runs == ["u1"]
async def test_cooldown_is_per_user(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
"""One person's cat picture used to silence everybody."""
store.record_use("u1", "ping", 100.0, DAY)
await cooldown(10, "wait {seconds}")(handler)(
_context(store, transport, config, uuid="u2", now=101.0)
)
assert runs == ["u2"]
async def test_daily_limit_allows_under_the_cap(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
await daily_limit(1, "no more")(handler)(_context(store, transport, config))
assert runs == ["u1"]
async def test_daily_limit_blocks_at_the_cap(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
store.record_use("u1", "ping", 100.0, DAY)
await daily_limit(1, "no more, used {used}/{max_uses}")(handler)(
_context(store, transport, config)
)
assert runs == []
assert transport.texts == ["no more, used 1/1"]
async def test_vote_gate_waits_for_quorum(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
await gated(_context(store, transport, config, uuid="u1"))
assert runs == []
assert transport.texts == ["1/2"]
async def test_vote_gate_fires_on_quorum(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
await gated(_context(store, transport, config, uuid="u1"))
await gated(_context(store, transport, config, uuid="u2"))
assert runs == ["u2"]
async def test_repeat_vote_does_not_reach_quorum(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
await gated(_context(store, transport, config, uuid="u1"))
await gated(_context(store, transport, config, uuid="u1", now=101.0))
assert runs == []
async def test_votes_expire_outside_the_window(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
gated = vote_gated(2, 60, "{votes}/{required}")(handler)
await gated(_context(store, transport, config, uuid="u1", now=100.0))
await gated(_context(store, transport, config, uuid="u2", now=1000.0))
assert runs == []
async def test_vote_tally_resets_after_firing(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
await gated(_context(store, transport, config, uuid="u1"))
await gated(_context(store, transport, config, uuid="u2"))
assert store.count_votes("ping", 0.0) == 0
async def test_gates_stack(
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
) -> None:
stacked = cooldown(10, "wait {seconds}")(daily_limit(2, "no more")(handler))
await stacked(_context(store, transport, config))
assert runs == ["u1"]
assert replace(config, cooldown_seconds=10).cooldown_seconds == 10

126
tests/test_dispatcher.py Normal file
View File

@ -0,0 +1,126 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Routing: scoping, counting, dispatch and error containment."""
from __future__ import annotations
import datetime as dt
from typing import TYPE_CHECKING
import pytest
from signal_bot.dispatcher import Dispatcher
from signal_bot.registry import CommandContext, CommandRegistry
from tests.conftest import OTHER_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
NOW = dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC)
@pytest.fixture
def runs() -> list[str]:
return []
@pytest.fixture
def registry(runs: list[str]) -> CommandRegistry:
async def handler(ctx: CommandContext) -> None:
runs.append(ctx.command_name)
reg = CommandRegistry()
reg.register("cat", ["!cat"], handler)
return reg
@pytest.fixture
def dispatcher(
config: Config, registry: CommandRegistry, store: Store, transport: FakeTransport
) -> Dispatcher:
return Dispatcher(
config=config,
registry=registry,
store=store,
transport=transport,
clock=lambda: NOW,
)
async def test_counts_a_plain_group_message(dispatcher: Dispatcher, store: Store) -> None:
await dispatcher.handle(envelope(text="hello"))
assert store.tallies()[0].count == 1
async def test_runs_a_command_and_records_the_use(
dispatcher: Dispatcher, store: Store, runs: list[str]
) -> None:
await dispatcher.handle(envelope(text="!cat"))
assert runs == ["cat"]
assert store.last_used("uuid-alice", "cat") == NOW.timestamp()
assert store.uses_today("uuid-alice", "cat", "2026-08-29") == 1
async def test_direct_messages_are_never_handled(
dispatcher: Dispatcher, store: Store, runs: list[str], transport: FakeTransport
) -> None:
"""A linked device sees every DM on the account; none may reach a handler."""
await dispatcher.handle(envelope(text="!cat", group_id=None))
assert runs == []
assert store.tallies() == []
assert transport.sent == []
async def test_other_groups_are_never_handled(
dispatcher: Dispatcher, store: Store, runs: list[str]
) -> None:
await dispatcher.handle(envelope(text="!cat", group_id=OTHER_GROUP))
assert runs == []
assert store.tallies() == []
async def test_unparseable_frames_are_dropped(dispatcher: Dispatcher, store: Store) -> None:
await dispatcher.handle("not json")
assert store.tallies() == []
async def test_stickers_are_not_counted(dispatcher: Dispatcher, store: Store) -> None:
await dispatcher.handle(envelope(text="hi", sticker=True, sync=True))
assert store.tallies() == []
async def test_reactions_are_not_counted(dispatcher: Dispatcher, store: Store) -> None:
await dispatcher.handle(envelope(text="hi", reaction=True))
assert store.tallies() == []
async def test_non_command_text_runs_nothing(dispatcher: Dispatcher, runs: list[str]) -> None:
await dispatcher.handle(envelope(text="just chatting"))
assert runs == []
async def test_a_failing_command_does_not_kill_the_loop(
config: Config, store: Store, transport: FakeTransport
) -> None:
async def boom(ctx: CommandContext) -> None:
msg = "upstream is down"
raise RuntimeError(msg)
registry = CommandRegistry()
registry.register("cat", ["!cat"], boom)
dispatcher = Dispatcher(
config=config,
registry=registry,
store=store,
transport=transport,
clock=lambda: NOW,
)
await dispatcher.handle(envelope(text="!cat"))
# The use is not recorded, so the cooldown does not punish a failed run.
assert store.last_used("uuid-alice", "cat") is None
async def test_command_message_is_also_counted(dispatcher: Dispatcher, store: Store) -> None:
await dispatcher.handle(envelope(text="!cat"))
assert store.tallies()[0].count == 1

102
tests/test_fake_server.py Normal file
View File

@ -0,0 +1,102 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The fake Signal server's own behaviour."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from fake_signal.server import create_app, default_state
from fake_signal.state import PIXEL_PNG, State
@pytest.fixture
def state() -> State:
return State(base_url="http://testserver")
@pytest.fixture
def client(state: State) -> TestClient:
return TestClient(create_app(state))
def test_about_reports_a_websocket_mode(client: TestClient) -> None:
body = client.get("/v1/about").json()
assert body["mode"] == "json-rpc-native"
def test_qrcodelink_returns_a_png(client: TestClient) -> None:
"""The real endpoint returns an image, which is why the runbook opens it."""
response = client.get("/v1/qrcodelink", params={"device_name": "bot"})
assert response.headers["content-type"] == "image/png"
assert response.headers["X-Device-Name"] == "bot"
assert response.content == PIXEL_PNG
def test_send_is_captured_and_mints_attachment_ids(client: TestClient, state: State) -> None:
client.post("/v2/send", json={"message": "hi", "recipients": ["g"]})
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
assert len(state.sent) == 2
assert state.attachments == ["attach-2-0"]
def test_attachments_can_be_listed_and_deleted(client: TestClient) -> None:
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
listed = client.get("/v1/attachments").json()
assert listed == ["attach-1-0"]
assert client.delete(f"/v1/attachments/{listed[0]}").status_code == 204
assert client.get("/v1/attachments").json() == []
def test_deleting_an_unknown_attachment_is_a_noop(client: TestClient) -> None:
assert client.delete("/v1/attachments/nope").status_code == 204
def test_reset_clears_captured_state(client: TestClient, state: State) -> None:
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
client.post("/_control/reset")
assert state.sent == []
assert state.attachments == []
def test_websocket_subscribes_and_receives_injected_frames(client: TestClient) -> None:
with client.websocket_connect("/v1/receive/+15550000000") as socket:
assert socket.receive_json()["envelope"]["subscribed"] == "+15550000000"
assert client.get("/_control/ready").json() == {"sockets": 1}
assert client.post("/_control/inject", json={"raw": "FRAME"}).json() == {"delivered": 1}
assert socket.receive_text() == "FRAME"
def test_socket_is_forgotten_after_disconnect(client: TestClient) -> None:
with client.websocket_connect("/v1/receive/+1") as socket:
socket.receive_json()
assert client.get("/_control/ready").json() == {"sockets": 0}
def test_inject_with_no_listeners_delivers_nothing(client: TestClient) -> None:
assert client.post("/_control/inject", json={"raw": "x"}).json() == {"delivered": 0}
def test_fixture_apis_mirror_the_real_response_shapes(client: TestClient) -> None:
cat = client.get("/_fixtures/cat-api").json()
dog = client.get("/_fixtures/dog-api").json()
assert cat[0]["url"].endswith("/_fixtures/image.png")
assert dog["message"].endswith("/_fixtures/image.png")
assert client.get("/_fixtures/image.png").content == PIXEL_PNG
def test_create_app_makes_its_own_state_when_none_given() -> None:
with TestClient(create_app()) as client:
assert client.get("/v1/about").status_code == 200
def test_default_state_reads_the_port_from_the_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("FAKE_PORT", "9123")
assert default_state().base_url.endswith(":9123")
def test_control_sent_exposes_captured_payloads(client: TestClient) -> None:
client.post("/v2/send", json={"message": "hi", "recipients": ["R=="]})
assert client.get("/_control/sent").json() == [{"message": "hi", "recipients": ["R=="]}]

87
tests/test_http_client.py Normal file
View File

@ -0,0 +1,87 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Image fetching and JSON path walking."""
from __future__ import annotations
import base64
from collections.abc import Callable
import httpx
import pytest
from signal_bot.http_client import ImageFetcher, ImageFetchError
IMAGE = b"\x89PNG\r\n\x1a\nfake"
# The callable httpx.MockTransport expects -- not a bot command handler.
MockHandler = Callable[[httpx.Request], httpx.Response]
def _fetcher(handler: MockHandler) -> ImageFetcher:
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
def _api(payload: object, image: bytes = IMAGE) -> MockHandler:
def handler(request: httpx.Request) -> httpx.Response:
if "images" in str(request.url) or "breeds" in str(request.url):
return httpx.Response(200, json=payload)
return httpx.Response(200, content=image)
return handler
async def test_fetches_via_list_then_key_path() -> None:
fetcher = _fetcher(_api([{"url": "https://cdn.example/cat.png"}]))
encoded = await fetcher.fetch_base64("https://api/images/search", [0, "url"])
assert base64.b64decode(encoded) == IMAGE
async def test_fetches_via_single_key_path() -> None:
fetcher = _fetcher(_api({"message": "https://cdn.example/dog.png"}))
encoded = await fetcher.fetch_base64("https://api/breeds/x", ["message"])
assert base64.b64decode(encoded) == IMAGE
async def test_negative_index_is_supported() -> None:
fetcher = _fetcher(_api([{"url": "https://cdn.example/cat.png"}]))
encoded = await fetcher.fetch_base64("https://api/images/search", [-1, "url"])
assert base64.b64decode(encoded) == IMAGE
@pytest.mark.parametrize(
("payload", "path"),
[
({"other": "x"}, ["message"]),
([], [0, "url"]),
([{"url": "x"}], [5, "url"]),
({"message": "x"}, [0]),
([{"url": "x"}], ["message"]),
],
)
async def test_missing_element_raises(payload: object, path: list) -> None:
fetcher = _fetcher(_api(payload))
with pytest.raises(ImageFetchError, match="no element at"):
await fetcher.fetch_base64("https://api/images/search", path)
@pytest.mark.parametrize("payload", [{"message": 42}, {"message": ""}])
async def test_non_url_element_raises(payload: object) -> None:
fetcher = _fetcher(_api(payload))
with pytest.raises(ImageFetchError, match="is not a URL"):
await fetcher.fetch_base64("https://api/breeds/x", ["message"])
async def test_api_error_propagates() -> None:
fetcher = _fetcher(lambda r: httpx.Response(503))
with pytest.raises(httpx.HTTPStatusError):
await fetcher.fetch_base64("https://api/images/search", [0, "url"])
async def test_image_download_error_propagates() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if "images" in str(request.url):
return httpx.Response(200, json=[{"url": "https://cdn.example/cat.png"}])
return httpx.Response(404)
with pytest.raises(httpx.HTTPStatusError):
await _fetcher(handler).fetch_base64("https://api/images/search", [0, "url"])

59
tests/test_main.py Normal file
View File

@ -0,0 +1,59 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The console entrypoint.
`python main.py` used to do literally nothing: there was no __main__ block
and no uvicorn.run() call anywhere in the project.
"""
from __future__ import annotations
import runpy
import pytest
import uvicorn
import signal_bot.__main__ as entrypoint
@pytest.fixture
def captured(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
seen: dict[str, object] = {}
def fake_run(target: str, **kwargs: object) -> None:
seen["target"] = target
seen.update(kwargs)
monkeypatch.setattr(entrypoint.uvicorn, "run", fake_run)
return seen
def test_runs_the_asgi_app_on_loopback_by_default(
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("BIND_HOST", raising=False)
monkeypatch.delenv("BIND_PORT", raising=False)
entrypoint.main()
assert captured["target"] == "signal_bot.app:app"
assert captured["host"] == "127.0.0.1"
assert captured["port"] == 8000
def test_bind_overrides_are_honoured(
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
) -> None:
# TEST-NET-1: proves the override without a bind-all literal.
monkeypatch.setenv("BIND_HOST", "192.0.2.10")
monkeypatch.setenv("BIND_PORT", "9001")
entrypoint.main()
assert captured["host"] == "192.0.2.10"
assert captured["port"] == 9001
def test_module_runs_as_a_script(monkeypatch: pytest.MonkeyPatch) -> None:
"""`python -m signal_bot` must actually start the server."""
seen: dict[str, object] = {}
monkeypatch.setattr(uvicorn, "run", lambda target, **kw: seen.update(target=target))
monkeypatch.delenv("BIND_HOST", raising=False)
monkeypatch.delenv("BIND_PORT", raising=False)
runpy.run_module("signal_bot", run_name="__main__", alter_sys=True)
assert seen["target"] == "signal_bot.app:app"

101
tests/test_models.py Normal file
View File

@ -0,0 +1,101 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Envelope parsing across both message shapes."""
from __future__ import annotations
import json
import pytest
from signal_bot.models import parse_envelope
from tests.conftest import WATCHED_GROUP, envelope
def test_parses_group_data_message() -> None:
message = parse_envelope(envelope(text="!cat"))
assert message is not None
assert message.text == "!cat"
assert message.group_id == WATCHED_GROUP
assert message.source_uuid == "uuid-alice"
assert message.source_name == "Alice"
assert not message.is_direct_message
def test_parses_own_account_sync_message() -> None:
message = parse_envelope(envelope(text="!dog", sync=True))
assert message is not None
assert message.text == "!dog"
assert message.group_id == WATCHED_GROUP
@pytest.mark.parametrize("shape", [{"sync": False}, {"sync": True}])
def test_detects_sticker_in_both_shapes(shape: dict[str, bool]) -> None:
"""The original bot only ever looked under dataMessage, so its own
stickers were never filtered out of the tally."""
message = parse_envelope(envelope(sticker=True, **shape))
assert message is not None
assert message.has_sticker
@pytest.mark.parametrize("shape", [{"sync": False}, {"sync": True}])
def test_detects_reaction_in_both_shapes(shape: dict[str, bool]) -> None:
message = parse_envelope(envelope(reaction=True, **shape))
assert message is not None
assert message.is_reaction
def test_direct_message_has_no_group() -> None:
message = parse_envelope(envelope(group_id=None))
assert message is not None
assert message.group_id is None
assert message.is_direct_message
def test_missing_text_is_none() -> None:
message = parse_envelope(envelope(text=None))
assert message is not None
assert message.text is None
def test_non_string_text_is_none() -> None:
raw = json.dumps({"envelope": {"dataMessage": {"message": 42}}})
message = parse_envelope(raw)
assert message is not None
assert message.text is None
@pytest.mark.parametrize(
"raw",
[
"not json at all",
json.dumps(["a", "list"]),
json.dumps({"envelope": "not a dict"}),
json.dumps({"account": "+1"}),
json.dumps({"envelope": {"receiptMessage": {"when": 1}}}),
json.dumps({"envelope": {"syncMessage": "not a dict"}}),
json.dumps({"envelope": {"syncMessage": {"readMessages": []}}}),
],
)
def test_unparseable_frames_return_none(raw: str) -> None:
assert parse_envelope(raw) is None
def test_accepts_bytes() -> None:
message = parse_envelope(envelope(text="hi").encode())
assert message is not None
assert message.text == "hi"
def test_invalid_utf8_bytes_return_none() -> None:
assert parse_envelope(b"\xff\xfe not utf8") is None
@pytest.mark.parametrize(
"group_info",
["not a dict", {"type": "DELIVER"}, {"groupId": ""}, {"groupId": 12}],
)
def test_malformed_group_info_reads_as_direct_message(group_info: object) -> None:
raw = json.dumps({"envelope": {"dataMessage": {"message": "hi", "groupInfo": group_info}}})
message = parse_envelope(raw)
assert message is not None
assert message.is_direct_message

51
tests/test_registry.py Normal file
View File

@ -0,0 +1,51 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Trigger resolution."""
from __future__ import annotations
import pytest
from signal_bot.registry import CommandContext, CommandRegistry, DuplicateTriggerError
async def _noop(ctx: CommandContext) -> None:
return None
def test_resolves_a_registered_trigger() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat", "!kot"], _noop)
assert registry.resolve("!kot") is not None
assert registry.resolve("!cat").name == "cat"
def test_resolution_is_case_insensitive_and_trims() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop)
assert registry.resolve(" !CAT ") is not None
def test_unknown_and_prefixless_text_resolves_to_nothing() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop)
assert registry.resolve("!nope") is None
assert registry.resolve("cat") is None
@pytest.mark.parametrize("text", [None, ""])
def test_empty_text_resolves_to_nothing(text: str | None) -> None:
assert CommandRegistry().resolve(text) is None
def test_duplicate_trigger_is_rejected() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop)
with pytest.raises(DuplicateTriggerError, match="!cat"):
registry.register("feline", ["!cat"], _noop)
def test_commands_lists_in_registration_order() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop)
registry.register("dog", ["!dog"], _noop)
assert [c.name for c in registry.commands] == ["cat", "dog"]

132
tests/test_scenario.py Normal file
View File

@ -0,0 +1,132 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Scenario loading, rendering and adjudication."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fake_signal.scenario import (
Expectation,
Injection,
Scenario,
ScenarioError,
check,
describe,
load_scenario,
)
from signal_bot.models import parse_envelope
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
def _write(tmp_path: Path, document: str) -> Path:
path = tmp_path / "s.yaml"
path.write_text(document, encoding="utf-8")
return path
def test_ships_scenarios_load_and_are_valid() -> None:
paths = sorted(Path("fake_signal/scenarios").glob("*.yaml"))
assert paths
for path in paths:
assert load_scenario(path).name
def test_injection_renders_a_parseable_group_envelope() -> None:
frame = Injection(text="!cat").to_frame(GROUPS)
message = parse_envelope(frame)
assert message is not None
assert message.text == "!cat"
assert message.group_id == "W=="
def test_injection_renders_a_direct_message() -> None:
message = parse_envelope(Injection(text="hi", group=None).to_frame(GROUPS))
assert message is not None
assert message.is_direct_message
def test_injection_renders_sync_sticker_and_reaction() -> None:
sticker = parse_envelope(Injection(sticker=True, sync=True).to_frame(GROUPS))
reaction = parse_envelope(Injection(reaction=True).to_frame(GROUPS))
assert sticker is not None
assert sticker.has_sticker
assert reaction is not None
assert reaction.is_reaction
def test_unknown_group_alias_is_rejected() -> None:
with pytest.raises(ScenarioError, match="unknown group alias"):
Injection(group="nope").to_frame(GROUPS)
def test_loads_a_full_scenario(tmp_path: Path) -> None:
path = _write(
tmp_path,
"name: demo\ninject:\n - {text: '!cat'}\nexpect:\n - {kind: image}\n",
)
scenario = load_scenario(path)
assert scenario.name == "demo"
assert scenario.inject[0].text == "!cat"
assert scenario.expect[0].kind == "image"
@pytest.mark.parametrize(
("document", "match"),
[
("- a list\n", "must contain a mapping"),
("inject: []\n", "missing a name"),
("name: x\ninject:\n - {bogus: 1}\n", "unknown inject keys"),
("name: x\nexpect:\n - {bogus: 1}\n", "unknown expect keys"),
("name: x\ninject:\n - not-a-mapping\n", "must be a mapping"),
],
)
def test_malformed_scenarios_are_rejected(tmp_path: Path, document: str, match: str) -> None:
with pytest.raises(ScenarioError, match=match):
load_scenario(_write(tmp_path, document))
def test_describe_classifies_sends() -> None:
assert describe({"message": "hi", "recipients": ["R=="]}) == ("text", "R==", "hi")
assert describe({"base64_attachments": ["A"], "recipients": ["R=="]})[0] == "image"
assert describe({})[1] == ""
def test_check_passes_on_a_match() -> None:
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply")])
sent = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
assert check(scenario, sent, GROUPS) == []
def test_check_reports_a_count_mismatch() -> None:
scenario = Scenario("s", expect=[Expectation()])
failures = check(scenario, [], GROUPS)
assert "expected 1 send(s), captured 0" in failures[0]
assert any("missing" in line for line in failures)
def test_check_reports_kind_recipient_and_body() -> None:
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply", contains="nope")])
sent = [{"message": "hello", "recipients": ["W=="]}]
failures = check(scenario, sent, GROUPS)
assert any("kind:" in f for f in failures)
assert any("recipient:" in f for f in failures)
assert any("body:" in f for f in failures)
def test_check_accepts_a_literal_recipient() -> None:
scenario = Scenario("s", expect=[Expectation(to="literal==")])
assert check(scenario, [{"message": "", "recipients": ["literal=="]}], GROUPS) == []
def test_unexpected_sends_are_a_failure() -> None:
"""A scoping regression shows up here: expect [] and the bot replied."""
failures = check(Scenario("s"), [{"message": "leak", "recipients": ["R=="]}], GROUPS)
assert "expected 0 send(s), captured 1" in failures[0]
def test_frame_is_json() -> None:
assert json.loads(Injection().to_frame(GROUPS))["account"] == "+15550000000"

68
tests/test_scheduler.py Normal file
View File

@ -0,0 +1,68 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The daily report, driven by an injected clock."""
from __future__ import annotations
import datetime as dt
from signal_bot.scheduler import SECONDS_PER_DAY, Action, DailyScheduler
def _scheduler(action: Action, now: dt.datetime, slept: list[float]) -> DailyScheduler:
async def sleeper(seconds: float) -> None:
slept.append(seconds)
return DailyScheduler(21, 37, action, lambda: now, sleeper)
async def _noop() -> None:
return None
def test_waits_until_later_today() -> None:
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
assert _scheduler(_noop, now, []).seconds_until_next(now) == 3600
def test_rolls_over_to_tomorrow_once_passed() -> None:
now = dt.datetime(2026, 8, 29, 22, 37, tzinfo=dt.UTC)
assert _scheduler(_noop, now, []).seconds_until_next(now) == SECONDS_PER_DAY - 3600
def test_exact_target_time_waits_a_full_day() -> None:
"""Never returns zero, which would busy-loop the report."""
now = dt.datetime(2026, 8, 29, 21, 37, tzinfo=dt.UTC)
assert _scheduler(_noop, now, []).seconds_until_next(now) == SECONDS_PER_DAY
async def test_runs_the_action_after_sleeping() -> None:
calls: list[int] = []
slept: list[float] = []
async def action() -> None:
calls.append(1)
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
await _scheduler(action, now, slept).run(iterations=2)
assert calls == [1, 1]
assert slept == [3600, 3600]
async def test_a_failing_report_does_not_stop_the_loop() -> None:
calls: list[int] = []
async def action() -> None:
calls.append(1)
msg = "signal is down"
raise RuntimeError(msg)
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
await _scheduler(action, now, []).run(iterations=2)
assert calls == [1, 1]
async def test_zero_iterations_does_nothing() -> None:
slept: list[float] = []
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
await _scheduler(_noop, now, slept).run(iterations=0)
assert slept == []

93
tests/test_store.py Normal file
View File

@ -0,0 +1,93 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""SQLite persistence, including survival across a restart."""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.store import Store, open_store
if TYPE_CHECKING:
from pathlib import Path
def test_increment_creates_and_accumulates(store: Store) -> None:
store.increment("u1", "Alice")
store.increment("u1", "Alice")
store.increment("u2", "Bob")
assert [(t.display_name, t.count) for t in store.tallies()] == [("Alice", 2), ("Bob", 1)]
def test_display_name_is_refreshed(store: Store) -> None:
store.increment("u1", "Alice")
store.increment("u1", "Alicja")
assert store.tallies()[0].display_name == "Alicja"
def test_tallies_sorted_by_count_then_name(store: Store) -> None:
store.increment("u1", "Zoe")
store.increment("u2", "Adam")
assert [t.display_name for t in store.tallies()] == ["Adam", "Zoe"]
def test_reset_clears_everything(store: Store) -> None:
store.increment("u1", "Alice")
store.reset_tallies()
assert store.tallies() == []
def test_tallies_survive_a_restart(tmp_path: Path) -> None:
"""The old bot kept the day's tally in a module global."""
db = str(tmp_path / "bot.sqlite3")
with open_store(db) as first:
first.increment("u1", "Alice")
with open_store(db) as second:
assert second.tallies()[0].count == 1
def test_last_used_is_none_before_first_use(store: Store) -> None:
assert store.last_used("u1", "cat") is None
def test_record_use_tracks_cooldown_and_daily(store: Store) -> None:
store.record_use("u1", "cat", 100.0, "2026-08-29")
assert store.last_used("u1", "cat") == 100.0
assert store.uses_today("u1", "cat", "2026-08-29") == 1
store.record_use("u1", "cat", 200.0, "2026-08-29")
assert store.last_used("u1", "cat") == 200.0
assert store.uses_today("u1", "cat", "2026-08-29") == 2
def test_daily_counter_is_per_day(store: Store) -> None:
store.record_use("u1", "cat", 100.0, "2026-08-29")
assert store.uses_today("u1", "cat", "2026-08-30") == 0
def test_uses_today_is_per_user_and_command(store: Store) -> None:
store.record_use("u1", "cat", 100.0, "2026-08-29")
assert store.uses_today("u2", "cat", "2026-08-29") == 0
assert store.uses_today("u1", "dog", "2026-08-29") == 0
def test_votes_are_deduplicated_per_person(store: Store) -> None:
store.add_vote("traps", "u1", 100.0)
store.add_vote("traps", "u1", 101.0)
store.add_vote("traps", "u2", 102.0)
assert store.count_votes("traps", 0.0) == 2
def test_votes_outside_the_window_are_ignored(store: Store) -> None:
store.add_vote("cmd", "u1", 100.0)
assert store.count_votes("cmd", 150.0) == 0
def test_clear_votes(store: Store) -> None:
store.add_vote("cmd", "u1", 100.0)
store.clear_votes("cmd")
assert store.count_votes("cmd", 0.0) == 0
def test_store_is_a_context_manager(tmp_path: Path) -> None:
with Store(str(tmp_path / "x.sqlite3")) as opened:
opened.increment("u1", "Alice")
assert opened.tallies.__self__ is opened

View File

@ -0,0 +1,39 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The transport seam.
Both implementations are checked against the same Protocol here, which is
what makes "the fake and the real adapter are interchangeable" a fact the
suite enforces rather than a claim in a docstring.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from signal_bot.transport_base import Transport
from signal_bot.transport_fake import FakeTransport
from signal_bot.transport_rest import RestTransport
if TYPE_CHECKING:
from signal_bot.config import Config
def test_fake_transport_satisfies_the_protocol() -> None:
assert isinstance(FakeTransport(), Transport)
def test_rest_transport_satisfies_the_protocol(config: Config) -> None:
assert isinstance(RestTransport(config, httpx.AsyncClient()), Transport)
def test_protocol_names_the_whole_surface() -> None:
for name in (
"receive",
"send_text",
"send_image",
"list_attachments",
"delete_attachment",
):
assert hasattr(Transport, name)

View File

@ -0,0 +1,27 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The in-process transport double."""
from __future__ import annotations
from signal_bot.transport_fake import FakeTransport
async def test_queue_and_receive() -> None:
transport = FakeTransport()
transport.queue(["a", "b"])
assert [f async for f in transport.receive()] == ["a", "b"]
async def test_records_sends() -> None:
transport = FakeTransport()
await transport.send_text("hi", "g")
await transport.send_image("B64", "g")
assert transport.texts == ["hi"]
assert transport.sent[1].image_base64 == "B64"
async def test_attachments_round_trip() -> None:
transport = FakeTransport(attachments=["a1"])
assert await transport.list_attachments() == ["a1"]
await transport.delete_attachment("a1")
assert transport.deleted == ["a1"]

View File

@ -0,0 +1,208 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The bbernhard adapter: sends, attachments, mode check and reconnect."""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from typing import TYPE_CHECKING
import httpx
import pytest
from websockets.exceptions import ConnectionClosedError
from signal_bot.transport_rest import RestTransport, TransportError
if TYPE_CHECKING:
from types import TracebackType
from signal_bot.config import Config
# The callable httpx.MockTransport expects -- not a bot command handler.
MockHandler = Callable[[httpx.Request], httpx.Response]
class FakeSocket:
"""Yields a canned list of frames, optionally failing partway through."""
def __init__(self, frames: list[str], *, fail_after: bool = False) -> None:
self._frames = frames
self._fail_after = fail_after
async def __aiter__(self) -> AsyncIterator[str]:
for frame in self._frames:
yield frame
if self._fail_after:
raise ConnectionClosedError(None, None)
class FakeConnect:
"""Stands in for websockets.connect, handing out sockets in order."""
def __init__(self, sockets: list[FakeSocket]) -> None:
self._sockets = sockets
self.urls: list[str] = []
def __call__(self, url: str) -> FakeConnect:
self.urls.append(url)
return self
async def __aenter__(self) -> FakeSocket:
if not self._sockets:
msg = "no more sockets"
raise OSError(msg)
return self._sockets.pop(0)
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
def _transport(config: Config, handler: MockHandler, **kwargs: object) -> RestTransport:
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return RestTransport(config, client, **kwargs)
async def test_send_text_posts_expected_payload(config: Config) -> None:
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(__import__("json").loads(request.content))
return httpx.Response(201)
await _transport(config, handler).send_text("hi", "group==")
assert seen[0] == {
"message": "hi",
"number": config.phone_number,
"recipients": ["group=="],
}
async def test_send_image_posts_base64_attachment(config: Config) -> None:
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(__import__("json").loads(request.content))
return httpx.Response(200)
await _transport(config, handler).send_image("BASE64", "group==")
assert seen[0]["base64_attachments"] == ["BASE64"]
async def test_send_raises_on_rejection(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(400, text="nope"))
with pytest.raises(TransportError, match="400"):
await transport.send_text("hi", "g")
async def test_verify_mode_accepts_json_rpc(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc"}))
assert await transport.verify_mode() == "json-rpc"
async def test_verify_mode_accepts_json_rpc_native(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
assert await transport.verify_mode() == "json-rpc-native"
@pytest.mark.parametrize("body", [{"mode": "normal"}, {"mode": "native"}, {}, ["nope"]])
async def test_verify_mode_rejects_non_websocket_modes(config: Config, body: object) -> None:
"""In normal/native mode /v1/receive silently degrades to a polling GET."""
transport = _transport(config, lambda r: httpx.Response(200, json=body))
with pytest.raises(TransportError, match="json-rpc-native"):
await transport.verify_mode()
async def test_list_attachments(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(200, json=["a", "b"]))
assert await transport.list_attachments() == ["a", "b"]
async def test_list_attachments_tolerates_non_list(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(200, json={"oops": 1}))
assert await transport.list_attachments() == []
async def test_delete_attachment(config: Config) -> None:
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(204)
await _transport(config, handler).delete_attachment("abc")
assert seen[0].endswith("/v1/attachments/abc")
async def test_delete_attachment_raises_on_failure(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(500))
with pytest.raises(TransportError, match="500"):
await transport.delete_attachment("abc")
async def test_receive_yields_frames(config: Config) -> None:
connector = FakeConnect([FakeSocket(["a", "b"])])
transport = _transport(
config,
lambda r: httpx.Response(200),
connector=connector,
sleeper=_no_sleep,
max_reconnects=0,
)
assert [f async for f in transport.receive()] == ["a", "b"]
async def test_receive_decodes_binary_frames(config: Config) -> None:
connector = FakeConnect([FakeSocket([b"bytes-frame"])])
transport = _transport(
config,
lambda r: httpx.Response(200),
connector=connector,
sleeper=_no_sleep,
max_reconnects=0,
)
assert [f async for f in transport.receive()] == ["bytes-frame"]
async def test_receive_reconnects_after_a_drop(config: Config) -> None:
"""A container restart used to kill the bot silently."""
slept: list[float] = []
async def sleeper(seconds: float) -> None:
slept.append(seconds)
connector = FakeConnect([FakeSocket(["a"], fail_after=True), FakeSocket(["b"])])
transport = _transport(
config,
lambda r: httpx.Response(200),
connector=connector,
sleeper=sleeper,
max_reconnects=1,
)
assert [f async for f in transport.receive()] == ["a", "b"]
assert slept == [1.0]
async def test_receive_backs_off_exponentially(config: Config) -> None:
slept: list[float] = []
async def sleeper(seconds: float) -> None:
slept.append(seconds)
connector = FakeConnect([])
transport = _transport(
config,
lambda r: httpx.Response(200),
connector=connector,
sleeper=sleeper,
max_reconnects=3,
)
assert [f async for f in transport.receive()] == []
assert slept == [1.0, 2.0, 4.0]
async def _no_sleep(seconds: float) -> None:
return None