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