signal-bot/signal_bot/analytics/recorder.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

197 lines
7.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Turning one attempted command into one row of history.
This is where the dispatcher's ``try/except`` around a handler now lives, so
that the dispatcher gains a ``with`` rather than forty lines and stays under
the 250-line cap. It also means the six outcome branches can be tested
directly, without driving a whole websocket frame through to reach an
exception path.
The invariant that must survive every future edit to this file: recording a
*use* and recording an *attempt* are two different writes with two different
rules, and they may never be merged.
* ``store.record_use`` is success-only and feeds the cooldown and the daily
limit. ``CommandRefusedError``'s own docstring records what happened when
a refusal reached it: the cooldown window slid forward every time somebody
was told to wait, so waiting the stated time never helped.
* ``store.commands.record`` takes every attempt, including the refusals and
the crashes -- which is the entire point, since a command that fails every
time used to be indistinguishable from one nobody ran.
The temptation this creates is to hoist one call above the other, or to route
both through a single store method. ``test_dispatcher_recording`` fires a
refused invocation and asserts both that the history grew *and* that
``last_used`` did not move, which is the cheapest way to notice.
"""
from __future__ import annotations
import contextlib
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from signal_bot.analytics.uses import (
ARGS_PRESENT,
OUTCOME_COOLDOWN,
OUTCOME_DAILY,
OUTCOME_ERROR,
OUTCOME_OFF,
OUTCOME_OK,
OUTCOME_VOTE,
CommandEvent,
)
from signal_bot.decorators import GATE_COOLDOWN, GATE_DAILY, GATE_OFF, GATE_VOTE
from signal_bot.registry import CommandRefusedError
from signal_bot.stats.periods import day_of, hour_of, weekday_of
if TYPE_CHECKING:
from collections.abc import Iterator
from signal_bot.analytics import probe
from signal_bot.analytics.uses import CommandEventStore
logger = logging.getLogger(__name__)
MS_PER_SECOND: Final = 1000
# A gate that did not name itself. Reachable only from a bare
# ``raise CommandRefusedError``, which nothing in this repository does any
# more -- but the constructor still allows it, so the mapping must too.
OUTCOME_REFUSED: Final = "refused_cooldown"
_BY_GATE: Final = {
GATE_COOLDOWN: OUTCOME_COOLDOWN,
GATE_DAILY: OUTCOME_DAILY,
GATE_VOTE: OUTCOME_VOTE,
GATE_OFF: OUTCOME_OFF,
}
def refusal_outcome(gate: str) -> str:
"""Name the outcome one gate's refusal should be filed under."""
return _BY_GATE.get(gate, OUTCOME_REFUSED)
def args_token(args: tuple[str, ...]) -> str:
"""Reduce a command's arguments to something that cannot hold a name.
The registry has already replaced a photo album's arguments with their
pseudonym keys before the dispatcher sees them -- that is what
``arg_resolver`` is for, and why it runs at resolution rather than in a
handler. Those keys are safe and worth keeping: "which person is asked
for most" is a real question.
Anything else becomes a flag. A column of words people typed is exactly
the shape that put real names in this repository once, and the personal
data gate learns the names it refuses from the live database -- so it
would catch a name typed correctly and could never catch a typo.
"""
if not args:
return ""
# One resolved token is a key. Several words are words.
if len(args) == 1 and args[0].isalnum():
return args[0]
return ARGS_PRESENT
@dataclass(slots=True)
class Attempt:
"""What the dispatcher learned by trying to run one command."""
outcome: str = OUTCOME_OK
error_class: str = ""
handler_ms: int = 0
@property
def ok(self) -> bool:
"""True when the handler actually ran to completion.
The dispatcher asks this rather than comparing to a constant, so
"did it work" has one spelling and the *use* -- the write that feeds
the cooldown -- cannot accidentally start counting refusals.
"""
return self.outcome == OUTCOME_OK
@contextlib.contextmanager
def attempting(command: str) -> Iterator[Attempt]:
"""Run one handler, and come out knowing how it went and what it cost.
Swallows the handler's exception rather than re-raising: the dispatcher
has always treated a failed command as an answered frame, because a raise
here ends the receive loop and the bot goes silently deaf until the
watchdog notices. That behaviour is unchanged -- only now the failure
leaves a row behind.
"""
attempt = Attempt()
started = time.perf_counter()
try:
yield attempt
except CommandRefusedError as refusal:
attempt.outcome = refusal_outcome(refusal.gate)
logger.debug("command %s refused by a gate", command)
except Exception as error:
# The class name, never str(error): an exception's message carries
# whatever it was processing, and here that is somebody's message.
# "17 ImageFetchError" is the whole diagnostic value anyway.
attempt.outcome = OUTCOME_ERROR
attempt.error_class = type(error).__name__
logger.exception("command %s failed", command)
attempt.handler_ms = round((time.perf_counter() - started) * MS_PER_SECOND)
@dataclass(frozen=True, slots=True)
class Invocation:
"""Who ran what, where, and when the message that asked was sent.
Grouped rather than passed as five arguments, because these five always
travel together and none of them means anything without the rest.
"""
group_id: str
user_uuid: str
command: str
args: tuple[str, ...]
sent_at_ms: int
def record_attempt(
store: CommandEventStore,
invocation: Invocation,
attempt: Attempt,
notes: probe.Probe,
) -> None:
"""Append one attempt, and never let doing so break the bot.
The whole body is guarded for the reason ``_answer_unknown``'s is: this
runs inside the receive loop, and an exception escaping it stops the bot
answering anything at all. Analytics are worth a great deal less than
that, so a storage failure is logged and the frame carries on.
"""
sent_at_ms = invocation.sent_at_ms
day = day_of(sent_at_ms)
try:
store.record(
CommandEvent(
group_id=invocation.group_id,
user_uuid=invocation.user_uuid,
command=invocation.command,
args=args_token(invocation.args),
item_id=notes.item_id,
outcome=attempt.outcome,
error_class=attempt.error_class,
invoked_at_ms=sent_at_ms,
day=day,
local_hour=hour_of(sent_at_ms),
weekday=weekday_of(day),
handler_ms=attempt.handler_ms,
send_ms=notes.send_ms,
calls=tuple(notes.calls),
)
)
except Exception:
logger.exception("could not record the use of %s", invocation.command)