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:
Krzysztof Rudnicki 2026-08-30 17:06:25 +02:00
parent 0d7cb99f5c
commit 558dd297a4
33 changed files with 2065 additions and 306 deletions

View File

@ -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

View File

@ -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
View 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.

View File

@ -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

View File

@ -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")

View 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 }

View File

@ -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"

View File

@ -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
View 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 "$@"

View File

@ -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

View File

@ -10,5 +10,9 @@
],
"gecko": [
"!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos"
],
"pope": [
"!papież", "!papiez", "!papieze", "!papieża", "!papa",
"!pope", "!popes", "!pontifex", "!𝗽𝗮𝗽𝗶𝗲𝘇"
]
}

View File

@ -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}

View File

@ -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,

View File

@ -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
View 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

View File

@ -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

View 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
View 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

View File

@ -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
View 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

View File

@ -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."""

View File

@ -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."""

View File

@ -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],

View File

@ -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"]
)

View File

@ -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:

View File

@ -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
View 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
View 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"

View File

@ -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
View 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)

View File

@ -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"

View File

@ -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")