mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 13:57:32 +01:00
Per-person photo commands, where the mechanism is public and the album is not. Nothing identifying is in this repository: who the people are, what fires their photos and which file shows whom all live in one directory outside it, and the tests, the docs and the offline e2e run use three invented people instead. Several names in one message intersect -- "!alfa beta" is a photo with both -- and where an entry offers a choice the bot picks among the readings that actually have a photo, so a question the album could answer is not refused. A combination nobody photographed gets one line rather than silence, which from the group's side is indistinguishable from a broken bot. Scoping is enforced in the dispatcher, not in the handler: a command the group is not allowed resolves to nothing, so it is not answered, not counted as a command, and never named in a log line outside its own groups. A handler that quietly returned would leak all three. Commands are named by pseudonym (pack:p1), so the journal and command_uses say somebody asked for a photo and nothing about whose. Video works: the attachment goes out as data:video/mp4;filename=...;base64, which is the container's own spelling, read off its /swagger/doc.json. Told it is a photo, Signal renders a clip as an unplayable file. tool/tagger.py serves one page on loopback to fill the album in -- names are typed into the page, never as arguments, and it prints how many photos each command would have when it stops, because a command with zero is dead on arrival. Also renames the real people who were still sitting in the stats test fixtures. They remain in the history; the point is that nothing new is added to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
214 lines
7.7 KiB
Python
214 lines
7.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
|
|
# Whatever followed the trigger, split on whitespace. Empty for every
|
|
# command that does not opt into taking any -- see Command.takes_args.
|
|
args: tuple[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
|
|
)
|
|
|
|
async def reply_media(self, payload_base64: str, media_type: str, filename: str) -> None:
|
|
"""Answer with an attachment that is not necessarily a photo.
|
|
|
|
Separate from reply_image because Signal decides how to render an
|
|
attachment from what it is told it is, and "a jpeg" is a lie that
|
|
makes a video arrive as a file nobody can play.
|
|
"""
|
|
await self.transport.send_media(
|
|
payload_base64,
|
|
self.group_id,
|
|
quote_of(self.message),
|
|
media_type=media_type,
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
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
|
|
# 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
|
|
|
|
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 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.
|
|
"""
|
|
|
|
|
|
@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."""
|
|
self._by_trigger: dict[str, 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,
|
|
) -> 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],
|
|
groups=groups,
|
|
takes_args=takes_args,
|
|
)
|
|
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)
|
|
|
|
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)
|
|
if command is not None:
|
|
return Resolved(command, ()) if command.allowed_in(group_id) else None
|
|
words = text.split()
|
|
if not words:
|
|
return None
|
|
head = self._by_trigger.get(fold(words[0]))
|
|
if head is None or not head.takes_args or not head.allowed_in(group_id):
|
|
return None
|
|
return Resolved(head, tuple(words[1:]))
|
|
|
|
@property
|
|
def commands(self) -> list[Command]:
|
|
"""Every registered command, in registration order."""
|
|
return list(self._commands)
|