signal-bot/signal_bot/animals.py
Krzysztof kuhy Rudnicki a202317072 feat: answer the message that asked, not just the room
A "!cat" in a busy group produced a cat picture floating loose in the chat with
nothing tying it to whoever asked. Replies now quote the triggering message.

The container advertises this and always could: /v1/about reports
capabilities {"v2/send": ["quotes", "mentions"]}, and /v2/send takes
quote_timestamp, quote_author and quote_message. What was missing was on this
side -- parse_envelope discarded the envelope's timestamp, which is the id
Signal quotes a message by, so nothing downstream could have built a quote even
in principle. IncomingMessage now carries it, preferring the payload's own
value over the envelope's for the case where they ever disagree.

Quote is a value object rather than three more parameters threaded through
every send: the Transport protocol has five methods and two of them send.
CommandContext grows reply_image() so quoting lives in one place instead of
each handler reaching for ctx.transport itself, which is what animals.py did.

quote_of() returns None when the timestamp or author is missing. Answering the
room is a worse reply than a quoted one but a much better outcome than a
payload the container rejects.

The e2e gate learned to tell the two apart: Expectation.quotes demands a send
either does or does not point at a message, defaulting to None so the ten
existing scenarios stay indifferent. Without it the fake would have accepted an
unquoted reply as a pass.

Verified in the testbot group on a real Signal client before this bump, per
kuhy's rule that a bump is a publish rather than a checkpoint: he sent !cat and
confirmed the picture renders attached to his message as a reply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-29 23:01:01 +02:00

76 lines
2.7 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 pathlib import Path
from typing import TYPE_CHECKING, Final
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
CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
CAT_URL_PATH: Final[tuple[str | int, ...]] = (0, "url")
DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
DOG_URL_PATH: Final[tuple[str | int, ...]] = ("message",)
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
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]) -> 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)
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 the cat and dog commands 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
urls = {"cat": CAT_API_URL, "dog": DOG_API_URL} | (endpoints or {})
paths: dict[str, tuple[str | int, ...]] = {
"cat": CAT_URL_PATH,
"dog": DOG_URL_PATH,
}
for name, api_url in urls.items():
url_path = paths[name]
handler = image_handler(fetcher, api_url, url_path)
if decorate is not None:
handler = decorate(handler)
registry.register(name, table[name], handler)