signal-bot/signal_bot/registry.py
Krzysztof kuhy Rudnicki 410dcda5d2
Some checks are pending
CI / check (push) Waiting to run
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

113 lines
3.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Command lookup: trigger words to handlers.
Reimplements the idea behind the unmerged ``rules.py`` branch: triggers are
data, not a chain of if-statements, so aliases (including the Unicode-styled
ones people actually type) cost one tuple entry each.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.folding import fold
from signal_bot.quoting import quote_of
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
from signal_bot.store import Store
from signal_bot.transport_base import Transport
COMMAND_PREFIX = "!"
@dataclass(frozen=True, slots=True)
class CommandContext:
"""Everything a command handler is allowed to touch."""
message: IncomingMessage
group_id: str
transport: Transport
store: Store
config: Config
command_name: str
now: float
day: str
async def reply(self, text: str) -> None:
"""Answer the message that triggered this command, in its group.
There is no configurable reply target. A command answers where it
was asked, which is the only routing that stays correct once the
bot watches a test group and a production group at once -- and it
quotes the asker, so a busy group can tell which "!cat" this is.
"""
await self.transport.send_text(text, self.group_id, quote_of(self.message))
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), caption
)
Handler = Callable[[CommandContext], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class Command:
"""A named command and every phrase that triggers it."""
name: str
triggers: frozenset[str]
handler: Handler
class DuplicateTriggerError(ValueError):
"""Raised when two commands claim the same trigger word."""
class CommandRegistry:
"""Maps trigger words to commands."""
def __init__(self) -> None:
"""Start with no commands registered."""
self._by_trigger: dict[str, Command] = {}
self._commands: list[Command] = []
def register(self, name: str, triggers: Iterable[str], handler: Handler) -> Command:
"""Register one command under every one of its triggers.
Raises:
DuplicateTriggerError: A trigger is already claimed.
"""
normalised = frozenset(fold(t) for t in triggers)
for trigger in sorted(normalised):
if trigger in self._by_trigger:
msg = f"trigger {trigger!r} is already registered"
raise DuplicateTriggerError(msg)
command = Command(name=name, triggers=normalised, handler=handler)
for trigger in normalised:
self._by_trigger[trigger] = command
self._commands.append(command)
return command
def resolve(self, text: str | None) -> Command | None:
"""Return the command a message body triggers, or None."""
if not text:
return None
# Folded, not just lowercased: a Cyrillic "o" is a different
# codepoint from a Latin one and used to resolve to nothing at all.
candidate = fold(text)
if not candidate.startswith(COMMAND_PREFIX):
return None
return self._by_trigger.get(candidate)
@property
def commands(self) -> list[Command]:
"""Every registered command, in registration order."""
return list(self._commands)