mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
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>
204 lines
7.5 KiB
Python
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)
|