diff --git a/.env.sample b/.env.sample index 3de6ac9..09799b4 100644 --- a/.env.sample +++ b/.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 diff --git a/DOCS-changelog.md b/DOCS-changelog.md index 64e3595..ae52b69 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -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 diff --git a/DOCS-selftest.md b/DOCS-selftest.md new file mode 100644 index 0000000..26f467c --- /dev/null +++ b/DOCS-selftest.md @@ -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. diff --git a/README.md b/README.md index 2566803..3509915 100644 --- a/README.md +++ b/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 diff --git a/fake_signal/routes_fixtures.py b/fake_signal/routes_fixtures.py index c8b4fa8..9fee56c 100644 --- a/fake_signal/routes_fixtures.py +++ b/fake_signal/routes_fixtures.py @@ -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") diff --git a/fake_signal/scenarios/pope_command.yaml b/fake_signal/scenarios/pope_command.yaml new file mode 100644 index 0000000..379e0a2 --- /dev/null +++ b/fake_signal/scenarios/pope_command.yaml @@ -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 } diff --git a/pyproject.toml b/pyproject.toml index 26ac64e..80b4b3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/e2e.sh b/scripts/e2e.sh index d055822..05a82bb 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 & diff --git a/scripts/selftest.sh b/scripts/selftest.sh new file mode 100755 index 0000000..9e98401 --- /dev/null +++ b/scripts/selftest.sh @@ -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 [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 "$@" diff --git a/scripts/signal-bot.service b/scripts/signal-bot.service index ef7a88a..ba875b2 100644 --- a/scripts/signal-bot.service +++ b/scripts/signal-bot.service @@ -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 diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index bff732e..3ab0363 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -10,5 +10,9 @@ ], "gecko": [ "!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos" + ], + "pope": [ + "!papież", "!papiez", "!papieze", "!papieża", "!papa", + "!pope", "!popes", "!pontifex", "!𝗽𝗮𝗽𝗶𝗲𝘇" ] } diff --git a/signal_bot/app.py b/signal_bot/app.py index 7efd7ea..a082eb6 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -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} diff --git a/signal_bot/bot.py b/signal_bot/bot.py index 79b4d4c..ae74a1f 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -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, diff --git a/signal_bot/config.py b/signal_bot/config.py index 2f49826..628edce 100644 --- a/signal_bot/config.py +++ b/signal_bot/config.py @@ -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, diff --git a/signal_bot/endpoints.py b/signal_bot/endpoints.py new file mode 100644 index 0000000..661995a --- /dev/null +++ b/signal_bot/endpoints.py @@ -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 diff --git a/signal_bot/http_client.py b/signal_bot/http_client.py index 7ceabdf..96dc697 100644 --- a/signal_bot/http_client.py +++ b/signal_bot/http_client.py @@ -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() diff --git a/signal_bot/popes.json b/signal_bot/popes.json index c1d8f99..f3d521f 100644 --- a/signal_bot/popes.json +++ b/signal_bot/popes.json @@ -4,1604 +4,2138 @@ { "ordinal": 1, "name": "Święty Piotr Apostoł", + "title": "Piotr Apostoł", "reign": "33-64", - "portrait": "1-St.Peter.jpg" + "portrait": "1-St.Peter.jpg", + "latin": "Petrus" }, { "ordinal": 2, "name": "Linus", + "title": "Linus (papież)", "reign": "64-76", - "portrait": "2-St.Linus.jpg" + "portrait": "2-St.Linus.jpg", + "latin": "Linus" }, { "ordinal": 3, "name": "Anaklet", + "title": "Anaklet", "reign": "76-88", - "portrait": "3-St.Cletus.jpg" + "portrait": "3-St.Cletus.jpg", + "latin": "Anacletus" }, { "ordinal": 4, "name": "Klemens I", + "title": "Klemens I", "reign": "88-97", - "portrait": "4-St.Clement I.jpg" + "portrait": "4-St.Clement I.jpg", + "latin": "Clemens Romanus" }, { "ordinal": 5, "name": "Ewaryst", + "title": "Ewaryst (papież)", "reign": "97-105", - "portrait": "5-St.Evaristus.jpg" + "portrait": "5-St.Evaristus.jpg", + "latin": "Evaristus" }, { "ordinal": 6, "name": "Aleksander I", + "title": "Aleksander I (papież)", "reign": "105-115", - "portrait": "6-St.Alexander I.jpg" + "portrait": "6-St.Alexander I.jpg", + "latin": "Alexander" }, { "ordinal": 7, "name": "Sykstus I", + "title": "Sykstus I", "reign": "115-125", - "portrait": "7-St.Sixtus I.jpg" + "portrait": "7-St.Sixtus I.jpg", + "latin": "Xystus" }, { "ordinal": 8, "name": "Telesfor", + "title": "Telesfor (papież)", "reign": "125-136", - "portrait": "8-St.Telesphrous.jpg" + "portrait": "8-St.Telesphrous.jpg", + "latin": "Telesphorus" }, { "ordinal": 9, "name": "Hygin", + "title": "Hygin (papież)", "reign": "136-140", - "portrait": "9-St.Hyginus.jpg" + "portrait": "9-St.Hyginus.jpg", + "latin": "Hyginus" }, { "ordinal": 10, "name": "Pius I", + "title": "Pius I", "reign": "140-155", - "portrait": "10-St.Pius I.jpg" + "portrait": "10-St.Pius I.jpg", + "latin": "Pius" }, { "ordinal": 11, "name": "Anicet", + "title": "Anicet (papież)", "reign": "155-166", - "portrait": "11-St.Anicetus.jpg" + "portrait": "11-St.Anicetus.jpg", + "latin": "Anicetus" }, { "ordinal": 12, "name": "Soter", + "title": "Soter (papież)", "reign": "166-175", - "portrait": "12-St.Soter.jpg" + "portrait": "12-St.Soter.jpg", + "latin": "Soterius" }, { "ordinal": 13, "name": "Eleuteriusz", + "title": "Eleuteriusz (papież)", "reign": "175-189", - "portrait": "13-St.Eleuterus.jpg" + "portrait": "13-St.Eleuterus.jpg", + "latin": "Eleutherius" }, { "ordinal": 14, "name": "Wiktor I", + "title": "Wiktor I", "reign": "189-199", - "portrait": "14-St.Victor I.jpg" + "portrait": "14-St.Victor I.jpg", + "latin": "Victor" }, { "ordinal": 15, "name": "Zefiryn", + "title": "Zefiryn (papież)", "reign": "199-217", - "portrait": "15-St.Zephyrinus.jpg" + "portrait": "15-St.Zephyrinus.jpg", + "latin": "Zephyrinus" }, { "ordinal": 16, "name": "Kalikst I", + "title": "Kalikst I", "reign": "217-222", - "portrait": "CalixtusI.jpg" + "portrait": "CalixtusI.jpg", + "latin": "Callistus" }, { "ordinal": 17, "name": "Urban I", + "title": "Urban I", "reign": "222-230", - "portrait": "UrbanI.jpg" + "portrait": "UrbanI.jpg", + "latin": "Urbanus" }, { "ordinal": 18, "name": "Poncjan", + "title": "Poncjan", "reign": "230-235", - "portrait": "Pontianus (pope).jpg" + "portrait": "Pontianus (pope).jpg", + "latin": "Pontianus" }, { "ordinal": 19, "name": "Anteros", + "title": "Anteros (papież)", "reign": "235-236", - "portrait": "Anterus.jpg" + "portrait": "Anterus.jpg", + "latin": "Anterus" }, { "ordinal": 20, "name": "Fabian", + "title": "Fabian (papież)", "reign": "236-250", - "portrait": "Saint Fabian1.jpg" + "portrait": "Saint Fabian1.jpg", + "latin": "Fabianus" }, { "ordinal": 21, "name": "Korneliusz", + "title": "Korneliusz (papież)", "reign": "251-253", - "portrait": "Heiliger Cornelius.jpg" + "portrait": "Heiliger Cornelius.jpg", + "latin": "Cornelius" }, { "ordinal": 22, "name": "Lucjusz I", + "title": "Lucjusz I", "reign": "253-254", - "portrait": "Lucius I.jpg" + "portrait": "Lucius I.jpg", + "latin": "Lucius" }, { "ordinal": 23, "name": "Stefan I", + "title": "Stefan I (papież)", "reign": "254-257", - "portrait": "Stephen I.jpg" + "portrait": "Stephen I.jpg", + "latin": "Stephanus" }, { "ordinal": 24, "name": "Sykstus II", + "title": "Sykstus II", "reign": "257-258", - "portrait": "Altenfelden - Hochaltar - Papst Sixtus II. 1858.jpg" + "portrait": "Altenfelden - Hochaltar - Papst Sixtus II. 1858.jpg", + "latin": "Xystus Secundus" }, { "ordinal": 25, "name": "Dionizy", + "title": "Dionizy (papież)", "reign": "259-268", - "portrait": "Pope Dionysius.jpg" + "portrait": "Pope Dionysius.jpg", + "latin": "Dionysius" }, { "ordinal": 26, "name": "Feliks I", + "title": "Feliks I", "reign": "269-274", - "portrait": "PopeFelixI.jpg" + "portrait": "PopeFelixI.jpg", + "latin": "Felix" }, { "ordinal": 27, "name": "Eutychian", + "title": "Eutychian", "reign": "275-283", - "portrait": "Eutychian.jpg" + "portrait": "Eutychian.jpg", + "latin": "Eutychianus" }, { "ordinal": 28, "name": "Kajus", + "title": "Kajus (papież)", "reign": "283-296", - "portrait": "PCaius.jpg" + "portrait": "PCaius.jpg", + "latin": "Caius" }, { "ordinal": 29, "name": "Marcelin", + "title": "Marcelin (papież)", "reign": "296-304", - "portrait": "Marcellinus.jpg" + "portrait": "Marcellinus.jpg", + "latin": "Marcellinus" }, { "ordinal": 30, "name": "Marceli I", + "title": "Marceli I", "reign": "308-309", - "portrait": "Papa Marcelo I.jpg" + "portrait": "Papa Marcelo I.jpg", + "latin": "Marcellus" }, { "ordinal": 31, "name": "Euzebiusz", + "title": "Euzebiusz (papież)", "reign": "309-310", - "portrait": "Eusebius.jpg" + "portrait": "Eusebius.jpg", + "latin": "Eusebius" }, { "ordinal": 32, "name": "Milcjades", + "title": "Milcjades", "reign": "311-314", - "portrait": "Pope miltiades.jpg" + "portrait": "Pope miltiades.jpg", + "latin": "Miltiades" }, { "ordinal": 33, "name": "Sylwester I", + "title": "Sylwester I", "reign": "314-335", - "portrait": "Sylvester I.jpg" + "portrait": "Sylvester I.jpg", + "latin": "Silvester" }, { "ordinal": 34, "name": "Marek", + "title": "Marek (papież)", "reign": "336-336", - "portrait": "Marcus (papa).jpg" + "portrait": "Marcus (papa).jpg", + "latin": "Marcus" }, { "ordinal": 35, "name": "Juliusz I", + "title": "Juliusz I", "reign": "337-352", - "portrait": "Iulius I.jpg" + "portrait": "Iulius I.jpg", + "latin": "Iulius" }, { "ordinal": 36, "name": "Liberiusz", + "title": "Liberiusz (papież)", "reign": "352-366", - "portrait": "Liberio papa1.gif" + "portrait": "Liberio papa1.gif", + "latin": "Liberius" }, { "ordinal": 37, "name": "Damazy I", + "title": "Damazy I", "reign": "366-384", - "portrait": "Saintdamasus.png" + "portrait": "Saintdamasus.png", + "latin": "Damasus" }, { "ordinal": 38, "name": "Syrycjusz", + "title": "Syrycjusz (papież)", "reign": "384-399", - "portrait": "Siricius.jpg" + "portrait": "Siricius.jpg", + "latin": "Siricius" }, { "ordinal": 39, "name": "Anastazy I", + "title": "Anastazy I", "reign": "399-401", - "portrait": "Anastasius I.jpg" + "portrait": "Anastasius I.jpg", + "latin": "Anastasius" }, { "ordinal": 40, "name": "Innocenty I", + "title": "Innocenty I", "reign": "401-417", - "portrait": "Innocentius I.jpg" + "portrait": "Innocentius I.jpg", + "latin": "Innocentius" }, { "ordinal": 41, "name": "Zozym", + "title": "Zozym (papież)", "reign": "417-418", - "portrait": "Zosimus.jpg" + "portrait": "Zosimus.jpg", + "latin": "Zosimus" }, { "ordinal": 42, "name": "Bonifacy I", + "title": "Bonifacy I", "reign": "418-422", - "portrait": "Bonifacius I.jpg" + "portrait": "Bonifacius I.jpg", + "latin": "Bonifacius" }, { "ordinal": 43, "name": "Celestyn I", + "title": "Celestyn I", "reign": "422-432", - "portrait": "Coelestinus I.png" + "portrait": "Coelestinus I.png", + "latin": "Coelestinus" }, { "ordinal": 44, "name": "Sykstus III", + "title": "Sykstus III", "reign": "432-440", - "portrait": "Pope-Sixtus-III.jpg" + "portrait": "Pope-Sixtus-III.jpg", + "latin": "Xystus Tertius" }, { "ordinal": 45, "name": "Leon I", + "title": "Leon I (papież)", "reign": "440-461", - "portrait": "Papa Leo I Magnus.jpg" + "portrait": "Papa Leo I Magnus.jpg", + "latin": "Leo Magnus" }, { "ordinal": 46, "name": "Hilary", + "title": "Hilary (papież)", "reign": "461-468", - "portrait": "Pope Hilarius.jpg" + "portrait": "Pope Hilarius.jpg", + "latin": "Hilarius" }, { "ordinal": 47, "name": "Symplicjusz", + "title": "Symplicjusz (papież)", "reign": "468-483", - "portrait": "Simplicius.jpg" + "portrait": "Simplicius.jpg", + "latin": "Simplicius" }, { "ordinal": 48, "name": "Feliks III", + "title": "Feliks III", "reign": "483-492", - "portrait": "Felix3.jpg" + "portrait": "Felix3.jpg", + "latin": "Felix Tertius" }, { "ordinal": 49, "name": "Gelazjusz I", + "title": "Gelazjusz I", "reign": "492-496", - "portrait": "San Galasio I.jpg" + "portrait": "San Galasio I.jpg", + "latin": "Gelasius" }, { "ordinal": 50, "name": "Anastazy II", + "title": "Anastazy II", "reign": "496-498", - "portrait": "Anastasius II.jpg" + "portrait": "Anastasius II.jpg", + "latin": "Anastasius Secundus" }, { "ordinal": 51, "name": "Symmachus", + "title": "Symmachus (papież)", "reign": "498-514", - "portrait": "Papa Symmachus.jpg" + "portrait": "Papa Symmachus.jpg", + "latin": "Symmachus" }, { "ordinal": 52, "name": "Hormizdas", + "title": "Hormizdas", "reign": "514-523", - "portrait": "Pope Hormisdas.jpg" + "portrait": "Pope Hormisdas.jpg", + "latin": "Hormisdus" }, { "ordinal": 53, "name": "Jan I", + "title": "Jan I (papież)", "reign": "523-526", - "portrait": "Johannes I.jpg" + "portrait": "Johannes I.jpg", + "latin": "Ioannes" }, { "ordinal": 54, "name": "Feliks IV", + "title": "Feliks IV", "reign": "526-530", - "portrait": "Papa Felix IV.jpg" + "portrait": "Papa Felix IV.jpg", + "latin": "Felix Quartus" }, { "ordinal": 55, "name": "Bonifacy II", + "title": "Bonifacy II", "reign": "530-532", - "portrait": "Boniface II.jpg" + "portrait": "Boniface II.jpg", + "latin": "Bonifacius Secundus" }, { "ordinal": 56, "name": "Jan II", + "title": "Jan II (papież)", "reign": "533-535", - "portrait": "Johannes II.jpg" + "portrait": "Johannes II.jpg", + "latin": "Ioannes Secundus" }, { "ordinal": 57, "name": "Agapit I", + "title": "Agapit I", "reign": "535-536", - "portrait": "Papa Agapito I.jpg" + "portrait": "Papa Agapito I.jpg", + "latin": "Agapetus" }, { "ordinal": 58, "name": "Sylweriusz", + "title": "Sylweriusz (papież)", "reign": "536-537", - "portrait": "Silverius.jpg" + "portrait": "Silverius.jpg", + "latin": "Silverius" }, { "ordinal": 59, "name": "Wigiliusz", + "title": "Wigiliusz (papież)", "reign": "537-555", - "portrait": "Vigilius.jpg" + "portrait": "Vigilius.jpg", + "latin": "Vigilius" }, { "ordinal": 60, "name": "Pelagiusz I", + "title": "Pelagiusz I", "reign": "556-561", - "portrait": "Pelagius I.jpg" + "portrait": "Pelagius I.jpg", + "latin": "Pelagius" }, { "ordinal": 61, "name": "Jan III", + "title": "Jan III (papież)", "reign": "561-574", - "portrait": "Papa Joao III.jpg" + "portrait": "Papa Joao III.jpg", + "latin": "Ioannes Tertius" }, { "ordinal": 62, "name": "Benedykt I", + "title": "Benedykt I", "reign": "575-579", - "portrait": "Benedict I.jpg" + "portrait": "Benedict I.jpg", + "latin": "Benedictus" }, { "ordinal": 63, "name": "Pelagiusz II", + "title": "Pelagiusz II", "reign": "579-590", - "portrait": "Pelagius II.jpg" + "portrait": "Pelagius II.jpg", + "latin": "Pelagius Secundus" }, { "ordinal": 64, "name": "Grzegorz I", + "title": "Grzegorz I", "reign": "590-604", - "portrait": "Gregorythegreat.jpg" + "portrait": "Gregorythegreat.jpg", + "latin": "Gregorius Magnus" }, { "ordinal": 65, "name": "Sabinian", + "title": "Sabinian (papież)", "reign": "604-606", - "portrait": "Sabinian.jpg" + "portrait": "Sabinian.jpg", + "latin": "Sabinianus" }, { "ordinal": 66, "name": "Bonifacy III", + "title": "Bonifacy III", "reign": "607-607", - "portrait": "Boniface III.jpg" + "portrait": "Boniface III.jpg", + "latin": "Bonifacius Tertius" }, { "ordinal": 67, "name": "Bonifacy IV", + "title": "Bonifacy IV", "reign": "608-615", - "portrait": "Boniface IV.jpg" + "portrait": "Boniface IV.jpg", + "latin": "Bonifacius Quartus" }, { "ordinal": 68, "name": "Adeodat I", + "title": "Adeodat I", "reign": "615-618", - "portrait": "Adeodatus I (Deusdedit I).jpg" + "portrait": "Adeodatus I (Deusdedit I).jpg", + "latin": "Adeodatus lub Deusdedit" }, { "ordinal": 69, "name": "Bonifacy V", + "title": "Bonifacy V", "reign": "619-625", - "portrait": "Papa Bonifacio V.jpg" + "portrait": "Papa Bonifacio V.jpg", + "latin": "Bonifacius Quintus" }, { "ordinal": 70, "name": "Honoriusz I", + "title": "Honoriusz I", "reign": "625-638", - "portrait": "Honorius I.jpg" + "portrait": "Honorius I.jpg", + "latin": "Honorius" }, { "ordinal": 71, "name": "Seweryn", + "title": "Seweryn (papież)", "reign": "640-640", - "portrait": "Severinus.jpg" + "portrait": "Severinus.jpg", + "latin": "Severinus" }, { "ordinal": 72, "name": "Jan IV", + "title": "Jan IV (papież)", "reign": "640-642", - "portrait": "Murner History Cod Karlsruhe 3117 (crop).jpg" + "portrait": "Murner History Cod Karlsruhe 3117 (crop).jpg", + "latin": "Ioannes Quartus" }, { "ordinal": 73, "name": "Teodor I", + "title": "Teodor I (papież)", "reign": "642-649", - "portrait": "Theodorus I.jpg" + "portrait": "Theodorus I.jpg", + "latin": "Theodorus" }, { "ordinal": 74, "name": "Marcin I", + "title": "Marcin I", "reign": "649-655", - "portrait": "Pope Martin I.jpg" + "portrait": "Pope Martin I.jpg", + "latin": "Martinus" }, { "ordinal": 75, "name": "Eugeniusz I", + "title": "Eugeniusz I", "reign": "655-657", - "portrait": "Papa Eugenio I (1).jpg" + "portrait": "Papa Eugenio I (1).jpg", + "latin": "Eugenius" }, { "ordinal": 76, "name": "Witalian", + "title": "Witalian", "reign": "657-672", - "portrait": "Pope Vitalian.jpg" + "portrait": "Pope Vitalian.jpg", + "latin": "Vitalianus" }, { "ordinal": 77, "name": "Adeodat II", + "title": "Adeodat II", "reign": "672-676", - "portrait": "Adeodatus II.jpg" + "portrait": "Adeodatus II.jpg", + "latin": "Adeodatus Secundus" }, { "ordinal": 78, "name": "Donus", + "title": "Donus", "reign": "676-678", - "portrait": "Dono I.jpg" + "portrait": "Dono I.jpg", + "latin": "Donus" }, { "ordinal": 79, "name": "Agaton", + "title": "Agaton (papież)", "reign": "678-681", - "portrait": "Agatho.jpg" + "portrait": "Agatho.jpg", + "latin": "Agatho" }, { "ordinal": 80, "name": "Leon II", + "title": "Leon II (papież)", "reign": "682-683", - "portrait": "LeoII.jpg" + "portrait": "LeoII.jpg", + "latin": "Leo Secundus" }, { "ordinal": 81, "name": "Benedykt II", + "title": "Benedykt II (papież)", "reign": "684-685", - "portrait": "BenedictII.jpg" + "portrait": "BenedictII.jpg", + "latin": "Benedictus Secundus" }, { "ordinal": 82, "name": "Jan V", + "title": "Jan V (papież)", "reign": "685-686", - "portrait": "Johannes V.jpg" + "portrait": "Johannes V.jpg", + "latin": "Ioannes Quintus" }, { "ordinal": 83, "name": "Konon", + "title": "Konon (papież)", "reign": "686-687", - "portrait": "Konon.jpg" + "portrait": "Konon.jpg", + "latin": "Conon" }, { "ordinal": 84, "name": "Sergiusz I", + "title": "Sergiusz I (papież)", "reign": "687-701", - "portrait": "Sergius I.jpg" + "portrait": "Sergius I.jpg", + "latin": "Sergius" }, { "ordinal": 85, "name": "Jan VI", + "title": "Jan VI (papież)", "reign": "701-705", - "portrait": "John VI.jpg" + "portrait": "John VI.jpg", + "latin": "Ioannes Sextus" }, { "ordinal": 86, "name": "Jan VII", + "title": "Jan VII (papież)", "reign": "705-707", - "portrait": "Greek Pope John VII.JPG" + "portrait": "Greek Pope John VII.JPG", + "latin": "Ioannes Septimus" }, { "ordinal": 87, "name": "Syzyniusz", + "title": "Syzyniusz", "reign": "708-708", - "portrait": "Sisinnius.jpg" + "portrait": "Sisinnius.jpg", + "latin": "Sisinnius" }, { "ordinal": 88, "name": "Konstantyn", + "title": "Konstantyn (papież)", "reign": "708-715", - "portrait": "PopeConstantine.jpg" + "portrait": "PopeConstantine.jpg", + "latin": "Constantinus" }, { "ordinal": 89, "name": "Grzegorz II", + "title": "Grzegorz II (papież)", "reign": "715-731", - "portrait": "GregoryII.jpg" + "portrait": "GregoryII.jpg", + "latin": "Gregorius Secundus" }, { "ordinal": 90, "name": "Grzegorz III", + "title": "Grzegorz III (papież)", "reign": "731-741", - "portrait": "Pope Gregory III.jpg" + "portrait": "Pope Gregory III.jpg", + "latin": "Gregorius Tertius" }, { "ordinal": 91, "name": "Zachariasz", + "title": "Zachariasz (papież)", "reign": "741-752", - "portrait": "Pope Zachary.jpg" + "portrait": "Pope Zachary.jpg", + "latin": "Zacharias" }, { "ordinal": 92, "name": "Stefan II", + "title": "Stefan II (papież)", "reign": "752-757", - "portrait": "La donacion de Pipino el Breve al Papa Esteban II.jpg" + "portrait": "La donacion de Pipino el Breve al Papa Esteban II.jpg", + "latin": "Stephanus Secundus" }, { "ordinal": 93, "name": "Paweł I", + "title": "Paweł I (papież)", "reign": "757-767", - "portrait": "Paul I.jpg" + "portrait": "Paul I.jpg", + "latin": "Paulus" }, { "ordinal": 94, "name": "Stefan III", + "title": "Stefan III (papież)", "reign": "768-772", - "portrait": "StephenIII.jpg" + "portrait": "StephenIII.jpg", + "latin": "Stephanus Tertius" }, { "ordinal": 95, "name": "Hadrian I", + "title": "Hadrian I", "reign": "772-795", - "portrait": "Papa Hadrianus I.jpg" + "portrait": "Papa Hadrianus I.jpg", + "latin": "Hadrianus" }, { "ordinal": 96, "name": "Leon III", + "title": "Leon III (papież)", "reign": "795-816", - "portrait": "Papa Leone III.jpg" + "portrait": "Papa Leone III.jpg", + "latin": "Leo Tertius" }, { "ordinal": 97, "name": "Stefan IV", + "title": "Stefan IV", "reign": "816-817", - "portrait": "Stephen IV.jpg" + "portrait": "Stephen IV.jpg", + "latin": "Stephanus Quartus" }, { "ordinal": 98, "name": "Paschalis I", + "title": "Paschalis I", "reign": "817-824", - "portrait": "Pope Paschalis I. in apsis mosaic of Santa Prassede in Rome.gif" + "portrait": "Pope Paschalis I. in apsis mosaic of Santa Prassede in Rome.gif", + "latin": "Paschalis" }, { "ordinal": 99, "name": "Eugeniusz II", + "title": "Eugeniusz II", "reign": "824-827", - "portrait": "Eugene II.jpg" + "portrait": "Eugene II.jpg", + "latin": "Eugenius Secundus" }, { "ordinal": 100, "name": "Walentyn", + "title": "Walentyn (papież)", "reign": "827-827", - "portrait": "Valentine.jpg" + "portrait": "Valentine.jpg", + "latin": "Valentinus" }, { "ordinal": 101, "name": "Grzegorz IV", + "title": "Grzegorz IV (papież)", "reign": "827-844", - "portrait": "Gregory IV.jpg" + "portrait": "Gregory IV.jpg", + "latin": "Gregorius Quartus" }, { "ordinal": 102, "name": "Sergiusz II", + "title": "Sergiusz II (papież)", "reign": "844-847", - "portrait": "Pope Sergius II.jpg" + "portrait": "Pope Sergius II.jpg", + "latin": "Sergius Secundus" }, { "ordinal": 103, "name": "Leon IV", + "title": "Leon IV (papież)", "reign": "847-855", - "portrait": "St.Leo IV.jpg" + "portrait": "St.Leo IV.jpg", + "latin": "Leo Quartus" }, { "ordinal": 104, "name": "Benedykt III", + "title": "Benedykt III", "reign": "855-858", - "portrait": "Benedict III.jpg" + "portrait": "Benedict III.jpg", + "latin": "Benedictus Tertius" }, { "ordinal": 105, "name": "Mikołaj I Wielki", + "title": "Mikołaj I Wielki", "reign": "858-867", - "portrait": "Nicholas I.jpg" + "portrait": "Nicholas I.jpg", + "latin": "Nicolaus Magnus" }, { "ordinal": 106, "name": "Hadrian II", + "title": "Hadrian II", "reign": "867-872", - "portrait": "Adrian II.jpg" + "portrait": "Adrian II.jpg", + "latin": "Hadrianus Secundus" }, { "ordinal": 107, "name": "Jan VIII", + "title": "Jan VIII (papież)", "reign": "872-882", - "portrait": "Papa Ioannes VIII.jpg" + "portrait": "Papa Ioannes VIII.jpg", + "latin": "Ioannes Octavus" }, { "ordinal": 108, "name": "Maryn I", + "title": "Maryn I", "reign": "882-884", - "portrait": "Marinus I.jpg" + "portrait": "Marinus I.jpg", + "latin": "Marinus" }, { "ordinal": 109, "name": "Hadrian III", + "title": "Hadrian III", "reign": "884-885", - "portrait": "Papa Hardianus III.jpg" + "portrait": "Papa Hardianus III.jpg", + "latin": "Hadrianus Tertius" }, { "ordinal": 110, "name": "Stefan V", + "title": "Stefan V", "reign": "885-891", - "portrait": "Stephen V.jpg" + "portrait": "Stephen V.jpg", + "latin": "Stephanus Quintus" }, { "ordinal": 111, "name": "Formozus", + "title": "Formozus", "reign": "891-896", - "portrait": "PopeFormosusBW.jpg" + "portrait": "PopeFormosusBW.jpg", + "latin": "Formosus" }, { "ordinal": 112, "name": "Bonifacy VI", + "title": "Bonifacy VI", "reign": "896", - "portrait": "Boniface VI.jpg" + "portrait": "Boniface VI.jpg", + "latin": "Bonifacius Sextus" }, { "ordinal": 113, "name": "Stefan VI", + "title": "Stefan VI", "reign": "896-897", - "portrait": "Stephen VI.jpg" + "portrait": "Stephen VI.jpg", + "latin": "Stephanus Sextus" }, { "ordinal": 114, "name": "Roman", + "title": "Roman (papież)", "reign": "897-897", - "portrait": "Pope Romanus.jpg" + "portrait": "Pope Romanus.jpg", + "latin": "Romanus" }, { "ordinal": 115, "name": "Teodor II", + "title": "Teodor II (papież)", "reign": "897", - "portrait": "Pope Theodore II.jpg" + "portrait": "Pope Theodore II.jpg", + "latin": "Theodorus Secundus" }, { "ordinal": 116, "name": "Jan IX", + "title": "Jan IX (papież)", "reign": "898-900", - "portrait": "John IX.jpg" + "portrait": "John IX.jpg", + "latin": "Ioannes Nonus" }, { "ordinal": 117, "name": "Benedykt IV", + "title": "Benedykt IV", "reign": "900-903", - "portrait": "Pope Benedict IV.jpg" + "portrait": "Pope Benedict IV.jpg", + "latin": "Benedictus Quartus" }, { "ordinal": 118, "name": "Leon V", + "title": "Leon V (papież)", "reign": "903-903", - "portrait": "Pope Leo V.jpg" + "portrait": "Pope Leo V.jpg", + "latin": "Leo Quintus" }, { "ordinal": 119, "name": "Sergiusz III", + "title": "Sergiusz III", "reign": "904-911", - "portrait": "SergiusIII.jpg" + "portrait": "SergiusIII.jpg", + "latin": "Sergius Tertius" }, { "ordinal": 120, "name": "Anastazy III", + "title": "Anastazy III", "reign": "911-913", - "portrait": "Pope Anastasius III.jpg" + "portrait": "Pope Anastasius III.jpg", + "latin": "Anastasius Tertius" }, { "ordinal": 121, "name": "Lando", + "title": "Lando (papież)", "reign": "913-914", - "portrait": "Pope Lando.jpg" + "portrait": "Pope Lando.jpg", + "latin": "Lando" }, { "ordinal": 122, "name": "Jan X", + "title": "Jan X (papież)", "reign": "914-928", - "portrait": "Pope John X.jpg" + "portrait": "Pope John X.jpg", + "latin": "Ioannes Decimus" }, { "ordinal": 123, "name": "Leon VI", + "title": "Leon VI (papież)", "reign": "928-928", - "portrait": "Pope Leo VI.jpg" + "portrait": "Pope Leo VI.jpg", + "latin": "Leo Sextus" }, { "ordinal": 124, "name": "Stefan VII", + "title": "Stefan VII", "reign": "928-931", - "portrait": "Stephen VII.jpg" + "portrait": "Stephen VII.jpg", + "latin": "Stephanus Septimus" }, { "ordinal": 125, "name": "Jan XI", + "title": "Jan XI (papież)", "reign": "931-935", - "portrait": "Papa Ioannes XI.jpg" + "portrait": "Papa Ioannes XI.jpg", + "latin": "Ioannes Undecimus" }, { "ordinal": 126, "name": "Leon VII", + "title": "Leon VII", "reign": "936-939", - "portrait": "Leone-VII.jpg" + "portrait": "Leone-VII.jpg", + "latin": "Leo Septimus" }, { "ordinal": 127, "name": "Stefan VIII", + "title": "Stefan VIII", "reign": "939-942", - "portrait": "Stephen VIII.png" + "portrait": "Stephen VIII.png", + "latin": "Stephanus Octavus" }, { "ordinal": 128, "name": "Maryn II", + "title": "Maryn II", "reign": "942-946", - "portrait": "Marinus II.jpg" + "portrait": "Marinus II.jpg", + "latin": "Marinus Secundus" }, { "ordinal": 129, "name": "Agapit II", + "title": "Agapit II", "reign": "946-955", - "portrait": "Pope Agapetus II.jpg" + "portrait": "Pope Agapetus II.jpg", + "latin": "Agapetus Secundus" }, { "ordinal": 130, "name": "Jan XII", + "title": "Jan XII (papież)", "reign": "955-964", - "portrait": "GiovanniXII.png" + "portrait": "GiovanniXII.png", + "latin": "Ioannes Duodecimus" }, { "ordinal": 131, "name": "Leon VIII", + "title": "Leon VIII", "reign": "963-965", - "portrait": "Pope Leo VIII.jpg" + "portrait": "Pope Leo VIII.jpg", + "latin": "Leo Octavus" }, { "ordinal": 132, "name": "Benedykt V", + "title": "Benedykt V", "reign": "964-965", - "portrait": "Pope Benedict V.jpg" + "portrait": "Pope Benedict V.jpg", + "latin": "Benedictus Quintus" }, { "ordinal": 133, "name": "Jan XIII", + "title": "Jan XIII (papież)", "reign": "965-972", - "portrait": "Giovanni13.jpg" + "portrait": "Giovanni13.jpg", + "latin": "Ioannes Tertius Decimus" }, { "ordinal": 134, "name": "Benedykt VI", + "title": "Benedykt VI", "reign": "973-974", - "portrait": "Pope Benedict VI.jpg" + "portrait": "Pope Benedict VI.jpg", + "latin": "Benedictus Sextus" }, { "ordinal": 135, "name": "Benedykt VII", + "title": "Benedykt VII", "reign": "974-983", - "portrait": "Pope Benedict VII.jpg" + "portrait": "Pope Benedict VII.jpg", + "latin": "Benedictus Septimus" }, { "ordinal": 136, "name": "Jan XIV", + "title": "Jan XIV (papież)", "reign": "983-984", - "portrait": "Pope John XIV.jpg" + "portrait": "Pope John XIV.jpg", + "latin": "Ioannes Quartus Decimus" }, { "ordinal": 137, "name": "Jan XV", + "title": "Jan XV", "reign": "985-996", - "portrait": "Pope John XV.jpg" + "portrait": "Pope John XV.jpg", + "latin": "Ioannes Quintus Decimus" }, { "ordinal": 138, "name": "Grzegorz V", + "title": "Grzegorz V (papież)", "reign": "996-999", - "portrait": "Otto III wird von Papst Gregor V. zum Kaiser gesalbt.jpg" + "portrait": "Otto III wird von Papst Gregor V. zum Kaiser gesalbt.jpg", + "latin": "Gregorius Quintus" }, { "ordinal": 139, "name": "Sylwester II", + "title": "Sylwester II", "reign": "999-1003", - "portrait": "Silvester II.JPG" + "portrait": "Silvester II.JPG", + "latin": "Silvester Secundus" }, { "ordinal": 140, "name": "Jan XVII", + "title": "Jan XVII", "reign": "1003-1003", - "portrait": "Papa Joao XVII.jpg" + "portrait": "Papa Joao XVII.jpg", + "latin": "Ioannes Septimus Decimus" }, { "ordinal": 141, "name": "Jan XVIII", + "title": "Jan XVIII", "reign": "1004-1009", - "portrait": "Ioannes XVIII.jpg" + "portrait": "Ioannes XVIII.jpg", + "latin": "Ioannes Duodevicesimus" }, { "ordinal": 142, "name": "Sergiusz IV", + "title": "Sergiusz IV", "reign": "1009-1012", - "portrait": "Sergius IV.jpg" + "portrait": "Sergius IV.jpg", + "latin": "Sergius Quartus" }, { "ordinal": 143, "name": "Benedykt VIII", + "title": "Benedykt VIII", "reign": "1012-1024", - "portrait": "BENEDICT VIII.jpg" + "portrait": "BENEDICT VIII.jpg", + "latin": "Benedictus Octavus" }, { "ordinal": 144, "name": "Jan XIX", + "title": "Jan XIX", "reign": "1024-1032", - "portrait": "B Johannes XIX.jpg" + "portrait": "B Johannes XIX.jpg", + "latin": "Ioannes Undevicesimus" }, { "ordinal": 145, "name": "Benedykt IX", + "title": "Benedykt IX", "reign": "1032-1044", - "portrait": "Pope Benedict IX.jpg" + "portrait": "Pope Benedict IX.jpg", + "latin": "Benedictus Nonus" }, { "ordinal": 146, "name": "Sylwester III", + "title": "Sylwester III", "reign": "1045-1045", - "portrait": "Silvestro3.jpg" + "portrait": "Silvestro3.jpg", + "latin": "Silvester Tertius" }, { "ordinal": 147, "name": "Benedykt IX", + "title": "Benedykt IX", "reign": "1045-1045", - "portrait": "Pope Benedict IX.jpg" + "portrait": "Pope Benedict IX.jpg", + "latin": "Benedictus Nonus" }, { "ordinal": 148, "name": "Grzegorz VI", + "title": "Grzegorz VI (papież)", "reign": "1045-1046", - "portrait": "148-Gregory VI.jpg" + "portrait": "148-Gregory VI.jpg", + "latin": "Gregorius Sextus" }, { "ordinal": 149, "name": "Klemens II", + "title": "Klemens II", "reign": "1046-1047", - "portrait": "ClementeII.jpg" + "portrait": "ClementeII.jpg", + "latin": "Clemens Secundus" }, { "ordinal": 150, "name": "Benedykt IX", + "title": "Benedykt IX", "reign": "1047-1048", - "portrait": "Pope Benedict IX.jpg" + "portrait": "Pope Benedict IX.jpg", + "latin": "Benedictus Nonus" }, { "ordinal": 151, "name": "Damazy II", + "title": "Damazy II", "reign": "1048-1048", - "portrait": "B Damasus II1.jpg" + "portrait": "B Damasus II1.jpg", + "latin": "Damasus Secundus" }, { "ordinal": 152, "name": "Leon IX", + "title": "Leon IX", "reign": "1049-1054", - "portrait": "Papa Leone IX.jpg" + "portrait": "Papa Leone IX.jpg", + "latin": "Leo Nonus" }, { "ordinal": 153, "name": "Wiktor II", + "title": "Wiktor II", "reign": "1055-1057", - "portrait": "Papa Vittore II.jpg" + "portrait": "Papa Vittore II.jpg", + "latin": "Victor Secundus" }, { "ordinal": 154, "name": "Stefan IX", + "title": "Stefan IX", "reign": "1057-1058", - "portrait": "Papa Stefano IX.jpg" + "portrait": "Papa Stefano IX.jpg", + "latin": "Stephanus Nonus" }, { "ordinal": 155, "name": "Mikołaj II", + "title": "Mikołaj II (papież)", "reign": "1058-1061", - "portrait": "Papa Nicolau II.jpg" + "portrait": "Papa Nicolau II.jpg", + "latin": "Nicolaus Secundus" }, { "ordinal": 156, "name": "Aleksander II", + "title": "Aleksander II (papież)", "reign": "1061-1073", - "portrait": "Papa Alexandre II.jpg" + "portrait": "Papa Alexandre II.jpg", + "latin": "Alexander Secundus" }, { "ordinal": 157, "name": "Grzegorz VII", + "title": "Grzegorz VII (papież)", "reign": "1073-1085", - "portrait": "Papa Gregorio VII.jpg" + "portrait": "Papa Gregorio VII.jpg", + "latin": "Gregorius Septimus" }, { "ordinal": 158, "name": "Wiktor III", + "title": "Wiktor III", "reign": "1086-1087", - "portrait": "Papa Vittorio III.jpg" + "portrait": "Papa Vittorio III.jpg", + "latin": "Victor Tertius" }, { "ordinal": 159, "name": "Urban II", + "title": "Urban II", "reign": "1088-1099", - "portrait": "Urban II cropped.png" + "portrait": "Urban II cropped.png", + "latin": "Urbanus Secundus" }, { "ordinal": 160, "name": "Paschalis II", + "title": "Paschalis II", "reign": "1099-1118", - "portrait": "B Paschalis II.jpg" + "portrait": "B Paschalis II.jpg", + "latin": "Paschalius Secundus" }, { "ordinal": 161, "name": "Gelazjusz II", + "title": "Gelazjusz II", "reign": "1118-1119", - "portrait": "GelasioII.gif" + "portrait": "GelasioII.gif", + "latin": "Gelasius Secundus" }, { "ordinal": 162, "name": "Kalikst II", + "title": "Kalikst II", "reign": "1119-1124", - "portrait": "Callistus II.png" + "portrait": "Callistus II.png", + "latin": "Callistus Secundus" }, { "ordinal": 163, "name": "Honoriusz II", + "title": "Honoriusz II", "reign": "1124-1130", - "portrait": "Pope honorius ii.jpg" + "portrait": "Pope honorius ii.jpg", + "latin": "Honorius Secundus" }, { "ordinal": 164, "name": "Innocenty II", + "title": "Innocenty II", "reign": "1130-1143", - "portrait": "B Innozenz II.jpg" + "portrait": "B Innozenz II.jpg", + "latin": "Innocentius Secundus" }, { "ordinal": 165, "name": "Celestyn II", + "title": "Celestyn II", "reign": "1143-1144", - "portrait": "Caelestinus II.jpg" + "portrait": "Caelestinus II.jpg", + "latin": "Coelestinus Secundus" }, { "ordinal": 166, "name": "Lucjusz II", + "title": "Lucjusz II", "reign": "1144-1145", - "portrait": "B Lucius II.jpg" + "portrait": "B Lucius II.jpg", + "latin": "Lucius Secundus" }, { "ordinal": 167, "name": "Eugeniusz III", + "title": "Eugeniusz III", "reign": "1145-1153", - "portrait": "B Eugen III.jpg" + "portrait": "B Eugen III.jpg", + "latin": "Eugenius Tertius" }, { "ordinal": 168, "name": "Anastazy IV", + "title": "Anastazy IV", "reign": "1153-1154", - "portrait": "B Anastasius IV.jpg" + "portrait": "B Anastasius IV.jpg", + "latin": "Anastasius Quartus" }, { "ordinal": 169, "name": "Hadrian IV", + "title": "Hadrian IV", "reign": "1154-1159", - "portrait": "PopeAdrianIVCameo.jpg" + "portrait": "PopeAdrianIVCameo.jpg", + "latin": "Hadrianus Quartus" }, { "ordinal": 170, "name": "Aleksander III", + "title": "Aleksander III (papież)", "reign": "1159-1181", - "portrait": "Papa alessandro III illustrazione di spinello aretino particolare siena italia 03.gif" + "portrait": "Papa alessandro III illustrazione di spinello aretino particolare siena italia 03.gif", + "latin": "Alexander Tertius" }, { "ordinal": 171, "name": "Lucjusz III", + "title": "Lucjusz III", "reign": "1181-1185", - "portrait": "Pope Lucius III.png" + "portrait": "Pope Lucius III.png", + "latin": "Lucius Tertius" }, { "ordinal": 172, "name": "Urban III", + "title": "Urban III", "reign": "1185-1187", - "portrait": "B Urban III.jpg" + "portrait": "B Urban III.jpg", + "latin": "Urbanus Tertius" }, { "ordinal": 173, "name": "Grzegorz VIII", + "title": "Grzegorz VIII", "reign": "1187-1187", - "portrait": "B Gregor VIII.jpg" + "portrait": "B Gregor VIII.jpg", + "latin": "Gregorius Octavus" }, { "ordinal": 174, "name": "Klemens III", + "title": "Klemens III", "reign": "1187-1191", - "portrait": "B Clemens III.jpg" + "portrait": "B Clemens III.jpg", + "latin": "Clemens Tertius" }, { "ordinal": 175, "name": "Celestyn III", + "title": "Celestyn III", "reign": "1191-1198", - "portrait": "Celestin III.jpg" + "portrait": "Celestin III.jpg", + "latin": "Coelestinus Tertius" }, { "ordinal": 176, "name": "Innocenty III", + "title": "Innocenty III", "reign": "1198-1216", - "portrait": "Innozenz3.jpg" + "portrait": "Innozenz3.jpg", + "latin": "Innocentius Tertius" }, { "ordinal": 177, "name": "Honoriusz III", + "title": "Honoriusz III", "reign": "1216-1227", - "portrait": "Interior of Santi Giovanni e Paolo (Venice) - Honorius III by Leandro Bassano.jpg" + "portrait": "Interior of Santi Giovanni e Paolo (Venice) - Honorius III by Leandro Bassano.jpg", + "latin": "Honorius Tertius" }, { "ordinal": 178, "name": "Grzegorz IX", + "title": "Grzegorz IX", "reign": "1227-1241", - "portrait": "PopeGregoryIX.jpg" + "portrait": "PopeGregoryIX.jpg", + "latin": "Gregorius Nonus" }, { "ordinal": 179, "name": "Celestyn IV", + "title": "Celestyn IV", "reign": "1241-1241", - "portrait": "B Colestin IV.jpg" + "portrait": "B Colestin IV.jpg", + "latin": "Coelestinus Quartus" }, { "ordinal": 180, "name": "Innocenty IV", + "title": "Innocenty IV", "reign": "1243-1254", - "portrait": "Inocenc IV.jpg" + "portrait": "Inocenc IV.jpg", + "latin": "Innocentius Quartus" }, { "ordinal": 181, "name": "Aleksander IV", + "title": "Aleksander IV (papież)", "reign": "1254-1261", - "portrait": "B Alexander IV.jpg" + "portrait": "B Alexander IV.jpg", + "latin": "Alexander Quartus" }, { "ordinal": 182, "name": "Urban IV", + "title": "Urban IV", "reign": "1261-1264", - "portrait": "Pope Urban IV.jpg" + "portrait": "Pope Urban IV.jpg", + "latin": "Urbanus Quartus" }, { "ordinal": 183, "name": "Klemens IV", + "title": "Klemens IV", "reign": "1265-1268", - "portrait": "Pope Clement IV.jpg" + "portrait": "Pope Clement IV.jpg", + "latin": "Clemens Quartus" }, { "ordinal": 184, "name": "Grzegorz X", + "title": "Grzegorz X", "reign": "1271-1276", - "portrait": "B Gregor X.jpg" + "portrait": "B Gregor X.jpg", + "latin": "Gregorius Decimus" }, { "ordinal": 185, "name": "Innocenty V", + "title": "Innocenty V", "reign": "1276-1276", - "portrait": "InnocentV.jpg" + "portrait": "InnocentV.jpg", + "latin": "Innocentius Quintus" }, { "ordinal": 186, "name": "Hadrian V", + "title": "Hadrian V", "reign": "1276-1276", - "portrait": "Hadrian V.png" + "portrait": "Hadrian V.png", + "latin": "Hadrianus Quintus" }, { "ordinal": 187, "name": "Jan XXI", + "title": "Jan XXI", "reign": "1276-1277", - "portrait": "Pope John XXI (crop).jpg" + "portrait": "Pope John XXI (crop).jpg", + "latin": "Ioannes Vicesimus Primus" }, { "ordinal": 188, "name": "Mikołaj III", + "title": "Mikołaj III (papież)", "reign": "1277-1280", - "portrait": "NicholasIII.jpg" + "portrait": "NicholasIII.jpg", + "latin": "Nicolaus Tertius" }, { "ordinal": 189, "name": "Marcin IV", + "title": "Marcin IV", "reign": "1281-1285", - "portrait": "B Martin IV.jpg" + "portrait": "B Martin IV.jpg", + "latin": "Martinus Quartus" }, { "ordinal": 190, "name": "Honoriusz IV", + "title": "Honoriusz IV", "reign": "1285-1287", - "portrait": "PopeOnorioIV.jpg" + "portrait": "PopeOnorioIV.jpg", + "latin": "Honorius Quartus" }, { "ordinal": 191, "name": "Mikołaj IV", + "title": "Mikołaj IV (papież)", "reign": "1288-1292", - "portrait": "NicholasIV.jpg" + "portrait": "NicholasIV.jpg", + "latin": "Nicolaus Quartus" }, { "ordinal": 192, "name": "Celestyn V", + "title": "Celestyn V", "reign": "1294-1294", - "portrait": "Celestinus quintus.jpg" + "portrait": "Celestinus quintus.jpg", + "latin": "Coelestinus Quintus" }, { "ordinal": 193, "name": "Bonifacy VIII", + "title": "Bonifacy VIII", "reign": "1294-1303", - "portrait": "Bonifatius viii papst.jpg" + "portrait": "Bonifatius viii papst.jpg", + "latin": "Bonifacius Octavus" }, { "ordinal": 194, "name": "Benedykt XI", + "title": "Benedykt XI", "reign": "1303-1304", - "portrait": "B Benedikt XI.jpg" + "portrait": "B Benedikt XI.jpg", + "latin": "Benedictus Undecimus" }, { "ordinal": 195, "name": "Klemens V", + "title": "Klemens V", "reign": "1305-1314", - "portrait": "Papst klemens v.jpg" + "portrait": "Papst klemens v.jpg", + "latin": "Clemens Quintus" }, { "ordinal": 196, "name": "Jan XXII", + "title": "Jan XXII", "reign": "1316-1334", - "portrait": "John22.jpg" + "portrait": "John22.jpg", + "latin": "Ioannes Vicesimus Secundus" }, { "ordinal": 197, "name": "Benedykt XII", + "title": "Benedykt XII", "reign": "1334-1342", - "portrait": "Benedikt XII1.png" + "portrait": "Benedikt XII1.png", + "latin": "Benedictus Duodecimus" }, { "ordinal": 198, "name": "Klemens VI", + "title": "Klemens VI", "reign": "1342-1352", - "portrait": "Clemens VI.png" + "portrait": "Clemens VI.png", + "latin": "Clemens Sextus" }, { "ordinal": 199, "name": "Innocenty VI", + "title": "Innocenty VI", "reign": "1352-1362", - "portrait": "Innozenz VI.gif" + "portrait": "Innozenz VI.gif", + "latin": "Innocentius Sextus" }, { "ordinal": 200, "name": "Urban V", + "title": "Urban V", "reign": "1362-1370", - "portrait": "Urban V.gif" + "portrait": "Urban V.gif", + "latin": "Urbanus Quintus" }, { "ordinal": 201, "name": "Grzegorz XI", + "title": "Grzegorz XI", "reign": "1370-1378", - "portrait": "PopeGregoryXI.jpg" + "portrait": "PopeGregoryXI.jpg", + "latin": "Gregorius Undecimus" }, { "ordinal": 202, "name": "Urban VI", + "title": "Urban VI", "reign": "1378-1389", - "portrait": "Urbanus VI.jpg" + "portrait": "Urbanus VI.jpg", + "latin": "Urbanus Sextus" }, { "ordinal": 203, "name": "Bonifacy IX", + "title": "Bonifacy IX", "reign": "1389-1404", - "portrait": "IX.Bonifac.jpg" + "portrait": "IX.Bonifac.jpg", + "latin": "Bonifacius Nonus" }, { "ordinal": 204, "name": "Innocenty VII", + "title": "Innocenty VII", "reign": "1404-1406", - "portrait": "Innocent VII.jpg" + "portrait": "Innocent VII.jpg", + "latin": "Innocentius Septimus" }, { "ordinal": 205, "name": "Grzegorz XII", + "title": "Grzegorz XII", "reign": "1406-1415", - "portrait": "Gregory XII.jpg" + "portrait": "Gregory XII.jpg", + "latin": "Gregorius Duodecimus" }, { "ordinal": 206, "name": "Marcin V", + "title": "Marcin V", "reign": "1417-1431", - "portrait": "Pisanello, copia da Ritratto di Martino V (Galleria Colonna).jpg" + "portrait": "Pisanello, copia da Ritratto di Martino V (Galleria Colonna).jpg", + "latin": "Martinus Quintus" }, { "ordinal": 207, "name": "Eugeniusz IV", + "title": "Eugeniusz IV", "reign": "1431-1447", - "portrait": "PapstEugen.jpg" + "portrait": "PapstEugen.jpg", + "latin": "Eugenius Quartus" }, { "ordinal": 208, "name": "Mikołaj V", + "title": "Mikołaj V", "reign": "1447-1455", - "portrait": "Paus Nicolaas V door Peter Paul Rubens.jpg" + "portrait": "Paus Nicolaas V door Peter Paul Rubens.jpg", + "latin": "Nicolaus Quintus" }, { "ordinal": 209, "name": "Kalikst III", + "title": "Kalikst III", "reign": "1455-1458", - "portrait": "Calixtus III.jpg" + "portrait": "Calixtus III.jpg", + "latin": "Callistus Tertius" }, { "ordinal": 210, "name": "Pius II", + "title": "Pius II", "reign": "1458-1464", - "portrait": "Pintoricchio 014.jpg" + "portrait": "Pintoricchio 014.jpg", + "latin": "Pius Secundus" }, { "ordinal": 211, "name": "Paweł II", + "title": "Paweł II (papież)", "reign": "1464-1471", - "portrait": "Pietrobarbo.jpg" + "portrait": "Pietrobarbo.jpg", + "latin": "Paulus Secundus" }, { "ordinal": 212, "name": "Sykstus IV", + "title": "Sykstus IV", "reign": "1471-1484", - "portrait": "Pope Sixtus IV (head).jpg" + "portrait": "Pope Sixtus IV (head).jpg", + "latin": "Xystus Quartus" }, { "ordinal": 213, "name": "Innocenty VIII", + "title": "Innocenty VIII", "reign": "1484-1492", - "portrait": "Ritratto di papa Innocenzo VIII – Calvi dell'Umbria.jpg" + "portrait": "Ritratto di papa Innocenzo VIII – Calvi dell'Umbria.jpg", + "latin": "Innocentius Octavus" }, { "ordinal": 214, "name": "Aleksander VI", + "title": "Aleksander VI", "reign": "1492-1503", - "portrait": "Pope Alexander Vi adj.JPG" + "portrait": "Pope Alexander Vi adj.JPG", + "latin": "Alexander Sextus" }, { "ordinal": 215, "name": "Pius III", + "title": "Pius III", "reign": "1503-1503", - "portrait": "PiusIII.jpg" + "portrait": "PiusIII.jpg", + "latin": "Pius Tertius" }, { "ordinal": 216, "name": "Juliusz II", + "title": "Juliusz II", "reign": "1503-1513", - "portrait": "Pope Julius II.jpg" + "portrait": "Pope Julius II.jpg", + "latin": "Iulius Secundus" }, { "ordinal": 217, "name": "Leon X", + "title": "Leon X", "reign": "1513-1521", - "portrait": "Pope-leo10.jpg" + "portrait": "Pope-leo10.jpg", + "latin": "Leo Decimus" }, { "ordinal": 218, "name": "Hadrian VI", + "title": "Hadrian VI", "reign": "1522-1523", - "portrait": "Hadrian VI.jpg" + "portrait": "Hadrian VI.jpg", + "latin": "Hadrianus Sextus" }, { "ordinal": 219, "name": "Klemens VII", + "title": "Klemens VII (papież)", "reign": "1523-1534", - "portrait": "Clement VII. Sebastiano del Piombo. c.1531..jpg" + "portrait": "Clement VII. Sebastiano del Piombo. c.1531..jpg", + "latin": "Clemens Septimus" }, { "ordinal": 220, "name": "Paweł III", + "title": "Paweł III (papież)", "reign": "1534-1549", - "portrait": "Tizian (Schule) 001.jpg" + "portrait": "Tizian (Schule) 001.jpg", + "latin": "Paulus Tertius" }, { "ordinal": 221, "name": "Juliusz III", + "title": "Juliusz III", "reign": "1550-1555", - "portrait": "Julius III.jpg" + "portrait": "Julius III.jpg", + "latin": "Iulius Tertius" }, { "ordinal": 222, "name": "Marceli II", + "title": "Marceli II", "reign": "1555-1555", - "portrait": "Portrait_of_Pope_Marcellus_II_Cervini_(Vatican_Museums_-_Musei_Vaticani,_Vatican).jpg" + "portrait": "Portrait_of_Pope_Marcellus_II_Cervini_(Vatican_Museums_-_Musei_Vaticani,_Vatican).jpg", + "latin": "Marcellus Secundus" }, { "ordinal": 223, "name": "Paweł IV", + "title": "Paweł IV (papież)", "reign": "1555-1559", - "portrait": "Pope Paul IV – Jacopino Conte (Manner), ca. 1560.jpg" + "portrait": "Pope Paul IV – Jacopino Conte (Manner), ca. 1560.jpg", + "latin": "Paulus Quartus" }, { "ordinal": 224, "name": "Pius IV", + "title": "Pius IV", "reign": "1559-1565", - "portrait": "Ritratto di Pio IV.jpg" + "portrait": "Ritratto di Pio IV.jpg", + "latin": "Pius Quartus" }, { "ordinal": 225, "name": "Pius V", + "title": "Pius V", "reign": "1566-1572", - "portrait": "El Greco 050.jpg" + "portrait": "El Greco 050.jpg", + "latin": "Pius Quintus" }, { "ordinal": 226, "name": "Grzegorz XIII", + "title": "Grzegorz XIII", "reign": "1572-1585", - "portrait": "Gregory XIII.jpg" + "portrait": "Gregory XIII.jpg", + "latin": "Gregorius Decimus Tertius" }, { "ordinal": 227, "name": "Sykstus V", + "title": "Sykstus V", "reign": "1585-1590", - "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Sixte V - PalissyIM81001477.jpg" + "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Sixte V - PalissyIM81001477.jpg", + "latin": "Xystus Quintus" }, { "ordinal": 228, "name": "Urban VII", + "title": "Urban VII", "reign": "1590-1590", - "portrait": "Portrait of Pope Urban VII Castagna (Vatican Museums - Musei Vaticani, Vatican).jpg" + "portrait": "Portrait of Pope Urban VII Castagna (Vatican Museums - Musei Vaticani, Vatican).jpg", + "latin": "Urbanus Septimus" }, { "ordinal": 229, "name": "Grzegorz XIV", + "title": "Grzegorz XIV", "reign": "1590-1591", - "portrait": "Roman School – Portrait of Pope Gregory XIV (16th Century).jpg" + "portrait": "Roman School – Portrait of Pope Gregory XIV (16th Century).jpg", + "latin": "Gregorius Decimus Quartus" }, { "ordinal": 230, "name": "Innocenty IX", + "title": "Innocenty IX", "reign": "1591-1591", - "portrait": "Portrait of Pope Innocent IX (Fidenza Cathedral Museum - Museo del Duomo di Fidenza, Fidenza).jpg" + "portrait": "Portrait of Pope Innocent IX (Fidenza Cathedral Museum - Museo del Duomo di Fidenza, Fidenza).jpg", + "latin": "Innocentius Nonus" }, { "ordinal": 231, "name": "Klemens VIII", + "title": "Klemens VIII", "reign": "1592-1605", - "portrait": "Papst Clemens VIII Italian 17th century.jpg" + "portrait": "Papst Clemens VIII Italian 17th century.jpg", + "latin": "Clemens Octavus" }, { "ordinal": 232, "name": "Leon XI", + "title": "Leon XI", "reign": "1605-1605", - "portrait": "Leo XI 2.jpg" + "portrait": "Leo XI 2.jpg", + "latin": "Leo Undecimus" }, { "ordinal": 233, "name": "Paweł V", + "title": "Paweł V", "reign": "1605-1621", - "portrait": "PaoloHasekura.jpg" + "portrait": "PaoloHasekura.jpg", + "latin": "Paulus Quintus" }, { "ordinal": 234, "name": "Grzegorz XV", + "title": "Grzegorz XV", "reign": "1621-1623", - "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Grégoire XV - PalissyIM81001477.jpg" + "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Grégoire XV - PalissyIM81001477.jpg", + "latin": "Gregorius Decimus Quintus" }, { "ordinal": 235, "name": "Urban VIII", + "title": "Urban VIII", "reign": "1623-1644", - "portrait": "Gian Lorenzo Bernini - Portrait d'Urbain VIII.jpg" + "portrait": "Gian Lorenzo Bernini - Portrait d'Urbain VIII.jpg", + "latin": "Urbanus Octavus" }, { "ordinal": 236, "name": "Innocenty X", + "title": "Innocenty X", "reign": "1644-1655", - "portrait": "Innocentus x-velazquez.jpg" + "portrait": "Innocentus x-velazquez.jpg", + "latin": "Innocentius Decimus" }, { "ordinal": 237, "name": "Aleksander VII", + "title": "Aleksander VII", "reign": "1655-1667", - "portrait": "Alexander VII.jpg" + "portrait": "Alexander VII.jpg", + "latin": "Alexander Septimus" }, { "ordinal": 238, "name": "Klemens IX", + "title": "Klemens IX", "reign": "1667-1669", - "portrait": "Clemente IX.jpg" + "portrait": "Clemente IX.jpg", + "latin": "Clemens Nonus" }, { "ordinal": 239, "name": "Klemens X", + "title": "Klemens X", "reign": "1670-1676", - "portrait": "Portrait of Pope Clement X Altieri (by Ciro Ferri).jpg" + "portrait": "Portrait of Pope Clement X Altieri (by Ciro Ferri).jpg", + "latin": "Clemens Decimus" }, { "ordinal": 240, "name": "Innocenty XI", + "title": "Innocenty XI", "reign": "1676-1689", - "portrait": "Jacob Ferdinand Voet - Portrait of Innocenzo XI Odescalchi (cropped).jpeg" + "portrait": "Jacob Ferdinand Voet - Portrait of Innocenzo XI Odescalchi (cropped).jpeg", + "latin": "Innocentius Undecimus" }, { "ordinal": 241, "name": "Aleksander VIII", + "title": "Aleksander VIII", "reign": "1689-1691", - "portrait": "Portrait of Pope Alexander VIII – Roman School of 17th Century – (unframed).jpg" + "portrait": "Portrait of Pope Alexander VIII – Roman School of 17th Century – (unframed).jpg", + "latin": "Alexander Octavus" }, { "ordinal": 242, "name": "Innocenty XII", + "title": "Innocenty XII", "reign": "1691-1700", - "portrait": "Pope Innocent XII.PNG" + "portrait": "Pope Innocent XII.PNG", + "latin": "Innocentius Duodecimus" }, { "ordinal": 243, "name": "Klemens XI", + "title": "Klemens XI", "reign": "1700-1721", - "portrait": "Portrait of Pope Clement XI Albani (Vatican Museums - Musei Vaticani, Vatican).jpg" + "portrait": "Portrait of Pope Clement XI Albani (Vatican Museums - Musei Vaticani, Vatican).jpg", + "latin": "Clemens Undecimus" }, { "ordinal": 244, "name": "Innocenty XIII", + "title": "Innocenty XIII", "reign": "1721-1724", - "portrait": "InnocientXIII.jpg" + "portrait": "InnocientXIII.jpg", + "latin": "Innocentius Decimus Tertius" }, { "ordinal": 245, "name": "Benedykt XIII", + "title": "Benedykt XIII (papież)", "reign": "1724-1730", - "portrait": "Benedict XIII.jpg" + "portrait": "Benedict XIII.jpg", + "latin": "Benedictus Decimus Tertius" }, { "ordinal": 246, "name": "Klemens XII", + "title": "Klemens XII", "reign": "1730-1740", - "portrait": "Agostino Masucci – Portrait of Pope Clement XII, seated.jpg" + "portrait": "Agostino Masucci – Portrait of Pope Clement XII, seated.jpg", + "latin": "Clemens Duodecimus" }, { "ordinal": 247, "name": "Benedykt XIV", + "title": "Benedykt XIV", "reign": "1740-1758", - "portrait": "Benoit XIV.jpg" + "portrait": "Benoit XIV.jpg", + "latin": "Benedictus Decimus Quartus" }, { "ordinal": 248, "name": "Klemens XIII", + "title": "Klemens XIII", "reign": "1758-1769", - "portrait": "Clemens XIII.jpg" + "portrait": "Clemens XIII.jpg", + "latin": "Clemens Decimus Tertius" }, { "ordinal": 249, "name": "Klemens XIV", + "title": "Klemens XIV", "reign": "1769-1774", - "portrait": "Clemens XIV.PNG" + "portrait": "Clemens XIV.PNG", + "latin": "Clemens Quartus Decimus" }, { "ordinal": 250, "name": "Pius VI", + "title": "Pius VI", "reign": "1775-1799", - "portrait": "Popepiusvi.jpg" + "portrait": "Popepiusvi.jpg", + "latin": "Pius Sextus" }, { "ordinal": 251, "name": "Pius VII", + "title": "Pius VII", "reign": "1800-1823", - "portrait": "Jacques-Louis David 018.jpg" + "portrait": "Jacques-Louis David 018.jpg", + "latin": "Pius Septimus" }, { "ordinal": 252, "name": "Leon XII", + "title": "Leon XII", "reign": "1823-1829", - "portrait": "Pope Leo XII.PNG" + "portrait": "Pope Leo XII.PNG", + "latin": "Leo Duodecimus" }, { "ordinal": 253, "name": "Pius VIII", + "title": "Pius VIII", "reign": "1829-1830", - "portrait": "Ritratto di Papa Pio VIII, by Clemente Alberi.jpg" + "portrait": "Ritratto di Papa Pio VIII, by Clemente Alberi.jpg", + "latin": "Pius Octavus" }, { "ordinal": 254, "name": "Grzegorz XVI", + "title": "Grzegorz XVI", "reign": "1831-1846", - "portrait": "Gregory XVI.jpg" + "portrait": "Gregory XVI.jpg", + "latin": "Gregorius Decimus Sextus" }, { "ordinal": 255, "name": "Pius IX", + "title": "Pius IX", "reign": "1846-1878", - "portrait": "G.P.A.Healy, Portrait of Pope Pius IX (1871).jpg" + "portrait": "G.P.A.Healy, Portrait of Pope Pius IX (1871).jpg", + "latin": "Pius Nonus" }, { "ordinal": 256, "name": "Leon XIII", + "title": "Leon XIII", "reign": "1878-1903", - "portrait": "Portrait of Pope Leo XIII, Fabio Cipolla.jpg" + "portrait": "Portrait of Pope Leo XIII, Fabio Cipolla.jpg", + "latin": "Leo Decimus Tertius" }, { "ordinal": 257, "name": "Pius X", + "title": "Pius X", "reign": "1903-1914", - "portrait": "Papst Pius X-01..jpg" + "portrait": "Papst Pius X-01..jpg", + "latin": "Pius Decimus" }, { "ordinal": 258, "name": "Benedykt XV", + "title": "Benedykt XV", "reign": "1914-1922", - "portrait": "G.B.Torriglia, Benedetto XV in poltrona.jpg" + "portrait": "G.B.Torriglia, Benedetto XV in poltrona.jpg", + "latin": "Benedictus Decimus Quintus" }, { "ordinal": 259, "name": "Pius XI", + "title": "Pius XI", "reign": "1922-1929", - "portrait": "Philip Alexius de László (1869–1937) - Portrait of Pope Pius XI (1857–1939) - JL 501 - Bodleian Libraries.jpg" + "portrait": "Philip Alexius de László (1869–1937) - Portrait of Pope Pius XI (1857–1939) - JL 501 - Bodleian Libraries.jpg", + "latin": "Pius Undecimus" }, { "ordinal": 260, "name": "Pius XII", + "title": "Pius XII", "reign": "1939-1958", - "portrait": "Papa Pio XII nel 1953.jpg" + "portrait": "Papa Pio XII nel 1953.jpg", + "latin": "Pius Duodecimus" }, { "ordinal": 261, "name": "Jan XXIII", + "title": "Jan XXIII", "reign": "1958-1963", - "portrait": "Ioannes XXIII, by De Agostini, 1958–1963.jpg" + "portrait": "Ioannes XXIII, by De Agostini, 1958–1963.jpg", + "latin": "Ioannes Vicesimus Tertius" }, { "ordinal": 262, "name": "Paweł VI", + "title": "Paweł VI", "reign": "1963-1978", - "portrait": "Paulus VI, by Fotografia Felici, 1969.jpg" + "portrait": "Paulus VI, by Fotografia Felici, 1969.jpg", + "latin": "Paulus Sextus" }, { "ordinal": 263, "name": "Jan Paweł I", + "title": "Jan Paweł I", "reign": "1978-1978", - "portrait": "Paus Johannes Paulus I (Bestanddeelnr 929-9074) (cropped-colour).png" + "portrait": "Paus Johannes Paulus I (Bestanddeelnr 929-9074) (cropped-colour).png", + "latin": "Ioannes Paulus Primus" }, { "ordinal": 264, "name": "Jan Paweł II", + "title": "Jan Paweł II", "reign": "1978-2005", - "portrait": "Jan Paweł II Autor Zbigniew Kotyłło.JPG" + "portrait": "Jan Paweł II Autor Zbigniew Kotyłło.JPG", + "latin": "Ioannes Paulus Secundus" }, { "ordinal": 265, "name": "Benedykt XVI", + "title": "Benedykt XVI", "reign": "2005-2013", - "portrait": "Benedict xvi.png" + "portrait": "Benedict xvi.png", + "latin": "Benedictus Decimus Sextus" }, { "ordinal": 266, "name": "Franciszek", + "title": "Franciszek (papież)", "reign": "2013-2025", - "portrait": "Korea Pope Francis Gwanghwamun Beatification 33 (14932565852) (cropped v3).jpg" + "portrait": "Korea Pope Francis Gwanghwamun Beatification 33 (14932565852) (cropped v3).jpg", + "latin": "Franciscus" }, { "ordinal": 267, "name": "Leon XIV", + "title": "Leon XIV", "reign": "2025", - "portrait": "Papa Leon XIV-20251011-54852216541-20251112111339.jpg" + "portrait": "Papa Leon XIV-20251011-54852216541-20251112111339.jpg", + "latin": "Leo Decimus Quartus" } ] } diff --git a/signal_bot/popes_command.py b/signal_bot/popes_command.py new file mode 100644 index 0000000..3f1cd3d --- /dev/null +++ b/signal_bot/popes_command.py @@ -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) diff --git a/signal_bot/portraits.py b/signal_bot/portraits.py new file mode 100644 index 0000000..74152f4 --- /dev/null +++ b/signal_bot/portraits.py @@ -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 diff --git a/signal_bot/registry.py b/signal_bot/registry.py index a7960d1..6f6a437 100644 --- a/signal_bot/registry.py +++ b/signal_bot/registry.py @@ -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]] diff --git a/signal_bot/selftest.py b/signal_bot/selftest.py new file mode 100644 index 0000000..d515764 --- /dev/null +++ b/signal_bot/selftest.py @@ -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 diff --git a/signal_bot/transport_base.py b/signal_bot/transport_base.py index b1cd2f6..c23e633 100644 --- a/signal_bot/transport_base.py +++ b/signal_bot/transport_base.py @@ -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.""" diff --git a/signal_bot/transport_fake.py b/signal_bot/transport_fake.py index 6fc4d47..e827c59 100644 --- a/signal_bot/transport_fake.py +++ b/signal_bot/transport_fake.py @@ -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.""" diff --git a/signal_bot/transport_rest.py b/signal_bot/transport_rest.py index bc78eff..472c5a5 100644 --- a/signal_bot/transport_rest.py +++ b/signal_bot/transport_rest.py @@ -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], diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index 3dbfab1..5910520 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -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"] ) diff --git a/tests/conftest.py b/tests/conftest.py index b0f8ebc..6085cb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from signal_bot.trust import InviteTrust if TYPE_CHECKING: from collections.abc import Callable + from pathlib import Path # Canonical ``group.`` 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: diff --git a/tests/test_fake_server.py b/tests/test_fake_server.py index a337839..0987bf0 100644 --- a/tests/test_fake_server.py +++ b/tests/test_fake_server.py @@ -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 diff --git a/tests/test_popes_command.py b/tests/test_popes_command.py new file mode 100644 index 0000000..c78bf9c --- /dev/null +++ b/tests/test_popes_command.py @@ -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 " (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 diff --git a/tests/test_portraits.py b/tests/test_portraits.py new file mode 100644 index 0000000..08cabe8 --- /dev/null +++ b/tests/test_portraits.py @@ -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" diff --git a/tests/test_registry.py b/tests/test_registry.py index d62caad..9591687 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -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 diff --git a/tests/test_selftest.py b/tests/test_selftest.py new file mode 100644 index 0000000..88e177a --- /dev/null +++ b/tests/test_selftest.py @@ -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) diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 1d87936..fe1d95d 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -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" diff --git a/tool/gen_popes.py b/tool/gen_popes.py index 0390d96..9c9729e 100755 --- a/tool/gen_popes.py +++ b/tool/gen_popes.py @@ -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"]*>.*?", "", cell, flags=re.DOTALL) + text = re.sub(r"]*/>", "", text) + # The regnal number sits on its own line: "Stephanus
Nonus". + text = re.sub(r"", " ", 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")