commit 6bde3493a4a60136967ab43f2dc0d9d415abfcbf Author: Krzysztof kuhy Rudnicki Date: Sat Aug 29 13:05:42 2026 +0200 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) Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..300c0c4 --- /dev/null +++ b/.env.sample @@ -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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6981006 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ca4be5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c93176d --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/DOCS-signal-envelopes.md b/DOCS-signal-envelopes.md new file mode 100644 index 0000000..819e9e2 --- /dev/null +++ b/DOCS-signal-envelopes.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6dabd43 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e34d324 --- /dev/null +++ b/README.md @@ -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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c952786 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/fake_signal/__init__.py b/fake_signal/__init__.py new file mode 100644 index 0000000..8e76319 --- /dev/null +++ b/fake_signal/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""A protocol-faithful fake of signal-cli-rest-api, for offline testing.""" diff --git a/fake_signal/cli.py b/fake_signal/cli.py new file mode 100644 index 0000000..0bd6a8f --- /dev/null +++ b/fake_signal/cli.py @@ -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()) diff --git a/fake_signal/routes_api.py b/fake_signal/routes_api.py new file mode 100644 index 0000000..e48a2ad --- /dev/null +++ b/fake_signal/routes_api.py @@ -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] diff --git a/fake_signal/routes_control.py b/fake_signal/routes_control.py new file mode 100644 index 0000000..ec3b367 --- /dev/null +++ b/fake_signal/routes_control.py @@ -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 diff --git a/fake_signal/routes_fixtures.py b/fake_signal/routes_fixtures.py new file mode 100644 index 0000000..7e12358 --- /dev/null +++ b/fake_signal/routes_fixtures.py @@ -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 diff --git a/fake_signal/scenario.py b/fake_signal/scenario.py new file mode 100644 index 0000000..4260a43 --- /dev/null +++ b/fake_signal/scenario.py @@ -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 diff --git a/fake_signal/scenarios/cat_command.yaml b/fake_signal/scenarios/cat_command.yaml new file mode 100644 index 0000000..c22da03 --- /dev/null +++ b/fake_signal/scenarios/cat_command.yaml @@ -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 } diff --git a/fake_signal/scenarios/cooldown.yaml b/fake_signal/scenarios/cooldown.yaml new file mode 100644 index 0000000..45d7b62 --- /dev/null +++ b/fake_signal/scenarios/cooldown.yaml @@ -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" } diff --git a/fake_signal/scenarios/cooldown_is_per_user.yaml b/fake_signal/scenarios/cooldown_is_per_user.yaml new file mode 100644 index 0000000..be972a4 --- /dev/null +++ b/fake_signal/scenarios/cooldown_is_per_user.yaml @@ -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 } diff --git a/fake_signal/scenarios/dog_command.yaml b/fake_signal/scenarios/dog_command.yaml new file mode 100644 index 0000000..5b0b548 --- /dev/null +++ b/fake_signal/scenarios/dog_command.yaml @@ -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 } diff --git a/fake_signal/scenarios/not_counted.yaml b/fake_signal/scenarios/not_counted.yaml new file mode 100644 index 0000000..dc45bcd --- /dev/null +++ b/fake_signal/scenarios/not_counted.yaml @@ -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: [] diff --git a/fake_signal/scenarios/scoping.yaml b/fake_signal/scenarios/scoping.yaml new file mode 100644 index 0000000..891b073 --- /dev/null +++ b/fake_signal/scenarios/scoping.yaml @@ -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: [] diff --git a/fake_signal/scenarios/unknown_command.yaml b/fake_signal/scenarios/unknown_command.yaml new file mode 100644 index 0000000..fbbd21a --- /dev/null +++ b/fake_signal/scenarios/unknown_command.yaml @@ -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: [] diff --git a/fake_signal/server.py b/fake_signal/server.py new file mode 100644 index 0000000..22049a1 --- /dev/null +++ b/fake_signal/server.py @@ -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()) diff --git a/fake_signal/state.py b/fake_signal/state.py new file mode 100644 index 0000000..f3efd05 --- /dev/null +++ b/fake_signal/state.py @@ -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}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5503dd9 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96ae52b --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..1f6f70e --- /dev/null +++ b/run.sh @@ -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 "$@" diff --git a/scripts/check_coverage.sh b/scripts/check_coverage.sh new file mode 100755 index 0000000..67f53aa --- /dev/null +++ b/scripts/check_coverage.sh @@ -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 "$@" diff --git a/scripts/check_dependency_freshness.sh b/scripts/check_dependency_freshness.sh new file mode 100755 index 0000000..8bb1d04 --- /dev/null +++ b/scripts/check_dependency_freshness.sh @@ -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 "$@" diff --git a/scripts/check_file_length.sh b/scripts/check_file_length.sh new file mode 100755 index 0000000..3c6d8c8 --- /dev/null +++ b/scripts/check_file_length.sh @@ -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 "$@" diff --git a/scripts/check_md_naming.sh b/scripts/check_md_naming.sh new file mode 100755 index 0000000..819847e --- /dev/null +++ b/scripts/check_md_naming.sh @@ -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 "$@" diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..9e6e9e2 --- /dev/null +++ b/scripts/e2e.sh @@ -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 "$@" diff --git a/scripts/live_smoke.sh b/scripts/live_smoke.sh new file mode 100755 index 0000000..264188d --- /dev/null +++ b/scripts/live_smoke.sh @@ -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" <&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 "$@" diff --git a/signal_bot/__init__.py b/signal_bot/__init__.py new file mode 100644 index 0000000..5767618 --- /dev/null +++ b/signal_bot/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""A Signal group bot: image commands and daily message tallies.""" diff --git a/signal_bot/__main__.py b/signal_bot/__main__.py new file mode 100644 index 0000000..f75217b --- /dev/null +++ b/signal_bot/__main__.py @@ -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() diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json new file mode 100644 index 0000000..85e7643 --- /dev/null +++ b/signal_bot/aliases.json @@ -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͓̽" + ] +} diff --git a/signal_bot/animals.py b/signal_bot/animals.py new file mode 100644 index 0000000..3afe934 --- /dev/null +++ b/signal_bot/animals.py @@ -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) diff --git a/signal_bot/app.py b/signal_bot/app.py new file mode 100644 index 0000000..71dd98c --- /dev/null +++ b/signal_bot/app.py @@ -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"} diff --git a/signal_bot/config.py b/signal_bot/config.py new file mode 100644 index 0000000..6f2a415 --- /dev/null +++ b/signal_bot/config.py @@ -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, + ) diff --git a/signal_bot/counting.py b/signal_bot/counting.py new file mode 100644 index 0000000..3eed7b5 --- /dev/null +++ b/signal_bot/counting.py @@ -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) diff --git a/signal_bot/decorators.py b/signal_bot/decorators.py new file mode 100644 index 0000000..20f3cf8 --- /dev/null +++ b/signal_bot/decorators.py @@ -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 diff --git a/signal_bot/dispatcher.py b/signal_bot/dispatcher.py new file mode 100644 index 0000000..f32e50b --- /dev/null +++ b/signal_bot/dispatcher.py @@ -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) diff --git a/signal_bot/http_client.py b/signal_bot/http_client.py new file mode 100644 index 0000000..4b0a0b6 --- /dev/null +++ b/signal_bot/http_client.py @@ -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 diff --git a/signal_bot/models.py b/signal_bot/models.py new file mode 100644 index 0000000..5d316e0 --- /dev/null +++ b/signal_bot/models.py @@ -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), + ) diff --git a/signal_bot/registry.py b/signal_bot/registry.py new file mode 100644 index 0000000..7e3d74d --- /dev/null +++ b/signal_bot/registry.py @@ -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) diff --git a/signal_bot/scheduler.py b/signal_bot/scheduler.py new file mode 100644 index 0000000..12a8ff5 --- /dev/null +++ b/signal_bot/scheduler.py @@ -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 diff --git a/signal_bot/store.py b/signal_bot/store.py new file mode 100644 index 0000000..34844d2 --- /dev/null +++ b/signal_bot/store.py @@ -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() diff --git a/signal_bot/transport_base.py b/signal_bot/transport_base.py new file mode 100644 index 0000000..591920c --- /dev/null +++ b/signal_bot/transport_base.py @@ -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.""" diff --git a/signal_bot/transport_fake.py b/signal_bot/transport_fake.py new file mode 100644 index 0000000..a2d86e3 --- /dev/null +++ b/signal_bot/transport_fake.py @@ -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] diff --git a/signal_bot/transport_rest.py b/signal_bot/transport_rest.py new file mode 100644 index 0000000..57d2ff6 --- /dev/null +++ b/signal_bot/transport_rest.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..af805b8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1ee0119 --- /dev/null +++ b/tests/conftest.py @@ -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"}) diff --git a/tests/test_animals.py b/tests/test_animals.py new file mode 100644 index 0000000..9168c6e --- /dev/null +++ b/tests/test_animals.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..2721308 --- /dev/null +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a1c2e68 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..862cdd8 --- /dev/null +++ b/tests/test_config.py @@ -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/") diff --git a/tests/test_counting.py b/tests/test_counting.py new file mode 100644 index 0000000..4ca5ed7 --- /dev/null +++ b/tests/test_counting.py @@ -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." diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..e7a9dd7 --- /dev/null +++ b/tests/test_decorators.py @@ -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 diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 0000000..bb7825b --- /dev/null +++ b/tests/test_dispatcher.py @@ -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 diff --git a/tests/test_fake_server.py b/tests/test_fake_server.py new file mode 100644 index 0000000..ff6f405 --- /dev/null +++ b/tests/test_fake_server.py @@ -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=="]}] diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..074abb6 --- /dev/null +++ b/tests/test_http_client.py @@ -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"]) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..7b5af20 --- /dev/null +++ b/tests/test_main.py @@ -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" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..438ae4e --- /dev/null +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..d62caad --- /dev/null +++ b/tests/test_registry.py @@ -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"] diff --git a/tests/test_scenario.py b/tests/test_scenario.py new file mode 100644 index 0000000..724595c --- /dev/null +++ b/tests/test_scenario.py @@ -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" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..f467919 --- /dev/null +++ b/tests/test_scheduler.py @@ -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 == [] diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..2ec78e9 --- /dev/null +++ b/tests/test_store.py @@ -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 diff --git a/tests/test_transport_base.py b/tests/test_transport_base.py new file mode 100644 index 0000000..befce14 --- /dev/null +++ b/tests/test_transport_base.py @@ -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) diff --git a/tests/test_transport_fake.py b/tests/test_transport_fake.py new file mode 100644 index 0000000..5f4509c --- /dev/null +++ b/tests/test_transport_fake.py @@ -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"] diff --git a/tests/test_transport_rest.py b/tests/test_transport_rest.py new file mode 100644 index 0000000..602e7fb --- /dev/null +++ b/tests/test_transport_rest.py @@ -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