mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
One row per command attempt, not two aggregates written only on success. command_uses holds last-use only and daily_uses counts successes; both are rate-limiter state and both are written by store.record_use, which the dispatcher calls only when a handler returns cleanly. A gate refusal and a crash returned before it, so a command that failed every time looked exactly like one nobody ran, and nothing knew which command a counted message was. command_events now takes every attempt with its outcome, its duration and the item it served; upstream_calls takes every outbound request made while serving one. The two writes stay separate on purpose -- recording a refusal as a use is what slid the cooldown window forward every time somebody was told to wait -- and test_dispatcher_outcomes asserts last_used does not move. Backfilled from journald joined against message_events, with daily_uses filling the residual so the two sources cannot double-count. Two things were wrong on the first attempt and both were found by measuring rather than by reasoning: the window has to be symmetric, because sent_at_ms comes from the sender's phone and the journal stamp from this host (194 of 299 lines landed *before* their own message, by up to 3.2 s); and matching has to be a global closest-pair assignment, because per-row greedy cannot separate two commands seconds apart. Together those took the dropped rows from 137 to zero, and recovered 418 invocations back to 29 August. Also: !analityka in the group, twelve Polish lines, naming at most three people and only ones whose display name has been seen; an Analytics tab with Nivo charts; and continuous latency measurement, which answers the open question in DOCS-latency.md -- a text send is 235 ms and an image send about 2.2 s, so the upload really is where the time goes. EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a schema bump rebuilds, and what leaving a group clears. daily_uses left the first (it is a backfill source now) and stayed in the second, and _drop_reshaped took over the repair a bump used to do for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
251 lines
10 KiB
Python
251 lines
10 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 signal_bot.context import CommandContext
|
|
from signal_bot.folding import fold
|
|
|
|
COMMAND_PREFIX = "!"
|
|
|
|
|
|
Handler = Callable[[CommandContext], Awaitable[None]]
|
|
|
|
# Turns the words after a trigger into whatever the handler is allowed to see.
|
|
# For the pack that means pseudonym keys and nothing else: the raw remainder
|
|
# stops here, so no handler, log line or command_uses row ever holds a name
|
|
# somebody typed. A command without one gets the words unchanged, which is
|
|
# what every command before this feature did.
|
|
ArgResolver = Callable[[tuple[str, ...]], tuple[str, ...]]
|
|
|
|
|
|
@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
|
|
# The only groups this command exists in, or None for everywhere. Not a
|
|
# gate inside the handler: the dispatcher treats a command that is not
|
|
# allowed here as no command at all, so it is never answered, never
|
|
# counted as a command and never logged by name in a group it does not
|
|
# belong to. A silent handler would still leak all three.
|
|
groups: frozenset[str] | None = None
|
|
# Whether words after the trigger are arguments rather than nonsense.
|
|
# Off by default, so "!cat blah" keeps resolving to nothing exactly as
|
|
# it does today.
|
|
takes_args: bool = False
|
|
# Applied to those words before they reach CommandContext.args. The
|
|
# scrubbing seam -- see ArgResolver.
|
|
arg_resolver: ArgResolver | None = None
|
|
|
|
def allowed_in(self, group_id: str) -> bool:
|
|
"""Whether this command exists in one group."""
|
|
return self.groups is None or group_id in self.groups
|
|
|
|
|
|
class DuplicateTriggerError(ValueError):
|
|
"""Raised when two commands that can both run claim the same trigger word.
|
|
|
|
"Can both run" is the whole test. Two albums scoped to different groups
|
|
may each define "!alfa" -- they are different people, and no message can
|
|
ever mean both, because a command a group is not scoped to is not a
|
|
command there at all. A collision is only a collision where the group
|
|
sets overlap, and then it is fatal at startup rather than a coin toss.
|
|
"""
|
|
|
|
|
|
class CommandRefusedError(Exception):
|
|
"""A gate declined the command, so nothing happened and no *use* 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.
|
|
|
|
The refusal is now recorded in the command *history*, which is a different
|
|
table and a different question: how often a gate actually bites is what
|
|
says whether it is tuned right. ``gate`` names which one refused, because
|
|
"this command is refused 40% of the time" and "this command is popular"
|
|
are the two readings of the same raw count.
|
|
|
|
It defaults to empty so that raising it bare stays legal -- every existing
|
|
raise site said only "no", and a required argument would turn a behaviour
|
|
that works into a signature change across three decorators.
|
|
"""
|
|
|
|
def __init__(self, gate: str = "") -> None:
|
|
"""Record which gate refused, if the caller says."""
|
|
super().__init__(gate)
|
|
self.gate = gate
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Resolved:
|
|
"""A command a message fired, and the words that followed it."""
|
|
|
|
command: Command
|
|
args: tuple[str, ...]
|
|
|
|
|
|
class CommandRegistry:
|
|
"""Maps trigger words to commands."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Start with no commands registered."""
|
|
# 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] = []
|
|
|
|
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 = list(triggers)
|
|
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)
|
|
|
|
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)
|