signal-bot/signal_bot/command_switch.py
Krzysztof kuhy Rudnicki d13ae42590
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
A switchboard: any command off, off until, or on its own cooldown
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>
2026-09-04 15:55:56 +02:00

60 lines
2.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The gate the management page throws: a command switched off says so.
Sits between the dispatcher and the handler, in front of every other gate,
and is the one gate not expressed as a decorator. The decorators are chosen
at registration time, once per deployment; this one is read from the store
on every attempt, because the whole point of the page is that flipping a
command off takes effect before the browser has repainted -- no reload,
nothing to remember to do.
It answers. A silent "off" reads as a bot that is broken, and the same
person asks again. "WYŁĄCZONE" once per attempt is the cooldown gate's
manners applied to a different reason, and it is filed as a refusal for the
same reason a cooldown refusal is: a command people keep asking for while it
is off is one worth switching back on.
"""
from __future__ import annotations
import datetime as dt
import logging
from typing import TYPE_CHECKING, Final
from signal_bot.decorators import GATE_OFF
from signal_bot.registry import CommandRefusedError
from signal_bot.stats.periods import WARSAW
if TYPE_CHECKING:
from signal_bot.context import CommandContext
from signal_bot.registry import Command
from signal_bot.store import Store
logger = logging.getLogger(__name__)
OFF: Final = "BEEP BOOP {trigger} WYŁĄCZONE."
OFF_UNTIL: Final = "BEEP BOOP {trigger} WYŁĄCZONE DO {until}."
def off_message(trigger: str, disabled_until: float | None, now: float) -> str:
"""What the group is told, naming the deadline when there is one to name.
Warsaw time, like every clock reading the bot prints. A deadline already
behind us gets the plain message: the hard switch is what is holding the
command off then, and naming a time that has passed reads as a lie.
"""
if disabled_until is None or disabled_until <= now:
return OFF.format(trigger=trigger)
at = dt.datetime.fromtimestamp(disabled_until, tz=dt.UTC).astimezone(WARSAW)
return OFF_UNTIL.format(trigger=trigger, until=at.strftime("%H:%M"))
async def run_unless_off(store: Store, command: Command, ctx: CommandContext) -> None:
"""Run the handler, or refuse with a word if the operator switched it off."""
setting = store.command_settings.get(command.name)
if setting.off_at(ctx.now):
logger.info("%s is switched off; refusing %s", command.name, ctx.message.source_name)
await ctx.reply(off_message(command.primary, setting.disabled_until, ctx.now))
raise CommandRefusedError(GATE_OFF)
await command.handler(ctx)