mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his Latin name, reign and pl.wikipedia link. popes.json already carried a Commons portrait filename for all 267 and nothing had ever consumed it. The Latin name comes from the Latin column of Poczet papieży, not from the la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong: la.wikipedia numbers several popes differently, so #154 came out as "Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one. The table's own column says "Stephanus Nonus" and cannot disagree with the name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog. Portraits are cached on disk under CacheDirectory=signal-bot, named from the ordinal so no upstream string ever reaches a path, and bounded at 267 entries so nothing needs evicting. Verified that CacheDirectory wins against ProtectHome=read-only before relying on it. Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an account its own sends, so no command has ever been exercised end-to-end without asking a human to type it. This injects a real frame into the live dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS, which is empty by default. Verified against the real endpoints, not just the fake: 267/267 portraits resolve on Commons, regeneration is byte-identical on every previously shipped field, and !papież was driven 7 times through the live dispatcher in testbot, returning distinct popes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
parent
0d7cb99f5c
commit
558dd297a4
13
.env.sample
13
.env.sample
@ -57,6 +57,19 @@ SILENCE_SECONDS=
|
||||
# Optional. Image APIs. Overridden by scripts/e2e.sh to serve local fixtures.
|
||||
CAT_API_URL=
|
||||
DOG_API_URL=
|
||||
GECKO_API_URL=
|
||||
COMMONS_API_URL=
|
||||
|
||||
# Optional. Where !papież keeps the 267 papal portraits it has downloaded.
|
||||
# Default: $CACHE_DIRECTORY/portraits under systemd (the unit sets
|
||||
# CacheDirectory=signal-bot), otherwise $XDG_CACHE_HOME/signal-bot/portraits.
|
||||
PORTRAIT_CACHE_DIR=
|
||||
|
||||
# Optional. Groups scripts/selftest.sh may make the bot post into. Empty by
|
||||
# default, which makes POST /_selftest refuse everything -- Signal never
|
||||
# delivers an account its own sends, so this is the only way to exercise a
|
||||
# command without a second account, and it posts for real.
|
||||
SELFTEST_GROUPS=
|
||||
|
||||
# Optional. Where the /healthz readiness probe binds.
|
||||
# Default: 127.0.0.1:8137 -- deliberately not 8000, which unrelated dev
|
||||
|
||||
@ -38,6 +38,13 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about
|
||||
`/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can
|
||||
reach, and it was rightly called out.
|
||||
|
||||
## 0.0.7 — Telesfor
|
||||
|
||||
- nowa komenda `!papież` (działa też jako `!papiez`, `!papa`, `!pope`) — bot
|
||||
wrzuca losowego papieża: zdjęcie, imię po łacinie, lata pontyfikatu i link
|
||||
do polskiej Wikipedii. Do wyboru jest cała lista, 267 papieży od Świętego
|
||||
Piotra do Leona XIV — czyli ci sami, po których nazywają się wersje bota
|
||||
|
||||
## 0.0.6 — Sykstus I
|
||||
|
||||
- po restarcie komputera bot wraca sam — wcześniej, jeśli włączył się zanim
|
||||
|
||||
47
DOCS-selftest.md
Normal file
47
DOCS-selftest.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Testing a command without a second Signal account
|
||||
|
||||
Signal never delivers an account its own sends. The bot cannot type `!kot` at
|
||||
itself, so until now every command was verified one of two weak ways: by asking
|
||||
a human to type it, or by rebuilding the object graph in a scratch script. The
|
||||
first proves nothing about the path between the websocket and the handler; the
|
||||
second is not the wiring that runs in production.
|
||||
|
||||
`POST /_selftest` injects a real envelope frame into the **live** dispatcher.
|
||||
Everything downstream of the socket read is the code that actually serves the
|
||||
group: envelope parsing, the scoping gate, the tally, trigger resolution, the
|
||||
cooldown decorator and the real REST transport. Only the socket read is skipped.
|
||||
|
||||
## Using it
|
||||
|
||||
Name the groups it may post into -- in `.env`, because the service reads it:
|
||||
|
||||
```
|
||||
SELFTEST_GROUPS=testbot,catbot,finaltestbot
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
scripts/selftest.sh testbot '!papież' 3
|
||||
```
|
||||
|
||||
`SELFTEST_INTERVAL` (default 11s) spaces the calls past the per-user cooldown;
|
||||
the self-test is always the same author, so a tighter loop just exercises the
|
||||
cooldown instead of the command.
|
||||
|
||||
## What it will not do
|
||||
|
||||
It **posts for real**, into a real group. Two things keep that from becoming a
|
||||
way to make the bot say anything anywhere:
|
||||
|
||||
- The endpoint lives on the same loopback bind as `/healthz`, never a public one.
|
||||
- `SELFTEST_GROUPS` is **empty by default**, and any group not named in it is
|
||||
refused with 403. So a fresh checkout cannot post into the production group
|
||||
even by accident.
|
||||
|
||||
## Why the author matters
|
||||
|
||||
The reply quotes whoever asked, and `quote_author` has to be somebody Signal can
|
||||
resolve -- a made-up uuid comes back as `The user ... is not registered` and the
|
||||
whole send fails. `resolve_group` therefore attributes the message to a real
|
||||
member of the target group who is not the bot.
|
||||
10
README.md
10
README.md
@ -197,13 +197,12 @@ container, and `TRUSTED_INVITERS` decides who may add this bot to a group:
|
||||
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
|
||||
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
|
||||
| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` |
|
||||
| pope | `!papież` `!papiez` `!papa` `!pope` `!popes` `!pontifex` + 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.
|
||||
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.
|
||||
## Watching more than one group
|
||||
|
||||
`GROUP_IDS` takes a comma-separated list, so one process can serve a test
|
||||
@ -244,6 +243,7 @@ No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION`
|
||||
read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while
|
||||
`CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
|
||||
Releases are named after the popes, in order, and a bump publishes to the group: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
|
||||
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@ -33,6 +33,17 @@ def build_fixture_router() -> APIRouter:
|
||||
photo = {"url": f"{state.base_url}/_fixtures/photos/square.png"}
|
||||
return {"results": [{"photos": [photo]}]}
|
||||
|
||||
# Commons answers an imageinfo query, not a fixed URL. Echoing the
|
||||
# requested titles= back proves the bot asked for the pope it drew,
|
||||
# rather than a hard-coded file that would hide a lookup regression.
|
||||
@router.get("/commons-api")
|
||||
async def commons_api(request: Request) -> dict[str, object]:
|
||||
page = {
|
||||
"title": request.query_params.get("titles", ""),
|
||||
"imageinfo": [{"thumburl": _image_url(request)}],
|
||||
}
|
||||
return {"query": {"pages": [page]}}
|
||||
|
||||
@router.get("/image.png")
|
||||
async def image() -> Response:
|
||||
return Response(content=PIXEL_PNG, media_type="image/png")
|
||||
|
||||
5
fake_signal/scenarios/pope_command.yaml
Normal file
5
fake_signal/scenarios/pope_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!papież posts a portrait captioned with the pope's Latin name and article"
|
||||
inject:
|
||||
- { text: "!papież", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "signal-bot"
|
||||
version = "0.0.6"
|
||||
version = "0.0.7"
|
||||
description = "A Signal group bot: image commands and daily message tallies."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@ -130,6 +130,8 @@ main() {
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
||||
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
||||
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
SILENCE_SECONDS=0 \
|
||||
"$PYTHON" -m signal_bot &
|
||||
|
||||
64
scripts/selftest.sh
Executable file
64
scripts/selftest.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Type a command at the running bot on its own behalf.
|
||||
#
|
||||
# Signal never delivers an account its own sends, so this is the only way to
|
||||
# exercise a command end-to-end without a second Signal account. It posts for
|
||||
# real, into a real group -- SELFTEST_GROUPS decides which.
|
||||
#
|
||||
# scripts/selftest.sh testbot '!papież' 5
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
readonly BOT="http://127.0.0.1:${BIND_PORT:-8137}"
|
||||
|
||||
GROUP="${1:-}"
|
||||
TEXT="${2:-}"
|
||||
TIMES="${3:-1}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME <group-name> <text> [times]"
|
||||
echo " The group must be listed in SELFTEST_GROUPS, or the bot refuses."
|
||||
exit 0
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if [[ -z "$GROUP" || -z "$TEXT" ]]; then
|
||||
echo "Error: group and text are required" >&2
|
||||
usage
|
||||
fi
|
||||
if ! command -v jq >/dev/null; then
|
||||
echo "Error: jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! curl -sf "${BOT}/healthz" >/dev/null; then
|
||||
echo "Error: no healthy bot at ${BOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
validate_requirements
|
||||
local reply status
|
||||
for ((i = 1; i <= TIMES; i++)); do
|
||||
reply="$(curl -s -X POST "${BOT}/_selftest" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg g "$GROUP" --arg t "$TEXT" '{group:$g,text:$t}')")"
|
||||
status="$(jq -r '.status' <<<"$reply")"
|
||||
if [[ "$status" != "delivered" ]]; then
|
||||
echo " ${i}/${TIMES} ${status}: $(jq -r '.reason // "?"' <<<"$reply")" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ${i}/${TIMES} delivered ${TEXT} to ${GROUP}"
|
||||
# The cooldown is per user per command, and the self-test is always
|
||||
# the same user, so a tighter loop just proves the cooldown works.
|
||||
[[ $i -lt $TIMES ]] && sleep "${SELFTEST_INTERVAL:-11}"
|
||||
done
|
||||
echo "== done; look at ${GROUP} in Signal"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -34,7 +34,11 @@ ExecStart=@REPO_ROOT@/run.sh
|
||||
# shut down cleanly -- so exit 0 must still count as a restart.
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
# The bot writes only its SQLite tally database, inside the repo.
|
||||
# The bot writes its SQLite tally database inside the repo, and papal
|
||||
# portraits into the cache directory. CacheDirectory= is load-bearing:
|
||||
# ProtectHome=read-only makes the rest of $HOME immutable, and systemd
|
||||
# exempts only the directory it created, exporting it as $CACHE_DIRECTORY.
|
||||
CacheDirectory=signal-bot
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
|
||||
@ -10,5 +10,9 @@
|
||||
],
|
||||
"gecko": [
|
||||
"!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos"
|
||||
],
|
||||
"pope": [
|
||||
"!papież", "!papiez", "!papieze", "!papieża", "!papa",
|
||||
"!pope", "!popes", "!pontifex", "!𝗽𝗮𝗽𝗶𝗲𝘇"
|
||||
]
|
||||
}
|
||||
|
||||
@ -16,8 +16,11 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from signal_bot.config import load_config
|
||||
from signal_bot.selftest import SelfTestError
|
||||
from signal_bot.selftest import run as run_selftest
|
||||
from signal_bot.startup import Starter
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.versioning import POPE, __version__
|
||||
@ -55,6 +58,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
store = Store(config.database_path)
|
||||
starter = Starter(config, client, store)
|
||||
app.state.starter = starter
|
||||
app.state.client = client
|
||||
connecting = asyncio.create_task(starter.run())
|
||||
logger.info("serving on %s:%d while connecting to Signal", config.bind_host, config.bind_port)
|
||||
try:
|
||||
@ -69,6 +73,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
store.close()
|
||||
|
||||
|
||||
class SelfTestRequest(BaseModel):
|
||||
"""One command to type at the bot, and where."""
|
||||
|
||||
group: str
|
||||
text: str
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
@ -93,3 +104,23 @@ async def healthz(response: Response) -> dict[str, str]:
|
||||
return {"status": "ok", **build}
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "deaf", "reason": liveness.reason, **build}
|
||||
|
||||
|
||||
@app.post("/_selftest")
|
||||
async def selftest(request: SelfTestRequest, response: Response) -> dict[str, str]:
|
||||
"""Type a command at the bot on its own behalf, for verification.
|
||||
|
||||
Bound to loopback with the rest of the app, and refuses any group not
|
||||
named in SELFTEST_GROUPS. See signal_bot/selftest.py for why this exists.
|
||||
"""
|
||||
starter: Starter = app.state.starter
|
||||
runtime = starter.runtime
|
||||
if runtime is None:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "connecting", "reason": starter.reason}
|
||||
try:
|
||||
group_id = await run_selftest(runtime.bot, app.state.client, request.group, request.text)
|
||||
except SelfTestError as refusal:
|
||||
response.status_code = status.HTTP_403_FORBIDDEN
|
||||
return {"status": "refused", "reason": str(refusal)}
|
||||
return {"status": "delivered", "group": group_id, "text": request.text}
|
||||
|
||||
@ -20,6 +20,8 @@ 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.popes_command import register_pope_command
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.registry import CommandRegistry
|
||||
from signal_bot.versioning import POPE, __version__
|
||||
|
||||
@ -108,12 +110,19 @@ def build_bot(
|
||||
) -> Bot:
|
||||
"""Assemble the object graph from its injected dependencies."""
|
||||
registry = CommandRegistry()
|
||||
fetcher = ImageFetcher(client)
|
||||
rate_limit = cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE)
|
||||
register_animal_commands(
|
||||
registry,
|
||||
ImageFetcher(client),
|
||||
decorate=cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE),
|
||||
fetcher,
|
||||
decorate=rate_limit,
|
||||
endpoints=config.image_api_urls,
|
||||
)
|
||||
register_pope_command(
|
||||
registry,
|
||||
PortraitCache(fetcher, config.commons_api_url),
|
||||
decorate=rate_limit,
|
||||
)
|
||||
dispatcher = Dispatcher(
|
||||
config=config,
|
||||
directory=directory,
|
||||
|
||||
@ -11,6 +11,13 @@ from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from signal_bot.endpoints import (
|
||||
DEFAULT_CAT_API_URL,
|
||||
DEFAULT_COMMONS_API_URL,
|
||||
DEFAULT_DOG_API_URL,
|
||||
DEFAULT_GECKO_API_URL,
|
||||
)
|
||||
|
||||
# Re-exported: every caller has always imported these from config, and the
|
||||
# split is a filing change, not an interface change.
|
||||
from signal_bot.groups import (
|
||||
@ -23,6 +30,10 @@ from signal_bot.groups import (
|
||||
from signal_bot.trust import parse_trusted_inviters
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CAT_API_URL",
|
||||
"DEFAULT_COMMONS_API_URL",
|
||||
"DEFAULT_DOG_API_URL",
|
||||
"DEFAULT_GECKO_API_URL",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"canonical_group_id",
|
||||
@ -36,18 +47,6 @@ 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"
|
||||
# taxon_id 371057 is Gekkota, the gecko infraorder. The licence filter is not
|
||||
# decoration: without it the API happily returns all-rights-reserved photos,
|
||||
# and this bot reposts whatever it is handed into a group chat.
|
||||
# Deliberately no order_by=random -- see animals.paged_url for why it does
|
||||
# not work and what supplies the randomness instead.
|
||||
DEFAULT_GECKO_API_URL: Final = (
|
||||
"https://api.inaturalist.org/v1/observations"
|
||||
"?taxon_id=371057&photos=true&quality_grade=research"
|
||||
"&photo_license=cc-by,cc-by-sa,cc0&per_page=1"
|
||||
)
|
||||
DEFAULT_BIND_HOST: Final = "127.0.0.1"
|
||||
# Not 8000: that port is a magnet for unrelated dev servers and was already
|
||||
# taken here by a long-running enforcer. 8137 sits below the ephemeral range
|
||||
@ -84,6 +83,7 @@ class Config:
|
||||
cat_api_url: str = DEFAULT_CAT_API_URL
|
||||
dog_api_url: str = DEFAULT_DOG_API_URL
|
||||
gecko_api_url: str = DEFAULT_GECKO_API_URL
|
||||
commons_api_url: str = DEFAULT_COMMONS_API_URL
|
||||
# Where the /healthz readiness probe binds. Read here rather than in
|
||||
# __main__ so that .env governs it: __main__ runs before anything has
|
||||
# loaded a .env file, so an os.getenv() there would silently ignore it.
|
||||
@ -238,6 +238,7 @@ def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -
|
||||
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,
|
||||
gecko_api_url=env.get("GECKO_API_URL", "").strip() or DEFAULT_GECKO_API_URL,
|
||||
commons_api_url=env.get("COMMONS_API_URL", "").strip() or DEFAULT_COMMONS_API_URL,
|
||||
bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST,
|
||||
bind_port=bind_port,
|
||||
log_level=log_level,
|
||||
|
||||
32
signal_bot/endpoints.py
Normal file
32
signal_bot/endpoints.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Default upstream endpoints for the image commands.
|
||||
|
||||
Split out of config.py, which had reached the 250-line cap. Like the groups.py
|
||||
and trust.py splits before it this is a filing change, not an interface change:
|
||||
config re-exports every name here, so nothing that imported them has to move.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
|
||||
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
|
||||
# taxon_id 371057 is Gekkota, the gecko infraorder. The licence filter is not
|
||||
# decoration: without it the API happily returns all-rights-reserved photos,
|
||||
# and this bot reposts whatever it is handed into a group chat.
|
||||
# Deliberately no order_by=random -- see animals.paged_url for why it does
|
||||
# not work and what supplies the randomness instead.
|
||||
DEFAULT_GECKO_API_URL: Final = (
|
||||
"https://api.inaturalist.org/v1/observations"
|
||||
"?taxon_id=371057&photos=true&quality_grade=research"
|
||||
"&photo_license=cc-by,cc-by-sa,cc0&per_page=1"
|
||||
)
|
||||
# Commons resolves a portrait *filename* to a real upload.wikimedia.org URL.
|
||||
# Special:FilePath would be shorter but answers 302, and this repo's httpx
|
||||
# client does not follow redirects; imageinfo hands back the final URL and a
|
||||
# thumbnail in one hop, which is also what keeps a 4000px original off Signal.
|
||||
DEFAULT_COMMONS_API_URL: Final = "https://commons.wikimedia.org/w/api.php"
|
||||
# Wide enough to look like a photograph rather than an avatar, small enough
|
||||
# that the whole 267-portrait cache stays well under 100 MB.
|
||||
PORTRAIT_WIDTH: Final = 640
|
||||
@ -33,7 +33,17 @@ class ImageFetcher:
|
||||
url_path: Sequence[str | int],
|
||||
rewrite: tuple[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Fetch an image and return it as base64.
|
||||
"""Fetch an image and return it base64-encoded, ready for Signal."""
|
||||
return base64.b64encode(await self.fetch_bytes(api_url, url_path, rewrite)).decode("ascii")
|
||||
|
||||
async def fetch_bytes(
|
||||
self,
|
||||
api_url: str,
|
||||
url_path: Sequence[str | int],
|
||||
rewrite: tuple[str, str] | None = None,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> bytes:
|
||||
"""Fetch an image and return its raw bytes.
|
||||
|
||||
Args:
|
||||
api_url: JSON endpoint that names an image.
|
||||
@ -45,23 +55,30 @@ class ImageFetcher:
|
||||
real photo -- and a substitution keeps that upstream quirk as
|
||||
data next to the endpoint rather than as a branch in here.
|
||||
|
||||
params: Query string for the JSON endpoint, when it is easier to
|
||||
pass than to embed -- the Commons API takes a title that has
|
||||
to be percent-encoded, the animal APIs are fixed URLs.
|
||||
|
||||
Returns:
|
||||
The image bytes, base64-encoded.
|
||||
The image bytes.
|
||||
|
||||
Raises:
|
||||
ImageFetchError: The JSON did not contain a usable URL.
|
||||
"""
|
||||
response = await self._client.get(api_url)
|
||||
response = await self._client.get(api_url, params=params)
|
||||
response.raise_for_status()
|
||||
image_url = _walk(response.json(), url_path)
|
||||
if rewrite is not None:
|
||||
image_url = image_url.replace(*rewrite)
|
||||
|
||||
# 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.
|
||||
# These bytes are never written to a path built from the remote URL:
|
||||
# the original bot named a local file after the URL's last path
|
||||
# segment and wrote it into the process CWD, which is both path
|
||||
# traversal and unbounded. portraits.py does cache to disk, but under
|
||||
# a name it derives from the pope's ordinal, never from upstream.
|
||||
image = await self._client.get(image_url)
|
||||
image.raise_for_status()
|
||||
return base64.b64encode(image.content).decode("ascii")
|
||||
return image.content
|
||||
|
||||
|
||||
_MISSING: Final = object()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
74
signal_bot/popes_command.py
Normal file
74
signal_bot/popes_command.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The !papież command: a random pope, his portrait, his Latin name, his article.
|
||||
|
||||
The list this draws from is the same signal_bot/popes.json the version ladder
|
||||
is built on, so the bot posts the popes it is named after.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from urllib.parse import quote
|
||||
|
||||
from signal_bot.animals import load_aliases
|
||||
from signal_bot.versioning import POPES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, Handler
|
||||
from signal_bot.versioning import Pope
|
||||
|
||||
COMMAND_NAME: Final = "pope"
|
||||
WIKI_BASE: Final = "https://pl.wikipedia.org/wiki/"
|
||||
|
||||
|
||||
def random_pope() -> Pope:
|
||||
"""Return one pope, uniformly at random.
|
||||
|
||||
secrets rather than random for the reason animals.paged_url gives: the
|
||||
choice is not security-sensitive, but ruff's ALL rule set flags `random`
|
||||
and this repo forbids the noqa that would silence it.
|
||||
"""
|
||||
return POPES[secrets.randbelow(len(POPES))]
|
||||
|
||||
|
||||
def article_url(pope: Pope) -> str:
|
||||
"""Return the pl.wikipedia URL for a pope.
|
||||
|
||||
Built from title, not name: half the early popes live at "Ewaryst
|
||||
(papież)" and only the title carries the disambiguator.
|
||||
"""
|
||||
return WIKI_BASE + quote(pope.title.replace(" ", "_"))
|
||||
|
||||
|
||||
def caption_for(pope: Pope) -> str:
|
||||
"""Return the message that rides along with the portrait."""
|
||||
return f"{pope.ordinal}. {pope.name} — {pope.latin} ({pope.reign})\n{article_url(pope)}"
|
||||
|
||||
|
||||
def pope_handler(cache: PortraitCache) -> Handler:
|
||||
"""Build the handler that posts a random pope."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pope = random_pope()
|
||||
image = await cache.get_base64(pope)
|
||||
await ctx.reply_image(image, caption_for(pope))
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def register_pope_command(
|
||||
registry: CommandRegistry,
|
||||
cache: PortraitCache,
|
||||
*,
|
||||
decorate: Decorator | None = None,
|
||||
aliases: dict[str, list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Register !papież under every trigger aliases.json gives it."""
|
||||
table = load_aliases() if aliases is None else aliases
|
||||
handler = pope_handler(cache)
|
||||
if decorate is not None:
|
||||
handler = decorate(handler)
|
||||
registry.register(COMMAND_NAME, table[COMMAND_NAME], handler)
|
||||
130
signal_bot/portraits.py
Normal file
130
signal_bot/portraits.py
Normal file
@ -0,0 +1,130 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""On-disk cache of papal portraits, keyed by ordinal.
|
||||
|
||||
The set is closed: 267 popes, one portrait each, so this cache is bounded by
|
||||
construction and never evicts. That is what makes writing images to disk safe
|
||||
here when http_client refuses to -- the filename comes from the ordinal, so
|
||||
nothing upstream controls a path, and the directory belongs to systemd.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.endpoints import PORTRAIT_WIDTH
|
||||
from signal_bot.http_client import ImageFetchError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.versioning import Pope
|
||||
|
||||
# Where imageinfo puts the thumbnail URL in its reply.
|
||||
_THUMB_PATH: Final = ("query", "pages", 0, "imageinfo", 0, "thumburl")
|
||||
# Commons hosts these four for the 267 portraits: 245 jpg, 15 png, 6 gif,
|
||||
# 1 jpeg. An unknown suffix is refused rather than guessed, because the
|
||||
# extension is what tells Signal how to render the attachment.
|
||||
_SUFFIXES: Final = frozenset({".jpg", ".jpeg", ".png", ".gif"})
|
||||
|
||||
|
||||
def default_cache_dir() -> Path:
|
||||
"""Return the portrait directory, preferring the one systemd hands us.
|
||||
|
||||
Under the unit CACHE_DIRECTORY is set and writable even though
|
||||
ProtectHome=read-only makes the rest of $HOME immutable; outside it -- a
|
||||
test run, a shell -- XDG applies. Mirrors scripts/signal_api_watchdog.sh.
|
||||
"""
|
||||
explicit = os.environ.get("PORTRAIT_CACHE_DIR", "").strip()
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
cache = os.environ.get("CACHE_DIRECTORY", "").strip()
|
||||
if cache:
|
||||
return Path(cache) / "portraits"
|
||||
home = os.environ.get("XDG_CACHE_HOME", "").strip() or str(Path.home() / ".cache")
|
||||
return Path(home) / "signal-bot" / "portraits"
|
||||
|
||||
|
||||
class PortraitCache:
|
||||
"""Fetches a pope's portrait once, then serves it from disk forever."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fetcher: ImageFetcher,
|
||||
api_url: str,
|
||||
cache_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""Store the fetcher, the Commons endpoint and where files land."""
|
||||
self._fetcher = fetcher
|
||||
self._api_url = api_url
|
||||
self._dir = default_cache_dir() if cache_dir is None else cache_dir
|
||||
|
||||
def path_for(self, pope: Pope) -> Path:
|
||||
"""Return the cache path for one pope.
|
||||
|
||||
Named from the ordinal, never from the Commons filename: those carry
|
||||
spaces, parentheses, commas and non-ASCII, and are attacker-adjacent
|
||||
data in a way an integer is not.
|
||||
"""
|
||||
suffix = Path(pope.portrait).suffix.lower()
|
||||
if suffix not in _SUFFIXES:
|
||||
msg = f"portrait for #{pope.ordinal} has an unusable suffix: {pope.portrait!r}"
|
||||
raise ImageFetchError(msg)
|
||||
return self._dir / f"{pope.ordinal:03d}{suffix}"
|
||||
|
||||
async def get_base64(self, pope: Pope) -> str:
|
||||
"""Return the portrait base64-encoded, as the transport wants it."""
|
||||
return base64.b64encode(await self.get_bytes(pope)).decode("ascii")
|
||||
|
||||
async def get_bytes(self, pope: Pope) -> bytes:
|
||||
"""Return the portrait bytes, downloading them only on a miss."""
|
||||
path = self.path_for(pope)
|
||||
cached = _read(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
image = await self._fetcher.fetch_bytes(
|
||||
self._api_url,
|
||||
_THUMB_PATH,
|
||||
params={
|
||||
"action": "query",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "url",
|
||||
"iiurlwidth": str(PORTRAIT_WIDTH),
|
||||
"titles": f"File:{pope.portrait}",
|
||||
},
|
||||
)
|
||||
_write(path, image)
|
||||
return image
|
||||
|
||||
|
||||
def _read(path: Path) -> bytes | None:
|
||||
"""Return cached bytes, or None when there is nothing usable there.
|
||||
|
||||
An empty file is a miss, not a hit: that is what a crash between create
|
||||
and write leaves behind, and serving it would post a broken attachment
|
||||
into the group forever.
|
||||
"""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return data or None
|
||||
|
||||
|
||||
def _write(path: Path, image: bytes) -> None:
|
||||
"""Write the portrait atomically, ignoring a cache that will not take it.
|
||||
|
||||
A read-only or missing cache directory must not cost the group its photo
|
||||
-- the bytes are already in hand. The next call simply misses again.
|
||||
"""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_bytes(image)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
return
|
||||
@ -47,9 +47,11 @@ class CommandContext:
|
||||
"""
|
||||
await self.transport.send_text(text, self.group_id, quote_of(self.message))
|
||||
|
||||
async def reply_image(self, image_base64: str) -> None:
|
||||
async def reply_image(self, image_base64: str, caption: str = "") -> None:
|
||||
"""Answer the triggering message with an image, quoting it."""
|
||||
await self.transport.send_image(image_base64, self.group_id, quote_of(self.message))
|
||||
await self.transport.send_image(
|
||||
image_base64, self.group_id, quote_of(self.message), caption
|
||||
)
|
||||
|
||||
|
||||
Handler = Callable[[CommandContext], Awaitable[None]]
|
||||
|
||||
109
signal_bot/selftest.py
Normal file
109
signal_bot/selftest.py
Normal file
@ -0,0 +1,109 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Let the bot exercise its own commands without a second Signal account.
|
||||
|
||||
Signal never delivers an account its own sends, so the bot cannot type
|
||||
`!papież` at itself, and every command shipped so far was verified either by
|
||||
asking a human to type it or by reconstructing the object graph in a scratch
|
||||
script. Both are weaker than they look: the scratch script is not the wiring
|
||||
that runs in production, and a human typing it proves nothing about the path
|
||||
between the websocket and the handler.
|
||||
|
||||
This injects a real envelope frame into the *live* dispatcher, so everything
|
||||
downstream of the websocket read is the code that actually serves the group:
|
||||
parsing, the scoping gate, the tally, trigger resolution, the cooldown, the
|
||||
handler and the real transport. Only the socket read itself is skipped.
|
||||
|
||||
Fail-closed: SELFTEST_GROUPS names the groups this may post into, and is
|
||||
empty by default. Without it the endpoint refuses, so a loopback caller
|
||||
cannot make the bot post into the real group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
from signal_bot.bot import Bot
|
||||
from signal_bot.config import Config
|
||||
|
||||
GROUPS_ENV: Final = "SELFTEST_GROUPS"
|
||||
|
||||
|
||||
class SelfTestError(RuntimeError):
|
||||
"""Raised when a self-test cannot be run as asked."""
|
||||
|
||||
|
||||
def allowed_groups(env: Mapping[str, str]) -> frozenset[str]:
|
||||
"""Return the group names the self-test may post into.
|
||||
|
||||
Empty unless SELFTEST_GROUPS says otherwise. A bot that would replay
|
||||
arbitrary text into the production group on a loopback POST is a worse
|
||||
problem than the one this module solves.
|
||||
"""
|
||||
raw = env.get(GROUPS_ENV, "")
|
||||
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
||||
|
||||
|
||||
async def resolve_group(client: httpx.AsyncClient, config: Config, name: str) -> tuple[str, str]:
|
||||
"""Return (internal group id, an author to attribute the message to).
|
||||
|
||||
The author must be somebody Signal can resolve, because the reply quotes
|
||||
them: a made-up uuid comes back as "user ... is not registered" and the
|
||||
send fails. Any real member other than the bot will do.
|
||||
"""
|
||||
response = await client.get(config.groups_url)
|
||||
response.raise_for_status()
|
||||
for entry in response.json():
|
||||
if entry.get("name") != name:
|
||||
continue
|
||||
group_id = str(entry.get("internal_id") or entry.get("id") or "")
|
||||
members = [m for m in entry.get("members", []) if m != config.phone_number]
|
||||
if not group_id or not members:
|
||||
msg = f"group {name!r} has no usable id or no other member"
|
||||
raise SelfTestError(msg)
|
||||
return group_id, str(members[0])
|
||||
msg = f"no group named {name!r}"
|
||||
raise SelfTestError(msg)
|
||||
|
||||
|
||||
def build_frame(group_id: str, author: str, text: str) -> str:
|
||||
"""Build the websocket frame signal-cli would emit for this message."""
|
||||
stamp = int(time.time() * 1000)
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": stamp,
|
||||
"message": text,
|
||||
"groupInfo": {"groupId": group_id, "type": "DELIVER"},
|
||||
}
|
||||
envelope: dict[str, Any] = {
|
||||
"source": author,
|
||||
"sourceNumber": author,
|
||||
"sourceUuid": author,
|
||||
"sourceName": "self-test",
|
||||
"sourceDevice": 1,
|
||||
"timestamp": stamp,
|
||||
"dataMessage": payload,
|
||||
}
|
||||
return json.dumps({"envelope": envelope, "account": author})
|
||||
|
||||
|
||||
async def run(
|
||||
bot: Bot,
|
||||
client: httpx.AsyncClient,
|
||||
group: str,
|
||||
text: str,
|
||||
env: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Feed one synthetic message through the live dispatcher."""
|
||||
if group not in allowed_groups(os.environ if env is None else env):
|
||||
msg = f"{group!r} is not in {GROUPS_ENV}; refusing to post there"
|
||||
raise SelfTestError(msg)
|
||||
group_id, author = await resolve_group(client, bot.config, group)
|
||||
await bot.dispatcher.handle(build_frame(group_id, author, text))
|
||||
return group_id
|
||||
@ -27,9 +27,17 @@ class Transport(Protocol):
|
||||
"""Send a plain-text message, optionally quoting one message."""
|
||||
|
||||
async def send_image(
|
||||
self, image_base64: str, recipient: str, quote: Quote | None = None
|
||||
self,
|
||||
image_base64: str,
|
||||
recipient: str,
|
||||
quote: Quote | None = None,
|
||||
caption: str = "",
|
||||
) -> None:
|
||||
"""Send a base64-encoded image attachment, optionally quoting one message."""
|
||||
"""Send an image attachment, optionally captioned and quoting a message.
|
||||
|
||||
/v2/send carries the caption in the same payload as the attachment, so
|
||||
a captioned photo is one message in the group rather than two.
|
||||
"""
|
||||
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the ids of attachments stored server-side."""
|
||||
|
||||
@ -24,6 +24,7 @@ class SentMessage:
|
||||
recipient: str
|
||||
text: str | None = None
|
||||
image_base64: str | None = None
|
||||
caption: str = ""
|
||||
quote: Quote | None = None
|
||||
|
||||
|
||||
@ -50,10 +51,18 @@ class FakeTransport:
|
||||
self.sent.append(SentMessage(recipient=recipient, text=text, quote=quote))
|
||||
|
||||
async def send_image(
|
||||
self, image_base64: str, recipient: str, quote: Quote | None = None
|
||||
self,
|
||||
image_base64: str,
|
||||
recipient: str,
|
||||
quote: Quote | None = None,
|
||||
caption: str = "",
|
||||
) -> None:
|
||||
"""Record an image send."""
|
||||
self.sent.append(SentMessage(recipient=recipient, image_base64=image_base64, quote=quote))
|
||||
self.sent.append(
|
||||
SentMessage(
|
||||
recipient=recipient, image_base64=image_base64, quote=quote, caption=caption
|
||||
)
|
||||
)
|
||||
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the configured attachment ids."""
|
||||
|
||||
@ -125,11 +125,20 @@ class RestTransport:
|
||||
await self._post_send(payload)
|
||||
|
||||
async def send_image(
|
||||
self, image_base64: str, recipient: str, quote: Quote | None = None
|
||||
self,
|
||||
image_base64: str,
|
||||
recipient: str,
|
||||
quote: Quote | None = None,
|
||||
caption: str = "",
|
||||
) -> None:
|
||||
"""Send a base64-encoded image attachment, optionally quoting one message."""
|
||||
"""Send an image attachment, captioned in the same payload.
|
||||
|
||||
Verified against the container on 2026-08-30: a non-empty "message"
|
||||
alongside base64_attachments comes back as one timestamp, so Signal
|
||||
renders it as a single captioned photo rather than two messages.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"message": "",
|
||||
"message": caption,
|
||||
"base64_attachments": [image_base64],
|
||||
"number": self._config.phone_number,
|
||||
"recipients": [recipient],
|
||||
|
||||
@ -28,7 +28,7 @@ PATCHES_PER_MINOR: Final = 8
|
||||
|
||||
# Which release this build is. Bump by one per release; everything else,
|
||||
# including the version string in pyproject.toml, follows from it.
|
||||
RELEASE_ORDINAL: Final = 7
|
||||
RELEASE_ORDINAL: Final = 8
|
||||
|
||||
_POPES_FILE: Final = Path(__file__).with_name("popes.json")
|
||||
|
||||
@ -39,8 +39,12 @@ class Pope:
|
||||
|
||||
ordinal: int
|
||||
name: str
|
||||
# The pl.wikipedia article title, which is name plus any "(papież)"
|
||||
# disambiguator -- so it is the URL slug, and name is not.
|
||||
title: str
|
||||
reign: str
|
||||
portrait: str
|
||||
latin: str
|
||||
|
||||
|
||||
def _load() -> tuple[Pope, ...]:
|
||||
@ -50,8 +54,10 @@ def _load() -> tuple[Pope, ...]:
|
||||
Pope(
|
||||
ordinal=int(entry["ordinal"]),
|
||||
name=str(entry["name"]),
|
||||
title=str(entry["title"]),
|
||||
reign=str(entry["reign"]),
|
||||
portrait=str(entry["portrait"]),
|
||||
latin=str(entry["latin"]),
|
||||
)
|
||||
for entry in document["popes"]
|
||||
)
|
||||
|
||||
@ -18,6 +18,7 @@ from signal_bot.trust import InviteTrust
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
# Canonical ``group.<base64>`` ids, the spelling /v1/groups reports and the
|
||||
# only one /v2/send accepts. The *_INTERNAL forms are what an envelope
|
||||
@ -86,6 +87,18 @@ async def directory(config: Config, store: Store) -> GroupDirectory:
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _portraits_stay_out_of_the_real_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point the portrait cache at a temp dir for every test in the suite.
|
||||
|
||||
Without this, any test that runs the !papież handler writes into the
|
||||
caller's real ~/.cache/signal-bot/portraits -- silently, and while still
|
||||
passing. Autouse because the write happens several layers below the test
|
||||
that triggers it, so opting in per test is a rule nobody would remember.
|
||||
"""
|
||||
monkeypatch.setenv("PORTRAIT_CACHE_DIR", str(tmp_path / "portraits"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> Store:
|
||||
with Store(":memory:") as opened:
|
||||
|
||||
@ -84,6 +84,13 @@ def test_fixture_apis_mirror_the_real_response_shapes(client: TestClient) -> Non
|
||||
assert cat[0]["url"].endswith("/_fixtures/image.png")
|
||||
assert dog["message"].endswith("/_fixtures/image.png")
|
||||
assert gecko["results"][0]["photos"][0]["url"].endswith("/_fixtures/photos/square.png")
|
||||
commons = client.get("/_fixtures/commons-api", params={"titles": "File:8-St.jpg"}).json()
|
||||
page = commons["query"]["pages"][0]
|
||||
# thumburl only, never url: the bot must ask for a bounded thumbnail.
|
||||
# One committed portrait is 37 MB, which is not a Signal attachment.
|
||||
assert page["imageinfo"][0]["thumburl"].endswith("/_fixtures/image.png")
|
||||
assert "url" not in page["imageinfo"][0]
|
||||
assert page["title"] == "File:8-St.jpg"
|
||||
assert client.get("/_fixtures/image.png").content == PIXEL_PNG
|
||||
# The square URL is deliberately absent: the bot must rewrite it.
|
||||
assert client.get("/_fixtures/photos/large.png").content == PIXEL_PNG
|
||||
|
||||
150
tests/test_popes_command.py
Normal file
150
tests/test_popes_command.py
Normal file
@ -0,0 +1,150 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The !papież command: a random pope, captioned and linked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from signal_bot.decorators import cooldown
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.popes_command import (
|
||||
COMMAND_NAME,
|
||||
article_url,
|
||||
caption_for,
|
||||
random_pope,
|
||||
register_pope_command,
|
||||
)
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from signal_bot.versioning import POPES
|
||||
from tests.conftest import WATCHED_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
IMAGE = b"portrait-bytes"
|
||||
ALIASES = {COMMAND_NAME: ["!papież", "!papiez", "!pope"]}
|
||||
|
||||
|
||||
def _cache(tmp_path: Path) -> PortraitCache:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "commons" in str(request.url):
|
||||
page = {"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}
|
||||
return httpx.Response(200, json={"query": {"pages": [page]}})
|
||||
return httpx.Response(200, content=IMAGE)
|
||||
|
||||
fetcher = ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
return PortraitCache(fetcher, "https://commons.example/w/api.php", tmp_path)
|
||||
|
||||
|
||||
def _ctx(transport: FakeTransport, store: Store, config: Config, text: str) -> CommandContext:
|
||||
message = parse_envelope(envelope(text=text))
|
||||
assert message is not None
|
||||
return CommandContext(
|
||||
message=message,
|
||||
group_id=WATCHED_GROUP,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name=COMMAND_NAME,
|
||||
now=100.0,
|
||||
day="2026-08-30",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trigger", ["!papież", "!papiez", "!pope"])
|
||||
def test_every_alias_resolves(trigger: str) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_pope_command(registry, _cache(Path("/nonexistent")), aliases=ALIASES)
|
||||
|
||||
command = registry.resolve(trigger)
|
||||
assert command is not None
|
||||
assert command.name == COMMAND_NAME
|
||||
|
||||
|
||||
def test_the_real_alias_file_carries_the_command() -> None:
|
||||
registry = CommandRegistry()
|
||||
register_pope_command(registry, _cache(Path("/nonexistent")))
|
||||
# The Polish spelling is the one kuhy asked for; folding makes the rest
|
||||
# of the keyboard layouts work, but this one must be listed outright.
|
||||
assert registry.resolve("!papież") is not None
|
||||
|
||||
|
||||
async def test_it_posts_a_portrait_with_the_caption_attached(
|
||||
tmp_path: Path, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_pope_command(registry, _cache(tmp_path), aliases=ALIASES)
|
||||
command = registry.resolve("!papież")
|
||||
assert command is not None
|
||||
|
||||
await command.handler(_ctx(transport, store, config, "!papież"))
|
||||
|
||||
sent = transport.sent[0]
|
||||
assert sent.recipient == WATCHED_GROUP
|
||||
assert sent.image_base64 is not None
|
||||
assert base64.b64decode(sent.image_base64) == IMAGE
|
||||
# One message, not two: the caption rides in the same payload.
|
||||
assert len(transport.sent) == 1
|
||||
assert sent.caption.startswith(tuple(str(p.ordinal) for p in POPES))
|
||||
assert "https://pl.wikipedia.org/wiki/" in sent.caption
|
||||
|
||||
|
||||
async def test_the_cooldown_decorator_applies(
|
||||
tmp_path: Path, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_pope_command(
|
||||
registry, _cache(tmp_path), decorate=cooldown(10, "WAIT {seconds}"), aliases=ALIASES
|
||||
)
|
||||
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-30")
|
||||
command = registry.resolve("!pope")
|
||||
assert command is not None
|
||||
|
||||
await command.handler(_ctx(transport, store, config, "!pope"))
|
||||
|
||||
assert transport.texts == ["WAIT 11"]
|
||||
|
||||
|
||||
def test_the_caption_names_the_pope_in_both_languages() -> None:
|
||||
pope = POPES[263]
|
||||
assert pope.name == "Jan Paweł II"
|
||||
assert caption_for(pope) == (
|
||||
"264. Jan Paweł II — Ioannes Paulus Secundus (1978-2005)\n"
|
||||
"https://pl.wikipedia.org/wiki/Jan_Pawe%C5%82_II"
|
||||
)
|
||||
|
||||
|
||||
def test_the_latin_name_never_contradicts_the_polish_number() -> None:
|
||||
"""Both names come from the same table row, so they cannot disagree.
|
||||
|
||||
la.wikipedia titles #154 "Stephanus X" while every other source calls
|
||||
him the ninth, and "Stefan IX — Stephanus X" in one caption reads as a
|
||||
bug. The Latin column of Poczet papieży says "Stephanus Nonus".
|
||||
"""
|
||||
assert POPES[153].name == "Stefan IX"
|
||||
assert POPES[153].latin == "Stephanus Nonus"
|
||||
assert POPES[266].latin == "Leo Decimus Quartus"
|
||||
|
||||
|
||||
def test_the_url_uses_the_title_so_disambiguated_popes_still_resolve() -> None:
|
||||
telesfor = POPES[7]
|
||||
assert telesfor.name == "Telesfor"
|
||||
# Half the early popes live at "<name> (papież)"; name alone 404s.
|
||||
assert article_url(telesfor) == "https://pl.wikipedia.org/wiki/Telesfor_%28papie%C5%BC%29"
|
||||
|
||||
|
||||
def test_the_draw_actually_varies() -> None:
|
||||
# 0.0.3 shipped a !gekon that posted one identical photo forever because
|
||||
# nobody drew twice. Twenty draws from 267 popes repeating is a 1-in-267^19
|
||||
# event; anything that pins the choice fails here immediately.
|
||||
drawn = {random_pope().ordinal for _ in range(20)}
|
||||
assert len(drawn) > 1
|
||||
152
tests/test_portraits.py
Normal file
152
tests/test_portraits.py
Normal file
@ -0,0 +1,152 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The on-disk papal portrait cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from signal_bot.http_client import ImageFetcher, ImageFetchError
|
||||
from signal_bot.portraits import PortraitCache, default_cache_dir
|
||||
from signal_bot.versioning import POPES, Pope
|
||||
|
||||
IMAGE = b"portrait-bytes"
|
||||
PIUS = POPES[7]
|
||||
|
||||
|
||||
def _names(directory: Path) -> list[str]:
|
||||
"""List a directory. A helper so the async tests touch no pathlib."""
|
||||
return sorted(entry.name for entry in directory.iterdir())
|
||||
|
||||
|
||||
def _truncate(path: Path) -> None:
|
||||
"""Leave behind what a crash between create and write would leave."""
|
||||
path.write_bytes(b"")
|
||||
|
||||
|
||||
def _mock_fetcher(seen: list[str] | None = None) -> ImageFetcher:
|
||||
"""Build a fetcher whose Commons endpoint is a mock returning IMAGE."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if seen is not None:
|
||||
seen.append(str(request.url))
|
||||
if "commons" in str(request.url):
|
||||
page = {"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}
|
||||
return httpx.Response(200, json={"query": {"pages": [page]}})
|
||||
return httpx.Response(200, content=IMAGE)
|
||||
|
||||
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
def _cache(tmp_path: Path, seen: list[str] | None = None) -> PortraitCache:
|
||||
"""Build a cache writing into tmp_path."""
|
||||
return PortraitCache(_mock_fetcher(seen), "https://commons.example/w/api.php", tmp_path)
|
||||
|
||||
|
||||
async def test_a_miss_downloads_and_writes_the_file(tmp_path: Path) -> None:
|
||||
seen: list[str] = []
|
||||
cache = _cache(tmp_path, seen)
|
||||
|
||||
assert await cache.get_bytes(PIUS) == IMAGE
|
||||
|
||||
assert _names(tmp_path) == [f"{PIUS.ordinal:03d}.jpg"]
|
||||
assert any("titles=File" in url for url in seen)
|
||||
|
||||
|
||||
async def test_a_miss_leaves_no_temporary_file_behind(tmp_path: Path) -> None:
|
||||
await _cache(tmp_path).get_bytes(PIUS)
|
||||
assert [name for name in _names(tmp_path) if name.endswith(".tmp")] == []
|
||||
|
||||
|
||||
async def test_a_hit_makes_no_http_request(tmp_path: Path) -> None:
|
||||
seen: list[str] = []
|
||||
cache = _cache(tmp_path, seen)
|
||||
await cache.get_bytes(PIUS)
|
||||
seen.clear()
|
||||
|
||||
assert await cache.get_bytes(PIUS) == IMAGE
|
||||
assert seen == []
|
||||
|
||||
|
||||
async def test_an_empty_cached_file_is_refetched(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
# Serving this would post a broken attachment for as long as it lived.
|
||||
_truncate(cache.path_for(PIUS))
|
||||
|
||||
assert await cache.get_bytes(PIUS) == IMAGE
|
||||
|
||||
|
||||
async def test_an_unwritable_cache_still_serves_the_photo(tmp_path: Path) -> None:
|
||||
cache = PortraitCache(
|
||||
_mock_fetcher(),
|
||||
"https://commons.example/w/api.php",
|
||||
tmp_path / "nested" / "file.txt" / "deeper",
|
||||
)
|
||||
(tmp_path / "nested").mkdir()
|
||||
(tmp_path / "nested" / "file.txt").write_text("not a directory")
|
||||
|
||||
assert await cache.get_bytes(PIUS) == IMAGE
|
||||
|
||||
|
||||
async def test_get_base64_encodes_what_get_bytes_returns(tmp_path: Path) -> None:
|
||||
encoded = await _cache(tmp_path).get_base64(PIUS)
|
||||
assert base64.b64decode(encoded) == IMAGE
|
||||
|
||||
|
||||
def test_the_filename_comes_from_the_ordinal_not_from_commons(tmp_path: Path) -> None:
|
||||
awkward = Pope(
|
||||
ordinal=9,
|
||||
name="Test",
|
||||
title="Test",
|
||||
reign="1-2",
|
||||
portrait="../../etc/passwd, and a space (v2).PNG",
|
||||
latin="Testus",
|
||||
)
|
||||
assert _cache(tmp_path).path_for(awkward) == tmp_path / "009.png"
|
||||
|
||||
|
||||
def test_an_unknown_suffix_is_refused_rather_than_guessed(tmp_path: Path) -> None:
|
||||
weird = Pope(ordinal=9, name="T", title="T", reign="1-2", portrait="x.svg", latin="T")
|
||||
with pytest.raises(ImageFetchError, match="unusable suffix"):
|
||||
_cache(tmp_path).path_for(weird)
|
||||
|
||||
|
||||
def test_every_committed_portrait_has_a_suffix_the_cache_accepts(tmp_path: Path) -> None:
|
||||
cache = _cache(tmp_path)
|
||||
for pope in POPES:
|
||||
assert cache.path_for(pope).parent == tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:
|
||||
"""A monkeypatch with every cache-directory variable removed."""
|
||||
for key in ("PORTRAIT_CACHE_DIR", "CACHE_DIRECTORY", "XDG_CACHE_HOME"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def test_an_explicit_directory_wins(clean_env: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
clean_env.setenv("PORTRAIT_CACHE_DIR", str(tmp_path / "explicit"))
|
||||
clean_env.setenv("CACHE_DIRECTORY", str(tmp_path / "systemd"))
|
||||
assert default_cache_dir() == tmp_path / "explicit"
|
||||
|
||||
|
||||
def test_systemd_cache_directory_is_used_when_set(
|
||||
clean_env: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Under the unit this is the only writable path: ProtectHome=read-only."""
|
||||
clean_env.setenv("CACHE_DIRECTORY", str(tmp_path / "systemd"))
|
||||
assert default_cache_dir() == tmp_path / "systemd" / "portraits"
|
||||
|
||||
|
||||
def test_xdg_is_the_fallback_outside_systemd(clean_env: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
clean_env.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
assert default_cache_dir() == tmp_path / "xdg" / "signal-bot" / "portraits"
|
||||
|
||||
|
||||
def test_home_is_the_last_resort(clean_env: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
clean_env.setattr(Path, "home", classmethod(lambda _: tmp_path / "home"))
|
||||
assert default_cache_dir() == tmp_path / "home" / ".cache" / "signal-bot" / "portraits"
|
||||
@ -3,9 +3,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, DuplicateTriggerError
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
from tests.conftest import WATCHED_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.store import Store
|
||||
|
||||
|
||||
async def _noop(ctx: CommandContext) -> None:
|
||||
@ -49,3 +58,34 @@ def test_commands_lists_in_registration_order() -> None:
|
||||
registry.register("cat", ["!cat"], _noop)
|
||||
registry.register("dog", ["!dog"], _noop)
|
||||
assert [c.name for c in registry.commands] == ["cat", "dog"]
|
||||
|
||||
|
||||
def _ctx(transport: FakeTransport, store: Store, config: Config) -> CommandContext:
|
||||
message = parse_envelope(envelope(text="!papiez"))
|
||||
assert message is not None
|
||||
return CommandContext(
|
||||
message=message,
|
||||
group_id=WATCHED_GROUP,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name="pope",
|
||||
now=100.0,
|
||||
day="2026-08-30",
|
||||
)
|
||||
|
||||
|
||||
async def test_reply_image_defaults_to_no_caption(store: Store, config: Config) -> None:
|
||||
transport = FakeTransport()
|
||||
await _ctx(transport, store, config).reply_image("Zm9v")
|
||||
assert transport.sent[0].caption == ""
|
||||
|
||||
|
||||
async def test_reply_image_passes_the_caption_through(store: Store, config: Config) -> None:
|
||||
"""The caption rides in the same payload, so Signal shows one message."""
|
||||
transport = FakeTransport()
|
||||
await _ctx(transport, store, config).reply_image("Zm9v", "8. Telesfor")
|
||||
sent = transport.sent[0]
|
||||
assert sent.caption == "8. Telesfor"
|
||||
assert sent.image_base64 == "Zm9v"
|
||||
assert sent.quote is not None
|
||||
|
||||
186
tests/test_selftest.py
Normal file
186
tests/test_selftest.py
Normal file
@ -0,0 +1,186 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Typing a command at the bot on its own behalf."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Response, status
|
||||
|
||||
from signal_bot import app as app_module
|
||||
from signal_bot.app import SelfTestRequest
|
||||
from signal_bot.app import selftest as selftest_route
|
||||
from signal_bot.bot import Bot, build_bot
|
||||
from signal_bot.liveness import Liveness
|
||||
from signal_bot.selftest import (
|
||||
SelfTestError,
|
||||
allowed_groups,
|
||||
build_frame,
|
||||
resolve_group,
|
||||
run,
|
||||
)
|
||||
from tests.conftest import WATCHED_GROUP, mock_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.discovery import GroupDirectory
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
GROUP_ID = "group.internal-id"
|
||||
OTHER = "+48000000000"
|
||||
|
||||
|
||||
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
|
||||
"""The production object graph, so !papież resolves the way it really does."""
|
||||
client = mock_client(_commons)
|
||||
return build_bot(config, client, store, transport, directory, Liveness(config, client))
|
||||
|
||||
|
||||
def _commons(request: httpx.Request) -> httpx.Response:
|
||||
if "commons" in str(request.url):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"query": {"pages": [{"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}]}},
|
||||
)
|
||||
return httpx.Response(200, content=b"portrait-bytes")
|
||||
|
||||
|
||||
def _client(entries: list[dict[str, object]]) -> httpx.AsyncClient:
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=entries)
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_no_groups_are_allowed_by_default() -> None:
|
||||
"""Fail closed: a loopback POST must not reach the production group."""
|
||||
assert allowed_groups({}) == frozenset()
|
||||
|
||||
|
||||
def test_the_allowlist_is_parsed_and_trimmed() -> None:
|
||||
assert allowed_groups({"SELFTEST_GROUPS": " testbot , catbot ,"}) == {"testbot", "catbot"}
|
||||
|
||||
|
||||
def test_the_frame_is_the_shape_signal_cli_emits() -> None:
|
||||
body = json.loads(build_frame(GROUP_ID, OTHER, "!papież"))["envelope"]
|
||||
assert body["sourceUuid"] == OTHER
|
||||
assert body["dataMessage"]["message"] == "!papież"
|
||||
assert body["dataMessage"]["groupInfo"] == {"groupId": GROUP_ID, "type": "DELIVER"}
|
||||
assert body["timestamp"] == body["dataMessage"]["timestamp"]
|
||||
|
||||
|
||||
async def test_resolve_group_picks_a_member_who_is_not_the_bot(config: Config) -> None:
|
||||
"""The reply quotes the author, so it has to be somebody Signal knows."""
|
||||
entries = [
|
||||
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]}
|
||||
]
|
||||
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)
|
||||
|
||||
|
||||
async def test_resolve_group_rejects_an_unknown_name(config: Config) -> None:
|
||||
with pytest.raises(SelfTestError, match="no group named"):
|
||||
await resolve_group(_client([]), config, "nope")
|
||||
|
||||
|
||||
async def test_resolve_group_rejects_a_group_with_nobody_else_in_it(config: Config) -> None:
|
||||
entries = [{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number]}]
|
||||
with pytest.raises(SelfTestError, match="no usable id or no other member"):
|
||||
await resolve_group(_client(entries), config, "testbot")
|
||||
|
||||
|
||||
async def test_run_refuses_a_group_outside_the_allowlist(
|
||||
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
||||
) -> None:
|
||||
bot = _bot(config, store, transport, directory)
|
||||
with pytest.raises(SelfTestError, match="refusing to post"):
|
||||
await run(bot, _client([]), "realgroup", "!papiez", env={"SELFTEST_GROUPS": "testbot"})
|
||||
|
||||
|
||||
async def test_run_feeds_the_live_dispatcher(
|
||||
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
||||
) -> None:
|
||||
"""The point of the module: the real wiring, not a reconstruction.
|
||||
|
||||
build_bot is what production calls, so a command that resolves here is a
|
||||
command the group can actually type -- which is exactly what could not
|
||||
be checked before, Signal never delivering an account its own sends.
|
||||
"""
|
||||
bot = _bot(config, store, transport, directory)
|
||||
entries = [
|
||||
{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}
|
||||
]
|
||||
|
||||
delivered = await run(
|
||||
bot, _client(entries), "testbot", "!papiez", env={"SELFTEST_GROUPS": "testbot"}
|
||||
)
|
||||
|
||||
assert delivered == WATCHED_GROUP
|
||||
assert transport.sent[0].image_base64 is not None
|
||||
assert "pl.wikipedia.org" in transport.sent[0].caption
|
||||
|
||||
|
||||
async def test_the_endpoint_reports_connecting_before_the_bot_is_up() -> None:
|
||||
"""A self-test during startup is not a failure, it is "not yet"."""
|
||||
app_module.app.state.starter = SimpleNamespace(runtime=None, reason="container down")
|
||||
response = Response()
|
||||
|
||||
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
|
||||
|
||||
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
assert body == {"status": "connecting", "reason": "container down"}
|
||||
|
||||
|
||||
async def test_the_endpoint_refuses_a_group_outside_the_allowlist(
|
||||
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
||||
) -> None:
|
||||
bot = _bot(config, store, transport, directory)
|
||||
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
|
||||
app_module.app.state.client = _client([])
|
||||
response = Response()
|
||||
|
||||
body = await selftest_route(SelfTestRequest(group="realgroup", text="!papiez"), response)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert body["status"] == "refused"
|
||||
assert transport.sent == []
|
||||
|
||||
|
||||
async def test_the_endpoint_delivers_and_says_where(
|
||||
config: Config,
|
||||
store: Store,
|
||||
transport: FakeTransport,
|
||||
directory: GroupDirectory,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SELFTEST_GROUPS", "testbot")
|
||||
bot = _bot(config, store, transport, directory)
|
||||
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
|
||||
app_module.app.state.client = _client(
|
||||
[{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}]
|
||||
)
|
||||
response = Response()
|
||||
|
||||
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
|
||||
|
||||
assert body == {"status": "delivered", "group": WATCHED_GROUP, "text": "!papiez"}
|
||||
assert transport.sent[0].image_base64 is not None
|
||||
|
||||
|
||||
async def test_resolve_group_rejects_an_entry_with_no_id(config: Config) -> None:
|
||||
entries = [{"name": "testbot", "members": [config.phone_number, OTHER]}]
|
||||
with pytest.raises(SelfTestError, match="no usable id or no other member"):
|
||||
await resolve_group(_client(entries), config, "testbot")
|
||||
|
||||
|
||||
async def test_resolve_group_skips_past_groups_with_other_names(config: Config) -> None:
|
||||
"""The bot is in several groups; only the named one may be posted into."""
|
||||
entries = [
|
||||
{"name": "produkcja", "internal_id": "group.real", "members": [config.phone_number, OTHER]},
|
||||
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]},
|
||||
]
|
||||
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)
|
||||
@ -92,3 +92,16 @@ def test_the_committed_pope_list_is_whole() -> None:
|
||||
assert len(entries) == 267
|
||||
assert [entry["ordinal"] for entry in entries] == list(range(1, 268))
|
||||
assert all(entry["name"] and entry["portrait"] and entry["reign"] for entry in entries)
|
||||
assert all(entry["latin"] and entry["title"] for entry in entries)
|
||||
|
||||
|
||||
def test_the_article_title_keeps_its_disambiguator() -> None:
|
||||
"""title is the URL slug; name is not, and the difference is a 404.
|
||||
|
||||
"Hygin" alone is a different pl.wikipedia article entirely, while
|
||||
"Klemens I (papież)" does not exist -- so neither appending nor omitting
|
||||
the suffix works as a rule. Only the wikitext link target is right.
|
||||
"""
|
||||
assert pope_for(9).title == "Hygin (papież)"
|
||||
assert pope_for(4).title == "Klemens I"
|
||||
assert pope_for(9).latin == "Hyginus"
|
||||
|
||||
@ -39,6 +39,10 @@ _YEARS: Final = re.compile(r"\[\[(\d{1,4})\]\]")
|
||||
|
||||
# The list spells the first entry "Piotr"; kuhy asked for his full title.
|
||||
FIRST_POPE: Final = "Święty Piotr Apostoł"
|
||||
# Ordinal -> Latin name, for any row whose Latin cell stops being parseable.
|
||||
# Empty as of 2026-08-30, when all 267 came out of the table clean; it exists
|
||||
# so a future gap is a two-line edit, and _rejection refuses to ship without.
|
||||
LATIN_OVERRIDES: Final[dict[int, str]] = {}
|
||||
EXPECTED_TOTAL: Final = 267
|
||||
JOHN_PAUL_II: Final = 264
|
||||
|
||||
@ -48,6 +52,33 @@ def _clean(name: str) -> str:
|
||||
return re.sub(r"\s*\(papież\)", "", name).strip()
|
||||
|
||||
|
||||
def _tidy(cell: str) -> str:
|
||||
"""Flatten one wikitext table cell to plain text."""
|
||||
text = re.sub(r"<ref[^>]*>.*?</ref>", "", cell, flags=re.DOTALL)
|
||||
text = re.sub(r"<ref[^>]*/>", "", text)
|
||||
# The regnal number sits on its own line: "Stephanus<br />Nonus".
|
||||
text = re.sub(r"<br\s*/?>", " ", text)
|
||||
text = text.replace(chr(39) * 3, "").replace(chr(39) * 2, "")
|
||||
text = re.sub(r"\[\[(?:[^\]|]*\|)?([^\]|]+)\]\]", r"\1", text)
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _latin(row: str) -> str:
|
||||
"""Return the row's Latin name, from the cell after the portrait.
|
||||
|
||||
Taken from this table rather than from la.wikipedia's article titles,
|
||||
which number several popes differently: the sitelink for #154 is
|
||||
"Stephanus X" while every other column here calls him the ninth. A
|
||||
caption reading "Stefan IX -- Stephanus X" reads as an off-by-one, and
|
||||
this column ("Stephanus Nonus") cannot disagree with the name beside it.
|
||||
"""
|
||||
cells = row.lstrip("\n").split("\n|")
|
||||
for index, cell in enumerate(cells):
|
||||
if "[[Plik:" in cell and index + 1 < len(cells):
|
||||
return _tidy(cells[index + 1])
|
||||
return ""
|
||||
|
||||
|
||||
def _reign(row: str) -> str:
|
||||
"""Return a row's reign years as ``start-end``, or ``start-`` if reigning.
|
||||
|
||||
@ -78,8 +109,13 @@ def parse(wikitext: str) -> list[dict[str, object]]:
|
||||
{
|
||||
"ordinal": ordinal,
|
||||
"name": _clean(match.group(2)),
|
||||
# The raw link target, disambiguator and all: it is both the
|
||||
# pl.wikipedia URL slug and the key the la sitelink is under.
|
||||
# _clean() drops "(papież)", which loses 72 of the 267.
|
||||
"title": match.group(2).strip(),
|
||||
"reign": _reign(row),
|
||||
"portrait": portrait.group(1).strip(),
|
||||
"latin": _latin(row),
|
||||
},
|
||||
)
|
||||
return [popes[n] for n in sorted(popes)]
|
||||
@ -93,6 +129,36 @@ def _rejection(popes: list[dict[str, object]]) -> str:
|
||||
return f"got {len(popes)} popes, expected {EXPECTED_TOTAL}"
|
||||
if popes[JOHN_PAUL_II - 1]["name"] != "Jan Paweł II":
|
||||
return f"#{JOHN_PAUL_II} is not Jan Paweł II"
|
||||
blank = [p["ordinal"] for p in popes if not p.get("latin")]
|
||||
if blank:
|
||||
return f"no Latin name for {blank}; add them to LATIN_OVERRIDES"
|
||||
# title carries the "(papież)" disambiguator and is what both the article
|
||||
# URL and the langlink lookup key on. An empty one 404s in the group.
|
||||
untitled = [p["ordinal"] for p in popes if not p.get("title")]
|
||||
if untitled:
|
||||
return f"no article title for {untitled}"
|
||||
return ""
|
||||
|
||||
|
||||
def _drift(popes: list[dict[str, object]], previous: Path) -> str:
|
||||
"""Return how this list contradicts the committed one, or "".
|
||||
|
||||
The version ladder is built from these names: if a regeneration quietly
|
||||
renamed pope 8, the next release would announce the wrong pope into every
|
||||
watched group. Growth is fine -- a conclave adds an ordinal -- but no
|
||||
already-shipped field may change value.
|
||||
"""
|
||||
if not previous.exists():
|
||||
return ""
|
||||
old = json.loads(previous.read_text(encoding="utf-8"))["popes"]
|
||||
fresh = {p["ordinal"]: p for p in popes}
|
||||
for entry in old:
|
||||
now = fresh.get(entry["ordinal"])
|
||||
if now is None:
|
||||
return f"#{entry['ordinal']} vanished from the list"
|
||||
changed = [k for k in ("name", "reign", "portrait") if entry.get(k, now[k]) != now[k]]
|
||||
if changed:
|
||||
return f"#{entry['ordinal']} changed {changed}: {entry} -> {now}"
|
||||
return ""
|
||||
|
||||
|
||||
@ -103,13 +169,17 @@ def main() -> int:
|
||||
popes = parse(str(response.json()["parse"]["wikitext"]))
|
||||
if popes:
|
||||
popes[0]["name"] = FIRST_POPE
|
||||
for pope in popes:
|
||||
override = LATIN_OVERRIDES.get(int(str(pope["ordinal"])))
|
||||
if override:
|
||||
pope["latin"] = override
|
||||
|
||||
rejection = _rejection(popes)
|
||||
out = Path(__file__).resolve().parent.parent / "signal_bot" / "popes.json"
|
||||
rejection = _rejection(popes) or _drift(popes, out)
|
||||
if rejection:
|
||||
sys.stderr.write(f"refusing to write: {rejection}\n")
|
||||
return 1
|
||||
|
||||
out = Path(__file__).resolve().parent.parent / "signal_bot" / "popes.json"
|
||||
document = {"source": API, "popes": popes}
|
||||
out.write_text(json.dumps(document, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
sys.stdout.write(f"wrote {len(popes)} popes to {out}\n")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user