mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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>
195 lines
8.0 KiB
Python
195 lines
8.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The database shape, kept apart from the code that queries it.
|
|
|
|
Split out of ``store.py`` so both stay under the 250-line cap, and because
|
|
"what the tables are" and "how the bot reads them" are two things.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Final
|
|
|
|
# Bumped whenever the shape below changes. See Store._apply_schema for why
|
|
# a version number is the only safe way to notice.
|
|
#
|
|
# Deliberately NOT bumped when a whole new table is added: CREATE TABLE IF
|
|
# NOT EXISTS is additive and harmless, whereas a bump drops every table and
|
|
# would throw away the live tallies to gain nothing.
|
|
SCHEMA_VERSION: Final = 2
|
|
|
|
# Every table is keyed by group first. The bot watches several groups, and a
|
|
# tally, a cooldown or a vote that leaked between them would mean a test
|
|
# message throttling the production group -- so the scope is in the primary
|
|
# key rather than in the callers' discipline.
|
|
SCHEMA: Final = """
|
|
CREATE TABLE IF NOT EXISTS command_uses (
|
|
group_id TEXT NOT NULL,
|
|
user_uuid TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
used_at REAL NOT NULL,
|
|
PRIMARY KEY (group_id, user_uuid, command)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS daily_uses (
|
|
group_id TEXT NOT NULL,
|
|
user_uuid TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
day TEXT NOT NULL,
|
|
uses INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (group_id, user_uuid, command, day)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS votes (
|
|
group_id TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
voter_uuid TEXT NOT NULL,
|
|
voted_at REAL NOT NULL,
|
|
PRIMARY KEY (group_id, command, voter_uuid)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS group_meta (
|
|
group_id TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
PRIMARY KEY (group_id, key)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS message_events (
|
|
group_id TEXT NOT NULL,
|
|
uuid TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
sent_at_ms INTEGER NOT NULL,
|
|
day TEXT NOT NULL,
|
|
local_hour INTEGER NOT NULL,
|
|
media_kind TEXT NOT NULL DEFAULT '',
|
|
media_count INTEGER NOT NULL DEFAULT 0,
|
|
char_len INTEGER NOT NULL DEFAULT 0,
|
|
is_command INTEGER NOT NULL DEFAULT 0,
|
|
is_reply INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (group_id, uuid, sent_at_ms)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_events_group_day
|
|
ON message_events (group_id, day);
|
|
CREATE INDEX IF NOT EXISTS idx_events_group_uuid_day
|
|
ON message_events (group_id, uuid, day);
|
|
CREATE TABLE IF NOT EXISTS group_people (
|
|
group_id TEXT NOT NULL,
|
|
uuid TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
number TEXT NOT NULL DEFAULT '',
|
|
first_seen TEXT NOT NULL,
|
|
last_seen TEXT NOT NULL,
|
|
PRIMARY KEY (group_id, uuid)
|
|
);
|
|
-- Keyed on the envelope's own sent timestamp, never on the wall clock.
|
|
-- signal-cli replays undelivered frames when the bot reconnects; a clock
|
|
-- reading is fresh on every replay, so a clock-keyed table collects a
|
|
-- duplicate row per reconnect instead of ignoring one. message_events
|
|
-- learned this already -- see its ON CONFLICT DO NOTHING.
|
|
CREATE TABLE IF NOT EXISTS unknown_commands (
|
|
group_id TEXT NOT NULL,
|
|
word TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
sent_at_ms INTEGER NOT NULL,
|
|
PRIMARY KEY (group_id, word, sent_at_ms)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_unknown_group_word
|
|
ON unknown_commands (group_id, word);
|
|
CREATE TABLE IF NOT EXISTS suggestions (
|
|
group_id TEXT NOT NULL,
|
|
command TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
sent_at_ms INTEGER NOT NULL,
|
|
PRIMARY KEY (group_id, command, sent_at_ms)
|
|
);
|
|
-- The operator's switches, one row per command that has been touched from
|
|
-- the management page; a command with no row is on, with the configured
|
|
-- cooldown. Not keyed by group on purpose: "!kot is off" is a decision
|
|
-- about the bot, and the page that makes it has no group selector.
|
|
-- disabled_until is epoch seconds, like group_meta's mute deadline.
|
|
CREATE TABLE IF NOT EXISTS command_settings (
|
|
command TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
disabled_until REAL,
|
|
cooldown_seconds REAL
|
|
);
|
|
"""
|
|
|
|
# These were one tuple, EPHEMERAL_TABLES, until command analytics needed the
|
|
# two jobs it was doing to come apart. "Dropped when the schema changes" and
|
|
# "cleared when the bot leaves a group" happened to name the same four tables,
|
|
# so one constant served both -- and then daily_uses became a source the
|
|
# analytics history is backfilled from, which must survive a schema bump but
|
|
# must still reset a rejoined group's daily limits. Two questions, two tuples.
|
|
|
|
# Dropped and recreated when SCHEMA_VERSION rises. Only tables whose contents
|
|
# cost nothing to lose: a vote in progress, a per-group marker.
|
|
#
|
|
# The price of taking command_uses/daily_uses out of here is explicit: if
|
|
# their *shape* ever changes, CREATE TABLE IF NOT EXISTS is a no-op against
|
|
# the old one and every INSERT starts failing. Reshaping either of them now
|
|
# needs a written migration rather than a version bump. That is the trade
|
|
# that keeps the use history.
|
|
REBUILT_ON_BUMP: Final = (
|
|
"votes",
|
|
"group_meta",
|
|
)
|
|
# Rows deleted for one group when the bot is removed from it, so that being
|
|
# added back starts with a clean cooldown rather than resurrecting whatever
|
|
# was true whenever it was last in there.
|
|
CLEARED_ON_LEAVE: Final = (
|
|
"command_uses",
|
|
"daily_uses",
|
|
"votes",
|
|
"group_meta",
|
|
)
|
|
# Never dropped and never cleared, by any path. These hold the whole
|
|
# statistical history, which cannot be recomputed from anything -- so a
|
|
# transient group listing that omits a member (the exact failure
|
|
# discovery._permitted guards against) must not be able to delete years of
|
|
# it, and neither must a future schema bump that only meant to reshape a
|
|
# cooldown table.
|
|
PERMANENT_TABLES: Final = (
|
|
"message_events",
|
|
"group_people",
|
|
# What the group wished existed. Neither can be recomputed from anything
|
|
# -- an idea somebody typed once is gone the moment it is dropped -- and
|
|
# forget_group() clears its own set on the transient group-listing gap
|
|
# discovery._permitted guards against, which must not cost a year of
|
|
# collected suggestions.
|
|
"unknown_commands",
|
|
"suggestions",
|
|
# Every command attempt ever made, and every upstream call made serving
|
|
# one. In neither tuple above: a schema bump must not throw away the
|
|
# answer to "which command is dying", and leaving a group must not
|
|
# either -- message_events already survives both for the same reason.
|
|
"command_events",
|
|
"upstream_calls",
|
|
# Not history but the operator's own decisions, which no group leaving
|
|
# and no schema bump has any business undoing.
|
|
"command_settings",
|
|
)
|
|
# Every table the schema declares. command_uses and daily_uses are in
|
|
# CLEARED_ON_LEAVE but not PERMANENT_TABLES, so the union is spelled out
|
|
# rather than concatenated -- test_schema_tables checks it against sqlite's
|
|
# own catalogue, which is what keeps this honest.
|
|
TABLES: Final = (
|
|
"command_uses",
|
|
"daily_uses",
|
|
*REBUILT_ON_BUMP,
|
|
*PERMANENT_TABLES,
|
|
)
|
|
|
|
# The two tables that carry history and are NOT rebuilt on a version bump.
|
|
# Because they survive, a bump can no longer fix them if their shape is
|
|
# wrong, and CREATE TABLE IF NOT EXISTS will not either -- which is the
|
|
# failure _apply_schema exists to prevent: a bot that boots clean and then
|
|
# fails on every INSERT. So their columns are declared here and checked, and
|
|
# a mismatch drops the table after all. Losing a cooldown is recoverable;
|
|
# a bot that answers nothing is not.
|
|
#
|
|
# This is the "written migration" the comment on REBUILT_ON_BUMP promises.
|
|
# A deliberate reshape means editing the tuple below in the same commit, and
|
|
# accepting that the old rows go. test_declared_columns_match_the_schema
|
|
# asserts these against what sqlite actually creates, so they cannot drift.
|
|
RETAINED_COLUMNS: Final = {
|
|
"command_uses": ("group_id", "user_uuid", "command", "used_at"),
|
|
"daily_uses": ("group_id", "user_uuid", "command", "day", "uses"),
|
|
}
|