signal-bot/signal_bot/animals.py
Krzysztof kuhy Rudnicki 79dab5cc01 feat: !gekon posts a research-grade gecko photo
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:26:40 +02:00

98 lines
3.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The image commands, and the alias tables people actually type."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final
from signal_bot.config import DEFAULT_CAT_API_URL, DEFAULT_DOG_API_URL, DEFAULT_GECKO_API_URL
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.decorators import Decorator
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandContext, CommandRegistry, Handler
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
@dataclass(frozen=True, slots=True)
class ImageSource:
"""Where one image command gets its picture, and how to read the answer."""
api_url: str
url_path: tuple[str | int, ...]
rewrite: tuple[str, str] | None = None
# iNaturalist answers with the 75px square thumbnail; the full-size photo is
# the same URL with one segment swapped. Kept beside the endpoint because it
# is a property of that endpoint, not of image fetching in general.
SOURCES: Final[dict[str, ImageSource]] = {
"cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url")),
"dog": ImageSource(DEFAULT_DOG_API_URL, ("message",)),
"gecko": ImageSource(
DEFAULT_GECKO_API_URL,
("results", 0, "photos", 0, "url"),
("/square.", "/large."),
),
}
def load_aliases(path: Path = ALIAS_FILE) -> dict[str, list[str]]:
"""Load the trigger table.
Aliases live in JSON rather than in source so that the styled Unicode
variants are data the linter never has to reason about.
"""
document = json.loads(path.read_text(encoding="utf-8"))
return {name: list(triggers) for name, triggers in document.items() if not name.startswith("_")}
def image_handler(
fetcher: ImageFetcher,
api_url: str,
url_path: Sequence[str | int],
rewrite: tuple[str, str] | None = None,
) -> Handler:
"""Build a handler that posts one random image from a JSON image API."""
async def handler(ctx: CommandContext) -> None:
encoded = await fetcher.fetch_base64(api_url, url_path, rewrite)
await ctx.reply_image(encoded)
return handler
def register_animal_commands(
registry: CommandRegistry,
fetcher: ImageFetcher,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
endpoints: dict[str, str] | None = None,
) -> None:
"""Register every image command on a registry.
Args:
registry: Registry to populate.
fetcher: Injected image fetcher.
decorate: Optional callable applied to each handler, used to attach
the cooldown gate without hard-wiring policy into the commands.
aliases: Trigger table. Defaults to the shipped aliases.json.
endpoints: Command name to image-API URL. Defaults to the real
public APIs; the offline environment points these at itself.
"""
table = load_aliases() if aliases is None else aliases
overrides = endpoints or {}
for name, source in SOURCES.items():
api_url = overrides.get(name, source.api_url)
handler = image_handler(fetcher, api_url, source.url_path, source.rewrite)
if decorate is not None:
handler = decorate(handler)
registry.register(name, table[name], handler)