mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 16:48:59 +01:00
feat: rewrite the bot so it runs, and gate it like the other repos
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.
Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.
Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read
Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.
Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.
Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.
Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
This commit is contained in:
commit
6bde3493a4
39
.env.sample
Normal file
39
.env.sample
Normal file
@ -0,0 +1,39 @@
|
||||
# Copy to .env and fill in. Every value here is read by signal_bot/config.py.
|
||||
# The bot refuses to start if any REQUIRED value is missing or blank.
|
||||
|
||||
# REQUIRED. The account signal-cli is linked to, in +E.164 form.
|
||||
PHONE_NUMBER=
|
||||
|
||||
# REQUIRED. The group the bot listens to.
|
||||
#
|
||||
# A linked Signal device receives every direct message and every group on the
|
||||
# account. This value is the ONLY thing scoping the bot to one conversation,
|
||||
# which is why it has no default and startup fails without it.
|
||||
# Find it with: curl -s localhost:9922/v1/groups/$PHONE_NUMBER | jq
|
||||
GROUP_ID=
|
||||
|
||||
# REQUIRED. Where replies and the daily report are posted. Set it to the same
|
||||
# value as GROUP_ID to reply in the group that triggered the command.
|
||||
GROUP_ID_SEND=
|
||||
|
||||
# Optional. The signal-cli-rest-api container. Point this at the fake server
|
||||
# to run entirely offline. Default: http://localhost:9922
|
||||
API_BASE_URL=
|
||||
|
||||
# Optional. Tally database. Survives restarts. Default: signal_bot.sqlite3
|
||||
DATABASE_PATH=
|
||||
|
||||
# Optional. When the daily tally is posted. Default: 21:37
|
||||
REPORT_HOUR=
|
||||
REPORT_MINUTE=
|
||||
|
||||
# Optional. Per-user, per-command cooldown in seconds. Default: 10
|
||||
COOLDOWN_SECONDS=
|
||||
|
||||
# Optional. Image APIs. Overridden by scripts/e2e.sh to serve local fixtures.
|
||||
CAT_API_URL=
|
||||
DOG_API_URL=
|
||||
|
||||
# Optional. Where the /healthz probe binds. Default: 127.0.0.1:8000
|
||||
BIND_HOST=
|
||||
BIND_PORT=
|
||||
46
.github/workflows/ci.yml
vendored
Normal file
46
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv venv .venv
|
||||
uv pip install --python .venv/bin/python -e '.[dev]'
|
||||
|
||||
- name: ruff format
|
||||
run: .venv/bin/ruff format --check .
|
||||
|
||||
- name: ruff check (ALL rules)
|
||||
run: .venv/bin/ruff check .
|
||||
|
||||
- name: mypy --strict
|
||||
run: .venv/bin/mypy signal_bot fake_signal
|
||||
|
||||
- name: shellcheck
|
||||
run: shellcheck run.sh scripts/*.sh
|
||||
|
||||
- name: pytest (100% branch coverage)
|
||||
run: .venv/bin/python -m pytest -q
|
||||
|
||||
# The real bot against the fake Signal server. No account, no network.
|
||||
- name: end-to-end
|
||||
run: ./scripts/e2e.sh
|
||||
165
.gitignore
vendored
Normal file
165
.gitignore
vendored
Normal file
@ -0,0 +1,165 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
*.jpg
|
||||
*.gif
|
||||
nohup.out
|
||||
91
.pre-commit-config.yaml
Normal file
91
.pre-commit-config.yaml
Normal file
@ -0,0 +1,91 @@
|
||||
# Pre-commit hooks for signal-bot.
|
||||
#
|
||||
# Install with: pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
#
|
||||
# Every hook is `language: system` because the tools already live in .venv;
|
||||
# pre-commit must not build isolated environments for them. fail_fast so a
|
||||
# later check cannot run after an earlier one has already rejected the commit.
|
||||
fail_fast: true
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: check-case-conflict
|
||||
- id: check-executables-have-shebangs
|
||||
- id: check-shebang-scripts-are-executable
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: check-ast
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
# The shared 250-line cap. Checks only the files in the commit, so an
|
||||
# unrelated commit never breaks. No baseline, no allowlist -- those are
|
||||
# suppressions. The exemption list lives with the checker in ~/utils so
|
||||
# this repo's idea of the rule cannot drift from every other repo's.
|
||||
- id: file-length
|
||||
name: file length <= 250 lines
|
||||
entry: scripts/check_file_length.sh
|
||||
language: system
|
||||
|
||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
||||
- id: md-naming
|
||||
name: markdown naming convention
|
||||
entry: scripts/check_md_naming.sh
|
||||
language: system
|
||||
files: \.md$
|
||||
|
||||
# Exact pins at newest stable. Runs repo-wide: a pin goes stale because
|
||||
# upstream released, not because this commit touched the manifest.
|
||||
- id: dependency-freshness
|
||||
name: dependencies are newest stable
|
||||
entry: scripts/check_dependency_freshness.sh --all
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- id: ruff-format
|
||||
name: ruff format
|
||||
entry: .venv/bin/ruff format --check
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
|
||||
- id: ruff
|
||||
name: ruff (ALL rules)
|
||||
entry: .venv/bin/ruff check --force-exclude
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
|
||||
- id: mypy
|
||||
name: mypy --strict
|
||||
entry: .venv/bin/mypy
|
||||
language: system
|
||||
pass_filenames: false
|
||||
args: [signal_bot, fake_signal]
|
||||
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
entry: shellcheck
|
||||
language: system
|
||||
types: [shell]
|
||||
|
||||
# 100% branch coverage, no omits. Runs on every commit: coverage is the
|
||||
# whole point of the suite, and a partial run cannot prove it.
|
||||
- id: pytest
|
||||
name: pytest (100% branch coverage)
|
||||
entry: .venv/bin/python -m pytest -q
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
78
DOCS-signal-envelopes.md
Normal file
78
DOCS-signal-envelopes.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Signal envelope shapes
|
||||
|
||||
Reference for the two JSON frames `signal-cli-rest-api` pushes down
|
||||
`ws://…/v1/receive/{number}`. `signal_bot/models.py` flattens both into one
|
||||
`IncomingMessage`; these are the shapes it flattens.
|
||||
|
||||
## From another account: `dataMessage`
|
||||
|
||||
```json
|
||||
{
|
||||
"envelope": {
|
||||
"source": "NAME",
|
||||
"sourceNumber": "PHONE_NUMBER",
|
||||
"sourceUuid": "SOURCE_ID",
|
||||
"sourceName": "USER_DEFINED_NAME",
|
||||
"sourceDevice": 69,
|
||||
"timestamp": 1719177917717,
|
||||
"dataMessage": {
|
||||
"timestamp": 1719177917717,
|
||||
"message": "MESSAGE_CONTENT",
|
||||
"expiresInSeconds": 0,
|
||||
"viewOnce": false,
|
||||
"sticker": { "packId": "166dd1b5…", "stickerId": 6 },
|
||||
"groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
|
||||
}
|
||||
},
|
||||
"account": "BOT_ACCOUNT_PHONE_NUMBER"
|
||||
}
|
||||
```
|
||||
|
||||
## From the bot's own account: `syncMessage.sentMessage`
|
||||
|
||||
Because the bot runs as a *linked device*, the account's own outgoing messages
|
||||
come back as sync messages. Same fields, one level deeper.
|
||||
|
||||
```json
|
||||
{
|
||||
"envelope": {
|
||||
"sourceUuid": "SOURCE_ID",
|
||||
"sourceName": "USER_DEFINED_NAME",
|
||||
"timestamp": 1719177728639,
|
||||
"syncMessage": {
|
||||
"sentMessage": {
|
||||
"destination": "DEST_NAME",
|
||||
"destinationNumber": "DEST_PHONE_NUMBER",
|
||||
"timestamp": 1719177728639,
|
||||
"message": "MESSAGE_CONTENT",
|
||||
"sticker": { "packId": "166dd1b5…", "stickerId": 6 },
|
||||
"groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": "BOT_ACCOUNT_PHONE_NUMBER"
|
||||
}
|
||||
```
|
||||
|
||||
## The trap this caused
|
||||
|
||||
The original bot read `envelope.dataMessage.sticker` even when the frame was a
|
||||
`syncMessage`. Its own stickers therefore never matched the sticker filter and
|
||||
were counted as messages. Four consecutive commits (`f1931b8`, `b325126`,
|
||||
`62352e5`, `d35aa59`) fought the symptom.
|
||||
|
||||
`models.py` resolves the payload *first* — `dataMessage` or
|
||||
`syncMessage.sentMessage` — and reads `sticker`, `reaction`, `message` and
|
||||
`groupInfo` off whichever it found. `tests/test_models.py` asserts both shapes.
|
||||
|
||||
## No `groupInfo` means a direct message
|
||||
|
||||
A frame without `groupInfo` arrived in a one-to-one conversation. A linked
|
||||
device receives every one of those on the account, so `counting.is_watched_group`
|
||||
rejects them explicitly rather than relying on a falsy comparison. See the
|
||||
`scoping` scenario in `fake_signal/scenarios/`.
|
||||
|
||||
## Frames the bot ignores
|
||||
|
||||
Receipts, typing indicators and anything else with no `dataMessage` and no
|
||||
`syncMessage.sentMessage` parse to `None` and are dropped.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 kuhyx
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
120
README.md
Normal file
120
README.md
Normal file
@ -0,0 +1,120 @@
|
||||
# signal-bot
|
||||
|
||||
A Signal group bot: `!cat` / `!dog` image commands and a daily message tally.
|
||||
|
||||
Runs as a **linked device** on an existing Signal account via
|
||||
[signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api).
|
||||
|
||||
## Scoping, first
|
||||
|
||||
A linked device receives **every direct message and every group** on the
|
||||
account it is linked to. `GROUP_ID` is the only thing that keeps this bot
|
||||
inside one conversation, so:
|
||||
|
||||
- the bot **refuses to start** without `GROUP_ID` and `GROUP_ID_SEND`;
|
||||
- direct messages are dropped explicitly, never by a falsy check;
|
||||
- the `scoping` scenario asserts that DMs and other groups produce **zero**
|
||||
outbound messages, and `scripts/e2e.sh` fails the build if that stops holding.
|
||||
|
||||
## Run it offline, with no Signal account
|
||||
|
||||
Everything works against a local fake that speaks the real protocol:
|
||||
|
||||
```sh
|
||||
uv venv .venv && uv pip install -e '.[dev]'
|
||||
./scripts/e2e.sh
|
||||
```
|
||||
|
||||
That boots the fake server, boots the unmodified bot pointed at it, replays
|
||||
every scenario in `fake_signal/scenarios/`, and **exits non-zero on mismatch**.
|
||||
It needs no container, no network and no phone.
|
||||
|
||||
```
|
||||
== booting the fake Signal server on :8099
|
||||
== booting the bot on :8098, pointed at the fake
|
||||
ok !cat in the watched group posts an image to the reply group
|
||||
ok one user's cooldown does not silence anybody else
|
||||
ok direct messages and other groups are never answered
|
||||
...
|
||||
```
|
||||
|
||||
Add a case by dropping a YAML file in `fake_signal/scenarios/`:
|
||||
|
||||
```yaml
|
||||
name: "what this proves"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-alice, name: Alice } # group: watched|other|null
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
```
|
||||
|
||||
Give each scenario its own `uuid`: one bot process serves them all and the
|
||||
cooldown lives in SQLite, so a shared actor leaks state between files.
|
||||
|
||||
## Run it for real
|
||||
|
||||
1. Start the container. Use **`json-rpc-native`** — `/v1/receive` is only a
|
||||
WebSocket in a json-rpc mode; in `normal`/`native` it silently degrades to
|
||||
a polling GET and the bot will never receive anything.
|
||||
|
||||
```sh
|
||||
docker run -d --name signal-api --restart=always -p 9922:8080 \
|
||||
-v "$HOME/signal-api:/home/.local/share/signal-cli" \
|
||||
-e MODE=json-rpc-native \
|
||||
bbernhard/signal-cli-rest-api:0.100
|
||||
```
|
||||
|
||||
Pin the **stable** tag. The dev series (`0.202-dev`) counts separately, so
|
||||
an older `0.167-dev` is not newer than `0.100`. If linking fails with a
|
||||
connection error, that is server-side client deprecation: try a recent
|
||||
`*-ci` tag, which tracks a newer signal-cli.
|
||||
|
||||
2. Link the device and pick a group — this does both, and only asks you to
|
||||
scan a QR code:
|
||||
|
||||
```sh
|
||||
./scripts/live_smoke.sh
|
||||
```
|
||||
|
||||
3. Or do it by hand: open `http://localhost:9922/v1/qrcodelink?device_name=signal-api`,
|
||||
scan it, copy `.env.sample` to `.env`, fill it in, then `./run.sh`.
|
||||
|
||||
Send `!cat` in the group. Because this is a linked device, the reply arrives
|
||||
from your own account.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Aliases |
|
||||
|---|---|
|
||||
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
|
||||
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
|
||||
|
||||
Aliases live in `signal_bot/aliases.json`, not in source. Each command is
|
||||
rate-limited per user, per command — the original bot shared one 10-second
|
||||
window across everybody.
|
||||
|
||||
`signal_bot/decorators.py` also ships `daily_limit` and `vote_gated` (run only
|
||||
once N distinct people ask, within a window). Both are generic and tested;
|
||||
attach them to any command.
|
||||
|
||||
## Daily tally
|
||||
|
||||
Every non-sticker, non-reaction message in the watched group increments its
|
||||
sender's count. At `REPORT_HOUR:REPORT_MINUTE` (default 21:37) the tally is
|
||||
posted to `GROUP_ID_SEND` and reset. Counts live in SQLite and survive a
|
||||
restart.
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
pytest # 189 tests, 100% branch coverage, no network
|
||||
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
|
||||
./scripts/e2e.sh # the real bot against the fake server
|
||||
```
|
||||
|
||||
No `# noqa`, no `# type: ignore`, no coverage omits. Docs on the wire format
|
||||
are in [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
|
||||
|
||||
## Licence
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
36
SECURITY.md
Normal file
36
SECURITY.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope of a linked device
|
||||
|
||||
This bot runs as a **linked device** on an existing Signal account. That means
|
||||
it can read every direct message and every group on that account, and anything
|
||||
it sends is sent **as the account owner**. It is not a separate identity.
|
||||
|
||||
`GROUP_ID` is the only thing that narrows this. The bot therefore:
|
||||
|
||||
- refuses to start when `GROUP_ID` or `GROUP_ID_SEND` is missing or blank,
|
||||
rather than defaulting to an empty string that matches nothing predictably;
|
||||
- drops messages with no `groupInfo` (direct messages) explicitly, in
|
||||
`counting.is_watched_group`, rather than relying on a falsy comparison;
|
||||
- ships a `scoping` scenario asserting that direct messages and other groups
|
||||
produce zero outbound messages, run by `scripts/e2e.sh` in CI.
|
||||
|
||||
If you widen that boundary, the scoping scenario is what should fail.
|
||||
|
||||
## Secrets
|
||||
|
||||
`.env` is gitignored and never committed. `.env.sample` contains no real
|
||||
values. The bot reads no credentials of its own: authentication is the
|
||||
signal-cli data directory mounted into the container, which stays on the host.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Open an issue at https://github.com/kuhyx/signal-bot/issues. If the issue
|
||||
would expose message content or account access, say so without a working
|
||||
proof of concept in the public issue.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Every dependency is exact-pinned at its newest stable release and checked by
|
||||
`scripts/check_dependency_freshness.sh` in pre-commit and CI. There are no
|
||||
pre-release pins.
|
||||
2
fake_signal/__init__.py
Normal file
2
fake_signal/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""A protocol-faithful fake of signal-cli-rest-api, for offline testing."""
|
||||
81
fake_signal/cli.py
Normal file
81
fake_signal/cli.py
Normal file
@ -0,0 +1,81 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Drive a running fake server through a scenario, and adjudicate the result.
|
||||
|
||||
Exits non-zero on any mismatch, so scripts/e2e.sh is a real gate rather than
|
||||
a log to read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from fake_signal.scenario import Scenario, check, load_scenario
|
||||
|
||||
SETTLE_SECONDS = 0.35
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
"""Write one report line to stdout."""
|
||||
sys.stdout.write(f"{line}\n")
|
||||
|
||||
|
||||
def _groups(args: argparse.Namespace) -> dict[str, str]:
|
||||
"""Map the scenario's symbolic group names onto real ids."""
|
||||
return {"watched": args.group_id, "reply": args.group_id_send, "other": args.other_group}
|
||||
|
||||
|
||||
async def run_scenario(
|
||||
base_url: str, scenario: Scenario, groups: dict[str, str], *, settle: float
|
||||
) -> list[str]:
|
||||
"""Inject every frame, wait for the bot to react, then compare."""
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
|
||||
await client.post("/_control/reset")
|
||||
for injection in scenario.inject:
|
||||
await client.post("/_control/inject", json={"raw": injection.to_frame(groups)})
|
||||
await asyncio.sleep(settle)
|
||||
response = await client.get("/_control/sent")
|
||||
response.raise_for_status()
|
||||
return check(scenario, response.json(), groups)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
"""Parse the injector's command line."""
|
||||
parser = argparse.ArgumentParser(description="Replay a scenario against the bot.")
|
||||
parser.add_argument("scenarios", nargs="+", type=Path)
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8099")
|
||||
parser.add_argument("--group-id", required=True)
|
||||
parser.add_argument("--group-id-send", required=True)
|
||||
parser.add_argument("--other-group", default="group-other==")
|
||||
parser.add_argument("--settle", type=float, default=SETTLE_SECONDS)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
async def _main_async(args: argparse.Namespace) -> int:
|
||||
"""Run every scenario, reporting failures as they happen."""
|
||||
groups = _groups(args)
|
||||
failed = 0
|
||||
for path in args.scenarios:
|
||||
scenario = load_scenario(path)
|
||||
failures = await run_scenario(args.base_url, scenario, groups, settle=args.settle)
|
||||
if failures:
|
||||
failed += 1
|
||||
_emit(f"FAIL {scenario.name}")
|
||||
for line in failures:
|
||||
_emit(f" {line}")
|
||||
else:
|
||||
_emit(f"ok {scenario.name}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point. Returns a process exit code."""
|
||||
return asyncio.run(_main_async(parse_args(argv)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
79
fake_signal/routes_api.py
Normal file
79
fake_signal/routes_api.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The endpoints the real signal-cli-rest-api exposes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from fake_signal.state import PIXEL_PNG, State
|
||||
|
||||
|
||||
def _state(request: Request) -> State:
|
||||
"""Return the State attached to the running app."""
|
||||
state: State = request.app.state.fake
|
||||
return state
|
||||
|
||||
|
||||
def build_api_router() -> APIRouter:
|
||||
"""Build the router mirroring the real container's API."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/v1/about")
|
||||
async def about(request: Request) -> dict[str, Any]:
|
||||
return {"build": 2, "mode": _state(request).mode, "versions": ["v1", "v2"]}
|
||||
|
||||
@router.get("/v1/qrcodelink")
|
||||
async def qrcodelink(device_name: str = "signal-api") -> Response:
|
||||
# The real endpoint returns a PNG, which is why the live runbook has
|
||||
# to open an image rather than print a string.
|
||||
return Response(
|
||||
content=PIXEL_PNG,
|
||||
media_type="image/png",
|
||||
headers={"X-Device-Name": device_name},
|
||||
)
|
||||
|
||||
@router.post("/v2/send")
|
||||
async def send(request: Request, payload: dict[str, Any]) -> JSONResponse:
|
||||
_state(request).record_send(payload)
|
||||
return JSONResponse({"timestamp": 1719177917717}, status_code=201)
|
||||
|
||||
@router.get("/v1/attachments")
|
||||
async def list_attachments(request: Request) -> list[str]:
|
||||
return list(_state(request).attachments)
|
||||
|
||||
@router.delete("/v1/attachments/{attachment_id}")
|
||||
async def delete_attachment(request: Request, attachment_id: str) -> Response:
|
||||
attachments = _state(request).attachments
|
||||
if attachment_id in attachments:
|
||||
attachments.remove(attachment_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
_add_receive_route(router)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _add_receive_route(router: APIRouter) -> None:
|
||||
"""Attach the WebSocket receive endpoint.
|
||||
|
||||
Split out so the router builder stays under the complexity cap.
|
||||
"""
|
||||
|
||||
@router.websocket("/v1/receive/{number}")
|
||||
async def receive(websocket: WebSocket, number: str) -> None:
|
||||
state: State = websocket.app.state.fake
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"envelope": {"subscribed": number}})
|
||||
state.sockets.append(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Blocks until the client goes away. The bot never sends.
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
# Branchless: a conditional remove leaves an arc no test can reach.
|
||||
state.sockets[:] = [s for s in state.sockets if s is not websocket]
|
||||
45
fake_signal/routes_control.py
Normal file
45
fake_signal/routes_control.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The injector's control plane. Not part of the real API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fake_signal.state import State
|
||||
|
||||
|
||||
def _state(request: Request) -> State:
|
||||
"""Return the State attached to the running app."""
|
||||
state: State = request.app.state.fake
|
||||
return state
|
||||
|
||||
|
||||
def build_control_router() -> APIRouter:
|
||||
"""Build the router the scenario injector drives."""
|
||||
router = APIRouter(prefix="/_control")
|
||||
|
||||
@router.post("/inject")
|
||||
async def inject(request: Request, frame: dict[str, Any]) -> dict[str, int]:
|
||||
delivered = 0
|
||||
for socket in list(_state(request).sockets):
|
||||
await socket.send_text(str(frame["raw"]))
|
||||
delivered += 1
|
||||
return {"delivered": delivered}
|
||||
|
||||
@router.get("/sent")
|
||||
async def captured(request: Request) -> list[dict[str, Any]]:
|
||||
return list(_state(request).sent)
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset(request: Request) -> dict[str, str]:
|
||||
_state(request).reset()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready(request: Request) -> dict[str, int]:
|
||||
return {"sockets": len(_state(request).sockets)}
|
||||
|
||||
return router
|
||||
34
fake_signal/routes_fixtures.py
Normal file
34
fake_signal/routes_fixtures.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Local stand-ins for thecatapi and dog.ceo, shaped like the real responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from fake_signal.state import PIXEL_PNG, State
|
||||
|
||||
|
||||
def _image_url(request: Request) -> str:
|
||||
"""Absolute URL of the fixture image, as this server sees itself."""
|
||||
state: State = request.app.state.fake
|
||||
return f"{state.base_url}/_fixtures/image.png"
|
||||
|
||||
|
||||
def build_fixture_router() -> APIRouter:
|
||||
"""Build the router serving fixture images and image-API responses."""
|
||||
router = APIRouter(prefix="/_fixtures")
|
||||
|
||||
@router.get("/cat-api")
|
||||
async def cat_api(request: Request) -> list[dict[str, str]]:
|
||||
return [{"url": _image_url(request)}]
|
||||
|
||||
@router.get("/dog-api")
|
||||
async def dog_api(request: Request) -> dict[str, str]:
|
||||
return {"message": _image_url(request)}
|
||||
|
||||
@router.get("/image.png")
|
||||
async def image() -> Response:
|
||||
return Response(content=PIXEL_PNG, media_type="image/png")
|
||||
|
||||
return router
|
||||
130
fake_signal/scenario.py
Normal file
130
fake_signal/scenario.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Scenario files: what to inject, and what the bot must do in response."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ScenarioError(ValueError):
|
||||
"""Raised when a scenario file is malformed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Injection:
|
||||
"""One inbound message to push down the websocket."""
|
||||
|
||||
text: str | None = None
|
||||
uuid: str = "uuid-alice"
|
||||
name: str = "Alice"
|
||||
group: str | None = "watched"
|
||||
sticker: bool = False
|
||||
reaction: bool = False
|
||||
sync: bool = False
|
||||
|
||||
def to_frame(self, groups: dict[str, str]) -> str:
|
||||
"""Render this injection as a raw signal-cli envelope."""
|
||||
payload: dict[str, Any] = {"timestamp": 1719177917717}
|
||||
if self.text is not None:
|
||||
payload["message"] = self.text
|
||||
if self.group is not None:
|
||||
if self.group not in groups:
|
||||
msg = f"unknown group alias {self.group!r}; known: {sorted(groups)}"
|
||||
raise ScenarioError(msg)
|
||||
payload["groupInfo"] = {"groupId": groups[self.group], "type": "DELIVER"}
|
||||
if self.sticker:
|
||||
payload["sticker"] = {"packId": "abc", "stickerId": 6}
|
||||
if self.reaction:
|
||||
payload["reaction"] = {"emoji": "\U0001f44d", "targetAuthor": "x"}
|
||||
|
||||
body: dict[str, Any] = {"sourceUuid": self.uuid, "sourceName": self.name}
|
||||
if self.sync:
|
||||
body["syncMessage"] = {"sentMessage": payload}
|
||||
else:
|
||||
body["dataMessage"] = payload
|
||||
return json.dumps({"envelope": body, "account": "+15550000000"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Expectation:
|
||||
"""One message the bot is expected to have sent."""
|
||||
|
||||
kind: str = "text"
|
||||
to: str = "reply"
|
||||
contains: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Scenario:
|
||||
"""A named list of injections and the sends they must produce."""
|
||||
|
||||
name: str
|
||||
inject: list[Injection] = field(default_factory=list)
|
||||
expect: list[Expectation] = field(default_factory=list)
|
||||
|
||||
|
||||
def _build[T](cls: type[T], entry: object, what: str) -> T:
|
||||
"""Instantiate a scenario dataclass, rejecting unknown keys."""
|
||||
if not isinstance(entry, dict):
|
||||
msg = f"each {what} entry must be a mapping, got {type(entry).__name__}"
|
||||
raise ScenarioError(msg)
|
||||
allowed = set(cls.__annotations__)
|
||||
unknown = set(entry) - allowed
|
||||
if unknown:
|
||||
msg = f"unknown {what} keys {sorted(unknown)}; allowed: {sorted(allowed)}"
|
||||
raise ScenarioError(msg)
|
||||
return cls(**entry)
|
||||
|
||||
|
||||
def load_scenario(path: Path) -> Scenario:
|
||||
"""Load and validate one scenario file.
|
||||
|
||||
Raises:
|
||||
ScenarioError: The file is not a mapping or carries unknown keys.
|
||||
"""
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(document, dict):
|
||||
msg = f"{path} must contain a mapping"
|
||||
raise ScenarioError(msg)
|
||||
if "name" not in document:
|
||||
msg = f"{path} is missing a name"
|
||||
raise ScenarioError(msg)
|
||||
return Scenario(
|
||||
name=str(document["name"]),
|
||||
inject=[_build(Injection, e, "inject") for e in document.get("inject", [])],
|
||||
expect=[_build(Expectation, e, "expect") for e in document.get("expect", [])],
|
||||
)
|
||||
|
||||
|
||||
def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""Reduce a captured send to (kind, recipient, body) for comparison."""
|
||||
kind = "image" if payload.get("base64_attachments") else "text"
|
||||
recipients = payload.get("recipients") or [""]
|
||||
return kind, str(recipients[0]), str(payload.get("message", ""))
|
||||
|
||||
|
||||
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:
|
||||
"""Compare captured sends against expectations, returning failure lines."""
|
||||
failures: list[str] = []
|
||||
if len(sent) != len(scenario.expect):
|
||||
failures.append(f"expected {len(scenario.expect)} send(s), captured {len(sent)}")
|
||||
for index, expected in enumerate(scenario.expect):
|
||||
if index >= len(sent):
|
||||
failures.append(f"[{index}] missing: expected a {expected.kind}")
|
||||
continue
|
||||
kind, recipient, body = describe(sent[index])
|
||||
want_to = groups.get(expected.to, expected.to)
|
||||
if kind != expected.kind:
|
||||
failures.append(f"[{index}] kind: expected {expected.kind}, got {kind}")
|
||||
if recipient != want_to:
|
||||
failures.append(f"[{index}] recipient: expected {want_to}, got {recipient}")
|
||||
if expected.contains is not None and expected.contains not in body:
|
||||
failures.append(f"[{index}] body: {expected.contains!r} not in {body!r}")
|
||||
return failures
|
||||
5
fake_signal/scenarios/cat_command.yaml
Normal file
5
fake_signal/scenarios/cat_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!cat in the watched group posts an image to the reply group"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
9
fake_signal/scenarios/cooldown.yaml
Normal file
9
fake_signal/scenarios/cooldown.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# Distinct actor per scenario: one bot process serves them all, and the
|
||||
# cooldown lives in SQLite, so sharing a uuid would leak state between files.
|
||||
name: "a second !cat inside the cooldown window is refused, not served"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-carol, name: Carol }
|
||||
- { text: "!cat", uuid: uuid-carol, name: Carol }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
- { kind: text, to: reply, contains: "BEEP BOOP" }
|
||||
9
fake_signal/scenarios/cooldown_is_per_user.yaml
Normal file
9
fake_signal/scenarios/cooldown_is_per_user.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The original bot shared one 10-second window across every user and every
|
||||
# command, so one person's cat picture silenced the whole group.
|
||||
name: "one user's cooldown does not silence anybody else"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-erin, name: Erin }
|
||||
- { text: "!cat", uuid: uuid-frank, name: Frank }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
- { kind: image, to: reply }
|
||||
5
fake_signal/scenarios/dog_command.yaml
Normal file
5
fake_signal/scenarios/dog_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!dog resolves through a styled alias and posts an image"
|
||||
inject:
|
||||
- { text: "!szczek", uuid: uuid-bob, name: Bob }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
5
fake_signal/scenarios/not_counted.yaml
Normal file
5
fake_signal/scenarios/not_counted.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "stickers and reactions produce no reply at all"
|
||||
inject:
|
||||
- { sticker: true, sync: true, uuid: uuid-dave, name: Dave }
|
||||
- { reaction: true, uuid: uuid-dave, name: Dave }
|
||||
expect: []
|
||||
9
fake_signal/scenarios/scoping.yaml
Normal file
9
fake_signal/scenarios/scoping.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The security invariant. A linked Signal device receives every direct
|
||||
# conversation and every group on the account; GROUP_ID is the only thing
|
||||
# scoping the bot. Nothing here may produce a single outbound message.
|
||||
name: "direct messages and other groups are never answered"
|
||||
inject:
|
||||
- { text: "!cat", group: null, uuid: uuid-stranger, name: Stranger }
|
||||
- { text: "!cat", group: other, uuid: uuid-stranger, name: Stranger }
|
||||
- { text: "hello there", group: null, uuid: uuid-stranger, name: Stranger }
|
||||
expect: []
|
||||
5
fake_signal/scenarios/unknown_command.yaml
Normal file
5
fake_signal/scenarios/unknown_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "chatter and unknown commands produce nothing"
|
||||
inject:
|
||||
- { text: "just chatting", uuid: uuid-alice, name: Alice }
|
||||
- { text: "!nosuchcommand", uuid: uuid-alice, name: Alice }
|
||||
expect: []
|
||||
40
fake_signal/server.py
Normal file
40
fake_signal/server.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""A protocol-faithful fake of signal-cli-rest-api.
|
||||
|
||||
Serves the exact surface the bot uses, so the unmodified bot connects to it
|
||||
by pointing API_BASE_URL at this process. A real local Signal server is not
|
||||
achievable -- signal-cli compiles its endpoints and trust store in, and
|
||||
Signal-Server needs FoundationDB, DynamoDB and Redis -- so faking this
|
||||
boundary is the only way to exercise every feature without a real account.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fake_signal.routes_api import build_api_router
|
||||
from fake_signal.routes_control import build_control_router
|
||||
from fake_signal.routes_fixtures import build_fixture_router
|
||||
from fake_signal.state import State
|
||||
|
||||
|
||||
def create_app(state: State | None = None) -> FastAPI:
|
||||
"""Build the fake server, optionally sharing an existing State."""
|
||||
app = FastAPI(title="fake-signal-cli-rest-api")
|
||||
app.state.fake = state if state is not None else State()
|
||||
app.include_router(build_api_router())
|
||||
app.include_router(build_fixture_router())
|
||||
app.include_router(build_control_router())
|
||||
return app
|
||||
|
||||
|
||||
def default_state() -> State:
|
||||
"""State whose self-URL matches the port the e2e script binds."""
|
||||
host = os.getenv("FAKE_HOST", "127.0.0.1")
|
||||
port = os.getenv("FAKE_PORT", "8099")
|
||||
return State(base_url=f"http://{host}:{port}")
|
||||
|
||||
|
||||
app = create_app(default_state())
|
||||
40
fake_signal/state.py
Normal file
40
fake_signal/state.py
Normal file
@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Shared mutable state for the fake server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
# A 1x1 PNG, inline so no binary file enters the repo.
|
||||
PIXEL_PNG: Final = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQ"
|
||||
"GAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
"""Everything the fake remembers between requests."""
|
||||
|
||||
sockets: list[WebSocket] = field(default_factory=list)
|
||||
sent: list[dict[str, Any]] = field(default_factory=list)
|
||||
attachments: list[str] = field(default_factory=list)
|
||||
mode: str = "json-rpc-native"
|
||||
base_url: str = "http://127.0.0.1:8099"
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Forget captured sends and attachments."""
|
||||
self.sent.clear()
|
||||
self.attachments.clear()
|
||||
|
||||
def record_send(self, payload: dict[str, Any]) -> None:
|
||||
"""Capture one outgoing message and mint ids for its attachments."""
|
||||
self.sent.append(payload)
|
||||
attachments = payload.get("base64_attachments") or []
|
||||
for index in range(len(attachments)):
|
||||
self.attachments.append(f"attach-{len(self.sent)}-{index}")
|
||||
124
pyproject.toml
Normal file
124
pyproject.toml
Normal file
@ -0,0 +1,124 @@
|
||||
[project]
|
||||
name = "signal-bot"
|
||||
version = "1.0.0"
|
||||
description = "A Signal group bot: image commands and daily message tallies."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.14"
|
||||
|
||||
# Exact pins, newest stable, per the dependency-freshness rule. A caret range
|
||||
# is "newest-compatible" by definition and gives the freshness gate nothing to
|
||||
# compare against.
|
||||
dependencies = [
|
||||
"fastapi==0.141.1",
|
||||
"httpx==0.28.1",
|
||||
"python-dotenv==1.2.3",
|
||||
"PyYAML==6.0.3",
|
||||
"uvicorn==0.52.4",
|
||||
"websockets==17.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy==2.3.1",
|
||||
"pre-commit==4.6.2",
|
||||
"pytest==9.1.1",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"ruff==0.16.5",
|
||||
"types-PyYAML==6.0.12.20260815",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
signal-bot = "signal_bot.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools==80.9.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["signal_bot", "fake_signal"]
|
||||
|
||||
# ============================================================================
|
||||
# RUFF
|
||||
# ============================================================================
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"D203", # conflicts with D211, we use D211
|
||||
"D213", # conflicts with D212, we use D212
|
||||
"COM812", # formatter owns trailing commas
|
||||
"ISC001", # formatter may create implicit string concatenation
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
|
||||
# Verified empirically, not copied: with this block removed, ruff reports
|
||||
# exactly these four rules across tests/ and nothing else. SLF001 was in
|
||||
# the original list and fired zero times, so it is gone.
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = [
|
||||
"ARG", # fixture and mock params exist for their side effect
|
||||
"D", # test names are the documentation
|
||||
"PLR2004", # `assert count == 2` beats a named constant
|
||||
"S101", # assert IS what tests do
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
# The test envelope builder takes 7 keyword-only fields, one per envelope
|
||||
# attribute. Splitting it into a dataclass would only move the count.
|
||||
max-args = 7
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
docstring-code-format = true
|
||||
|
||||
# ============================================================================
|
||||
# MYPY
|
||||
# ============================================================================
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
disallow_any_explicit = false
|
||||
|
||||
# ============================================================================
|
||||
# PYTEST / COVERAGE
|
||||
# ============================================================================
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
addopts = [
|
||||
"--cov=signal_bot",
|
||||
"--cov=fake_signal",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=100",
|
||||
"--strict-markers",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
# No omits. Every line of both packages is covered by tests.
|
||||
|
||||
[tool.coverage.report]
|
||||
# No "pragma: no cover" escape hatch: nothing in this repo uses one, and
|
||||
# leaving the pattern configured is an invitation to hide code from the
|
||||
# 100% gate later. TYPE_CHECKING bodies are genuinely never executed.
|
||||
exclude_lines = ["if TYPE_CHECKING:"]
|
||||
show_missing = true
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
# Runtime dependencies, exact-pinned at newest stable.
|
||||
# Dev/test dependencies live in pyproject.toml under [project.optional-dependencies].
|
||||
fastapi==0.141.1
|
||||
httpx==0.28.1
|
||||
python-dotenv==1.2.3
|
||||
PyYAML==6.0.3
|
||||
uvicorn==0.52.4
|
||||
websockets==17.1
|
||||
25
run.sh
Executable file
25
run.sh
Executable file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Run the Signal bot.
|
||||
#
|
||||
# The readme used to say `python main.py`, which did nothing at all: there was
|
||||
# no __main__ block and no uvicorn.run() call anywhere in the project.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly REPO_ROOT
|
||||
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "Error: no interpreter at $PYTHON" >&2
|
||||
echo " Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$PYTHON" -m signal_bot
|
||||
}
|
||||
|
||||
main "$@"
|
||||
26
scripts/check_coverage.sh
Executable file
26
scripts/check_coverage.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Thin delegate to the shared gate in ~/utils.
|
||||
#
|
||||
# Copying the logic here is what lets one repo's idea of the rule drift from
|
||||
# every other repo's, so this script only locates the shared checker and
|
||||
# forwards its arguments.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_coverage.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
26
scripts/check_dependency_freshness.sh
Executable file
26
scripts/check_dependency_freshness.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Thin delegate to the shared gate in ~/utils.
|
||||
#
|
||||
# Copying the logic here is what lets one repo's idea of the rule drift from
|
||||
# every other repo's, so this script only locates the shared checker and
|
||||
# forwards its arguments.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_dependency_freshness.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
26
scripts/check_file_length.sh
Executable file
26
scripts/check_file_length.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Thin delegate to the shared gate in ~/utils.
|
||||
#
|
||||
# Copying the logic here is what lets one repo's idea of the rule drift from
|
||||
# every other repo's, so this script only locates the shared checker and
|
||||
# forwards its arguments.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_file_length.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
26
scripts/check_md_naming.sh
Executable file
26
scripts/check_md_naming.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Thin delegate to the shared gate in ~/utils.
|
||||
#
|
||||
# Copying the logic here is what lets one repo's idea of the rule drift from
|
||||
# every other repo's, so this script only locates the shared checker and
|
||||
# forwards its arguments.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_md_naming.sh"
|
||||
|
||||
main() {
|
||||
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||
echo "Error: shared gate not found at $SHARED_GATE" >&2
|
||||
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
|
||||
echo " UTILS_ROOT to where it lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec bash "$SHARED_GATE" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
106
scripts/e2e.sh
Executable file
106
scripts/e2e.sh
Executable file
@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# End-to-end test: the real bot against the fake Signal server.
|
||||
#
|
||||
# Boots the protocol-faithful fake, boots the unmodified bot pointed at it,
|
||||
# replays every scenario and exits non-zero on the first mismatch. No Signal
|
||||
# account, no network, no container.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
readonly REPO_ROOT
|
||||
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
readonly FAKE_PORT="${FAKE_PORT:-8099}"
|
||||
readonly BOT_PORT="${BOT_PORT:-8098}"
|
||||
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
|
||||
|
||||
readonly WATCHED_GROUP="group.e2e.watched=="
|
||||
readonly REPLY_GROUP="group.e2e.reply=="
|
||||
readonly OTHER_GROUP="group.e2e.other=="
|
||||
|
||||
FAKE_PID=""
|
||||
BOT_PID=""
|
||||
TEMP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
[[ -n "$BOT_PID" ]] && kill "$BOT_PID" 2>/dev/null || true
|
||||
[[ -n "$FAKE_PID" ]] && kill "$FAKE_PID" 2>/dev/null || true
|
||||
wait 2>/dev/null || true
|
||||
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_url() {
|
||||
local url="$1" name="$2" attempt=0
|
||||
until curl -sf "$url" >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge 100 ]]; then
|
||||
echo "Error: $name did not come up at $url" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_subscription() {
|
||||
local attempt=0
|
||||
until [[ "$(curl -sf "${FAKE_URL}/_control/ready" | tr -dc '0-9')" != "0" ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge 100 ]]; then
|
||||
echo "Error: the bot never subscribed to the receive websocket" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
|
||||
}
|
||||
|
||||
main() {
|
||||
validate_requirements
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
|
||||
echo "== booting the fake Signal server on :${FAKE_PORT}"
|
||||
export FAKE_PORT
|
||||
"$PYTHON" -m uvicorn fake_signal.server:app \
|
||||
--host 127.0.0.1 --port "$FAKE_PORT" --log-level warning &
|
||||
FAKE_PID=$!
|
||||
wait_for_url "${FAKE_URL}/v1/about" "fake signal server"
|
||||
|
||||
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
||||
env -u CAT_API \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
GROUP_ID="$WATCHED_GROUP" \
|
||||
GROUP_ID_SEND="$REPLY_GROUP" \
|
||||
API_BASE_URL="$FAKE_URL" \
|
||||
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
"$PYTHON" -m signal_bot &
|
||||
BOT_PID=$!
|
||||
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
||||
wait_for_subscription
|
||||
|
||||
echo "== replaying scenarios"
|
||||
"$PYTHON" -m fake_signal.cli \
|
||||
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
|
||||
--base-url "$FAKE_URL" \
|
||||
--group-id "$WATCHED_GROUP" \
|
||||
--group-id-send "$REPLY_GROUP" \
|
||||
--other-group "$OTHER_GROUP"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
152
scripts/live_smoke.sh
Executable file
152
scripts/live_smoke.sh
Executable file
@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Live verification against a real Signal account.
|
||||
#
|
||||
# Starts the signal-cli-rest-api container, opens the linking QR code, waits
|
||||
# for the link, lists your groups so you can pick a private one, writes .env
|
||||
# and starts the bot. The only manual step is scanning the QR code.
|
||||
#
|
||||
# Run scripts/e2e.sh first: this costs a device link, that costs nothing.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
readonly REPO_ROOT
|
||||
|
||||
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
readonly CONTAINER="${CONTAINER:-signal-api}"
|
||||
readonly PORT="${PORT:-9922}"
|
||||
readonly API="http://localhost:${PORT}"
|
||||
readonly DATA_DIR="${DATA_DIR:-$HOME/signal-api}"
|
||||
# Stable tag. The dev series counts separately, so 0.167-dev is OLDER than this.
|
||||
readonly IMAGE="${IMAGE:-bbernhard/signal-cli-rest-api:0.100}"
|
||||
# Fallback when linking fails on client deprecation: tracks a newer signal-cli.
|
||||
readonly FALLBACK_IMAGE="${FALLBACK_IMAGE:-bbernhard/signal-cli-rest-api:1787779383-ci}"
|
||||
|
||||
TEMP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
|
||||
return 0
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
validate_requirements() {
|
||||
command -v docker >/dev/null || { echo "Error: docker is required" >&2; exit 1; }
|
||||
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
|
||||
command -v jq >/dev/null || { echo "Error: jq is required" >&2; exit 1; }
|
||||
[[ -x "$PYTHON" ]] || {
|
||||
echo "Error: no interpreter at $PYTHON" >&2
|
||||
echo " Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
start_container() {
|
||||
local image="$1"
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
mkdir -p "$DATA_DIR"
|
||||
echo "== starting $image on :${PORT} (MODE=json-rpc-native)"
|
||||
docker run -d --name "$CONTAINER" --restart=always -p "${PORT}:8080" \
|
||||
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
|
||||
-e 'MODE=json-rpc-native' "$image" >/dev/null
|
||||
|
||||
local attempt=0
|
||||
until curl -sf "${API}/v1/about" >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge 150 ]]; then
|
||||
echo "Error: container never became ready. Logs:" >&2
|
||||
docker logs --tail 30 "$CONTAINER" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 0.4
|
||||
done
|
||||
}
|
||||
|
||||
verify_mode() {
|
||||
local mode
|
||||
mode="$(curl -sf "${API}/v1/about" | jq -r '.mode // "unknown"')"
|
||||
if [[ "$mode" != *json-rpc* ]]; then
|
||||
echo "Error: container mode is '$mode'." >&2
|
||||
echo " /v1/receive is only a WebSocket in a json-rpc mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "== container mode: $mode"
|
||||
}
|
||||
|
||||
show_qr_code() {
|
||||
local png="${TEMP_DIR}/link.png"
|
||||
curl -sf "${API}/v1/qrcodelink?device_name=${CONTAINER}" -o "$png"
|
||||
echo "== scan this QR code: Signal > Settings > Linked devices > +"
|
||||
# The endpoint returns a PNG, so it has to be opened, not printed.
|
||||
if command -v xdg-open >/dev/null; then
|
||||
xdg-open "$png" >/dev/null 2>&1 &
|
||||
else
|
||||
echo " open this file: $png"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_link() {
|
||||
echo "== waiting for the link (Ctrl-C to abort)"
|
||||
local attempt=0
|
||||
until [[ "$(curl -sf "${API}/v1/accounts" | jq -r 'length // 0')" != "0" ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge 300 ]]; then
|
||||
echo "Error: no account linked after 5 minutes." >&2
|
||||
echo " If linking failed with a connection error, retry with:" >&2
|
||||
echo " IMAGE=$FALLBACK_IMAGE $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
choose_group() {
|
||||
local number="$1"
|
||||
echo "== groups on this account:" >&2
|
||||
curl -sf "${API}/v1/groups/${number}" \
|
||||
| jq -r '.[] | "\(.id)\t\(.name // "(unnamed)")"' \
|
||||
| nl -w3 -s' ' >&2
|
||||
echo >&2
|
||||
read -r -p "Pick the number of the PRIVATE group to use: " choice
|
||||
curl -sf "${API}/v1/groups/${number}" \
|
||||
| jq -r --argjson n "$choice" '.[$n - 1].id'
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local number="$1" group="$2"
|
||||
cat > "${REPO_ROOT}/.env" <<ENV
|
||||
PHONE_NUMBER=${number}
|
||||
GROUP_ID=${group}
|
||||
GROUP_ID_SEND=${group}
|
||||
API_BASE_URL=${API}
|
||||
DATABASE_PATH=${REPO_ROOT}/signal_bot.sqlite3
|
||||
ENV
|
||||
echo "== wrote ${REPO_ROOT}/.env (GROUP_ID == GROUP_ID_SEND, replies land in the same group)"
|
||||
}
|
||||
|
||||
main() {
|
||||
validate_requirements
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
|
||||
start_container "$IMAGE"
|
||||
verify_mode
|
||||
show_qr_code
|
||||
wait_for_link
|
||||
|
||||
local number group
|
||||
number="$(curl -sf "${API}/v1/accounts" | jq -r '.[0]')"
|
||||
echo "== linked as ${number}"
|
||||
group="$(choose_group "$number")"
|
||||
[[ -n "$group" && "$group" != "null" ]] || { echo "Error: no group chosen" >&2; exit 1; }
|
||||
write_env "$number" "$group"
|
||||
|
||||
echo "== starting the bot. Send '!cat' in that group; you should get a cat."
|
||||
exec "$REPO_ROOT/run.sh"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
2
signal_bot/__init__.py
Normal file
2
signal_bot/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""A Signal group bot: image commands and daily message tallies."""
|
||||
30
signal_bot/__main__.py
Normal file
30
signal_bot/__main__.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Console entrypoint, so `python -m signal_bot` actually starts the bot.
|
||||
|
||||
The old readme told people to run `python main.py`, which had no __main__
|
||||
block and no uvicorn.run() call, so it silently did nothing at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
# Loopback by default: the only inbound surface is /healthz, and the link to
|
||||
# signal-cli-rest-api is outbound. Override with BIND_HOST when containerised.
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8000
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the ASGI app under uvicorn."""
|
||||
uvicorn.run(
|
||||
"signal_bot.app:app",
|
||||
host=os.getenv("BIND_HOST", DEFAULT_HOST),
|
||||
port=int(os.getenv("BIND_PORT", str(DEFAULT_PORT))),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
signal_bot/aliases.json
Normal file
11
signal_bot/aliases.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"_comment": "Command trigger words, including the styled Unicode variants people actually type. These live as data, not source, because they are data: adding an alias should never mean touching Python.",
|
||||
"cat": [
|
||||
"!kot", "!koty", "!kots", "!cat", "!cats", "!meow", "!miau",
|
||||
"!ᴋᴏᴛ", "!𝓴𝓸𝓽", "!𝗸𝗼𝘁"
|
||||
],
|
||||
"dog": [
|
||||
"!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek",
|
||||
"!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽"
|
||||
]
|
||||
}
|
||||
75
signal_bot/animals.py
Normal file
75
signal_bot/animals.py
Normal file
@ -0,0 +1,75 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The image commands, and the alias tables people actually type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, Handler
|
||||
|
||||
CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
|
||||
CAT_URL_PATH: Final[tuple[str | int, ...]] = (0, "url")
|
||||
DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
|
||||
DOG_URL_PATH: Final[tuple[str | int, ...]] = ("message",)
|
||||
|
||||
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
|
||||
|
||||
|
||||
def load_aliases(path: Path = ALIAS_FILE) -> dict[str, list[str]]:
|
||||
"""Load the trigger table.
|
||||
|
||||
Aliases live in JSON rather than in source so that the styled Unicode
|
||||
variants are data the linter never has to reason about.
|
||||
"""
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {name: list(triggers) for name, triggers in document.items() if not name.startswith("_")}
|
||||
|
||||
|
||||
def image_handler(fetcher: ImageFetcher, api_url: str, url_path: Sequence[str | int]) -> Handler:
|
||||
"""Build a handler that posts one random image from a JSON image API."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
encoded = await fetcher.fetch_base64(api_url, url_path)
|
||||
await ctx.transport.send_image(encoded, ctx.recipient)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def register_animal_commands(
|
||||
registry: CommandRegistry,
|
||||
fetcher: ImageFetcher,
|
||||
*,
|
||||
decorate: Decorator | None = None,
|
||||
aliases: dict[str, list[str]] | None = None,
|
||||
endpoints: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Register the cat and dog commands on a registry.
|
||||
|
||||
Args:
|
||||
registry: Registry to populate.
|
||||
fetcher: Injected image fetcher.
|
||||
decorate: Optional callable applied to each handler, used to attach
|
||||
the cooldown gate without hard-wiring policy into the commands.
|
||||
aliases: Trigger table. Defaults to the shipped aliases.json.
|
||||
endpoints: Command name to image-API URL. Defaults to the real
|
||||
public APIs; the offline environment points these at itself.
|
||||
"""
|
||||
table = load_aliases() if aliases is None else aliases
|
||||
urls = {"cat": CAT_API_URL, "dog": DOG_API_URL} | (endpoints or {})
|
||||
paths: dict[str, tuple[str | int, ...]] = {
|
||||
"cat": CAT_URL_PATH,
|
||||
"dog": DOG_URL_PATH,
|
||||
}
|
||||
for name, api_url in urls.items():
|
||||
url_path = paths[name]
|
||||
handler = image_handler(fetcher, api_url, url_path)
|
||||
if decorate is not None:
|
||||
handler = decorate(handler)
|
||||
registry.register(name, table[name], handler)
|
||||
150
signal_bot/app.py
Normal file
150
signal_bot/app.py
Normal file
@ -0,0 +1,150 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Application wiring and the ASGI entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from signal_bot.transport_base import Transport
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
from signal_bot.animals import register_animal_commands
|
||||
from signal_bot.config import Config, load_config
|
||||
from signal_bot.counting import format_tally
|
||||
from signal_bot.decorators import cooldown
|
||||
from signal_bot.dispatcher import Dispatcher
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.registry import CommandRegistry
|
||||
from signal_bot.scheduler import DailyScheduler
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_rest import RestTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND."
|
||||
HTTP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def now_local() -> dt.datetime:
|
||||
"""Current local time, timezone-aware."""
|
||||
return dt.datetime.now().astimezone()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bot:
|
||||
"""The wired-up bot and its two long-running loops."""
|
||||
|
||||
config: Config
|
||||
store: Store
|
||||
transport: Transport
|
||||
dispatcher: Dispatcher
|
||||
|
||||
async def publish_report(self) -> None:
|
||||
"""Post the tally to the reply group and reset it."""
|
||||
rows = [(t.display_name or t.uuid, t.count) for t in self.store.tallies()]
|
||||
await self.transport.send_text(format_tally(rows), self.config.group_id_send)
|
||||
self.store.reset_tallies()
|
||||
|
||||
async def listen(self) -> None:
|
||||
"""Consume frames forever, handing each to the dispatcher."""
|
||||
async for frame in self.transport.receive():
|
||||
await self.dispatcher.handle(frame)
|
||||
|
||||
|
||||
def build_bot(config: Config, client: httpx.AsyncClient, store: Store, transport: Transport) -> Bot:
|
||||
"""Assemble the object graph from its injected dependencies."""
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(
|
||||
registry,
|
||||
ImageFetcher(client),
|
||||
decorate=cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE),
|
||||
endpoints={"cat": config.cat_api_url, "dog": config.dog_api_url},
|
||||
)
|
||||
dispatcher = Dispatcher(
|
||||
config=config,
|
||||
registry=registry,
|
||||
store=store,
|
||||
transport=transport,
|
||||
clock=now_local,
|
||||
)
|
||||
return Bot(config=config, store=store, transport=transport, dispatcher=dispatcher)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Runtime:
|
||||
"""Owns everything that must be torn down when the app stops."""
|
||||
|
||||
bot: Bot
|
||||
client: httpx.AsyncClient
|
||||
store: Store
|
||||
tasks: list[asyncio.Task[None]] = field(default_factory=list)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Spawn the listener and the daily scheduler as background tasks."""
|
||||
scheduler = DailyScheduler(
|
||||
self.bot.config.report_hour,
|
||||
self.bot.config.report_minute,
|
||||
self.bot.publish_report,
|
||||
now_local,
|
||||
asyncio.sleep,
|
||||
)
|
||||
self.tasks = [
|
||||
asyncio.create_task(self.bot.listen()),
|
||||
asyncio.create_task(scheduler.run()),
|
||||
]
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the background tasks and close every resource."""
|
||||
for task in self.tasks:
|
||||
task.cancel()
|
||||
for task in self.tasks:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await self.client.aclose()
|
||||
self.store.close()
|
||||
|
||||
|
||||
async def create_runtime(config: Config, client: httpx.AsyncClient) -> Runtime:
|
||||
"""Build a Runtime, verifying the container exposes a WebSocket first."""
|
||||
store = Store(config.database_path)
|
||||
transport = RestTransport(config, client)
|
||||
await transport.verify_mode()
|
||||
return Runtime(bot=build_bot(config, client, store, transport), client=client, store=store)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Start the background tasks without blocking ASGI startup.
|
||||
|
||||
The previous version awaited asyncio.gather() on two infinite tasks inside
|
||||
an on_event("startup") handler, so uvicorn never finished starting up and
|
||||
the HTTP app never began serving.
|
||||
"""
|
||||
config = load_config()
|
||||
client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS)
|
||||
runtime = await create_runtime(config, client)
|
||||
runtime.start()
|
||||
app.state.runtime = runtime
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness probe. The old app defined no routes at all."""
|
||||
return {"status": "ok"}
|
||||
133
signal_bot/config.py
Normal file
133
signal_bot/config.py
Normal file
@ -0,0 +1,133 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Runtime configuration, read once from the environment and validated eagerly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
DEFAULT_API_BASE_URL: Final = "http://localhost:9922"
|
||||
DEFAULT_DATABASE_PATH: Final = "signal_bot.sqlite3"
|
||||
DEFAULT_REPORT_HOUR: Final = 21
|
||||
DEFAULT_REPORT_MINUTE: Final = 37
|
||||
DEFAULT_COOLDOWN_SECONDS: Final = 10
|
||||
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
|
||||
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
|
||||
MAX_HOUR: Final = 23
|
||||
MAX_MINUTE: Final = 59
|
||||
|
||||
_WS_SCHEMES: Final = {"http": "ws", "https": "wss"}
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when the environment does not describe a runnable bot."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Config:
|
||||
"""Everything the bot needs to know, resolved and validated."""
|
||||
|
||||
phone_number: str
|
||||
group_id: str
|
||||
group_id_send: str
|
||||
api_base_url: str = DEFAULT_API_BASE_URL
|
||||
database_path: str = DEFAULT_DATABASE_PATH
|
||||
report_hour: int = DEFAULT_REPORT_HOUR
|
||||
report_minute: int = DEFAULT_REPORT_MINUTE
|
||||
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS
|
||||
# Overridable so the offline environment can serve fixture images
|
||||
# without the bot knowing it is not talking to the real APIs.
|
||||
cat_api_url: str = DEFAULT_CAT_API_URL
|
||||
dog_api_url: str = DEFAULT_DOG_API_URL
|
||||
|
||||
@property
|
||||
def receive_url(self) -> str:
|
||||
"""WebSocket URL the bot subscribes to for incoming envelopes."""
|
||||
parts = urlsplit(self.api_base_url)
|
||||
scheme = _WS_SCHEMES.get(parts.scheme, parts.scheme)
|
||||
path = f"/v1/receive/{self.phone_number}"
|
||||
query = "send_read_receipts=false"
|
||||
return urlunsplit((scheme, parts.netloc, path, query, ""))
|
||||
|
||||
@property
|
||||
def send_url(self) -> str:
|
||||
"""REST endpoint for outgoing messages and attachments."""
|
||||
return f"{self.api_base_url}/v2/send"
|
||||
|
||||
@property
|
||||
def attachments_url(self) -> str:
|
||||
"""REST endpoint listing server-side stored attachments."""
|
||||
return f"{self.api_base_url}/v1/attachments"
|
||||
|
||||
@property
|
||||
def about_url(self) -> str:
|
||||
"""REST endpoint reporting the container build and its mode."""
|
||||
return f"{self.api_base_url}/v1/about"
|
||||
|
||||
|
||||
def _require(env: dict[str, str], key: str) -> str:
|
||||
"""Return a non-empty environment value or raise ConfigError."""
|
||||
value = env.get(key, "").strip()
|
||||
if not value:
|
||||
msg = (
|
||||
f"{key} is required and must not be empty. "
|
||||
"A linked Signal device sees every chat on the account, so the bot "
|
||||
"refuses to start without an explicit group to scope itself to."
|
||||
)
|
||||
raise ConfigError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def _positive_int(env: dict[str, str], key: str, default: int) -> int:
|
||||
"""Return an int environment value, falling back to default when unset."""
|
||||
raw = env.get(key, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
msg = f"{key} must be an integer, got {raw!r}"
|
||||
raise ConfigError(msg) from None
|
||||
if value < 0:
|
||||
msg = f"{key} must not be negative, got {value}"
|
||||
raise ConfigError(msg)
|
||||
return value
|
||||
|
||||
|
||||
def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config:
|
||||
"""Build a Config from the environment, failing loudly on anything missing.
|
||||
|
||||
Args:
|
||||
env: Mapping to read instead of os.environ. Used by the tests.
|
||||
use_dotenv: Load a local .env file first. Disabled in tests.
|
||||
|
||||
Returns:
|
||||
A validated, immutable Config.
|
||||
"""
|
||||
if env is None:
|
||||
if use_dotenv:
|
||||
load_dotenv()
|
||||
env = dict(os.environ)
|
||||
|
||||
hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR)
|
||||
minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE)
|
||||
if hour > MAX_HOUR or minute > MAX_MINUTE:
|
||||
msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}"
|
||||
raise ConfigError(msg)
|
||||
|
||||
return Config(
|
||||
phone_number=_require(env, "PHONE_NUMBER"),
|
||||
group_id=_require(env, "GROUP_ID"),
|
||||
group_id_send=_require(env, "GROUP_ID_SEND"),
|
||||
api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL,
|
||||
database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH,
|
||||
report_hour=hour,
|
||||
report_minute=minute,
|
||||
cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS),
|
||||
cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL,
|
||||
dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL,
|
||||
)
|
||||
44
signal_bot/counting.py
Normal file
44
signal_bot/counting.py
Normal file
@ -0,0 +1,44 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Which messages count toward the daily tally, and which chats we listen to."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.models import IncomingMessage
|
||||
|
||||
|
||||
def is_watched_group(message: IncomingMessage, config: Config) -> bool:
|
||||
"""True only for messages from the one group the bot is scoped to.
|
||||
|
||||
A linked Signal device receives every direct conversation and every group
|
||||
on the account. This predicate is the entire boundary between "a bot in
|
||||
one group" and "a bot reading all of your chats", so it is deliberately
|
||||
written as an explicit group-id equality rather than a falsy check: a
|
||||
direct message has no group id at all and must never reach a handler.
|
||||
"""
|
||||
if message.group_id is None:
|
||||
return False
|
||||
return message.group_id == config.group_id
|
||||
|
||||
|
||||
def should_count(message: IncomingMessage) -> bool:
|
||||
"""True when a message should add to its sender's tally.
|
||||
|
||||
Reactions and stickers are not messages for tally purposes, and a frame
|
||||
with no text body carries nothing to count.
|
||||
"""
|
||||
if message.is_reaction or message.has_sticker:
|
||||
return False
|
||||
return bool(message.text)
|
||||
|
||||
|
||||
def format_tally(rows: list[tuple[str, int]]) -> str:
|
||||
"""Render the daily report, or a placeholder when nobody spoke."""
|
||||
if not rows:
|
||||
return "Brak wiadomosci dzisiaj."
|
||||
width = max(len(name) for name, _ in rows)
|
||||
lines = [f"{name.ljust(width)} {count}" for name, count in rows]
|
||||
return "\n".join(lines)
|
||||
79
signal_bot/decorators.py
Normal file
79
signal_bot/decorators.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Reusable gates that wrap a command handler.
|
||||
|
||||
These are the mechanisms salvaged from the unmerged vote and rate-limit
|
||||
branches, detached from the NSFW command they were originally written for.
|
||||
They only decide whether a handler may run; recording a successful use is the
|
||||
dispatcher's job, so stacking two gates cannot double-count one invocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
|
||||
from signal_bot.registry import CommandContext, Handler
|
||||
|
||||
Decorator = Callable[[Handler], Handler]
|
||||
|
||||
|
||||
def cooldown(seconds: float, message: str) -> Decorator:
|
||||
"""Refuse a command until `seconds` have passed since that user's last use.
|
||||
|
||||
The original bot shared one cooldown window across every user and every
|
||||
command, so one person's cat picture silenced everybody.
|
||||
"""
|
||||
|
||||
def decorate(handler: Handler) -> Handler:
|
||||
@functools.wraps(handler)
|
||||
async def wrapper(ctx: CommandContext) -> None:
|
||||
last = ctx.store.last_used(ctx.message.source_uuid, ctx.command_name)
|
||||
if last is not None and ctx.now - last < seconds:
|
||||
remaining = seconds - (ctx.now - last)
|
||||
await ctx.reply(message.format(seconds=int(remaining) + 1))
|
||||
return
|
||||
await handler(ctx)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def daily_limit(max_uses: int, message: str) -> Decorator:
|
||||
"""Allow a command at most `max_uses` times per user per calendar day."""
|
||||
|
||||
def decorate(handler: Handler) -> Handler:
|
||||
@functools.wraps(handler)
|
||||
async def wrapper(ctx: CommandContext) -> None:
|
||||
used = ctx.store.uses_today(ctx.message.source_uuid, ctx.command_name, ctx.day)
|
||||
if used >= max_uses:
|
||||
await ctx.reply(message.format(max_uses=max_uses, used=used))
|
||||
return
|
||||
await handler(ctx)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def vote_gated(required: int, window_seconds: float, message: str) -> Decorator:
|
||||
"""Run a command only once `required` distinct people ask within a window.
|
||||
|
||||
A repeat vote from the same person is ignored, and the tally is cleared
|
||||
once the command fires so the next run starts from zero.
|
||||
"""
|
||||
|
||||
def decorate(handler: Handler) -> Handler:
|
||||
@functools.wraps(handler)
|
||||
async def wrapper(ctx: CommandContext) -> None:
|
||||
ctx.store.add_vote(ctx.command_name, ctx.message.source_uuid, ctx.now)
|
||||
votes = ctx.store.count_votes(ctx.command_name, ctx.now - window_seconds)
|
||||
if votes < required:
|
||||
await ctx.reply(message.format(votes=votes, required=required))
|
||||
return
|
||||
ctx.store.clear_votes(ctx.command_name)
|
||||
await handler(ctx)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorate
|
||||
72
signal_bot/dispatcher.py
Normal file
72
signal_bot/dispatcher.py
Normal file
@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Routes one inbound frame: scope it, count it, maybe run a command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.counting import is_watched_group, should_count
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_base import Transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Clock = Callable[[], dt.datetime]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Dispatcher:
|
||||
"""Applies the scoping rules and dispatches commands."""
|
||||
|
||||
config: Config
|
||||
registry: CommandRegistry
|
||||
store: Store
|
||||
transport: Transport
|
||||
clock: Clock
|
||||
|
||||
async def handle(self, frame: str) -> None:
|
||||
"""Process one raw websocket frame end to end."""
|
||||
message = parse_envelope(frame)
|
||||
if message is None:
|
||||
return
|
||||
|
||||
# The scoping gate. Direct messages and every other group the account
|
||||
# belongs to stop here, before counting and before any handler runs.
|
||||
if not is_watched_group(message, self.config):
|
||||
logger.debug("ignoring message outside the watched group")
|
||||
return
|
||||
|
||||
if should_count(message):
|
||||
self.store.increment(message.source_uuid, message.source_name)
|
||||
|
||||
command = self.registry.resolve(message.text)
|
||||
if command is None:
|
||||
return
|
||||
|
||||
now = self.clock()
|
||||
ctx = CommandContext(
|
||||
message=message,
|
||||
recipient=self.config.group_id_send,
|
||||
transport=self.transport,
|
||||
store=self.store,
|
||||
config=self.config,
|
||||
command_name=command.name,
|
||||
now=now.timestamp(),
|
||||
day=now.date().isoformat(),
|
||||
)
|
||||
try:
|
||||
await command.handler(ctx)
|
||||
except Exception:
|
||||
logger.exception("command %s failed", command.name)
|
||||
return
|
||||
# Recorded once, here, so stacked gates cannot double-count a use.
|
||||
self.store.record_use(message.source_uuid, command.name, ctx.now, ctx.day)
|
||||
87
signal_bot/http_client.py
Normal file
87
signal_bot/http_client.py
Normal file
@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Outbound HTTP for the image commands.
|
||||
|
||||
Every outbound request goes through this one injectable client, so the tests
|
||||
and the offline environment can serve fixtures without the bot knowing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ImageFetchError(RuntimeError):
|
||||
"""Raised when an upstream image API did not yield a usable image."""
|
||||
|
||||
|
||||
class ImageFetcher:
|
||||
"""Resolves an image URL from a JSON API and returns it base64-encoded."""
|
||||
|
||||
def __init__(self, client: httpx.AsyncClient) -> None:
|
||||
"""Store the injected HTTP client."""
|
||||
self._client = client
|
||||
|
||||
async def fetch_base64(self, api_url: str, url_path: Sequence[str | int]) -> str:
|
||||
"""Fetch an image and return it as base64.
|
||||
|
||||
Args:
|
||||
api_url: JSON endpoint that names an image.
|
||||
url_path: Keys/indices to walk in the JSON to reach the image URL.
|
||||
|
||||
Returns:
|
||||
The image bytes, base64-encoded.
|
||||
|
||||
Raises:
|
||||
ImageFetchError: The JSON did not contain a usable URL.
|
||||
"""
|
||||
response = await self._client.get(api_url)
|
||||
response.raise_for_status()
|
||||
image_url = _walk(response.json(), url_path)
|
||||
|
||||
# Never written to disk: the original bot named a local file after the
|
||||
# remote URL's last path segment and wrote it into the process CWD.
|
||||
image = await self._client.get(image_url)
|
||||
image.raise_for_status()
|
||||
return base64.b64encode(image.content).decode("ascii")
|
||||
|
||||
|
||||
_MISSING: Final = object()
|
||||
|
||||
|
||||
def _in_range(items: list[object], index: int) -> bool:
|
||||
"""True when index addresses a real element, counting from either end."""
|
||||
return -len(items) <= index < len(items)
|
||||
|
||||
|
||||
def _step(node: object, key: str | int, path: Sequence[str | int]) -> object:
|
||||
"""Descend one level into a JSON document.
|
||||
|
||||
Narrows explicitly instead of indexing blindly, so a malformed upstream
|
||||
response raises ImageFetchError rather than a TypeError from deeper in.
|
||||
"""
|
||||
found: object = _MISSING
|
||||
if isinstance(key, str) and isinstance(node, dict):
|
||||
found = node.get(key, _MISSING)
|
||||
elif isinstance(key, int) and isinstance(node, list) and _in_range(node, key):
|
||||
found = node[key]
|
||||
if found is _MISSING:
|
||||
msg = f"image API response has no element at {path!r}"
|
||||
raise ImageFetchError(msg)
|
||||
return found
|
||||
|
||||
|
||||
def _walk(document: object, path: Sequence[str | int]) -> str:
|
||||
"""Walk a JSON document along path and return the string found there."""
|
||||
node: object = document
|
||||
for key in path:
|
||||
node = _step(node, key, path)
|
||||
if not isinstance(node, str) or not node:
|
||||
msg = f"image API element at {path!r} is not a URL: {node!r}"
|
||||
raise ImageFetchError(msg)
|
||||
return node
|
||||
105
signal_bot/models.py
Normal file
105
signal_bot/models.py
Normal file
@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Parsing of signal-cli-rest-api envelopes into a flat, typed message."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IncomingMessage:
|
||||
"""One inbound Signal message, normalised across envelope shapes.
|
||||
|
||||
signal-cli reports messages from other people under ``dataMessage`` and
|
||||
the account's own outgoing messages under ``syncMessage.sentMessage``.
|
||||
The two carry the same fields at different depths; everything downstream
|
||||
works on this flattened form instead of re-deriving the nesting.
|
||||
"""
|
||||
|
||||
source_uuid: str
|
||||
source_name: str
|
||||
group_id: str | None
|
||||
text: str | None
|
||||
has_sticker: bool
|
||||
is_reaction: bool
|
||||
|
||||
@property
|
||||
def is_direct_message(self) -> bool:
|
||||
"""True when the message did not arrive via a group.
|
||||
|
||||
A linked device receives every direct conversation on the account, so
|
||||
this is the discriminator that keeps the bot scoped to its group.
|
||||
"""
|
||||
return self.group_id is None
|
||||
|
||||
|
||||
def _payload(envelope: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return the dataMessage or sentMessage body, whichever is present."""
|
||||
data = envelope.get("dataMessage")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
sync = envelope.get("syncMessage")
|
||||
if isinstance(sync, dict):
|
||||
sent = sync.get("sentMessage")
|
||||
if isinstance(sent, dict):
|
||||
return sent
|
||||
return None
|
||||
|
||||
|
||||
def _group_id(payload: dict[str, Any]) -> str | None:
|
||||
"""Return the group id, or None for a direct message."""
|
||||
info = payload.get("groupInfo")
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
group_id = info.get("groupId")
|
||||
if not isinstance(group_id, str) or not group_id:
|
||||
return None
|
||||
return group_id
|
||||
|
||||
|
||||
def _text(payload: dict[str, Any]) -> str | None:
|
||||
"""Return the message body, or None when there is no text."""
|
||||
message = payload.get("message")
|
||||
if not isinstance(message, str):
|
||||
return None
|
||||
return message
|
||||
|
||||
|
||||
def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
|
||||
"""Parse one raw websocket frame.
|
||||
|
||||
Args:
|
||||
raw: The JSON frame exactly as received.
|
||||
|
||||
Returns:
|
||||
The normalised message, or None when the frame carries no message
|
||||
body at all (receipts, typing indicators, malformed JSON).
|
||||
"""
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except json.JSONDecodeError, UnicodeDecodeError:
|
||||
return None
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
|
||||
envelope = decoded.get("envelope")
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
|
||||
payload = _payload(envelope)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
return IncomingMessage(
|
||||
source_uuid=str(envelope.get("sourceUuid", "")),
|
||||
source_name=str(envelope.get("sourceName", "")),
|
||||
group_id=_group_id(payload),
|
||||
text=_text(payload),
|
||||
# The original bot looked for the sticker under dataMessage even when
|
||||
# the envelope was a syncMessage, so its own stickers were never
|
||||
# filtered. Reading it off the resolved payload fixes both shapes.
|
||||
has_sticker=isinstance(payload.get("sticker"), dict),
|
||||
is_reaction=isinstance(payload.get("reaction"), dict),
|
||||
)
|
||||
95
signal_bot/registry.py
Normal file
95
signal_bot/registry.py
Normal file
@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Command lookup: trigger words to handlers.
|
||||
|
||||
Reimplements the idea behind the unmerged ``rules.py`` branch: triggers are
|
||||
data, not a chain of if-statements, so aliases (including the Unicode-styled
|
||||
ones people actually type) cost one tuple entry each.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.models import IncomingMessage
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_base import Transport
|
||||
|
||||
COMMAND_PREFIX = "!"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommandContext:
|
||||
"""Everything a command handler is allowed to touch."""
|
||||
|
||||
message: IncomingMessage
|
||||
recipient: str
|
||||
transport: Transport
|
||||
store: Store
|
||||
config: Config
|
||||
command_name: str
|
||||
now: float
|
||||
day: str
|
||||
|
||||
async def reply(self, text: str) -> None:
|
||||
"""Send a text reply to the configured reply target."""
|
||||
await self.transport.send_text(text, self.recipient)
|
||||
|
||||
|
||||
Handler = Callable[[CommandContext], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Command:
|
||||
"""A named command and every phrase that triggers it."""
|
||||
|
||||
name: str
|
||||
triggers: frozenset[str]
|
||||
handler: Handler
|
||||
|
||||
|
||||
class DuplicateTriggerError(ValueError):
|
||||
"""Raised when two commands claim the same trigger word."""
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
"""Maps trigger words to commands."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start with no commands registered."""
|
||||
self._by_trigger: dict[str, Command] = {}
|
||||
self._commands: list[Command] = []
|
||||
|
||||
def register(self, name: str, triggers: Iterable[str], handler: Handler) -> Command:
|
||||
"""Register one command under every one of its triggers.
|
||||
|
||||
Raises:
|
||||
DuplicateTriggerError: A trigger is already claimed.
|
||||
"""
|
||||
normalised = frozenset(t.strip().lower() for t in triggers)
|
||||
for trigger in sorted(normalised):
|
||||
if trigger in self._by_trigger:
|
||||
msg = f"trigger {trigger!r} is already registered"
|
||||
raise DuplicateTriggerError(msg)
|
||||
command = Command(name=name, triggers=normalised, handler=handler)
|
||||
for trigger in normalised:
|
||||
self._by_trigger[trigger] = command
|
||||
self._commands.append(command)
|
||||
return command
|
||||
|
||||
def resolve(self, text: str | None) -> Command | None:
|
||||
"""Return the command a message body triggers, or None."""
|
||||
if not text:
|
||||
return None
|
||||
candidate = text.strip().lower()
|
||||
if not candidate.startswith(COMMAND_PREFIX):
|
||||
return None
|
||||
return self._by_trigger.get(candidate)
|
||||
|
||||
@property
|
||||
def commands(self) -> list[Command]:
|
||||
"""Every registered command, in registration order."""
|
||||
return list(self._commands)
|
||||
66
signal_bot/scheduler.py
Normal file
66
signal_bot/scheduler.py
Normal file
@ -0,0 +1,66 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The daily tally report.
|
||||
|
||||
Time is injected so the tests assert on the computed interval instead of
|
||||
sleeping through it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Clock = Callable[[], dt.datetime]
|
||||
Sleeper = Callable[[float], Awaitable[None]]
|
||||
Action = Callable[[], Awaitable[None]]
|
||||
|
||||
SECONDS_PER_DAY = 24 * 60 * 60
|
||||
|
||||
|
||||
class DailyScheduler:
|
||||
"""Runs one action at a fixed local wall-clock time, every day."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hour: int,
|
||||
minute: int,
|
||||
action: Action,
|
||||
clock: Clock,
|
||||
sleeper: Sleeper,
|
||||
) -> None:
|
||||
"""Store the target time, the action, and the injected time sources."""
|
||||
self._hour = hour
|
||||
self._minute = minute
|
||||
self._action = action
|
||||
self._clock = clock
|
||||
self._sleep = sleeper
|
||||
|
||||
def seconds_until_next(self, now: dt.datetime) -> float:
|
||||
"""Seconds from now until the next occurrence of the target time.
|
||||
|
||||
Rolls over to tomorrow when today's target has already passed, so the
|
||||
result is always strictly positive.
|
||||
"""
|
||||
target = now.replace(hour=self._hour, minute=self._minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += dt.timedelta(days=1)
|
||||
return (target - now).total_seconds()
|
||||
|
||||
async def run(self, iterations: int | None = None) -> None:
|
||||
"""Sleep until the target time and run the action, forever by default.
|
||||
|
||||
Args:
|
||||
iterations: Stop after this many runs. None means run forever.
|
||||
The tests use a finite count; production passes None.
|
||||
"""
|
||||
completed = 0
|
||||
while iterations is None or completed < iterations:
|
||||
await self._sleep(self.seconds_until_next(self._clock()))
|
||||
try:
|
||||
await self._action()
|
||||
except Exception:
|
||||
logger.exception("daily report failed")
|
||||
completed += 1
|
||||
179
signal_bot/store.py
Normal file
179
signal_bot/store.py
Normal file
@ -0,0 +1,179 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""SQLite persistence for tallies, cooldowns, daily limits and votes.
|
||||
|
||||
Everything the bot remembers lives here so a restart before the daily report
|
||||
does not lose the day. The original bot kept all of it in module globals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Self
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from types import TracebackType
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS message_counts (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS command_uses (
|
||||
user_uuid TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
used_at REAL NOT NULL,
|
||||
PRIMARY KEY (user_uuid, command)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS daily_uses (
|
||||
user_uuid TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_uuid, command, day)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
command TEXT NOT NULL,
|
||||
voter_uuid TEXT NOT NULL,
|
||||
voted_at REAL NOT NULL,
|
||||
PRIMARY KEY (command, voter_uuid)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Tally:
|
||||
"""One person's message count for the current reporting period."""
|
||||
|
||||
uuid: str
|
||||
display_name: str
|
||||
count: int
|
||||
|
||||
|
||||
class Store:
|
||||
"""Thin, synchronous SQLite wrapper. All state the bot keeps lives here."""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
"""Open (or create) the database at path and apply the schema."""
|
||||
self._conn = sqlite3.connect(path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Enter a context manager that closes the connection on exit."""
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Close the underlying connection."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying connection."""
|
||||
self._conn.close()
|
||||
|
||||
# -- message tallies ---------------------------------------------------
|
||||
|
||||
def increment(self, uuid: str, display_name: str) -> None:
|
||||
"""Add one to a person's tally, creating the row when new."""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO message_counts (uuid, display_name, count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
count = count + 1,
|
||||
display_name = excluded.display_name
|
||||
""",
|
||||
(uuid, display_name),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def tallies(self) -> list[Tally]:
|
||||
"""Return every tally, highest count first."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT uuid, display_name, count FROM message_counts "
|
||||
"ORDER BY count DESC, display_name ASC"
|
||||
).fetchall()
|
||||
return [Tally(r["uuid"], r["display_name"], r["count"]) for r in rows]
|
||||
|
||||
def reset_tallies(self) -> None:
|
||||
"""Clear every tally, as the daily report does after publishing."""
|
||||
self._conn.execute("DELETE FROM message_counts")
|
||||
self._conn.commit()
|
||||
|
||||
# -- per-user, per-command rate limiting -------------------------------
|
||||
|
||||
def record_use(self, user_uuid: str, command: str, now: float, day: str) -> None:
|
||||
"""Record one successful use for both cooldown and daily-limit checks."""
|
||||
self._conn.execute(
|
||||
"INSERT INTO command_uses (user_uuid, command, used_at) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(user_uuid, command) DO UPDATE SET used_at = excluded.used_at",
|
||||
(user_uuid, command, now),
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO daily_uses (user_uuid, command, day, uses)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON CONFLICT(user_uuid, command, day) DO UPDATE SET uses = uses + 1
|
||||
""",
|
||||
(user_uuid, command, day),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def last_used(self, user_uuid: str, command: str) -> float | None:
|
||||
"""Return when this user last ran this command, or None."""
|
||||
row = self._conn.execute(
|
||||
"SELECT used_at FROM command_uses WHERE user_uuid = ? AND command = ?",
|
||||
(user_uuid, command),
|
||||
).fetchone()
|
||||
return None if row is None else float(row["used_at"])
|
||||
|
||||
def uses_today(self, user_uuid: str, command: str, day: str) -> int:
|
||||
"""Return how many times this user ran this command on the given day."""
|
||||
row = self._conn.execute(
|
||||
"SELECT uses FROM daily_uses WHERE user_uuid = ? AND command = ? AND day = ?",
|
||||
(user_uuid, command, day),
|
||||
).fetchone()
|
||||
return 0 if row is None else int(row["uses"])
|
||||
|
||||
# -- vote gating -------------------------------------------------------
|
||||
|
||||
def add_vote(self, command: str, voter_uuid: str, now: float) -> None:
|
||||
"""Record one vote, ignoring a repeat vote from the same person."""
|
||||
self._conn.execute(
|
||||
"INSERT INTO votes (command, voter_uuid, voted_at) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(command, voter_uuid) DO NOTHING",
|
||||
(command, voter_uuid, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def count_votes(self, command: str, not_before: float) -> int:
|
||||
"""Return how many votes for command are still inside the window."""
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM votes WHERE command = ? AND voted_at >= ?",
|
||||
(command, not_before),
|
||||
).fetchone()
|
||||
return int(row["n"])
|
||||
|
||||
def clear_votes(self, command: str) -> None:
|
||||
"""Drop every vote for a command, after it fires or expires."""
|
||||
self._conn.execute("DELETE FROM votes WHERE command = ?", (command,))
|
||||
self._conn.commit()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_store(path: str) -> Iterator[Store]:
|
||||
"""Open a Store as a context manager."""
|
||||
store = Store(path)
|
||||
try:
|
||||
yield store
|
||||
finally:
|
||||
store.close()
|
||||
34
signal_bot/transport_base.py
Normal file
34
signal_bot/transport_base.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The seam between the bot and Signal.
|
||||
|
||||
Everything above this interface is transport-agnostic, which is what lets the
|
||||
same bot code run against the real signal-cli-rest-api container and against
|
||||
the local fake used by the tests and the end-to-end script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""What the bot needs from a Signal backend."""
|
||||
|
||||
def receive(self) -> AsyncIterator[str]:
|
||||
"""Yield raw envelope frames until the transport is closed."""
|
||||
|
||||
async def send_text(self, text: str, recipient: str) -> None:
|
||||
"""Send a plain-text message to a group id or phone number."""
|
||||
|
||||
async def send_image(self, image_base64: str, recipient: str) -> None:
|
||||
"""Send a base64-encoded image attachment to a recipient."""
|
||||
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the ids of attachments stored server-side."""
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Delete one stored attachment by id."""
|
||||
64
signal_bot/transport_fake.py
Normal file
64
signal_bot/transport_fake.py
Normal file
@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""In-process transport double used by the unit tests.
|
||||
|
||||
This is the fast path: no sockets, no server, no event loop scheduling games.
|
||||
The protocol-faithful HTTP/WebSocket fake lives in the ``fake_signal`` package
|
||||
and is what the end-to-end script drives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentMessage:
|
||||
"""One outbound call captured by the fake."""
|
||||
|
||||
recipient: str
|
||||
text: str | None = None
|
||||
image_base64: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTransport:
|
||||
"""Replays canned frames and records everything the bot sends."""
|
||||
|
||||
frames: list[str] = field(default_factory=list)
|
||||
sent: list[SentMessage] = field(default_factory=list)
|
||||
attachments: list[str] = field(default_factory=list)
|
||||
deleted: list[str] = field(default_factory=list)
|
||||
|
||||
def queue(self, frames: Iterable[str]) -> None:
|
||||
"""Add frames to be yielded by the next receive() iteration."""
|
||||
self.frames.extend(frames)
|
||||
|
||||
async def receive(self) -> AsyncIterator[str]:
|
||||
"""Yield every queued frame once, then stop."""
|
||||
for frame in list(self.frames):
|
||||
yield frame
|
||||
|
||||
async def send_text(self, text: str, recipient: str) -> None:
|
||||
"""Record a text send."""
|
||||
self.sent.append(SentMessage(recipient=recipient, text=text))
|
||||
|
||||
async def send_image(self, image_base64: str, recipient: str) -> None:
|
||||
"""Record an image send."""
|
||||
self.sent.append(SentMessage(recipient=recipient, image_base64=image_base64))
|
||||
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the configured attachment ids."""
|
||||
return list(self.attachments)
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Record an attachment deletion."""
|
||||
self.deleted.append(attachment_id)
|
||||
|
||||
@property
|
||||
def texts(self) -> list[str]:
|
||||
"""Every text body sent so far, in order."""
|
||||
return [m.text for m in self.sent if m.text is not None]
|
||||
145
signal_bot/transport_rest.py
Normal file
145
signal_bot/transport_rest.py
Normal file
@ -0,0 +1,145 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Transport adapter for bbernhard/signal-cli-rest-api.
|
||||
|
||||
Receive is a WebSocket, which the container only exposes when it runs in one
|
||||
of the json-rpc modes; in normal/native mode the same path silently degrades
|
||||
to a polling GET, so the mode is verified at startup rather than assumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from websockets.asyncio.client import connect as ws_connect
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from signal_bot.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INITIAL_BACKOFF: Final = 1.0
|
||||
MAX_BACKOFF: Final = 30.0
|
||||
BACKOFF_FACTOR: Final = 2.0
|
||||
_OK_STATUSES: Final = frozenset({200, 201, 204})
|
||||
|
||||
Connector = Callable[[str], Any]
|
||||
Sleeper = Callable[[float], Awaitable[None]]
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""Raised when the Signal REST API rejects a request."""
|
||||
|
||||
|
||||
class RestTransport:
|
||||
"""Talks to the signal-cli-rest-api container over REST plus a WebSocket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
connector: Connector | None = None,
|
||||
sleeper: Sleeper | None = None,
|
||||
max_reconnects: int | None = None,
|
||||
) -> None:
|
||||
"""Wire the adapter to its config and its injectable I/O.
|
||||
|
||||
Args:
|
||||
config: Resolved runtime configuration.
|
||||
client: HTTP client used for every REST call.
|
||||
connector: WebSocket connect callable. Injected by the tests.
|
||||
sleeper: Async sleep used for reconnect backoff.
|
||||
max_reconnects: Stop after this many reconnects. None means forever.
|
||||
"""
|
||||
self._config = config
|
||||
self._client = client
|
||||
self._connect = connector if connector is not None else ws_connect
|
||||
self._sleep = sleeper if sleeper is not None else asyncio.sleep
|
||||
self._max_reconnects = max_reconnects
|
||||
|
||||
async def verify_mode(self) -> str:
|
||||
"""Return the container's mode, raising unless it exposes a WebSocket.
|
||||
|
||||
Raises:
|
||||
TransportError: The container is not running a json-rpc mode.
|
||||
"""
|
||||
response = await self._client.get(self._config.about_url)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
mode = str(body.get("mode", "")) if isinstance(body, dict) else ""
|
||||
if "json-rpc" not in mode:
|
||||
msg = (
|
||||
f"signal-cli-rest-api is running in mode {mode!r}; the receive "
|
||||
"endpoint is only a WebSocket in a json-rpc mode. Start the "
|
||||
"container with MODE=json-rpc-native."
|
||||
)
|
||||
raise TransportError(msg)
|
||||
return mode
|
||||
|
||||
async def receive(self) -> AsyncIterator[str]:
|
||||
"""Yield raw envelope frames, reconnecting with backoff when dropped."""
|
||||
backoff = INITIAL_BACKOFF
|
||||
reconnects = 0
|
||||
while True:
|
||||
try:
|
||||
async with self._connect(self._config.receive_url) as socket:
|
||||
backoff = INITIAL_BACKOFF
|
||||
async for frame in socket:
|
||||
yield frame if isinstance(frame, str) else frame.decode()
|
||||
except (WebSocketException, OSError) as exc:
|
||||
logger.warning("signal websocket dropped: %s", exc)
|
||||
if self._max_reconnects is not None and reconnects >= self._max_reconnects:
|
||||
return
|
||||
reconnects += 1
|
||||
await self._sleep(backoff)
|
||||
backoff = min(backoff * BACKOFF_FACTOR, MAX_BACKOFF)
|
||||
|
||||
async def _post_send(self, payload: dict[str, Any]) -> None:
|
||||
"""POST one send payload and raise unless the API accepted it."""
|
||||
response = await self._client.post(self._config.send_url, json=payload)
|
||||
if response.status_code not in _OK_STATUSES:
|
||||
msg = f"send failed with HTTP {response.status_code}: {response.text}"
|
||||
raise TransportError(msg)
|
||||
|
||||
async def send_text(self, text: str, recipient: str) -> None:
|
||||
"""Send a plain-text message to a group id or phone number."""
|
||||
await self._post_send(
|
||||
{
|
||||
"message": text,
|
||||
"number": self._config.phone_number,
|
||||
"recipients": [recipient],
|
||||
}
|
||||
)
|
||||
|
||||
async def send_image(self, image_base64: str, recipient: str) -> None:
|
||||
"""Send a base64-encoded image attachment to a recipient."""
|
||||
await self._post_send(
|
||||
{
|
||||
"message": "",
|
||||
"base64_attachments": [image_base64],
|
||||
"number": self._config.phone_number,
|
||||
"recipients": [recipient],
|
||||
}
|
||||
)
|
||||
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the ids of attachments stored server-side."""
|
||||
response = await self._client.get(self._config.attachments_url)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
if not isinstance(body, list):
|
||||
return []
|
||||
return [str(item) for item in body]
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Delete one stored attachment by id."""
|
||||
response = await self._client.delete(f"{self._config.attachments_url}/{attachment_id}")
|
||||
if response.status_code not in _OK_STATUSES:
|
||||
msg = f"attachment delete failed with HTTP {response.status_code}"
|
||||
raise TransportError(msg)
|
||||
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Test suite."""
|
||||
81
tests/conftest.py
Normal file
81
tests/conftest.py
Normal file
@ -0,0 +1,81 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Shared fixtures and envelope builders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
WATCHED_GROUP = "group-watched=="
|
||||
REPLY_GROUP = "group-reply=="
|
||||
OTHER_GROUP = "group-other=="
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> Config:
|
||||
return Config(
|
||||
phone_number="+15550000000",
|
||||
group_id=WATCHED_GROUP,
|
||||
group_id_send=REPLY_GROUP,
|
||||
api_base_url="http://localhost:9922",
|
||||
database_path=":memory:",
|
||||
cooldown_seconds=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> Store:
|
||||
with Store(":memory:") as opened:
|
||||
yield opened
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport() -> FakeTransport:
|
||||
return FakeTransport()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> dt.datetime:
|
||||
return dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC)
|
||||
|
||||
|
||||
def envelope(
|
||||
*,
|
||||
text: str | None = "hello",
|
||||
group_id: str | None = WATCHED_GROUP,
|
||||
uuid: str = "uuid-alice",
|
||||
name: str = "Alice",
|
||||
sticker: bool = False,
|
||||
reaction: bool = False,
|
||||
sync: bool = False,
|
||||
) -> str:
|
||||
"""Build one raw websocket frame in either envelope shape."""
|
||||
payload: dict[str, Any] = {"timestamp": 1719177917717}
|
||||
if text is not None:
|
||||
payload["message"] = text
|
||||
if group_id is not None:
|
||||
payload["groupInfo"] = {"groupId": group_id, "type": "DELIVER"}
|
||||
if sticker:
|
||||
payload["sticker"] = {"packId": "abc", "stickerId": 6}
|
||||
if reaction:
|
||||
payload["reaction"] = {"emoji": "👍", "targetAuthor": "x"}
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"source": name,
|
||||
"sourceUuid": uuid,
|
||||
"sourceName": name,
|
||||
"sourceDevice": 69,
|
||||
"timestamp": 1719177917717,
|
||||
}
|
||||
if sync:
|
||||
body["syncMessage"] = {"sentMessage": payload}
|
||||
else:
|
||||
body["dataMessage"] = payload
|
||||
return json.dumps({"envelope": body, "account": "+15550000000"})
|
||||
111
tests/test_animals.py
Normal file
111
tests/test_animals.py
Normal file
@ -0,0 +1,111 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The cat and dog commands, and their data-driven aliases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from signal_bot.animals import (
|
||||
ALIAS_FILE,
|
||||
load_aliases,
|
||||
register_animal_commands,
|
||||
)
|
||||
from signal_bot.decorators import cooldown
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from tests.conftest import REPLY_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
IMAGE = b"png-bytes"
|
||||
|
||||
|
||||
def _fetcher() -> ImageFetcher:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "thecatapi" in str(request.url):
|
||||
return httpx.Response(200, json=[{"url": "https://cdn/cat.png"}])
|
||||
if "dog.ceo" in str(request.url):
|
||||
return httpx.Response(200, json={"message": "https://cdn/dog.png"})
|
||||
return httpx.Response(200, content=IMAGE)
|
||||
|
||||
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
def _ctx(
|
||||
transport: FakeTransport, store: Store, config: Config, name: str, text: str
|
||||
) -> CommandContext:
|
||||
message = parse_envelope(envelope(text=text))
|
||||
assert message is not None
|
||||
return CommandContext(
|
||||
message=message,
|
||||
recipient=REPLY_GROUP,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name=name,
|
||||
now=100.0,
|
||||
day="2026-08-29",
|
||||
)
|
||||
|
||||
|
||||
def test_alias_file_ships_with_the_package() -> None:
|
||||
assert ALIAS_FILE.is_file()
|
||||
aliases = load_aliases()
|
||||
assert "!cat" in aliases["cat"]
|
||||
assert "!pies" in aliases["dog"]
|
||||
|
||||
|
||||
def test_comment_keys_are_skipped(tmp_path: Path) -> None:
|
||||
path = tmp_path / "a.json"
|
||||
path.write_text(json.dumps({"_comment": "x", "cat": ["!cat"]}), encoding="utf-8")
|
||||
assert load_aliases(path) == {"cat": ["!cat"]}
|
||||
|
||||
|
||||
def test_styled_unicode_aliases_resolve() -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher())
|
||||
assert registry.resolve("!\U0001d5f8\U0001d5fc\U0001d601") is not None
|
||||
assert registry.resolve("!miau").name == "cat"
|
||||
assert registry.resolve("!szczek").name == "dog"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("trigger", "name"), [("!cat", "cat"), ("!dog", "dog")])
|
||||
async def test_commands_post_an_image(
|
||||
transport: FakeTransport, store: Store, config: Config, trigger: str, name: str
|
||||
) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher())
|
||||
command = registry.resolve(trigger)
|
||||
assert command is not None
|
||||
await command.handler(_ctx(transport, store, config, name, trigger))
|
||||
assert transport.sent[0].image_base64 is not None
|
||||
assert transport.sent[0].recipient == REPLY_GROUP
|
||||
|
||||
|
||||
async def test_decorator_is_applied_when_given(
|
||||
transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher(), decorate=cooldown(10, "wait {seconds}"))
|
||||
store.record_use("uuid-alice", "cat", 100.0, "2026-08-29")
|
||||
command = registry.resolve("!cat")
|
||||
assert command is not None
|
||||
await command.handler(_ctx(transport, store, config, "cat", "!cat"))
|
||||
assert transport.texts == ["wait 11"]
|
||||
|
||||
|
||||
def test_custom_alias_table_is_honoured() -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher(), aliases={"cat": ["!c"], "dog": ["!d"]})
|
||||
assert registry.resolve("!c").name == "cat"
|
||||
assert registry.resolve("!cat") is None
|
||||
134
tests/test_app.py
Normal file
134
tests/test_app.py
Normal file
@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Wiring, the background loops, and ASGI startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from signal_bot import app as app_module
|
||||
from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local
|
||||
from signal_bot.transport_rest import TransportError
|
||||
from tests.conftest import REPLY_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.registry import Handler
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
BASE_ENV = {
|
||||
"PHONE_NUMBER": "+15550000000",
|
||||
"GROUP_ID": "watched==",
|
||||
"GROUP_ID_SEND": "reply==",
|
||||
"DATABASE_PATH": ":memory:",
|
||||
}
|
||||
|
||||
|
||||
# Captured before any monkeypatching, so a patched httpx.AsyncClient cannot
|
||||
# recurse into this helper.
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _client(handler: Handler) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _bot(config: Config, store: Store, transport: FakeTransport) -> Bot:
|
||||
return build_bot(config, _client(lambda r: httpx.Response(200)), store, transport)
|
||||
|
||||
|
||||
def test_now_local_is_timezone_aware() -> None:
|
||||
assert now_local().tzinfo is not None
|
||||
|
||||
|
||||
def test_build_bot_registers_the_animal_commands(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
bot = _bot(config, store, transport)
|
||||
assert bot.dispatcher.registry.resolve("!cat") is not None
|
||||
assert bot.dispatcher.registry.resolve("!pies") is not None
|
||||
|
||||
|
||||
async def test_publish_report_posts_and_resets(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
store.increment("u1", "Alice")
|
||||
await _bot(config, store, transport).publish_report()
|
||||
assert transport.texts == ["Alice 1"]
|
||||
assert transport.sent[0].recipient == REPLY_GROUP
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
async def test_publish_report_falls_back_to_uuid_when_unnamed(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
store.increment("u1", "")
|
||||
await _bot(config, store, transport).publish_report()
|
||||
assert transport.texts == ["u1 1"]
|
||||
|
||||
|
||||
async def test_listen_dispatches_every_frame(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
transport.queue([envelope(text="hello", group_id=config.group_id)])
|
||||
await _bot(config, store, transport).listen()
|
||||
assert store.tallies()[0].count == 1
|
||||
|
||||
|
||||
async def test_create_runtime_verifies_the_container_mode(config: Config) -> None:
|
||||
runtime = await create_runtime(
|
||||
config, _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
|
||||
)
|
||||
assert isinstance(runtime, Runtime)
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
async def test_create_runtime_refuses_a_polling_container(config: Config) -> None:
|
||||
with pytest.raises(TransportError):
|
||||
await create_runtime(
|
||||
config, _client(lambda r: httpx.Response(200, json={"mode": "normal"}))
|
||||
)
|
||||
|
||||
|
||||
async def test_runtime_start_and_stop_cancels_tasks(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
client = _client(lambda r: httpx.Response(200))
|
||||
runtime = Runtime(bot=_bot(config, store, transport), client=client, store=store)
|
||||
runtime.start()
|
||||
assert len(runtime.tasks) == 2
|
||||
await asyncio.sleep(0)
|
||||
await runtime.stop()
|
||||
assert all(task.done() for task in runtime.tasks)
|
||||
|
||||
|
||||
def test_healthz_serves_and_startup_completes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The old app never finished startup, so no route was ever reachable."""
|
||||
for key, value in BASE_ENV.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
def fake_client(**kwargs: object) -> httpx.AsyncClient:
|
||||
return _client(lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
|
||||
|
||||
monkeypatch.setattr(app_module.httpx, "AsyncClient", fake_client)
|
||||
with TestClient(app_module.app) as client:
|
||||
assert client.get("/healthz").json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_lifespan_is_an_async_context_manager(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
for key, value in BASE_ENV.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
monkeypatch.setattr(
|
||||
app_module.httpx,
|
||||
"AsyncClient",
|
||||
lambda **kwargs: _client(lambda r: httpx.Response(200, json={"mode": "json-rpc"})),
|
||||
)
|
||||
async with lifespan(app_module.app):
|
||||
assert app_module.app.state.runtime is not None
|
||||
118
tests/test_cli.py
Normal file
118
tests/test_cli.py
Normal file
@ -0,0 +1,118 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The scenario injector CLI, including its exit codes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fake_signal import cli
|
||||
from fake_signal.scenario import Expectation, Injection, Scenario
|
||||
|
||||
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Stands in for the fake server's control plane."""
|
||||
|
||||
def __init__(self, sent: list[dict]) -> None:
|
||||
self.sent = sent
|
||||
self.injected: list[str] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path == "/_control/inject":
|
||||
self.injected.append(request.content.decode())
|
||||
return httpx.Response(200, json={"delivered": 1})
|
||||
if path == "/_control/sent":
|
||||
return httpx.Response(200, json=self.sent)
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
|
||||
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: Recorder) -> None:
|
||||
real = httpx.AsyncClient
|
||||
|
||||
def factory(**kwargs: object) -> httpx.AsyncClient:
|
||||
# base_url must survive: run_scenario uses relative paths.
|
||||
return real(
|
||||
transport=httpx.MockTransport(recorder),
|
||||
base_url=str(kwargs.get("base_url", "http://x")),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli.httpx, "AsyncClient", factory)
|
||||
|
||||
|
||||
async def test_run_scenario_injects_and_passes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder = Recorder([{"base64_attachments": ["A"], "recipients": ["R=="]}])
|
||||
_patch_client(monkeypatch, recorder)
|
||||
scenario = Scenario("s", inject=[Injection(text="!cat")], expect=[Expectation(kind="image")])
|
||||
failures = await cli.run_scenario("http://x", scenario, GROUPS, settle=0)
|
||||
assert failures == []
|
||||
assert "!cat" in recorder.injected[0]
|
||||
|
||||
|
||||
async def test_run_scenario_reports_a_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_client(monkeypatch, Recorder([]))
|
||||
scenario = Scenario("s", expect=[Expectation(kind="image")])
|
||||
assert await cli.run_scenario("http://x", scenario, GROUPS, settle=0)
|
||||
|
||||
|
||||
def test_parse_args_requires_the_group_ids() -> None:
|
||||
args = cli.parse_args(["a.yaml", "--group-id", "W==", "--group-id-send", "R=="])
|
||||
assert args.scenarios == [Path("a.yaml")]
|
||||
assert args.group_id == "W=="
|
||||
|
||||
|
||||
def test_parse_args_rejects_missing_groups() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
cli.parse_args(["a.yaml"])
|
||||
|
||||
|
||||
def _scenario_file(tmp_path: Path, expect: str) -> Path:
|
||||
path = tmp_path / "s.yaml"
|
||||
path.write_text(f"name: demo\ninject:\n - {{text: '!cat'}}\n{expect}", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_main_exits_zero_when_every_scenario_passes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
_patch_client(monkeypatch, Recorder([{"base64_attachments": ["A"], "recipients": ["R=="]}]))
|
||||
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
|
||||
code = cli.main([str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"])
|
||||
assert code == 0
|
||||
assert "ok demo" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_exits_non_zero_on_a_mismatch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The e2e script is a gate only because this returns 1."""
|
||||
_patch_client(monkeypatch, Recorder([]))
|
||||
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
|
||||
code = cli.main([str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"])
|
||||
assert code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "FAIL demo" in out
|
||||
|
||||
|
||||
def test_module_runs_as_a_script(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`python -m fake_signal.cli` must exit with the adjudication code.
|
||||
|
||||
runpy re-imports into a fresh namespace, so the patch has to land on the
|
||||
shared httpx module rather than on fake_signal.cli.
|
||||
"""
|
||||
_patch_client(monkeypatch, Recorder([]))
|
||||
path = _scenario_file(tmp_path, "expect:\n - {kind: image}\n")
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["cli", str(path), "--group-id", "W==", "--group-id-send", "R==", "--settle", "0"],
|
||||
)
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
runpy.run_module("fake_signal.cli", run_name="__main__", alter_sys=True)
|
||||
assert exit_info.value.code == 1
|
||||
105
tests/test_config.py
Normal file
105
tests/test_config.py
Normal file
@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Configuration loading, validation and URL derivation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.config import Config, ConfigError, load_config
|
||||
|
||||
BASE_ENV = {
|
||||
"PHONE_NUMBER": "+15550000000",
|
||||
"GROUP_ID": "watched==",
|
||||
"GROUP_ID_SEND": "reply==",
|
||||
}
|
||||
|
||||
|
||||
def test_loads_minimal_environment() -> None:
|
||||
config = load_config(dict(BASE_ENV))
|
||||
assert config.phone_number == "+15550000000"
|
||||
assert config.api_base_url == "http://localhost:9922"
|
||||
assert config.report_hour == 21
|
||||
assert config.report_minute == 37
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
|
||||
def test_refuses_to_start_without_required_value(missing: str) -> None:
|
||||
env = dict(BASE_ENV)
|
||||
del env[missing]
|
||||
with pytest.raises(ConfigError, match=missing):
|
||||
load_config(env)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
|
||||
def test_refuses_to_start_on_blank_value(missing: str) -> None:
|
||||
"""A linked device sees every chat, so an unscoped bot must not start."""
|
||||
env = dict(BASE_ENV) | {missing: " "}
|
||||
with pytest.raises(ConfigError, match=missing):
|
||||
load_config(env)
|
||||
|
||||
|
||||
def test_overrides_are_honoured() -> None:
|
||||
env = dict(BASE_ENV) | {
|
||||
"API_BASE_URL": "https://signal.example:8443",
|
||||
"DATABASE_PATH": "/var/lib/bot/bot.sqlite3",
|
||||
"REPORT_HOUR": "6",
|
||||
"REPORT_MINUTE": "5",
|
||||
"COOLDOWN_SECONDS": "0",
|
||||
}
|
||||
config = load_config(env)
|
||||
assert config.api_base_url == "https://signal.example:8443"
|
||||
assert config.database_path == "/var/lib/bot/bot.sqlite3"
|
||||
assert config.report_hour == 6
|
||||
assert config.cooldown_seconds == 0
|
||||
|
||||
|
||||
def test_blank_optionals_fall_back_to_defaults() -> None:
|
||||
env = dict(BASE_ENV) | {"API_BASE_URL": " ", "DATABASE_PATH": "", "REPORT_HOUR": ""}
|
||||
config = load_config(env)
|
||||
assert config.api_base_url == "http://localhost:9922"
|
||||
assert config.database_path == "signal_bot.sqlite3"
|
||||
assert config.report_hour == 21
|
||||
|
||||
|
||||
def test_rejects_non_integer_option() -> None:
|
||||
with pytest.raises(ConfigError, match="must be an integer"):
|
||||
load_config(dict(BASE_ENV) | {"REPORT_HOUR": "quarter past"})
|
||||
|
||||
|
||||
def test_rejects_negative_option() -> None:
|
||||
with pytest.raises(ConfigError, match="must not be negative"):
|
||||
load_config(dict(BASE_ENV) | {"COOLDOWN_SECONDS": "-1"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("hour", "minute"), [("24", "0"), ("0", "60")])
|
||||
def test_rejects_out_of_range_clock(hour: str, minute: str) -> None:
|
||||
env = dict(BASE_ENV) | {"REPORT_HOUR": hour, "REPORT_MINUTE": minute}
|
||||
with pytest.raises(ConfigError, match="out of range"):
|
||||
load_config(env)
|
||||
|
||||
|
||||
def test_reads_os_environ_when_no_mapping_given(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key, value in BASE_ENV.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
config = load_config(use_dotenv=False)
|
||||
assert config.group_id == "watched=="
|
||||
|
||||
|
||||
def test_derived_urls() -> None:
|
||||
config = load_config(dict(BASE_ENV))
|
||||
assert config.receive_url == (
|
||||
"ws://localhost:9922/v1/receive/+15550000000?send_read_receipts=false"
|
||||
)
|
||||
assert config.send_url == "http://localhost:9922/v2/send"
|
||||
assert config.attachments_url == "http://localhost:9922/v1/attachments"
|
||||
assert config.about_url == "http://localhost:9922/v1/about"
|
||||
|
||||
|
||||
def test_https_base_url_yields_wss() -> None:
|
||||
config = Config(
|
||||
phone_number="+1",
|
||||
group_id="g",
|
||||
group_id_send="r",
|
||||
api_base_url="https://signal.example",
|
||||
)
|
||||
assert config.receive_url.startswith("wss://signal.example/")
|
||||
57
tests/test_counting.py
Normal file
57
tests/test_counting.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Scoping and tally predicates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.counting import format_tally, is_watched_group, should_count
|
||||
from signal_bot.models import parse_envelope
|
||||
from tests.conftest import OTHER_GROUP, WATCHED_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
|
||||
|
||||
def _message(**kwargs: object) -> object:
|
||||
parsed = parse_envelope(envelope(**kwargs))
|
||||
assert parsed is not None
|
||||
return parsed
|
||||
|
||||
|
||||
def test_watched_group_is_accepted(config: Config) -> None:
|
||||
assert is_watched_group(_message(group_id=WATCHED_GROUP), config)
|
||||
|
||||
|
||||
def test_other_group_is_rejected(config: Config) -> None:
|
||||
assert not is_watched_group(_message(group_id=OTHER_GROUP), config)
|
||||
|
||||
|
||||
def test_direct_message_is_rejected(config: Config) -> None:
|
||||
"""The dangerous case: a linked device receives every DM on the account."""
|
||||
assert not is_watched_group(_message(group_id=None), config)
|
||||
|
||||
|
||||
def test_plain_text_counts() -> None:
|
||||
assert should_count(_message(text="hello"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [{"sticker": True}, {"reaction": True}])
|
||||
def test_stickers_and_reactions_do_not_count(kwargs: dict[str, bool]) -> None:
|
||||
assert not should_count(_message(**kwargs))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [None, ""])
|
||||
def test_empty_text_does_not_count(text: str | None) -> None:
|
||||
assert not should_count(_message(text=text))
|
||||
|
||||
|
||||
def test_format_tally_pads_names() -> None:
|
||||
rendered = format_tally([("Alice", 5), ("Bo", 2)])
|
||||
assert rendered == "Alice 5\nBo 2"
|
||||
|
||||
|
||||
def test_format_tally_when_nobody_spoke() -> None:
|
||||
assert format_tally([]) == "Brak wiadomosci dzisiaj."
|
||||
159
tests/test_decorators.py
Normal file
159
tests/test_decorators.py
Normal file
@ -0,0 +1,159 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The reusable gates, tested against a harmless command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.decorators import cooldown, daily_limit, vote_gated
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.registry import CommandContext, Handler
|
||||
from tests.conftest import REPLY_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
DAY = "2026-08-29"
|
||||
|
||||
|
||||
def _context(
|
||||
store: Store, transport: FakeTransport, config: Config, *, uuid: str = "u1", now: float = 100.0
|
||||
) -> CommandContext:
|
||||
message = parse_envelope(envelope(text="!ping", uuid=uuid))
|
||||
assert message is not None
|
||||
return CommandContext(
|
||||
message=message,
|
||||
recipient=REPLY_GROUP,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name="ping",
|
||||
now=now,
|
||||
day=DAY,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runs() -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(runs: list[str]) -> Handler:
|
||||
async def _handler(ctx: CommandContext) -> None:
|
||||
runs.append(ctx.message.source_uuid)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
async def test_cooldown_allows_first_use(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
await cooldown(10, "wait {seconds}")(handler)(_context(store, transport, config))
|
||||
assert runs == ["u1"]
|
||||
|
||||
|
||||
async def test_cooldown_blocks_and_reports_remaining(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
store.record_use("u1", "ping", 100.0, DAY)
|
||||
ctx = _context(store, transport, config, now=104.0)
|
||||
await cooldown(10, "wait {seconds}")(handler)(ctx)
|
||||
assert runs == []
|
||||
assert transport.texts == ["wait 7"]
|
||||
|
||||
|
||||
async def test_cooldown_expires(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
store.record_use("u1", "ping", 100.0, DAY)
|
||||
await cooldown(10, "wait {seconds}")(handler)(_context(store, transport, config, now=111.0))
|
||||
assert runs == ["u1"]
|
||||
|
||||
|
||||
async def test_cooldown_is_per_user(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
"""One person's cat picture used to silence everybody."""
|
||||
store.record_use("u1", "ping", 100.0, DAY)
|
||||
await cooldown(10, "wait {seconds}")(handler)(
|
||||
_context(store, transport, config, uuid="u2", now=101.0)
|
||||
)
|
||||
assert runs == ["u2"]
|
||||
|
||||
|
||||
async def test_daily_limit_allows_under_the_cap(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
await daily_limit(1, "no more")(handler)(_context(store, transport, config))
|
||||
assert runs == ["u1"]
|
||||
|
||||
|
||||
async def test_daily_limit_blocks_at_the_cap(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
store.record_use("u1", "ping", 100.0, DAY)
|
||||
await daily_limit(1, "no more, used {used}/{max_uses}")(handler)(
|
||||
_context(store, transport, config)
|
||||
)
|
||||
assert runs == []
|
||||
assert transport.texts == ["no more, used 1/1"]
|
||||
|
||||
|
||||
async def test_vote_gate_waits_for_quorum(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
|
||||
await gated(_context(store, transport, config, uuid="u1"))
|
||||
assert runs == []
|
||||
assert transport.texts == ["1/2"]
|
||||
|
||||
|
||||
async def test_vote_gate_fires_on_quorum(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
|
||||
await gated(_context(store, transport, config, uuid="u1"))
|
||||
await gated(_context(store, transport, config, uuid="u2"))
|
||||
assert runs == ["u2"]
|
||||
|
||||
|
||||
async def test_repeat_vote_does_not_reach_quorum(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
|
||||
await gated(_context(store, transport, config, uuid="u1"))
|
||||
await gated(_context(store, transport, config, uuid="u1", now=101.0))
|
||||
assert runs == []
|
||||
|
||||
|
||||
async def test_votes_expire_outside_the_window(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
gated = vote_gated(2, 60, "{votes}/{required}")(handler)
|
||||
await gated(_context(store, transport, config, uuid="u1", now=100.0))
|
||||
await gated(_context(store, transport, config, uuid="u2", now=1000.0))
|
||||
assert runs == []
|
||||
|
||||
|
||||
async def test_vote_tally_resets_after_firing(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
gated = vote_gated(2, 900, "{votes}/{required}")(handler)
|
||||
await gated(_context(store, transport, config, uuid="u1"))
|
||||
await gated(_context(store, transport, config, uuid="u2"))
|
||||
assert store.count_votes("ping", 0.0) == 0
|
||||
|
||||
|
||||
async def test_gates_stack(
|
||||
store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str]
|
||||
) -> None:
|
||||
stacked = cooldown(10, "wait {seconds}")(daily_limit(2, "no more")(handler))
|
||||
await stacked(_context(store, transport, config))
|
||||
assert runs == ["u1"]
|
||||
assert replace(config, cooldown_seconds=10).cooldown_seconds == 10
|
||||
126
tests/test_dispatcher.py
Normal file
126
tests/test_dispatcher.py
Normal file
@ -0,0 +1,126 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Routing: scoping, counting, dispatch and error containment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.dispatcher import Dispatcher
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from tests.conftest import OTHER_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
NOW = dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runs() -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(runs: list[str]) -> CommandRegistry:
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
runs.append(ctx.command_name)
|
||||
|
||||
reg = CommandRegistry()
|
||||
reg.register("cat", ["!cat"], handler)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dispatcher(
|
||||
config: Config, registry: CommandRegistry, store: Store, transport: FakeTransport
|
||||
) -> Dispatcher:
|
||||
return Dispatcher(
|
||||
config=config,
|
||||
registry=registry,
|
||||
store=store,
|
||||
transport=transport,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
|
||||
async def test_counts_a_plain_group_message(dispatcher: Dispatcher, store: Store) -> None:
|
||||
await dispatcher.handle(envelope(text="hello"))
|
||||
assert store.tallies()[0].count == 1
|
||||
|
||||
|
||||
async def test_runs_a_command_and_records_the_use(
|
||||
dispatcher: Dispatcher, store: Store, runs: list[str]
|
||||
) -> None:
|
||||
await dispatcher.handle(envelope(text="!cat"))
|
||||
assert runs == ["cat"]
|
||||
assert store.last_used("uuid-alice", "cat") == NOW.timestamp()
|
||||
assert store.uses_today("uuid-alice", "cat", "2026-08-29") == 1
|
||||
|
||||
|
||||
async def test_direct_messages_are_never_handled(
|
||||
dispatcher: Dispatcher, store: Store, runs: list[str], transport: FakeTransport
|
||||
) -> None:
|
||||
"""A linked device sees every DM on the account; none may reach a handler."""
|
||||
await dispatcher.handle(envelope(text="!cat", group_id=None))
|
||||
assert runs == []
|
||||
assert store.tallies() == []
|
||||
assert transport.sent == []
|
||||
|
||||
|
||||
async def test_other_groups_are_never_handled(
|
||||
dispatcher: Dispatcher, store: Store, runs: list[str]
|
||||
) -> None:
|
||||
await dispatcher.handle(envelope(text="!cat", group_id=OTHER_GROUP))
|
||||
assert runs == []
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
async def test_unparseable_frames_are_dropped(dispatcher: Dispatcher, store: Store) -> None:
|
||||
await dispatcher.handle("not json")
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
async def test_stickers_are_not_counted(dispatcher: Dispatcher, store: Store) -> None:
|
||||
await dispatcher.handle(envelope(text="hi", sticker=True, sync=True))
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
async def test_reactions_are_not_counted(dispatcher: Dispatcher, store: Store) -> None:
|
||||
await dispatcher.handle(envelope(text="hi", reaction=True))
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
async def test_non_command_text_runs_nothing(dispatcher: Dispatcher, runs: list[str]) -> None:
|
||||
await dispatcher.handle(envelope(text="just chatting"))
|
||||
assert runs == []
|
||||
|
||||
|
||||
async def test_a_failing_command_does_not_kill_the_loop(
|
||||
config: Config, store: Store, transport: FakeTransport
|
||||
) -> None:
|
||||
async def boom(ctx: CommandContext) -> None:
|
||||
msg = "upstream is down"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat"], boom)
|
||||
dispatcher = Dispatcher(
|
||||
config=config,
|
||||
registry=registry,
|
||||
store=store,
|
||||
transport=transport,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
await dispatcher.handle(envelope(text="!cat"))
|
||||
# The use is not recorded, so the cooldown does not punish a failed run.
|
||||
assert store.last_used("uuid-alice", "cat") is None
|
||||
|
||||
|
||||
async def test_command_message_is_also_counted(dispatcher: Dispatcher, store: Store) -> None:
|
||||
await dispatcher.handle(envelope(text="!cat"))
|
||||
assert store.tallies()[0].count == 1
|
||||
102
tests/test_fake_server.py
Normal file
102
tests/test_fake_server.py
Normal file
@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The fake Signal server's own behaviour."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from fake_signal.server import create_app, default_state
|
||||
from fake_signal.state import PIXEL_PNG, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state() -> State:
|
||||
return State(base_url="http://testserver")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(state: State) -> TestClient:
|
||||
return TestClient(create_app(state))
|
||||
|
||||
|
||||
def test_about_reports_a_websocket_mode(client: TestClient) -> None:
|
||||
body = client.get("/v1/about").json()
|
||||
assert body["mode"] == "json-rpc-native"
|
||||
|
||||
|
||||
def test_qrcodelink_returns_a_png(client: TestClient) -> None:
|
||||
"""The real endpoint returns an image, which is why the runbook opens it."""
|
||||
response = client.get("/v1/qrcodelink", params={"device_name": "bot"})
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
assert response.headers["X-Device-Name"] == "bot"
|
||||
assert response.content == PIXEL_PNG
|
||||
|
||||
|
||||
def test_send_is_captured_and_mints_attachment_ids(client: TestClient, state: State) -> None:
|
||||
client.post("/v2/send", json={"message": "hi", "recipients": ["g"]})
|
||||
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
|
||||
assert len(state.sent) == 2
|
||||
assert state.attachments == ["attach-2-0"]
|
||||
|
||||
|
||||
def test_attachments_can_be_listed_and_deleted(client: TestClient) -> None:
|
||||
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
|
||||
listed = client.get("/v1/attachments").json()
|
||||
assert listed == ["attach-1-0"]
|
||||
assert client.delete(f"/v1/attachments/{listed[0]}").status_code == 204
|
||||
assert client.get("/v1/attachments").json() == []
|
||||
|
||||
|
||||
def test_deleting_an_unknown_attachment_is_a_noop(client: TestClient) -> None:
|
||||
assert client.delete("/v1/attachments/nope").status_code == 204
|
||||
|
||||
|
||||
def test_reset_clears_captured_state(client: TestClient, state: State) -> None:
|
||||
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
|
||||
client.post("/_control/reset")
|
||||
assert state.sent == []
|
||||
assert state.attachments == []
|
||||
|
||||
|
||||
def test_websocket_subscribes_and_receives_injected_frames(client: TestClient) -> None:
|
||||
with client.websocket_connect("/v1/receive/+15550000000") as socket:
|
||||
assert socket.receive_json()["envelope"]["subscribed"] == "+15550000000"
|
||||
assert client.get("/_control/ready").json() == {"sockets": 1}
|
||||
assert client.post("/_control/inject", json={"raw": "FRAME"}).json() == {"delivered": 1}
|
||||
assert socket.receive_text() == "FRAME"
|
||||
|
||||
|
||||
def test_socket_is_forgotten_after_disconnect(client: TestClient) -> None:
|
||||
with client.websocket_connect("/v1/receive/+1") as socket:
|
||||
socket.receive_json()
|
||||
assert client.get("/_control/ready").json() == {"sockets": 0}
|
||||
|
||||
|
||||
def test_inject_with_no_listeners_delivers_nothing(client: TestClient) -> None:
|
||||
assert client.post("/_control/inject", json={"raw": "x"}).json() == {"delivered": 0}
|
||||
|
||||
|
||||
def test_fixture_apis_mirror_the_real_response_shapes(client: TestClient) -> None:
|
||||
cat = client.get("/_fixtures/cat-api").json()
|
||||
dog = client.get("/_fixtures/dog-api").json()
|
||||
assert cat[0]["url"].endswith("/_fixtures/image.png")
|
||||
assert dog["message"].endswith("/_fixtures/image.png")
|
||||
assert client.get("/_fixtures/image.png").content == PIXEL_PNG
|
||||
|
||||
|
||||
def test_create_app_makes_its_own_state_when_none_given() -> None:
|
||||
with TestClient(create_app()) as client:
|
||||
assert client.get("/v1/about").status_code == 200
|
||||
|
||||
|
||||
def test_default_state_reads_the_port_from_the_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("FAKE_PORT", "9123")
|
||||
assert default_state().base_url.endswith(":9123")
|
||||
|
||||
|
||||
def test_control_sent_exposes_captured_payloads(client: TestClient) -> None:
|
||||
client.post("/v2/send", json={"message": "hi", "recipients": ["R=="]})
|
||||
assert client.get("/_control/sent").json() == [{"message": "hi", "recipients": ["R=="]}]
|
||||
87
tests/test_http_client.py
Normal file
87
tests/test_http_client.py
Normal file
@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Image fetching and JSON path walking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from signal_bot.http_client import ImageFetcher, ImageFetchError
|
||||
|
||||
IMAGE = b"\x89PNG\r\n\x1a\nfake"
|
||||
|
||||
# The callable httpx.MockTransport expects -- not a bot command handler.
|
||||
MockHandler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _fetcher(handler: MockHandler) -> ImageFetcher:
|
||||
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
def _api(payload: object, image: bytes = IMAGE) -> MockHandler:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "images" in str(request.url) or "breeds" in str(request.url):
|
||||
return httpx.Response(200, json=payload)
|
||||
return httpx.Response(200, content=image)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
async def test_fetches_via_list_then_key_path() -> None:
|
||||
fetcher = _fetcher(_api([{"url": "https://cdn.example/cat.png"}]))
|
||||
encoded = await fetcher.fetch_base64("https://api/images/search", [0, "url"])
|
||||
assert base64.b64decode(encoded) == IMAGE
|
||||
|
||||
|
||||
async def test_fetches_via_single_key_path() -> None:
|
||||
fetcher = _fetcher(_api({"message": "https://cdn.example/dog.png"}))
|
||||
encoded = await fetcher.fetch_base64("https://api/breeds/x", ["message"])
|
||||
assert base64.b64decode(encoded) == IMAGE
|
||||
|
||||
|
||||
async def test_negative_index_is_supported() -> None:
|
||||
fetcher = _fetcher(_api([{"url": "https://cdn.example/cat.png"}]))
|
||||
encoded = await fetcher.fetch_base64("https://api/images/search", [-1, "url"])
|
||||
assert base64.b64decode(encoded) == IMAGE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "path"),
|
||||
[
|
||||
({"other": "x"}, ["message"]),
|
||||
([], [0, "url"]),
|
||||
([{"url": "x"}], [5, "url"]),
|
||||
({"message": "x"}, [0]),
|
||||
([{"url": "x"}], ["message"]),
|
||||
],
|
||||
)
|
||||
async def test_missing_element_raises(payload: object, path: list) -> None:
|
||||
fetcher = _fetcher(_api(payload))
|
||||
with pytest.raises(ImageFetchError, match="no element at"):
|
||||
await fetcher.fetch_base64("https://api/images/search", path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"message": 42}, {"message": ""}])
|
||||
async def test_non_url_element_raises(payload: object) -> None:
|
||||
fetcher = _fetcher(_api(payload))
|
||||
with pytest.raises(ImageFetchError, match="is not a URL"):
|
||||
await fetcher.fetch_base64("https://api/breeds/x", ["message"])
|
||||
|
||||
|
||||
async def test_api_error_propagates() -> None:
|
||||
fetcher = _fetcher(lambda r: httpx.Response(503))
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await fetcher.fetch_base64("https://api/images/search", [0, "url"])
|
||||
|
||||
|
||||
async def test_image_download_error_propagates() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "images" in str(request.url):
|
||||
return httpx.Response(200, json=[{"url": "https://cdn.example/cat.png"}])
|
||||
return httpx.Response(404)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await _fetcher(handler).fetch_base64("https://api/images/search", [0, "url"])
|
||||
59
tests/test_main.py
Normal file
59
tests/test_main.py
Normal file
@ -0,0 +1,59 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The console entrypoint.
|
||||
|
||||
`python main.py` used to do literally nothing: there was no __main__ block
|
||||
and no uvicorn.run() call anywhere in the project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
import signal_bot.__main__ as entrypoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_run(target: str, **kwargs: object) -> None:
|
||||
seen["target"] = target
|
||||
seen.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(entrypoint.uvicorn, "run", fake_run)
|
||||
return seen
|
||||
|
||||
|
||||
def test_runs_the_asgi_app_on_loopback_by_default(
|
||||
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("BIND_HOST", raising=False)
|
||||
monkeypatch.delenv("BIND_PORT", raising=False)
|
||||
entrypoint.main()
|
||||
assert captured["target"] == "signal_bot.app:app"
|
||||
assert captured["host"] == "127.0.0.1"
|
||||
assert captured["port"] == 8000
|
||||
|
||||
|
||||
def test_bind_overrides_are_honoured(
|
||||
captured: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# TEST-NET-1: proves the override without a bind-all literal.
|
||||
monkeypatch.setenv("BIND_HOST", "192.0.2.10")
|
||||
monkeypatch.setenv("BIND_PORT", "9001")
|
||||
entrypoint.main()
|
||||
assert captured["host"] == "192.0.2.10"
|
||||
assert captured["port"] == 9001
|
||||
|
||||
|
||||
def test_module_runs_as_a_script(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`python -m signal_bot` must actually start the server."""
|
||||
seen: dict[str, object] = {}
|
||||
monkeypatch.setattr(uvicorn, "run", lambda target, **kw: seen.update(target=target))
|
||||
monkeypatch.delenv("BIND_HOST", raising=False)
|
||||
monkeypatch.delenv("BIND_PORT", raising=False)
|
||||
runpy.run_module("signal_bot", run_name="__main__", alter_sys=True)
|
||||
assert seen["target"] == "signal_bot.app:app"
|
||||
101
tests/test_models.py
Normal file
101
tests/test_models.py
Normal file
@ -0,0 +1,101 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Envelope parsing across both message shapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.models import parse_envelope
|
||||
from tests.conftest import WATCHED_GROUP, envelope
|
||||
|
||||
|
||||
def test_parses_group_data_message() -> None:
|
||||
message = parse_envelope(envelope(text="!cat"))
|
||||
assert message is not None
|
||||
assert message.text == "!cat"
|
||||
assert message.group_id == WATCHED_GROUP
|
||||
assert message.source_uuid == "uuid-alice"
|
||||
assert message.source_name == "Alice"
|
||||
assert not message.is_direct_message
|
||||
|
||||
|
||||
def test_parses_own_account_sync_message() -> None:
|
||||
message = parse_envelope(envelope(text="!dog", sync=True))
|
||||
assert message is not None
|
||||
assert message.text == "!dog"
|
||||
assert message.group_id == WATCHED_GROUP
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", [{"sync": False}, {"sync": True}])
|
||||
def test_detects_sticker_in_both_shapes(shape: dict[str, bool]) -> None:
|
||||
"""The original bot only ever looked under dataMessage, so its own
|
||||
stickers were never filtered out of the tally."""
|
||||
message = parse_envelope(envelope(sticker=True, **shape))
|
||||
assert message is not None
|
||||
assert message.has_sticker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", [{"sync": False}, {"sync": True}])
|
||||
def test_detects_reaction_in_both_shapes(shape: dict[str, bool]) -> None:
|
||||
message = parse_envelope(envelope(reaction=True, **shape))
|
||||
assert message is not None
|
||||
assert message.is_reaction
|
||||
|
||||
|
||||
def test_direct_message_has_no_group() -> None:
|
||||
message = parse_envelope(envelope(group_id=None))
|
||||
assert message is not None
|
||||
assert message.group_id is None
|
||||
assert message.is_direct_message
|
||||
|
||||
|
||||
def test_missing_text_is_none() -> None:
|
||||
message = parse_envelope(envelope(text=None))
|
||||
assert message is not None
|
||||
assert message.text is None
|
||||
|
||||
|
||||
def test_non_string_text_is_none() -> None:
|
||||
raw = json.dumps({"envelope": {"dataMessage": {"message": 42}}})
|
||||
message = parse_envelope(raw)
|
||||
assert message is not None
|
||||
assert message.text is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"not json at all",
|
||||
json.dumps(["a", "list"]),
|
||||
json.dumps({"envelope": "not a dict"}),
|
||||
json.dumps({"account": "+1"}),
|
||||
json.dumps({"envelope": {"receiptMessage": {"when": 1}}}),
|
||||
json.dumps({"envelope": {"syncMessage": "not a dict"}}),
|
||||
json.dumps({"envelope": {"syncMessage": {"readMessages": []}}}),
|
||||
],
|
||||
)
|
||||
def test_unparseable_frames_return_none(raw: str) -> None:
|
||||
assert parse_envelope(raw) is None
|
||||
|
||||
|
||||
def test_accepts_bytes() -> None:
|
||||
message = parse_envelope(envelope(text="hi").encode())
|
||||
assert message is not None
|
||||
assert message.text == "hi"
|
||||
|
||||
|
||||
def test_invalid_utf8_bytes_return_none() -> None:
|
||||
assert parse_envelope(b"\xff\xfe not utf8") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_info",
|
||||
["not a dict", {"type": "DELIVER"}, {"groupId": ""}, {"groupId": 12}],
|
||||
)
|
||||
def test_malformed_group_info_reads_as_direct_message(group_info: object) -> None:
|
||||
raw = json.dumps({"envelope": {"dataMessage": {"message": "hi", "groupInfo": group_info}}})
|
||||
message = parse_envelope(raw)
|
||||
assert message is not None
|
||||
assert message.is_direct_message
|
||||
51
tests/test_registry.py
Normal file
51
tests/test_registry.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Trigger resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, DuplicateTriggerError
|
||||
|
||||
|
||||
async def _noop(ctx: CommandContext) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_resolves_a_registered_trigger() -> None:
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat", "!kot"], _noop)
|
||||
assert registry.resolve("!kot") is not None
|
||||
assert registry.resolve("!cat").name == "cat"
|
||||
|
||||
|
||||
def test_resolution_is_case_insensitive_and_trims() -> None:
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat"], _noop)
|
||||
assert registry.resolve(" !CAT ") is not None
|
||||
|
||||
|
||||
def test_unknown_and_prefixless_text_resolves_to_nothing() -> None:
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat"], _noop)
|
||||
assert registry.resolve("!nope") is None
|
||||
assert registry.resolve("cat") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [None, ""])
|
||||
def test_empty_text_resolves_to_nothing(text: str | None) -> None:
|
||||
assert CommandRegistry().resolve(text) is None
|
||||
|
||||
|
||||
def test_duplicate_trigger_is_rejected() -> None:
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat"], _noop)
|
||||
with pytest.raises(DuplicateTriggerError, match="!cat"):
|
||||
registry.register("feline", ["!cat"], _noop)
|
||||
|
||||
|
||||
def test_commands_lists_in_registration_order() -> None:
|
||||
registry = CommandRegistry()
|
||||
registry.register("cat", ["!cat"], _noop)
|
||||
registry.register("dog", ["!dog"], _noop)
|
||||
assert [c.name for c in registry.commands] == ["cat", "dog"]
|
||||
132
tests/test_scenario.py
Normal file
132
tests/test_scenario.py
Normal file
@ -0,0 +1,132 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Scenario loading, rendering and adjudication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fake_signal.scenario import (
|
||||
Expectation,
|
||||
Injection,
|
||||
Scenario,
|
||||
ScenarioError,
|
||||
check,
|
||||
describe,
|
||||
load_scenario,
|
||||
)
|
||||
from signal_bot.models import parse_envelope
|
||||
|
||||
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
|
||||
|
||||
|
||||
def _write(tmp_path: Path, document: str) -> Path:
|
||||
path = tmp_path / "s.yaml"
|
||||
path.write_text(document, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_ships_scenarios_load_and_are_valid() -> None:
|
||||
paths = sorted(Path("fake_signal/scenarios").glob("*.yaml"))
|
||||
assert paths
|
||||
for path in paths:
|
||||
assert load_scenario(path).name
|
||||
|
||||
|
||||
def test_injection_renders_a_parseable_group_envelope() -> None:
|
||||
frame = Injection(text="!cat").to_frame(GROUPS)
|
||||
message = parse_envelope(frame)
|
||||
assert message is not None
|
||||
assert message.text == "!cat"
|
||||
assert message.group_id == "W=="
|
||||
|
||||
|
||||
def test_injection_renders_a_direct_message() -> None:
|
||||
message = parse_envelope(Injection(text="hi", group=None).to_frame(GROUPS))
|
||||
assert message is not None
|
||||
assert message.is_direct_message
|
||||
|
||||
|
||||
def test_injection_renders_sync_sticker_and_reaction() -> None:
|
||||
sticker = parse_envelope(Injection(sticker=True, sync=True).to_frame(GROUPS))
|
||||
reaction = parse_envelope(Injection(reaction=True).to_frame(GROUPS))
|
||||
assert sticker is not None
|
||||
assert sticker.has_sticker
|
||||
assert reaction is not None
|
||||
assert reaction.is_reaction
|
||||
|
||||
|
||||
def test_unknown_group_alias_is_rejected() -> None:
|
||||
with pytest.raises(ScenarioError, match="unknown group alias"):
|
||||
Injection(group="nope").to_frame(GROUPS)
|
||||
|
||||
|
||||
def test_loads_a_full_scenario(tmp_path: Path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"name: demo\ninject:\n - {text: '!cat'}\nexpect:\n - {kind: image}\n",
|
||||
)
|
||||
scenario = load_scenario(path)
|
||||
assert scenario.name == "demo"
|
||||
assert scenario.inject[0].text == "!cat"
|
||||
assert scenario.expect[0].kind == "image"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("document", "match"),
|
||||
[
|
||||
("- a list\n", "must contain a mapping"),
|
||||
("inject: []\n", "missing a name"),
|
||||
("name: x\ninject:\n - {bogus: 1}\n", "unknown inject keys"),
|
||||
("name: x\nexpect:\n - {bogus: 1}\n", "unknown expect keys"),
|
||||
("name: x\ninject:\n - not-a-mapping\n", "must be a mapping"),
|
||||
],
|
||||
)
|
||||
def test_malformed_scenarios_are_rejected(tmp_path: Path, document: str, match: str) -> None:
|
||||
with pytest.raises(ScenarioError, match=match):
|
||||
load_scenario(_write(tmp_path, document))
|
||||
|
||||
|
||||
def test_describe_classifies_sends() -> None:
|
||||
assert describe({"message": "hi", "recipients": ["R=="]}) == ("text", "R==", "hi")
|
||||
assert describe({"base64_attachments": ["A"], "recipients": ["R=="]})[0] == "image"
|
||||
assert describe({})[1] == ""
|
||||
|
||||
|
||||
def test_check_passes_on_a_match() -> None:
|
||||
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply")])
|
||||
sent = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
|
||||
assert check(scenario, sent, GROUPS) == []
|
||||
|
||||
|
||||
def test_check_reports_a_count_mismatch() -> None:
|
||||
scenario = Scenario("s", expect=[Expectation()])
|
||||
failures = check(scenario, [], GROUPS)
|
||||
assert "expected 1 send(s), captured 0" in failures[0]
|
||||
assert any("missing" in line for line in failures)
|
||||
|
||||
|
||||
def test_check_reports_kind_recipient_and_body() -> None:
|
||||
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply", contains="nope")])
|
||||
sent = [{"message": "hello", "recipients": ["W=="]}]
|
||||
failures = check(scenario, sent, GROUPS)
|
||||
assert any("kind:" in f for f in failures)
|
||||
assert any("recipient:" in f for f in failures)
|
||||
assert any("body:" in f for f in failures)
|
||||
|
||||
|
||||
def test_check_accepts_a_literal_recipient() -> None:
|
||||
scenario = Scenario("s", expect=[Expectation(to="literal==")])
|
||||
assert check(scenario, [{"message": "", "recipients": ["literal=="]}], GROUPS) == []
|
||||
|
||||
|
||||
def test_unexpected_sends_are_a_failure() -> None:
|
||||
"""A scoping regression shows up here: expect [] and the bot replied."""
|
||||
failures = check(Scenario("s"), [{"message": "leak", "recipients": ["R=="]}], GROUPS)
|
||||
assert "expected 0 send(s), captured 1" in failures[0]
|
||||
|
||||
|
||||
def test_frame_is_json() -> None:
|
||||
assert json.loads(Injection().to_frame(GROUPS))["account"] == "+15550000000"
|
||||
68
tests/test_scheduler.py
Normal file
68
tests/test_scheduler.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The daily report, driven by an injected clock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from signal_bot.scheduler import SECONDS_PER_DAY, Action, DailyScheduler
|
||||
|
||||
|
||||
def _scheduler(action: Action, now: dt.datetime, slept: list[float]) -> DailyScheduler:
|
||||
async def sleeper(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
return DailyScheduler(21, 37, action, lambda: now, sleeper)
|
||||
|
||||
|
||||
async def _noop() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_waits_until_later_today() -> None:
|
||||
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
|
||||
assert _scheduler(_noop, now, []).seconds_until_next(now) == 3600
|
||||
|
||||
|
||||
def test_rolls_over_to_tomorrow_once_passed() -> None:
|
||||
now = dt.datetime(2026, 8, 29, 22, 37, tzinfo=dt.UTC)
|
||||
assert _scheduler(_noop, now, []).seconds_until_next(now) == SECONDS_PER_DAY - 3600
|
||||
|
||||
|
||||
def test_exact_target_time_waits_a_full_day() -> None:
|
||||
"""Never returns zero, which would busy-loop the report."""
|
||||
now = dt.datetime(2026, 8, 29, 21, 37, tzinfo=dt.UTC)
|
||||
assert _scheduler(_noop, now, []).seconds_until_next(now) == SECONDS_PER_DAY
|
||||
|
||||
|
||||
async def test_runs_the_action_after_sleeping() -> None:
|
||||
calls: list[int] = []
|
||||
slept: list[float] = []
|
||||
|
||||
async def action() -> None:
|
||||
calls.append(1)
|
||||
|
||||
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
|
||||
await _scheduler(action, now, slept).run(iterations=2)
|
||||
assert calls == [1, 1]
|
||||
assert slept == [3600, 3600]
|
||||
|
||||
|
||||
async def test_a_failing_report_does_not_stop_the_loop() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
async def action() -> None:
|
||||
calls.append(1)
|
||||
msg = "signal is down"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
|
||||
await _scheduler(action, now, []).run(iterations=2)
|
||||
assert calls == [1, 1]
|
||||
|
||||
|
||||
async def test_zero_iterations_does_nothing() -> None:
|
||||
slept: list[float] = []
|
||||
now = dt.datetime(2026, 8, 29, 20, 37, tzinfo=dt.UTC)
|
||||
await _scheduler(_noop, now, slept).run(iterations=0)
|
||||
assert slept == []
|
||||
93
tests/test_store.py
Normal file
93
tests/test_store.py
Normal file
@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""SQLite persistence, including survival across a restart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.store import Store, open_store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_increment_creates_and_accumulates(store: Store) -> None:
|
||||
store.increment("u1", "Alice")
|
||||
store.increment("u1", "Alice")
|
||||
store.increment("u2", "Bob")
|
||||
assert [(t.display_name, t.count) for t in store.tallies()] == [("Alice", 2), ("Bob", 1)]
|
||||
|
||||
|
||||
def test_display_name_is_refreshed(store: Store) -> None:
|
||||
store.increment("u1", "Alice")
|
||||
store.increment("u1", "Alicja")
|
||||
assert store.tallies()[0].display_name == "Alicja"
|
||||
|
||||
|
||||
def test_tallies_sorted_by_count_then_name(store: Store) -> None:
|
||||
store.increment("u1", "Zoe")
|
||||
store.increment("u2", "Adam")
|
||||
assert [t.display_name for t in store.tallies()] == ["Adam", "Zoe"]
|
||||
|
||||
|
||||
def test_reset_clears_everything(store: Store) -> None:
|
||||
store.increment("u1", "Alice")
|
||||
store.reset_tallies()
|
||||
assert store.tallies() == []
|
||||
|
||||
|
||||
def test_tallies_survive_a_restart(tmp_path: Path) -> None:
|
||||
"""The old bot kept the day's tally in a module global."""
|
||||
db = str(tmp_path / "bot.sqlite3")
|
||||
with open_store(db) as first:
|
||||
first.increment("u1", "Alice")
|
||||
with open_store(db) as second:
|
||||
assert second.tallies()[0].count == 1
|
||||
|
||||
|
||||
def test_last_used_is_none_before_first_use(store: Store) -> None:
|
||||
assert store.last_used("u1", "cat") is None
|
||||
|
||||
|
||||
def test_record_use_tracks_cooldown_and_daily(store: Store) -> None:
|
||||
store.record_use("u1", "cat", 100.0, "2026-08-29")
|
||||
assert store.last_used("u1", "cat") == 100.0
|
||||
assert store.uses_today("u1", "cat", "2026-08-29") == 1
|
||||
store.record_use("u1", "cat", 200.0, "2026-08-29")
|
||||
assert store.last_used("u1", "cat") == 200.0
|
||||
assert store.uses_today("u1", "cat", "2026-08-29") == 2
|
||||
|
||||
|
||||
def test_daily_counter_is_per_day(store: Store) -> None:
|
||||
store.record_use("u1", "cat", 100.0, "2026-08-29")
|
||||
assert store.uses_today("u1", "cat", "2026-08-30") == 0
|
||||
|
||||
|
||||
def test_uses_today_is_per_user_and_command(store: Store) -> None:
|
||||
store.record_use("u1", "cat", 100.0, "2026-08-29")
|
||||
assert store.uses_today("u2", "cat", "2026-08-29") == 0
|
||||
assert store.uses_today("u1", "dog", "2026-08-29") == 0
|
||||
|
||||
|
||||
def test_votes_are_deduplicated_per_person(store: Store) -> None:
|
||||
store.add_vote("traps", "u1", 100.0)
|
||||
store.add_vote("traps", "u1", 101.0)
|
||||
store.add_vote("traps", "u2", 102.0)
|
||||
assert store.count_votes("traps", 0.0) == 2
|
||||
|
||||
|
||||
def test_votes_outside_the_window_are_ignored(store: Store) -> None:
|
||||
store.add_vote("cmd", "u1", 100.0)
|
||||
assert store.count_votes("cmd", 150.0) == 0
|
||||
|
||||
|
||||
def test_clear_votes(store: Store) -> None:
|
||||
store.add_vote("cmd", "u1", 100.0)
|
||||
store.clear_votes("cmd")
|
||||
assert store.count_votes("cmd", 0.0) == 0
|
||||
|
||||
|
||||
def test_store_is_a_context_manager(tmp_path: Path) -> None:
|
||||
with Store(str(tmp_path / "x.sqlite3")) as opened:
|
||||
opened.increment("u1", "Alice")
|
||||
assert opened.tallies.__self__ is opened
|
||||
39
tests/test_transport_base.py
Normal file
39
tests/test_transport_base.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The transport seam.
|
||||
|
||||
Both implementations are checked against the same Protocol here, which is
|
||||
what makes "the fake and the real adapter are interchangeable" a fact the
|
||||
suite enforces rather than a claim in a docstring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from signal_bot.transport_base import Transport
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
from signal_bot.transport_rest import RestTransport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
|
||||
|
||||
def test_fake_transport_satisfies_the_protocol() -> None:
|
||||
assert isinstance(FakeTransport(), Transport)
|
||||
|
||||
|
||||
def test_rest_transport_satisfies_the_protocol(config: Config) -> None:
|
||||
assert isinstance(RestTransport(config, httpx.AsyncClient()), Transport)
|
||||
|
||||
|
||||
def test_protocol_names_the_whole_surface() -> None:
|
||||
for name in (
|
||||
"receive",
|
||||
"send_text",
|
||||
"send_image",
|
||||
"list_attachments",
|
||||
"delete_attachment",
|
||||
):
|
||||
assert hasattr(Transport, name)
|
||||
27
tests/test_transport_fake.py
Normal file
27
tests/test_transport_fake.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The in-process transport double."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
|
||||
async def test_queue_and_receive() -> None:
|
||||
transport = FakeTransport()
|
||||
transport.queue(["a", "b"])
|
||||
assert [f async for f in transport.receive()] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_records_sends() -> None:
|
||||
transport = FakeTransport()
|
||||
await transport.send_text("hi", "g")
|
||||
await transport.send_image("B64", "g")
|
||||
assert transport.texts == ["hi"]
|
||||
assert transport.sent[1].image_base64 == "B64"
|
||||
|
||||
|
||||
async def test_attachments_round_trip() -> None:
|
||||
transport = FakeTransport(attachments=["a1"])
|
||||
assert await transport.list_attachments() == ["a1"]
|
||||
await transport.delete_attachment("a1")
|
||||
assert transport.deleted == ["a1"]
|
||||
208
tests/test_transport_rest.py
Normal file
208
tests/test_transport_rest.py
Normal file
@ -0,0 +1,208 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The bbernhard adapter: sends, attachments, mode check and reconnect."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from websockets.exceptions import ConnectionClosedError
|
||||
|
||||
from signal_bot.transport_rest import RestTransport, TransportError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import TracebackType
|
||||
|
||||
from signal_bot.config import Config
|
||||
|
||||
# The callable httpx.MockTransport expects -- not a bot command handler.
|
||||
MockHandler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
"""Yields a canned list of frames, optionally failing partway through."""
|
||||
|
||||
def __init__(self, frames: list[str], *, fail_after: bool = False) -> None:
|
||||
self._frames = frames
|
||||
self._fail_after = fail_after
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[str]:
|
||||
for frame in self._frames:
|
||||
yield frame
|
||||
if self._fail_after:
|
||||
raise ConnectionClosedError(None, None)
|
||||
|
||||
|
||||
class FakeConnect:
|
||||
"""Stands in for websockets.connect, handing out sockets in order."""
|
||||
|
||||
def __init__(self, sockets: list[FakeSocket]) -> None:
|
||||
self._sockets = sockets
|
||||
self.urls: list[str] = []
|
||||
|
||||
def __call__(self, url: str) -> FakeConnect:
|
||||
self.urls.append(url)
|
||||
return self
|
||||
|
||||
async def __aenter__(self) -> FakeSocket:
|
||||
if not self._sockets:
|
||||
msg = "no more sockets"
|
||||
raise OSError(msg)
|
||||
return self._sockets.pop(0)
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _transport(config: Config, handler: MockHandler, **kwargs: object) -> RestTransport:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return RestTransport(config, client, **kwargs)
|
||||
|
||||
|
||||
async def test_send_text_posts_expected_payload(config: Config) -> None:
|
||||
seen: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(__import__("json").loads(request.content))
|
||||
return httpx.Response(201)
|
||||
|
||||
await _transport(config, handler).send_text("hi", "group==")
|
||||
assert seen[0] == {
|
||||
"message": "hi",
|
||||
"number": config.phone_number,
|
||||
"recipients": ["group=="],
|
||||
}
|
||||
|
||||
|
||||
async def test_send_image_posts_base64_attachment(config: Config) -> None:
|
||||
seen: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(__import__("json").loads(request.content))
|
||||
return httpx.Response(200)
|
||||
|
||||
await _transport(config, handler).send_image("BASE64", "group==")
|
||||
assert seen[0]["base64_attachments"] == ["BASE64"]
|
||||
|
||||
|
||||
async def test_send_raises_on_rejection(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(400, text="nope"))
|
||||
with pytest.raises(TransportError, match="400"):
|
||||
await transport.send_text("hi", "g")
|
||||
|
||||
|
||||
async def test_verify_mode_accepts_json_rpc(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc"}))
|
||||
assert await transport.verify_mode() == "json-rpc"
|
||||
|
||||
|
||||
async def test_verify_mode_accepts_json_rpc_native(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json={"mode": "json-rpc-native"}))
|
||||
assert await transport.verify_mode() == "json-rpc-native"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{"mode": "normal"}, {"mode": "native"}, {}, ["nope"]])
|
||||
async def test_verify_mode_rejects_non_websocket_modes(config: Config, body: object) -> None:
|
||||
"""In normal/native mode /v1/receive silently degrades to a polling GET."""
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json=body))
|
||||
with pytest.raises(TransportError, match="json-rpc-native"):
|
||||
await transport.verify_mode()
|
||||
|
||||
|
||||
async def test_list_attachments(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json=["a", "b"]))
|
||||
assert await transport.list_attachments() == ["a", "b"]
|
||||
|
||||
|
||||
async def test_list_attachments_tolerates_non_list(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json={"oops": 1}))
|
||||
assert await transport.list_attachments() == []
|
||||
|
||||
|
||||
async def test_delete_attachment(config: Config) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(204)
|
||||
|
||||
await _transport(config, handler).delete_attachment("abc")
|
||||
assert seen[0].endswith("/v1/attachments/abc")
|
||||
|
||||
|
||||
async def test_delete_attachment_raises_on_failure(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(500))
|
||||
with pytest.raises(TransportError, match="500"):
|
||||
await transport.delete_attachment("abc")
|
||||
|
||||
|
||||
async def test_receive_yields_frames(config: Config) -> None:
|
||||
connector = FakeConnect([FakeSocket(["a", "b"])])
|
||||
transport = _transport(
|
||||
config,
|
||||
lambda r: httpx.Response(200),
|
||||
connector=connector,
|
||||
sleeper=_no_sleep,
|
||||
max_reconnects=0,
|
||||
)
|
||||
assert [f async for f in transport.receive()] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_receive_decodes_binary_frames(config: Config) -> None:
|
||||
connector = FakeConnect([FakeSocket([b"bytes-frame"])])
|
||||
transport = _transport(
|
||||
config,
|
||||
lambda r: httpx.Response(200),
|
||||
connector=connector,
|
||||
sleeper=_no_sleep,
|
||||
max_reconnects=0,
|
||||
)
|
||||
assert [f async for f in transport.receive()] == ["bytes-frame"]
|
||||
|
||||
|
||||
async def test_receive_reconnects_after_a_drop(config: Config) -> None:
|
||||
"""A container restart used to kill the bot silently."""
|
||||
slept: list[float] = []
|
||||
|
||||
async def sleeper(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
connector = FakeConnect([FakeSocket(["a"], fail_after=True), FakeSocket(["b"])])
|
||||
transport = _transport(
|
||||
config,
|
||||
lambda r: httpx.Response(200),
|
||||
connector=connector,
|
||||
sleeper=sleeper,
|
||||
max_reconnects=1,
|
||||
)
|
||||
assert [f async for f in transport.receive()] == ["a", "b"]
|
||||
assert slept == [1.0]
|
||||
|
||||
|
||||
async def test_receive_backs_off_exponentially(config: Config) -> None:
|
||||
slept: list[float] = []
|
||||
|
||||
async def sleeper(seconds: float) -> None:
|
||||
slept.append(seconds)
|
||||
|
||||
connector = FakeConnect([])
|
||||
transport = _transport(
|
||||
config,
|
||||
lambda r: httpx.Response(200),
|
||||
connector=connector,
|
||||
sleeper=sleeper,
|
||||
max_reconnects=3,
|
||||
)
|
||||
assert [f async for f in transport.receive()] == []
|
||||
assert slept == [1.0, 2.0, 4.0]
|
||||
|
||||
|
||||
async def _no_sleep(seconds: float) -> None:
|
||||
return None
|
||||
Loading…
Reference in New Issue
Block a user