mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +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>
151 lines
5.4 KiB
Python
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,
|
|
),
|
|
)
|