signal-bot/signal_bot/http_client.py
Krzysztof kuhy Rudnicki 558dd297a4 release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

118 lines
4.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Outbound HTTP for the image commands.
Every outbound request goes through this one injectable client, so the tests
and the offline environment can serve fixtures without the bot knowing.
"""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Sequence
import httpx
class ImageFetchError(RuntimeError):
"""Raised when an upstream image API did not yield a usable image."""
class ImageFetcher:
"""Resolves an image URL from a JSON API and returns it base64-encoded."""
def __init__(self, client: httpx.AsyncClient) -> None:
"""Store the injected HTTP client."""
self._client = client
async def fetch_base64(
self,
api_url: str,
url_path: Sequence[str | int],
rewrite: tuple[str, str] | None = None,
) -> str:
"""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.
url_path: Keys/indices to walk in the JSON to reach the image URL.
rewrite: Optional (find, replace) pair applied to the URL found
there. Some APIs name a thumbnail and leave the full-size
photo one substitution away -- iNaturalist's observation JSON
gives a 75px `/square.` URL whose `/large.` sibling is the
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.
Raises:
ImageFetchError: The JSON did not contain a usable 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)
# 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 image.content
_MISSING: Final = object()
def _in_range(items: list[object], index: int) -> bool:
"""True when index addresses a real element, counting from either end."""
return -len(items) <= index < len(items)
def _step(node: object, key: str | int, path: Sequence[str | int]) -> object:
"""Descend one level into a JSON document.
Narrows explicitly instead of indexing blindly, so a malformed upstream
response raises ImageFetchError rather than a TypeError from deeper in.
"""
found: object = _MISSING
if isinstance(key, str) and isinstance(node, dict):
found = node.get(key, _MISSING)
elif isinstance(key, int) and isinstance(node, list) and _in_range(node, key):
found = node[key]
if found is _MISSING:
msg = f"image API response has no element at {path!r}"
raise ImageFetchError(msg)
return found
def _walk(document: object, path: Sequence[str | int]) -> str:
"""Walk a JSON document along path and return the string found there."""
node: object = document
for key in path:
node = _step(node, key, path)
if not isinstance(node, str) or not node:
msg = f"image API element at {path!r} is not a URL: {node!r}"
raise ImageFetchError(msg)
return node