mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 21:54:03 +01:00
Some checks are pending
CI / check (push) Waiting to run
Thirteen commands, and the only list of them was a README table nobody in a Signal group can read — which had itself gone stale two releases earlier, still advertising four commands after nine more had shipped. 0.1.1 gave each command an output that says what the command is; that only helps once you already know to type it. !pomoc (also !pomocy, !help, !komendy, !commands) prints all fourteen, grouped, one Polish line each. The interesting part is that it cannot go stale. CommandRegistry.register now takes the description as a REQUIRED argument, so a command that does not explain itself does not compile; and render_help prints any command that help_command.GROUPS forgot to file, under no heading, rather than dropping it. Two independent ways to fail closed, because the README proved that a hand-maintained second list is a list that drifts. Command also gained `primary`, the first raw alias. The trigger set could not supply it: those are folded skeletons in an unordered frozenset, so all that survives of "!papież" there is "!papiez", and the styled Unicode aliases come out as zalgo. aliases.json already lists the canonical Polish spelling first. The full trigger table moves to DOCS-commands.md. It did not fit in README — that file was already sitting exactly on the shared 250-line cap, and the gate caught the overflow on the first commit attempt. Exercised in testbot at the unbumped version before this bump: the live dispatcher ran it and posted the list, journal clean. pytest 708 green at 100% branch coverage, e2e green including a new scenario that asserts !pomoc mentions a command registered by a different module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
141 lines
4.8 KiB
Python
141 lines
4.8 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, every phrase that triggers it, and how to explain it."""
|
|
|
|
name: str
|
|
triggers: frozenset[str]
|
|
handler: Handler
|
|
# Group-facing Polish, one line, for !pomoc. Required at registration so
|
|
# that a command cannot exist without one -- a parallel help table would
|
|
# go stale exactly the way the README's command list did.
|
|
description: str
|
|
# The spelling !pomoc shows. It cannot come from `triggers`: those are
|
|
# folded skeletons in an unordered set, so "!papiez" is all that survives
|
|
# of "!papież". This is the first raw trigger instead, which is the
|
|
# canonical Polish one for every entry in aliases.json.
|
|
primary: str
|
|
|
|
|
|
class DuplicateTriggerError(ValueError):
|
|
"""Raised when two commands claim the same trigger word."""
|
|
|
|
|
|
class CommandRefusedError(Exception):
|
|
"""A gate declined the command, so nothing happened and nothing is recorded.
|
|
|
|
The dispatcher records one use per invocation, and it cannot see inside a
|
|
handler. Without this signal a *refused* run looked exactly like a
|
|
successful one, which slid the cooldown window forward every time somebody
|
|
was told to wait -- so waiting the stated time never helped.
|
|
"""
|
|
|
|
|
|
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, description: str
|
|
) -> Command:
|
|
"""Register one command under every one of its triggers.
|
|
|
|
Raises:
|
|
DuplicateTriggerError: A trigger is already claimed.
|
|
"""
|
|
raw = list(triggers)
|
|
normalised = frozenset(fold(t) for t in raw)
|
|
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,
|
|
description=description,
|
|
primary=raw[0],
|
|
)
|
|
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)
|