# 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_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, ), )