mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:14:09 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 lines
3.3 KiB
Python
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)
|