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

151 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Writing the command history: one row per attempt, and what it cost.
Shares the ``Store`` connection exactly as ``EventStore`` does, so a command
and the message that triggered it are committed by the same connection in
the same order they happened.
The column set is pinned by ``test_command_events`` for the reason
``message_events``' is: this table sits beside a public repository that
leaked real names once, and a column added quietly is how the next one would
arrive. Nothing here holds free text. ``command`` is a registry name,
``args`` is a resolved token, ``item_id`` is a public identifier and
``error_class`` is an exception's class name.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
import sqlite3
from collections.abc import Sequence
from signal_bot.analytics.probe import UpstreamCall
OUTCOME_OK: Final = "ok"
OUTCOME_COOLDOWN: Final = "refused_cooldown"
OUTCOME_DAILY: Final = "refused_daily"
OUTCOME_VOTE: Final = "refused_vote"
OUTCOME_OFF: Final = "refused_off"
OUTCOME_ERROR: Final = "error"
OUTCOME_UNKNOWN: Final = "unknown_word"
SOURCE_LIVE: Final = "live"
SOURCE_JOURNAL: Final = "journald"
SOURCE_DAILY: Final = "daily_uses"
# What ``args`` holds when a command took arguments that resolve to nothing
# nameable. Never the words themselves: a user-typed argument column is the
# exact shape that put real names in this repository once.
ARGS_PRESENT: Final = "+args"
@dataclass(frozen=True, slots=True)
class CommandEvent:
"""One attempt to run one command, whether or not it ran."""
group_id: str
user_uuid: str
command: str
outcome: str
invoked_at_ms: int
day: str
local_hour: int
weekday: int
args: str = ""
item_id: str | None = None
error_class: str = ""
# None rather than 0: a backfilled row has no duration, and zero
# milliseconds is a lie that reads as data and would drag a p50 down.
# The lesson is already written into backfill.UNKNOWN_HOUR's comment.
handler_ms: int | None = None
send_ms: int | None = None
source: str = SOURCE_LIVE
calls: Sequence[UpstreamCall] = field(default_factory=tuple)
class CommandEventStore:
"""Reads and writes the command history. Shares the Store's connection."""
def __init__(self, conn: sqlite3.Connection) -> None:
"""Wrap an open connection whose schema has already been applied."""
self._conn = conn
@property
def connection(self) -> sqlite3.Connection:
"""The shared connection, for the query modules in this package."""
return self._conn
def record(self, event: CommandEvent) -> None:
"""Append one attempt and the upstream calls it made.
``ON CONFLICT DO NOTHING`` against the natural key is what makes a
signal-cli redelivery harmless, the same property ``message_events``
gets from its primary key -- ``invoked_at_ms`` is the envelope's own
timestamp, so a replayed frame collides with the row it already
wrote instead of adding a second one.
It is also what makes the backfill re-runnable, which is why the
backfill needs no delete-then-rebuild pass and cannot lose rows to
a journal that has since been vacuumed.
"""
self._conn.execute(
"""
INSERT INTO command_events (
group_id, user_uuid, command, args, item_id, outcome,
error_class, invoked_at_ms, day, local_hour, weekday,
handler_ms, send_ms, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_id, user_uuid, command, invoked_at_ms, source)
DO NOTHING
""",
(
event.group_id,
event.user_uuid,
event.command,
event.args,
event.item_id,
event.outcome,
event.error_class,
event.invoked_at_ms,
event.day,
event.local_hour,
event.weekday,
event.handler_ms,
event.send_ms,
event.source,
),
)
self._record_calls(event)
self._conn.commit()
def _record_calls(self, event: CommandEvent) -> None:
"""Append the upstream calls one invocation made.
Keyed on (group, timestamp, seq) rather than on the parent's rowid.
``lastrowid`` is unusable here: ``ON CONFLICT DO NOTHING`` leaves it
holding whatever the previous successful insert returned, so a
replayed frame would file its calls under an unrelated invocation.
"""
for seq, call in enumerate(event.calls):
self._conn.execute(
"""
INSERT INTO upstream_calls (
group_id, invoked_at_ms, command, host, status,
duration_ms, seq
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(group_id, invoked_at_ms, seq) DO NOTHING
""",
(
event.group_id,
event.invoked_at_ms,
event.command,
call.host,
call.status,
call.ms,
seq,
),
)