mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +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>
104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The operator's switches: which commands are on, and how fast they may go.
|
|
|
|
One row per command that has been touched from the management page. A
|
|
command without a row is on and keeps the cooldown the deployment was
|
|
started with; a row overrides exactly what it spells out and nothing else.
|
|
The bot reads these on every command, so a flip on the page is live before
|
|
the browser has finished repainting -- no reload, no restart, nothing to
|
|
forget to do.
|
|
|
|
"Off" comes in two shapes and they are read together: ``enabled`` is the
|
|
hard switch, ``disabled_until`` a timed one that lifts itself. A command is
|
|
off while either says so.
|
|
|
|
Shares the Store's connection, the same way stats.events.EventStore does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CommandSetting:
|
|
"""What the operator decided about one command."""
|
|
|
|
command: str
|
|
enabled: bool = True
|
|
disabled_until: float | None = None
|
|
cooldown_seconds: float | None = None
|
|
|
|
def off_at(self, now: float) -> bool:
|
|
"""Whether the command is switched off at this moment.
|
|
|
|
The timed switch is exclusive at its deadline, the same way a mute
|
|
is: "off until 18:00" is on again at 18:00 sharp.
|
|
"""
|
|
if not self.enabled:
|
|
return True
|
|
return self.disabled_until is not None and now < self.disabled_until
|
|
|
|
|
|
class CommandSettingStore:
|
|
"""Reads and writes the switches. 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
|
|
|
|
def get(self, command: str) -> CommandSetting:
|
|
"""The setting for one command; the defaults when it was never touched."""
|
|
row = self._conn.execute(
|
|
"SELECT command, enabled, disabled_until, cooldown_seconds "
|
|
"FROM command_settings WHERE command = ?",
|
|
(command,),
|
|
).fetchone()
|
|
return CommandSetting(command) if row is None else _setting(row)
|
|
|
|
def all(self) -> dict[str, CommandSetting]:
|
|
"""Every command that has a row, by name."""
|
|
rows = self._conn.execute(
|
|
"SELECT command, enabled, disabled_until, cooldown_seconds FROM command_settings"
|
|
).fetchall()
|
|
return {str(row["command"]): _setting(row) for row in rows}
|
|
|
|
def put(self, setting: CommandSetting) -> None:
|
|
"""Store one command's setting, replacing whatever was there."""
|
|
self._conn.execute(
|
|
"INSERT INTO command_settings (command, enabled, disabled_until, cooldown_seconds) "
|
|
"VALUES (?, ?, ?, ?) "
|
|
"ON CONFLICT(command) DO UPDATE SET "
|
|
"enabled = excluded.enabled, "
|
|
"disabled_until = excluded.disabled_until, "
|
|
"cooldown_seconds = excluded.cooldown_seconds",
|
|
(
|
|
setting.command,
|
|
int(setting.enabled),
|
|
setting.disabled_until,
|
|
setting.cooldown_seconds,
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
|
|
def cooldown_for(self, command: str, default: float) -> float:
|
|
"""The cooldown to enforce: the operator's if set, else the deployment's."""
|
|
override = self.get(command).cooldown_seconds
|
|
return default if override is None else override
|
|
|
|
|
|
def _setting(row: sqlite3.Row) -> CommandSetting:
|
|
"""One row, typed."""
|
|
until = row["disabled_until"]
|
|
cooldown = row["cooldown_seconds"]
|
|
return CommandSetting(
|
|
command=str(row["command"]),
|
|
enabled=bool(row["enabled"]),
|
|
disabled_until=None if until is None else float(until),
|
|
cooldown_seconds=None if cooldown is None else float(cooldown),
|
|
)
|