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>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The switchboard: what the management page reads and writes per command.
|
|
|
|
Loopback only, like everything on this port; the management app proxies it.
|
|
The list is the *live* table -- the one the dispatcher is resolving against
|
|
this instant -- joined with the store's switches, so a command the album
|
|
added on the last reload shows up, and one it dropped is gone. A PUT writes
|
|
the store and nothing else: the dispatcher reads it on the next attempt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, status
|
|
from pydantic import BaseModel
|
|
|
|
from signal_bot.command_settings import CommandSetting
|
|
from signal_bot.wiring import UNGATED
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.bot import Bot
|
|
from signal_bot.registry import Command
|
|
from signal_bot.startup_retry import Starter
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class SettingRequest(BaseModel):
|
|
"""The three things the page may decide about one command."""
|
|
|
|
enabled: bool = True
|
|
disabled_until: float | None = None
|
|
cooldown_seconds: float | None = None
|
|
|
|
|
|
def _bot(request: Request) -> Bot:
|
|
"""The connected bot, or the same 503 every other route gives while connecting."""
|
|
starter: Starter = request.app.state.starter
|
|
runtime = starter.runtime
|
|
if runtime is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail={"status": "connecting", "reason": starter.reason},
|
|
)
|
|
return runtime.bot
|
|
|
|
|
|
def _row(bot: Bot, command: Command) -> dict[str, Any]:
|
|
"""One command as the page sees it: identity, switches, and the default."""
|
|
setting = bot.store.command_settings.get(command.name)
|
|
return {
|
|
"name": command.name,
|
|
"trigger": command.primary,
|
|
"description": command.description,
|
|
"enabled": setting.enabled,
|
|
"disabled_until": setting.disabled_until,
|
|
"cooldown_seconds": setting.cooldown_seconds,
|
|
# What the cooldown falls back to when the override is cleared, or
|
|
# null for the commands wiring registers without one -- a number
|
|
# there would be an input that does nothing.
|
|
"default_cooldown_seconds": (
|
|
None if command.name in UNGATED else bot.config.cooldown_seconds
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/_commands")
|
|
async def list_commands(request: Request) -> dict[str, list[dict[str, Any]]]:
|
|
"""Every live command with its switches, by name."""
|
|
bot = _bot(request)
|
|
commands = sorted(bot.dispatcher.registry.commands, key=lambda c: c.name)
|
|
return {"commands": [_row(bot, command) for command in commands]}
|
|
|
|
|
|
@router.put("/_commands/{name}")
|
|
async def put_command(name: str, body: SettingRequest, request: Request) -> dict[str, Any]:
|
|
"""Store one command's switches and answer with the row as it now stands."""
|
|
bot = _bot(request)
|
|
command = next((c for c in bot.dispatcher.registry.commands if c.name == name), None)
|
|
if command is None:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no command named {name!r}")
|
|
bot.store.command_settings.put(
|
|
CommandSetting(
|
|
command=name,
|
|
enabled=body.enabled,
|
|
disabled_until=body.disabled_until,
|
|
cooldown_seconds=body.cooldown_seconds,
|
|
)
|
|
)
|
|
return _row(bot, command)
|