mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-12 08:11:03 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
113 lines
3.7 KiB
Python
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)
|