mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +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>
43 lines
1.5 KiB
Python
43 lines
1.5 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""UNGATED is a second copy of a fact wiring.py expresses in code, so it is checked."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.config import DEFAULT_COMMONS_API_URL
|
|
from signal_bot.portraits import PortraitCache
|
|
from signal_bot.wiring import UNGATED, build_registry
|
|
from tests.test_help_command import _offline_fetcher
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.registry import Handler
|
|
|
|
COOLDOWN_WRAPPER = "cooldown.<locals>.decorate.<locals>.wrapper"
|
|
|
|
|
|
def _has_cooldown(handler: Handler) -> bool:
|
|
"""Whether the cooldown decorator sits anywhere in a handler's wrapping.
|
|
|
|
``functools.wraps`` copies the inner function's ``__qualname__`` outward,
|
|
so the wrapper is recognisable only by its own code object, which wraps
|
|
leaves alone.
|
|
"""
|
|
current: object = handler
|
|
while current is not None:
|
|
code = getattr(current, "__code__", None)
|
|
if code is not None and code.co_qualname == COOLDOWN_WRAPPER:
|
|
return True
|
|
current = getattr(current, "__wrapped__", None)
|
|
return False
|
|
|
|
|
|
def test_ungated_names_exactly_the_commands_registered_without_a_cooldown(
|
|
config: Config,
|
|
) -> None:
|
|
fetcher = _offline_fetcher()
|
|
registry = build_registry(config, fetcher, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
|
|
ungated = {c.name for c in registry.commands if not _has_cooldown(c.handler)}
|
|
assert ungated == set(UNGATED)
|