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>
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Which tables survive what.
|
|
|
|
EPHEMERAL_TABLES used to answer two different questions with one tuple, and
|
|
this file exists because splitting it is exactly the kind of change that looks
|
|
right and silently stops clearing something. Every assertion here is against
|
|
either sqlite's own catalogue or the code that does the dropping -- never
|
|
against a second copy of the list.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.command_settings import CommandSetting
|
|
from signal_bot.schema import (
|
|
CLEARED_ON_LEAVE,
|
|
PERMANENT_TABLES,
|
|
REBUILT_ON_BUMP,
|
|
RETAINED_COLUMNS,
|
|
SCHEMA_VERSION,
|
|
TABLES,
|
|
)
|
|
from signal_bot.store import Store
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
GROUP = "group-a=="
|
|
|
|
|
|
def _declared(store: Store) -> set[str]:
|
|
"""Every table sqlite actually holds, as opposed to every one we listed."""
|
|
rows = store.events.connection.execute(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
|
|
).fetchall()
|
|
return {str(row["name"]) for row in rows}
|
|
|
|
|
|
def _seed(store: Store, group_id: str = GROUP) -> None:
|
|
"""One row in every table a group can own."""
|
|
store.record_use(group_id, "u1", "cat", 1.0, "2026-08-29")
|
|
store.add_vote(group_id, "cat", "u1", 1.0)
|
|
store.set_meta(group_id, "last_reported_day", "2026-08-29")
|
|
store.suggestions.record_suggestion(group_id, "!pogoda", "na jutro", 1)
|
|
store.suggestions.record_unknown(group_id, "!cta", "!cta", 2)
|
|
store.command_settings.put(CommandSetting("cat", enabled=False))
|
|
|
|
|
|
# Both maps are spelled out rather than interpolated, for the reason
|
|
# forget_group spells its DELETEs out: a table name in an f-string is a
|
|
# SQL-injection shape even when the input is a module constant, and this
|
|
# repository takes no lint suppressions. PRAGMA additionally takes no
|
|
# parameters at all. test_every_table_can_be_counted keeps COUNTS complete.
|
|
COLUMNS = {
|
|
"command_uses": "PRAGMA table_info(command_uses)",
|
|
"daily_uses": "PRAGMA table_info(daily_uses)",
|
|
}
|
|
|
|
COUNTS = {
|
|
"command_uses": "SELECT count(*) AS n FROM command_uses WHERE group_id = ?",
|
|
"daily_uses": "SELECT count(*) AS n FROM daily_uses WHERE group_id = ?",
|
|
"votes": "SELECT count(*) AS n FROM votes WHERE group_id = ?",
|
|
"group_meta": "SELECT count(*) AS n FROM group_meta WHERE group_id = ?",
|
|
"message_events": "SELECT count(*) AS n FROM message_events WHERE group_id = ?",
|
|
"group_people": "SELECT count(*) AS n FROM group_people WHERE group_id = ?",
|
|
"unknown_commands": "SELECT count(*) AS n FROM unknown_commands WHERE group_id = ?",
|
|
"suggestions": "SELECT count(*) AS n FROM suggestions WHERE group_id = ?",
|
|
"command_events": "SELECT count(*) AS n FROM command_events WHERE group_id = ?",
|
|
"upstream_calls": "SELECT count(*) AS n FROM upstream_calls WHERE group_id = ?",
|
|
# Not keyed by group: a switch is thrown for the bot, not for a chat. The
|
|
# placeholder is there so every entry takes the same one argument.
|
|
"command_settings": "SELECT count(*) AS n FROM command_settings WHERE ? IS NOT NULL",
|
|
}
|
|
|
|
|
|
def _rows(store: Store, table: str, group_id: str = GROUP) -> int:
|
|
"""How many rows one group owns in one table."""
|
|
row = store.events.connection.execute(COUNTS[table], (group_id,)).fetchone()
|
|
return int(row["n"])
|
|
|
|
|
|
def test_every_table_can_be_counted() -> None:
|
|
"""A new table without a count query would skip every check below it."""
|
|
assert set(COUNTS) == set(TABLES)
|
|
|
|
|
|
def test_declared_columns_match_the_schema(store: Store) -> None:
|
|
"""RETAINED_COLUMNS is a second copy of the DDL, so it must be checked.
|
|
|
|
_drop_reshaped compares against it to decide whether a retained table is
|
|
still writable. A stale entry here would drop a perfectly good table on
|
|
every bump -- silently, since dropping is the recovery path -- and take
|
|
the use history it was moved out of REBUILT_ON_BUMP to protect.
|
|
"""
|
|
for table, expected in RETAINED_COLUMNS.items():
|
|
found = tuple(str(row["name"]) for row in store.events.connection.execute(COLUMNS[table]))
|
|
assert found == expected
|
|
|
|
|
|
def test_a_retained_table_with_the_wrong_shape_is_dropped(tmp_path: Path) -> None:
|
|
"""The failure _apply_schema exists to prevent, on a table it no longer rebuilds.
|
|
|
|
A database written before the group key holds a two-column command_uses.
|
|
CREATE TABLE IF NOT EXISTS will not touch it and the bump no longer drops
|
|
it, so without _drop_reshaped the bot boots clean and fails on every
|
|
INSERT.
|
|
"""
|
|
path = str(tmp_path / "legacy.sqlite3")
|
|
with sqlite3.connect(path) as raw:
|
|
raw.execute("CREATE TABLE daily_uses (uuid TEXT PRIMARY KEY, uses INTEGER NOT NULL)")
|
|
raw.execute("INSERT INTO daily_uses VALUES ('u1', 7)")
|
|
with Store(path) as store:
|
|
store.record_use(GROUP, "u1", "cat", 1.0, "2026-08-29")
|
|
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1
|
|
|
|
|
|
def test_the_list_matches_what_sqlite_holds(store: Store) -> None:
|
|
"""TABLES is the schema's index, so it may not drift from the schema."""
|
|
assert set(TABLES) == _declared(store)
|
|
|
|
|
|
def test_no_table_is_both_kept_and_thrown_away() -> None:
|
|
"""A table in two of these tuples is a contradiction, not a configuration."""
|
|
assert not set(PERMANENT_TABLES) & set(REBUILT_ON_BUMP)
|
|
assert not set(PERMANENT_TABLES) & set(CLEARED_ON_LEAVE)
|
|
|
|
|
|
def test_the_history_is_permanent() -> None:
|
|
"""The four tables nothing can recompute, named where it is configured."""
|
|
for table in ("message_events", "command_events", "upstream_calls", "group_people"):
|
|
assert table in PERMANENT_TABLES
|
|
|
|
|
|
def test_the_use_counters_reset_on_leave_but_survive_a_bump() -> None:
|
|
"""The whole point of the split, asserted as one sentence.
|
|
|
|
daily_uses feeds a rate limiter *and* is a source the analytics history is
|
|
backfilled from. Clearing it when the bot leaves a group is correct;
|
|
dropping it on a schema bump would throw away use history that predates
|
|
command_events.
|
|
"""
|
|
for table in ("command_uses", "daily_uses"):
|
|
assert table in CLEARED_ON_LEAVE
|
|
assert table not in REBUILT_ON_BUMP
|
|
|
|
|
|
def test_forget_group_clears_every_cleared_table(store: Store) -> None:
|
|
"""The DELETEs in forget_group are spelled out, so something must count them.
|
|
|
|
Without this, adding a table to CLEARED_ON_LEAVE and forgetting the
|
|
matching DELETE leaves a cooldown alive across a leave and a rejoin --
|
|
invisible until somebody is told to wait for a command they never ran.
|
|
"""
|
|
_seed(store)
|
|
for table in CLEARED_ON_LEAVE:
|
|
assert _rows(store, table) > 0
|
|
store.forget_group(GROUP)
|
|
for table in CLEARED_ON_LEAVE:
|
|
assert _rows(store, table) == 0
|
|
|
|
|
|
def test_forget_group_leaves_the_permanent_tables_alone(store: Store) -> None:
|
|
"""A transient group listing must not be able to delete years of history."""
|
|
_seed(store)
|
|
store.forget_group(GROUP)
|
|
assert len(store.suggestions.filed()) == 1
|
|
assert len(store.suggestions.unknown()) == 1
|
|
assert not store.command_settings.get("cat").enabled
|
|
|
|
|
|
def test_a_version_bump_keeps_the_use_history(tmp_path: Path) -> None:
|
|
"""A bump drops REBUILT_ON_BUMP and nothing else.
|
|
|
|
Written against a file rather than :memory: because the bump is only
|
|
observable across two openings of the same database.
|
|
"""
|
|
path = str(tmp_path / "bump.sqlite3")
|
|
with Store(path) as store:
|
|
_seed(store)
|
|
with sqlite3.connect(path) as raw:
|
|
raw.execute(f"PRAGMA user_version = {SCHEMA_VERSION - 1}")
|
|
with Store(path) as reopened:
|
|
# Thrown away, and cheap to lose.
|
|
assert _rows(reopened, "votes") == 0
|
|
assert _rows(reopened, "group_meta") == 0
|
|
# Kept, because nothing recomputes them.
|
|
assert _rows(reopened, "command_uses") == 1
|
|
assert _rows(reopened, "daily_uses") == 1
|
|
assert len(reopened.suggestions.filed()) == 1
|
|
assert not reopened.command_settings.get("cat").enabled
|