mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 21:27:16 +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>
132 lines
5.2 KiB
Python
132 lines
5.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""A command switched off from the page: answered, filed, and live at once."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from signal_bot.command_settings import CommandSetting
|
|
from signal_bot.command_switch import off_message
|
|
from signal_bot.decorators import cooldown
|
|
from signal_bot.registry import CommandRegistry
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
from tests.test_dispatcher_recording import SENDER, build, history
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
# 2026-09-04 12:00 UTC, which Warsaw (CEST) reads as 14:00.
|
|
NOON_UTC = dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC).timestamp()
|
|
|
|
|
|
def _registry(runs: list[str], *, cooldown_seconds: float = 0) -> CommandRegistry:
|
|
"""!cat, wrapped in a cooldown when asked, that records each run."""
|
|
|
|
async def handler(ctx: CommandContext) -> None:
|
|
runs.append(ctx.command_name)
|
|
|
|
reg = CommandRegistry()
|
|
wrapped = (
|
|
cooldown(cooldown_seconds, "poczekaj {seconds} {unit}")(handler)
|
|
if cooldown_seconds
|
|
else handler
|
|
)
|
|
reg.register("cat", ["!cat"], wrapped, "kot")
|
|
return reg
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("disabled_until", "text"),
|
|
[
|
|
(None, "BEEP BOOP !cat WYŁĄCZONE."),
|
|
(NOON_UTC + 3600, "BEEP BOOP !cat WYŁĄCZONE DO 15:00."),
|
|
# A deadline that has passed is not the reason the command is off.
|
|
(NOON_UTC - 1, "BEEP BOOP !cat WYŁĄCZONE."),
|
|
(NOON_UTC, "BEEP BOOP !cat WYŁĄCZONE."),
|
|
],
|
|
)
|
|
def test_the_refusal_names_the_deadline_in_warsaw_time(
|
|
disabled_until: float | None, text: str
|
|
) -> None:
|
|
assert off_message("!cat", disabled_until, NOON_UTC) == text
|
|
|
|
|
|
async def test_a_command_that_is_off_answers_and_is_filed_as_a_refusal(
|
|
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
|
|
) -> None:
|
|
"""Every attempt gets the word, none of them counts as a use."""
|
|
runs: list[str] = []
|
|
dispatcher = build(config, directory, _registry(runs), store, transport)
|
|
store.command_settings.put(CommandSetting("cat", enabled=False))
|
|
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
|
|
|
|
assert runs == []
|
|
assert [m.text for m in transport.sent] == ["BEEP BOOP !cat WYŁĄCZONE."] * 2
|
|
assert [row["outcome"] for row in history(store)] == ["refused_off"] * 2
|
|
assert store.last_used(WATCHED_GROUP, SENDER, "cat") is None
|
|
|
|
|
|
async def test_the_switch_is_live_in_both_directions(
|
|
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
|
|
) -> None:
|
|
"""Off, then on again, between two frames: no reload in between."""
|
|
runs: list[str] = []
|
|
dispatcher = build(config, directory, _registry(runs), store, transport)
|
|
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
|
|
store.command_settings.put(CommandSetting("cat", enabled=False))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
|
|
store.command_settings.put(CommandSetting("cat"))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=3000))
|
|
|
|
assert runs == ["cat", "cat"]
|
|
assert [row["outcome"] for row in history(store)] == ["ok", "refused_off", "ok"]
|
|
|
|
|
|
async def test_a_timed_switch_lifts_itself(
|
|
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
|
|
) -> None:
|
|
runs: list[str] = []
|
|
clock = [dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC)]
|
|
dispatcher = build(config, directory, _registry(runs), store, transport, clock=lambda: clock[0])
|
|
store.command_settings.put(CommandSetting("cat", disabled_until=NOON_UTC + 60))
|
|
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
|
|
clock[0] += dt.timedelta(seconds=60)
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
|
|
|
|
assert runs == ["cat"]
|
|
assert transport.sent[0].text == "BEEP BOOP !cat WYŁĄCZONE DO 14:01."
|
|
|
|
|
|
async def test_the_operator_cooldown_is_read_on_every_attempt(
|
|
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
|
|
) -> None:
|
|
"""A deployment cooldown of an hour, cut to nothing from the page, live."""
|
|
runs: list[str] = []
|
|
dispatcher = build(config, directory, _registry(runs, cooldown_seconds=3600), store, transport)
|
|
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=1000))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=2000))
|
|
assert runs == ["cat"]
|
|
assert transport.sent[-1].text.startswith("poczekaj")
|
|
|
|
store.command_settings.put(CommandSetting("cat", cooldown_seconds=0))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=3000))
|
|
assert runs == ["cat", "cat"]
|
|
|
|
# And back: a longer one than the deployment's bites where it did not.
|
|
store.command_settings.put(CommandSetting("cat", cooldown_seconds=7200))
|
|
await dispatcher.handle(envelope(text="!cat", timestamp=4000))
|
|
assert runs == ["cat", "cat"]
|
|
assert [row["outcome"] for row in history(store)][-1] == "refused_cooldown"
|