signal-bot/signal_bot/registry.py
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
A custom alias is data the command table is built from, so it cannot be a
live switch like the ones already on the tab. Instead the management page
writes aliases_custom.json in the bot's state directory and then asks the
bot to reload, in one step: whenever the answer is anything but `reloaded`
-- a word another command has, a name no command has, a bot still
connecting -- the file is restored byte for byte and the bot's own reason is
shown next to the field. What is on disk is therefore always what the bot
serves, and a typo cannot sit in the file poisoning every later reload.

The registry appends the operator's words to the shipped list at
registration, which keeps `!pomoc` on the shipped spelling and routes the
new words through the same clash check as aliases.json. An alias for a name
no command has fails the whole build (ConfigError -> `kept`), because a
silently ignored entry is how a file drifts. The tagger treats custom
aliases as reserved words too, so a person cannot be given one.

registry.py crossed the 250-line cap and now re-exports the record and the
error types from command.py; the bot's state directory lookup, previously
duplicated in pack.py and the suggestions export, is state_dir.py.

Proven end to end: !e2ekot is unknown, the file is written and reload.sh
runs, !e2ekot posts a cat, a file naming a command that does not exist is
refused with 409 and the previous table still answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 16:20:05 +02:00

204 lines
7.5 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. The record itself and
its error types live in ``command.py`` and are re-exported here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.command import (
COMMAND_PREFIX,
ArgResolver,
Command,
CommandRefusedError,
DuplicateTriggerError,
Handler,
Resolved,
)
from signal_bot.folding import fold
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
__all__ = [
"COMMAND_PREFIX",
"ArgResolver",
"Command",
"CommandRefusedError",
"CommandRegistry",
"DuplicateTriggerError",
"Handler",
"Resolved",
]
class CommandRegistry:
"""Maps trigger words to commands."""
def __init__(self, extra: Mapping[str, Sequence[str]] | None = None) -> None:
"""Start with no commands registered.
``extra`` is the operator's own aliases, by command name: appended to
whatever a command registers with, so ``primary`` -- the spelling
!pomoc prints -- stays the shipped one. Applied here rather than by
each register_* call because a name is the one thing every command
has, and the one thing this table checks for clashes anyway.
"""
# One trigger can be claimed by several commands, as long as no two
# of them are ever live in the same group. See DuplicateTriggerError.
self._by_trigger: dict[str, list[Command]] = {}
self._commands: list[Command] = []
self._extra: dict[str, tuple[str, ...]] = {
name: tuple(words) for name, words in (extra or {}).items()
}
self._unclaimed: set[str] = set(self._extra)
def register(
self,
name: str,
triggers: Iterable[str],
handler: Handler,
description: str,
*,
groups: frozenset[str] | None = None,
takes_args: bool = False,
arg_resolver: ArgResolver | None = None,
) -> Command:
"""Register one command under every one of its triggers.
Raises:
DuplicateTriggerError: A trigger is already claimed.
"""
raw = [*triggers, *self._extra.get(name, ())]
self._unclaimed.discard(name)
normalised = frozenset(fold(t) for t in raw)
for trigger in sorted(normalised):
clash = next(
(c for c in self._by_trigger.get(trigger, ()) if _overlap(c.groups, groups)),
None,
)
if clash is not None:
msg = f"trigger {trigger!r} is already registered by {clash.name!r}"
raise DuplicateTriggerError(msg)
command = Command(
name=name,
triggers=normalised,
handler=handler,
description=description,
primary=raw[0],
groups=groups,
takes_args=takes_args,
arg_resolver=arg_resolver,
)
for trigger in normalised:
self._by_trigger.setdefault(trigger, []).append(command)
self._commands.append(command)
return command
def _claimant(self, trigger: str, group_id: str | None) -> Command | None:
"""The command that owns one folded trigger, in one group.
``group_id`` None means "anywhere", which is what a caller asking
"is this word a command at all?" wants -- pack_add uses it to refuse
minting a label that already means something.
"""
claimants = self._by_trigger.get(trigger, [])
if group_id is None:
return claimants[0] if claimants else None
return next((c for c in claimants if c.allowed_in(group_id)), None)
def resolve(self, text: str | None, group_id: str | None = None) -> Command | None:
"""Return the command a message body triggers in one group, or None.
Without a group this answers "does anything claim this word", which
is a different and deliberately broader question.
"""
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._claimant(candidate, group_id)
def resolve_with_args(self, text: str | None, group_id: str) -> Resolved | None:
"""Resolve a message in one group, with anything that followed it.
Two lookups, in this order. The whole text first, which is what every
command has always been matched by and stays byte-for-byte the same
rule -- so "!cat blah" still resolves to nothing. Only then the first
word, and only for a command that says it takes arguments.
A command the group is not allowed is not a near miss: it comes back
as None, indistinguishable from a word nobody registered.
"""
if not text:
return None
command = self.resolve(text, group_id)
if command is not None:
return Resolved(command, ())
words = text.split()
if not words:
return None
head = self._claimant(fold(words[0]), group_id)
if head is None or not head.takes_args:
return None
tail = tuple(words[1:])
# Applied here rather than in the handler, which is the whole point:
# a resolver that runs later has already let the raw words past the
# log line the dispatcher writes when it names the command.
if head.arg_resolver is not None:
tail = head.arg_resolver(tail)
return Resolved(head, tail)
@property
def commands(self) -> list[Command]:
"""Every registered command, in registration order."""
return list(self._commands)
@property
def unclaimed_aliases(self) -> frozenset[str]:
"""Names in ``extra`` that nothing registered under.
An alias for a command that does not exist is a typo, or a person
no longer in the album; either way it is the operator's mistake to
hear about, not a line to drop quietly.
"""
return frozenset(self._unclaimed)
def triggers_in(self, group_id: str) -> dict[str, Command]:
"""Every folded trigger live in one group, mapped to its command.
Scoped rather than global, and that is the whole point: it is what
the near-miss search reads, so a command another group owns cannot
be offered as a suggestion here. Offering one would name it, which
is precisely what resolve_with_args refuses to do.
One trigger cannot have two claimants in a single group --
DuplicateTriggerError is raised at registration for exactly the
overlapping case -- so collapsing to a dict loses nothing.
"""
return {
trigger: command
for trigger, claimants in self._by_trigger.items()
for command in claimants
if command.allowed_in(group_id)
}
def _overlap(one: frozenset[str] | None, other: frozenset[str] | None) -> bool:
"""Whether two group scopes can ever both be live for one message.
None means "every group", so it overlaps with anything -- including
another None.
"""
if one is None or other is None:
return True
return not one.isdisjoint(other)