signal-bot/signal_bot/dispatcher.py
Krzysztof kuhy Rudnicki d13ae42590
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
A switchboard: any command off, off until, or on its own cooldown
One row per live command on a new Commands tab of the management page,
each with an on/off switch, an optional "off until" moment and a cooldown
that overrides the bot's default for that command alone. Every switch is
live: the bot keeps them in its own SQLite (command_settings, a permanent
table) and reads the row on every attempt, so nothing goes through
/_reload and nothing restarts.

A command that is off answers "BEEP BOOP !kot WYŁĄCZONE." (with "DO HH:MM"
for a timed switch) rather than going quiet, so nobody reports it broken;
the refusal is filed as refused_off, which the analytics count with the
other refusals, and it neither starts the cooldown nor counts as a use.

The bot grows GET /_commands and PUT /_commands/{name}; the management app
proxies them under /api/commands and passes the bot's 404/503 through
unwrapped. !cisza and !odcisz carry no cooldown and the page says so
instead of accepting a number the bot would ignore (test_wiring_ungated
keeps that set honest against the real table).

Proven end to end: scripts/e2e.sh now brackets a PUT with switch_off.yaml
and switch_on.yaml, the same actor refused and then served, with no reload
between them. Also bumps vitest and @vitest/coverage-v8 to 5.0.0 and
@types/react-dom to 19.2.7, which the freshness gate asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 15:55:56 +02:00

191 lines
8.1 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 import mute
from signal_bot.analytics import probe, recorder
from signal_bot.command_switch import run_unless_off
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,
)
# Below the recording, above every reply: a muted group is still
# listened to and counted, it is just not talked to. The one command
# that passes is the one that ends the mute.
now = self.clock()
if not mute.allows(self.store, group, now.timestamp(), command):
logger.debug("muted in %s; not answering", group)
return
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)
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 run_unless_off(self.store, command, 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)