signal-bot/signal_bot/dispatcher.py
Krzysztof kuhy Rudnicki 0717df646a
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Rebuild the command table on demand, and survive a tag whose file is gone
The registry used to be built once, inline in build_bot, so every change to
the data behind it -- a person tagged in the web UI, an alias, soon a command
switched off from the management page -- meant restarting the unit. That is
cheap and lossless, but it is a deploy step somebody has to remember, and a
21:37 report can land in the middle of it.

Now the registration sequence lives in wiring.build_registry, and
RegistryHolder runs it again on POST /_reload: the new table is built whole
and swapped in with one assignment, never patched in place, so a duplicate
trigger is found before anything the dispatcher reads has changed. Bad data
(ConfigError, DuplicateTriggerError) keeps the previous table and answers
409 with the reason; a programming error still surfaces as a 500.
scripts/reload.sh wraps it, with --code for the restart a Python change
needs, and an optional command to type in testbot so "reloaded" means
"answered". No Python hot-reload on purpose.

The loader change is what makes deploying this safe: two rows in the live
tags.json name files that are no longer in media/, and load_pack refused
them, so the next restart would have looped on ConfigError. A stale tag is
drift in a file a page edits mid-session, not a broken album -- it is now
skipped with a warning naming it. A tag that reaches outside the album is
still refused. pack_load.py split at the 250-line cap; pack_tags.py is the
half whose checks are not all fatal.

Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is
tagged into the synthetic pack, scripts/reload.sh runs, and the same
process posts delta's photo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:14:39 +02:00

182 lines
7.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Routes one inbound frame: scope it, count it, maybe run a command."""
from __future__ import annotations
import datetime as dt
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.analytics import probe, recorder
from signal_bot.context import CommandContext
from signal_bot.counting import should_count, watched_group
from signal_bot.envelope_shape import shape_of
from signal_bot.models import parse_envelope
from signal_bot.stats.recorder import record_message, sent_at_ms_of
from signal_bot.unknown import answer_unknown
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.models import IncomingMessage
from signal_bot.registry import CommandRegistry
from signal_bot.registry_holder import RegistryHolder
from signal_bot.store import Store
from signal_bot.transport_base import Transport
logger = logging.getLogger(__name__)
Clock = Callable[[], dt.datetime]
@dataclass(frozen=True, slots=True)
class Dispatcher:
"""Applies the scoping rules and dispatches commands."""
config: Config
directory: GroupDirectory
# The box, not the table: the table is rebuilt and swapped when the
# data behind it changes, and a dispatcher holding the table itself
# would keep answering from the one it was built with.
holder: RegistryHolder
store: Store
transport: Transport
clock: Clock
@property
def registry(self) -> CommandRegistry:
"""The command table as it stands right now."""
return self.holder.registry
async def _answer_unknown(self, message: IncomingMessage, group: str) -> None:
"""Answer a "!" word nothing claims, and remember it.
Guarded on its own because it runs *above* the try that wraps a
handler, and Bot.listen has no exception handling of its own: one
raise here ends the receive loop and the bot goes silently deaf
until the watchdog notices. A sqlite insert and a network send are
exactly the two things in this branch that can raise.
"""
try:
await answer_unknown(
message=message,
group_id=group,
registry=self.registry,
store=self.store,
transport=self.transport,
)
except Exception:
logger.exception("could not answer an unrecognised command in %s", group)
async def handle(self, frame: str) -> None:
"""Process one raw websocket frame end to end."""
# Keys and types only, never values -- see envelope_shape. This is
# how "what does a frame with an attachment look like?" gets answered
# without a code change and a lucky moment.
logger.debug("envelope shape: %s", shape_of(frame))
message = parse_envelope(frame)
if message is None:
return
# Before the scoping gate: a group-update frame arrives while its
# group is still unknown, and it is what vouches for that group.
self.directory.note_group_update(message)
# The scoping gate. Direct messages and every other group the account
# belongs to stop here, before counting and before any handler runs.
# The canonical id it yields is the reply address and the key for
# every piece of per-group state, so a message can only ever be
# counted, rate-limited and answered in the group it came from.
group = watched_group(message, self.directory)
if group is None and message.group_id is not None:
# The bot was probably just added: Signal is already delivering
# this group's messages while our listing still predates it.
# Refreshing here is what makes the *first* command after an add
# work, instead of the one after the next 30-second sweep.
group = await self.directory.refresh_for(message.group_id)
if group is None:
logger.debug(
"ignoring message from group %r; watching %r",
message.group_id,
list(self.directory.groups),
)
return
# Resolved before recording, not after: is_command is part of the
# row, so "was this a command" has to be known at write time. It is
# the same lookup the handler below uses, not a second one.
#
# Scoped to this group, and that is the whole enforcement: a command
# a group is not allowed comes back as None here, so it is not
# answered, not recorded as a command and never named in a log line
# outside the groups it belongs to.
resolved = self.registry.resolve_with_args(message.text, group)
command = resolved.command if resolved is not None else None
# Computed once and reused below. should_count is False for a
# reaction and for the bot's own envelope, and the second of those
# is the loop guard: handle() does not check is_own anywhere else,
# so without this the bot could answer itself.
counted = should_count(message)
if counted:
record_message(
self.store.events,
group,
message,
self.clock(),
is_command=command is not None,
)
if command is None:
# is_command above stays False, and this stays below the
# recording rather than in front of it: an unrecognised word is
# not a command just because the bot now says something about it.
if counted:
await self._answer_unknown(message, group)
return
logger.info("running command %s for %s", command.name, message.source_name)
now = self.clock()
ctx = CommandContext(
message=message,
group_id=group,
transport=self.transport,
store=self.store,
config=self.config,
command_name=command.name,
now=now.timestamp(),
day=now.date().isoformat(),
args=resolved.args if resolved is not None else (),
)
# The envelope's own timestamp, not the clock, and the same value
# record_message keyed its row on -- that shared key is what lets
# the two tables be joined at all.
sent_at_ms = sent_at_ms_of(message, now)
# Two recordings, and they are deliberately not the same write.
#
# The *history* takes every attempt: a refusal and a crash are rows,
# because a command that fails every time used to be indistinguishable
# from one nobody ran. The *use* is success-only and feeds the
# cooldown -- recording a refusal there would push the wait N seconds
# further away every time somebody was told to wait N seconds.
# See analytics.recorder for the invariant and the test that holds it.
with probe.probing() as notes, recorder.attempting(command.name) as attempt:
await command.handler(ctx)
recorder.record_attempt(
self.store.commands,
recorder.Invocation(
group_id=group,
user_uuid=message.source_uuid,
command=command.name,
args=ctx.args,
sent_at_ms=sent_at_ms,
),
attempt,
notes,
)
if not attempt.ok:
return
# Recorded once, here, so stacked gates cannot double-count a use.
self.store.record_use(group, message.source_uuid, command.name, ctx.now, ctx.day)