A switchboard: any command off, off until, or on its own cooldown
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run

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>
This commit is contained in:
Krzysztof Rudnicki 2026-09-04 15:55:56 +02:00
parent 41df308393
commit d13ae42590
39 changed files with 1417 additions and 173 deletions

View File

@ -88,6 +88,13 @@ group must not reach the album without somebody approving it, and no approval
queue exists yet. It comes back when one does; `DOCS-add-photo.md` describes
the path as it was.
Any command can be **switched off** from the management page (Commands tab),
outright or until a moment, and given its own cooldown. The bot reads the
switch on every attempt, so it is live without a reload; a command that is
off answers `BEEP BOOP !kot WYŁĄCZONE.` (or `… WYŁĄCZONE DO 14:01.`, Warsaw
time) rather than going quiet, and the refusal neither starts the cooldown nor
counts as a use. `!pomoc` still lists it. `DOCS-web-ui.md` has the page.
## Why `!pomoc` shows the first alias and not a trigger
`Command.triggers` is a `frozenset` of **folded skeletons** — `folding.py`

View File

@ -4,9 +4,10 @@ Two kinds of change reach the running bot, and they are handled differently.
## Data: `POST /_reload`
Aliases, an album's `people.json`/`tags.json`, and every setting a page in the
management UI writes are *data*. The command table is built from that data by
one function (`signal_bot/wiring.py`), and `RegistryHolder` can run it again:
Aliases, an album's `people.json`/`tags.json`, and the `.env` and album
settings the management UI writes are *data*. The command table is built from
that data by one function (`signal_bot/wiring.py`), and `RegistryHolder` can
run it again:
the new table is built whole, then swapped in with a single assignment. Nothing
restarts, the websocket stays up, and no frame is lost or replayed.
@ -33,6 +34,12 @@ Startup is the one time a bad table is fatal: there is nothing to fall back
on, and `Restart=always` looping on a `ConfigError` is a clearer signal than a
bot that answers nothing.
One thing the management UI writes needs neither: the per-command switches
on its Commands tab (`/_commands`) live in the bot's own database and are read
on every attempt, so they are live on the next message without a rebuild.
They are also what a reload does *not* reset -- `command_settings` is a
permanent table.
## Code: `scripts/reload.sh --code`
A Python change needs the process replaced. The restart is about a second and
@ -57,3 +64,5 @@ until the first `isinstance` check.
synthetic pack, the script runs, and the same process posts delta's photo.
The route itself is covered in `tests/test_app.py`, the holder in
`tests/test_registry_holder.py`, and the album case in `tests/test_bot.py`.
The switches get the same bracket: `switch_off.yaml` and `switch_on.yaml`
around a `PUT /_commands/cat`, with no reload in between.

View File

@ -133,6 +133,27 @@ Charts are Nivo, always the fixed-size components: the `Responsive*` ones
measure their container, and a jsdom container has none, so the whole test
suite would assert against empty divs.
## Commands
The second tab is the switchboard: one row per command the bot is serving
right now, each with an on/off switch, an optional "off until" moment and a
cooldown that overrides the bot's default for that one command. Every row has
its own Save, because a switch is thrown for one command at a time.
Nothing here goes through `/_reload`. The bot keeps these in its own SQLite
(`command_settings`) and reads the row on every attempt, so a switch is live
on the next message -- `DOCS-reload.md` is about the *table*, and this does
not touch the table. A command that is off answers `BEEP BOOP !kot WYŁĄCZONE`
(with `DO HH:MM` for a timed switch) rather than staying silent, so nobody
reports it broken, and the refusal is filed under `refused_off` where the
Analytics tab counts it with the other refusals. A refusal does not start the
cooldown.
The list is the bot's, fetched live, which is why the page says so when the
bot is still connecting: there is no table to list until it has one. `!cisza`
and `!odcisz` have no cooldown to override and the field is disabled with
"none" in it, since a number typed there would be a lie the bot ignored.
## Ideas
The fourth tab lists what the group has asked for: `!zaproponuj` notes with
@ -170,6 +191,8 @@ other path, so a reload on a sub-view works.
| POST | `/api/album/person` | Add one person mid-session. |
| GET | `/api/album/media/{index}` | One item, **by position**. |
| GET | `/api/suggestions` | Filed ideas and unrecognised-word tallies, bodies included. |
| GET | `/api/commands` | Proxies the bot's `/_commands`: the live table with each command's switches. |
| PUT | `/api/commands/{name}` | One command's switches, forwarded to the bot; its 404/503 come back unwrapped. |
| GET | `/api/analytics/groups` | Every group with a command history, for the picker. |
| GET | `/api/analytics/{overview,time,health,demand,behaviour,content}` | One per panel, all read-only, all taking an optional `?group=`. |
| POST | `/api/suggestions/export` | Write those to a file **outside the repository**. |

View File

@ -0,0 +1,9 @@
# Not in scenarios/: this pair brackets the PUT /_commands/cat that
# scripts/e2e_proofs.sh makes in between, with no reload and no restart.
# After the switch is thrown, the same process that has been posting cats
# all run refuses in so many words.
name: "!cat switched off answers WYŁĄCZONE instead of a photo"
inject:
- { text: "!cat", uuid: uuid-switch, name: Switch }
expect:
- { kind: text, to: reply, contains: "!kot WYŁĄCZONE" }

View File

@ -0,0 +1,9 @@
# The second half of the pair: the switch is back on, and the *same* actor
# gets the photo. Same uuid on purpose -- a refusal must not start the
# cooldown, or switching a command back on would leave everyone who tried
# it meanwhile waiting out a window they never used.
name: "!cat switched back on posts a photo to the actor it just refused"
inject:
- { text: "!cat", uuid: uuid-switch, name: Switch }
expect:
- { kind: image, to: reply }

View File

@ -183,6 +183,7 @@ main() {
# the album (2026-09-04). Its scenarios, the media generation step and
# prove_the_add_photo_invariants left with it; git has them at 64852ae.
prove_the_table_reloads_without_a_restart
prove_a_switch_is_live_without_a_reload
prove_the_deafness_gate
enforce_the_time_budget "$STARTED_AT"
}

View File

@ -65,6 +65,28 @@ prove_the_table_reloads_without_a_restart() {
replay "${proofs}/reload_after.yaml"
}
# A switch thrown from the management page is read on the very next attempt:
# no reload, no restart. The PUT here is what the page's Save button sends,
# aimed at the bot directly; the two scenarios bracket it the same way the
# reload pair does. Not a timed switch, because a proof that depends on the
# clock has to wait on it, and the unit tests already cover the deadline.
prove_a_switch_is_live_without_a_reload() {
local proofs="${REPO_ROOT}/fake_signal/proofs"
local switch="http://127.0.0.1:${BOT_PORT}/_commands/cat"
echo "== switching !kot off"
curl -sf -X PUT "$switch" -H 'Content-Type: application/json' \
-d '{"enabled": false}' | grep -q '"enabled":false' ||
die "PUT /_commands/cat did not report the command off"
replay "${proofs}/switch_off.yaml"
echo "== switching !kot back on"
curl -sf -X PUT "$switch" -H 'Content-Type: application/json' \
-d '{"enabled": true}' | grep -q '"enabled":true' ||
die "PUT /_commands/cat did not report the command on"
replay "${proofs}/switch_on.yaml"
}
# A budget, not a benchmark. This run was four minutes until the liveness
# tick became configurable, and nothing announced the cost -- slower is the
# one regression a green suite never reports. Exceeding it fails the run;

View File

@ -38,11 +38,12 @@ from signal_bot.analytics.uses import (
OUTCOME_COOLDOWN,
OUTCOME_DAILY,
OUTCOME_ERROR,
OUTCOME_OFF,
OUTCOME_OK,
OUTCOME_VOTE,
CommandEvent,
)
from signal_bot.decorators import GATE_COOLDOWN, GATE_DAILY, GATE_VOTE
from signal_bot.decorators import GATE_COOLDOWN, GATE_DAILY, GATE_OFF, GATE_VOTE
from signal_bot.registry import CommandRefusedError
from signal_bot.stats.periods import day_of, hour_of, weekday_of
@ -65,6 +66,7 @@ _BY_GATE: Final = {
GATE_COOLDOWN: OUTCOME_COOLDOWN,
GATE_DAILY: OUTCOME_DAILY,
GATE_VOTE: OUTCOME_VOTE,
GATE_OFF: OUTCOME_OFF,
}

View File

@ -28,6 +28,7 @@ OUTCOME_OK: Final = "ok"
OUTCOME_COOLDOWN: Final = "refused_cooldown"
OUTCOME_DAILY: Final = "refused_daily"
OUTCOME_VOTE: Final = "refused_vote"
OUTCOME_OFF: Final = "refused_off"
OUTCOME_ERROR: Final = "error"
OUTCOME_UNKNOWN: Final = "unknown_word"

View File

@ -18,6 +18,7 @@ import httpx
from fastapi import FastAPI, Response, status
from pydantic import BaseModel
from signal_bot.app_commands import router as commands_router
from signal_bot.config_env import load_config
from signal_bot.selftest import SelfTestError
from signal_bot.selftest import run as run_selftest
@ -88,6 +89,9 @@ class SelfTestRequest(BaseModel):
app = FastAPI(lifespan=lifespan)
# The per-command switchboard, in its own module so this one stays about
# serving and connecting.
app.include_router(commands_router)
@app.get("/healthz")

View File

@ -0,0 +1,91 @@
# 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)

View File

@ -0,0 +1,103 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The operator's switches: which commands are on, and how fast they may go.
One row per command that has been touched from the management page. A
command without a row is on and keeps the cooldown the deployment was
started with; a row overrides exactly what it spells out and nothing else.
The bot reads these on every command, so a flip on the page is live before
the browser has finished repainting -- no reload, no restart, nothing to
forget to do.
"Off" comes in two shapes and they are read together: ``enabled`` is the
hard switch, ``disabled_until`` a timed one that lifts itself. A command is
off while either says so.
Shares the Store's connection, the same way stats.events.EventStore does.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import sqlite3
@dataclass(frozen=True, slots=True)
class CommandSetting:
"""What the operator decided about one command."""
command: str
enabled: bool = True
disabled_until: float | None = None
cooldown_seconds: float | None = None
def off_at(self, now: float) -> bool:
"""Whether the command is switched off at this moment.
The timed switch is exclusive at its deadline, the same way a mute
is: "off until 18:00" is on again at 18:00 sharp.
"""
if not self.enabled:
return True
return self.disabled_until is not None and now < self.disabled_until
class CommandSettingStore:
"""Reads and writes the switches. Shares the Store's connection."""
def __init__(self, conn: sqlite3.Connection) -> None:
"""Wrap an open connection whose schema has already been applied."""
self._conn = conn
def get(self, command: str) -> CommandSetting:
"""The setting for one command; the defaults when it was never touched."""
row = self._conn.execute(
"SELECT command, enabled, disabled_until, cooldown_seconds "
"FROM command_settings WHERE command = ?",
(command,),
).fetchone()
return CommandSetting(command) if row is None else _setting(row)
def all(self) -> dict[str, CommandSetting]:
"""Every command that has a row, by name."""
rows = self._conn.execute(
"SELECT command, enabled, disabled_until, cooldown_seconds FROM command_settings"
).fetchall()
return {str(row["command"]): _setting(row) for row in rows}
def put(self, setting: CommandSetting) -> None:
"""Store one command's setting, replacing whatever was there."""
self._conn.execute(
"INSERT INTO command_settings (command, enabled, disabled_until, cooldown_seconds) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(command) DO UPDATE SET "
"enabled = excluded.enabled, "
"disabled_until = excluded.disabled_until, "
"cooldown_seconds = excluded.cooldown_seconds",
(
setting.command,
int(setting.enabled),
setting.disabled_until,
setting.cooldown_seconds,
),
)
self._conn.commit()
def cooldown_for(self, command: str, default: float) -> float:
"""The cooldown to enforce: the operator's if set, else the deployment's."""
override = self.get(command).cooldown_seconds
return default if override is None else override
def _setting(row: sqlite3.Row) -> CommandSetting:
"""One row, typed."""
until = row["disabled_until"]
cooldown = row["cooldown_seconds"]
return CommandSetting(
command=str(row["command"]),
enabled=bool(row["enabled"]),
disabled_until=None if until is None else float(until),
cooldown_seconds=None if cooldown is None else float(cooldown),
)

View File

@ -0,0 +1,59 @@
# 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)

View File

@ -37,6 +37,10 @@ Decorator = Callable[[Handler], Handler]
GATE_COOLDOWN: Final = "cooldown"
GATE_DAILY: Final = "daily"
GATE_VOTE: Final = "vote"
# The operator's switch, thrown from the management page. Not a decorator
# (see command_switch), but a gate all the same, and named with the others
# so the outcome list stays in one place.
GATE_OFF: Final = "off"
# Padding on the *displayed* wait only, never on the gate itself. A number that
# is a second short sends somebody back for another refusal, which reads as the
@ -64,9 +68,13 @@ def cooldown(seconds: float, message: str) -> Decorator:
def decorate(handler: Handler) -> Handler:
@functools.wraps(handler)
async def wrapper(ctx: CommandContext) -> None:
# The operator's number wins over the deployment's, and is read
# here rather than at registration so a change on the page is
# live without a reload.
limit = ctx.store.command_settings.cooldown_for(ctx.command_name, seconds)
last = ctx.store.last_used(ctx.group_id, ctx.message.source_uuid, ctx.command_name)
if last is not None and ctx.now - last < seconds:
shown, unit = wait_display(seconds - (ctx.now - last))
if last is not None and ctx.now - last < limit:
shown, unit = wait_display(limit - (ctx.now - last))
# Logged with the number the group was given: when this said
# one thing and the gate did another, nothing recorded either.
logger.info(

View File

@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
from signal_bot import mute
from signal_bot.analytics import probe, recorder
from signal_bot.command_switch import run_unless_off
from signal_bot.context import CommandContext
from signal_bot.counting import should_count, watched_group
from signal_bot.envelope_shape import shape_of
@ -170,7 +171,7 @@ class Dispatcher:
# further away every time somebody was told to wait N seconds.
# See analytics.recorder for the invariant and the test that holds it.
with probe.probing() as notes, recorder.attempting(command.name) as attempt:
await command.handler(ctx)
await run_unless_off(self.store, command, ctx)
recorder.record_attempt(
self.store.commands,
recorder.Invocation(

View File

@ -24,6 +24,7 @@ from signal_bot.manage import (
routes_actions,
routes_album,
routes_analytics,
routes_commands,
routes_env,
routes_status,
routes_suggestions,
@ -53,5 +54,6 @@ def build_app(deps: ManageDeps, *, container: str = DEFAULT_CONTAINER) -> FastAP
app.include_router(routes_album.router(deps))
app.include_router(routes_suggestions.router(deps))
app.include_router(routes_analytics.router(deps))
app.include_router(routes_commands.router(deps))
assets.install(app, deps.dist_dir)
return app

View File

@ -0,0 +1,80 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The switchboard, proxied.
The page reads the bot's database read-only everywhere else, and it stays
that way here: a switch is written by the *bot*, through its own loopback
route, so there is exactly one process with the database open for writing
and the live command table the switches are joined against is the one the
dispatcher is actually using. This module forwards and passes the answer
on, the way ``/api/selftest`` does.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
if TYPE_CHECKING:
from signal_bot.manage.deps import ManageDeps
class Setting(BaseModel):
"""What the page may decide about one command; mirrors the bot's request."""
enabled: bool = True
disabled_until: float | None = None
cooldown_seconds: float | None = None
def router(deps: ManageDeps) -> APIRouter:
"""The list and the per-command PUT, both forwarded to the bot.
Args:
deps: The running app's configuration and shared HTTP client.
Returns:
A router to include in the management app.
"""
api = APIRouter(prefix="/api")
async def forward(request: httpx.Request) -> dict[str, Any]:
"""Send one request to the bot and hand back its body, verbatim."""
try:
response = await deps.client.send(request)
except httpx.HTTPError as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the bot did not answer: {error}",
) from error
body: dict[str, Any] = response.json()
# 503 while connecting and 404 for a name the table does not hold
# are the bot's own sentences, passed on unchanged -- unwrapped
# from the bot's own ``detail`` so this does not nest one inside
# another on the way through.
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=response.status_code, detail=body.get("detail", body))
return body
@api.get("/commands")
async def list_commands() -> dict[str, Any]:
"""Every live command with its switches."""
return await forward(
deps.client.build_request("GET", f"{deps.bot_url}/_commands", timeout=10.0)
)
@api.put("/commands/{name}")
async def put_command(name: str, setting: Setting) -> dict[str, Any]:
"""Store one command's switches, live."""
return await forward(
deps.client.build_request(
"PUT",
f"{deps.bot_url}/_commands/{name}",
json=setting.model_dump(),
timeout=10.0,
)
)
return api

View File

@ -98,6 +98,17 @@ CREATE TABLE IF NOT EXISTS suggestions (
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
@ -150,6 +161,9 @@ PERMANENT_TABLES: Final = (
# 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

View File

@ -8,7 +8,9 @@ The message history itself is reached through ``store.events`` -- see
``signal_bot.stats.events``. It is kept apart because it is the one thing here
that is append-only and never discarded. ``store.suggestions`` is the second
such thing: what the group asked for and what it mistyped, which no amount of
recomputation can bring back once dropped.
recomputation can bring back once dropped. ``store.command_settings`` is the
operator's switches, which are not history but are just as much not ours to
throw away.
"""
from __future__ import annotations
@ -19,6 +21,7 @@ import sqlite3
from typing import TYPE_CHECKING, Self
from signal_bot.analytics.uses import CommandEventStore
from signal_bot.command_settings import CommandSettingStore
from signal_bot.schema import REBUILT_ON_BUMP, RETAINED_COLUMNS, SCHEMA, SCHEMA_VERSION
from signal_bot.schema_commands import COMMAND_SCHEMA
from signal_bot.stats.events import EventStore
@ -42,6 +45,7 @@ class Store:
self.events = EventStore(self._conn)
self.suggestions = SuggestionStore(self._conn)
self.commands = CommandEventStore(self._conn)
self.command_settings = CommandSettingStore(self._conn)
def _apply_schema(self) -> None:
"""Create the tables, rebuilding them when the file predates them.

View File

@ -11,14 +11,15 @@ a rebuild reuses them rather than opening a second of each.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
from signal_bot.analytics_command import register_analytics_command
from signal_bot.animals import load_aliases, register_animal_commands
from signal_bot.art_command import register_art_command
from signal_bot.decorators import cooldown
from signal_bot.help_command import register_help_command
from signal_bot.mute_command import register_mute_commands
from signal_bot.mute import UNMUTE_NAME
from signal_bot.mute_command import MUTE_NAME, register_mute_commands
from signal_bot.pack_command import register_pack_commands
from signal_bot.pack_holder import PackHolder
from signal_bot.pack_load import load as load_pack
@ -41,6 +42,13 @@ if TYPE_CHECKING:
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} {unit}."
# The commands registered below without ``decorate=rate_limit``. Named so
# the switchboard can say "no cooldown" for them instead of showing the
# deployment's number next to an input that would do nothing.
# test_wiring_ungated walks the real table to keep this honest.
UNGATED: Final = frozenset({MUTE_NAME, UNMUTE_NAME})
def _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None:
"""Wire the album's commands, all of them reading one mutable holder.

View File

@ -0,0 +1,76 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The switchboard routes: the live table in, a setting out, live at once."""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from signal_bot.mute_command import MUTE_NAME
from tests.conftest import WATCHED_GROUP, mock_client, signal_client
from tests.test_app import _connected, _offline, _serving
if TYPE_CHECKING:
import pytest
def test_the_list_is_refused_while_still_connecting(monkeypatch: pytest.MonkeyPatch) -> None:
with _serving(monkeypatch, mock_client(_offline)) as client:
response = client.get("/_commands")
assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE
assert response.json()["detail"]["status"] == "connecting"
def test_every_live_command_is_listed_on_with_its_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client:
_connected(client)
rows = client.get("/_commands").json()["commands"]
names = [row["name"] for row in rows]
assert names == sorted(names)
assert "cat" in names
by_name = {row["name"]: row for row in rows}
cat = by_name["cat"]
assert cat["trigger"].startswith("!")
assert cat["description"]
assert cat["enabled"] is True
assert cat["disabled_until"] is None
assert cat["cooldown_seconds"] is None
assert cat["default_cooldown_seconds"] > 0
# No cooldown on the mute pair, and the page must not pretend otherwise.
assert by_name[MUTE_NAME]["default_cooldown_seconds"] is None
def test_a_put_is_stored_and_read_back_from_the_list(monkeypatch: pytest.MonkeyPatch) -> None:
with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client:
_connected(client)
body = {"enabled": False, "disabled_until": 1_800_000_000.0, "cooldown_seconds": 5}
response = client.put("/_commands/cat", json=body)
assert response.status_code == httpx.codes.OK
row = response.json()
assert (row["enabled"], row["disabled_until"], row["cooldown_seconds"]) == (
False,
1_800_000_000.0,
5,
)
listed = {r["name"]: r for r in client.get("/_commands").json()["commands"]}["cat"]
assert listed == row
# And back to the defaults: the row is replaced, not merged.
assert client.put("/_commands/cat", json={}).json()["enabled"] is True
def test_a_name_the_table_does_not_hold_is_a_404(monkeypatch: pytest.MonkeyPatch) -> None:
"""The page's dropdown cannot produce one; a typo in curl can."""
with _serving(monkeypatch, signal_client(WATCHED_GROUP)) as client:
_connected(client)
response = client.put("/_commands/nosuch", json={"enabled": False})
assert response.status_code == httpx.codes.NOT_FOUND
assert "nosuch" in response.json()["detail"]
def test_a_put_is_refused_while_still_connecting(monkeypatch: pytest.MonkeyPatch) -> None:
with _serving(monkeypatch, mock_client(_offline)) as client:
response = client.put("/_commands/cat", json={"enabled": False})
assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE

View File

@ -0,0 +1,62 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The operator's switches: defaults, overrides, and what "off" means."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.command_settings import CommandSetting
if TYPE_CHECKING:
from signal_bot.store import Store
NOW = 1_000.0
def test_a_command_that_was_never_touched_is_on_with_no_override(store: Store) -> None:
setting = store.command_settings.get("cat")
assert setting == CommandSetting("cat")
assert not setting.off_at(NOW)
assert store.command_settings.cooldown_for("cat", 30.0) == 30.0
assert store.command_settings.all() == {}
def test_a_setting_round_trips_through_the_database(store: Store) -> None:
setting = CommandSetting("cat", enabled=False, disabled_until=NOW + 60, cooldown_seconds=5)
store.command_settings.put(setting)
assert store.command_settings.get("cat") == setting
assert store.command_settings.all() == {"cat": setting}
def test_a_later_put_replaces_the_row(store: Store) -> None:
"""One row per command: switching back on must not leave the old row behind."""
store.command_settings.put(CommandSetting("cat", enabled=False))
store.command_settings.put(CommandSetting("cat", cooldown_seconds=1))
assert store.command_settings.get("cat") == CommandSetting("cat", cooldown_seconds=1)
assert len(store.command_settings.all()) == 1
def test_the_operator_cooldown_wins_over_the_deployment_one(store: Store) -> None:
store.command_settings.put(CommandSetting("cat", cooldown_seconds=0))
assert store.command_settings.cooldown_for("cat", 30.0) == 0
store.command_settings.put(CommandSetting("cat", cooldown_seconds=None))
assert store.command_settings.cooldown_for("cat", 30.0) == 30.0
@pytest.mark.parametrize(
("setting", "off"),
[
(CommandSetting("cat"), False),
(CommandSetting("cat", enabled=False), True),
(CommandSetting("cat", disabled_until=NOW + 1), True),
# Exclusive at the deadline, like a mute.
(CommandSetting("cat", disabled_until=NOW), False),
(CommandSetting("cat", disabled_until=NOW - 1), False),
# The hard switch is not lifted by a deadline that has passed.
(CommandSetting("cat", enabled=False, disabled_until=NOW - 1), True),
],
)
def test_off_is_either_switch(setting: CommandSetting, *, off: bool) -> None:
assert setting.off_at(NOW) is off

View File

@ -0,0 +1,131 @@
# 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"

View File

@ -23,6 +23,8 @@ from signal_bot.registry_holder import RegistryHolder
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from collections.abc import Callable
from signal_bot.config import Config
from signal_bot.context import CommandContext
from signal_bot.discovery import GroupDirectory
@ -57,6 +59,7 @@ def build(
registry: CommandRegistry,
store: Store,
transport: FakeTransport,
clock: Callable[[], dt.datetime] = lambda: NOW,
) -> Dispatcher:
"""A dispatcher over one caller-supplied registry."""
return Dispatcher(
@ -65,7 +68,7 @@ def build(
holder=RegistryHolder(lambda: registry),
store=store,
transport=transport,
clock=lambda: NOW,
clock=clock,
)

View File

@ -0,0 +1,73 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The switchboard proxy: forwarded to the bot, answered in the bot's words."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from tests.manage_fixture import bot_is_down, client_for, deps_at
if TYPE_CHECKING:
from pathlib import Path
ROW = {
"name": "cat",
"trigger": "!kot",
"description": "kot",
"enabled": True,
"disabled_until": None,
"cooldown_seconds": None,
"default_cooldown_seconds": 30,
}
def test_the_list_is_the_bots_list(tmp_path: Path) -> None:
def bot(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/_commands"
return httpx.Response(200, json={"commands": [ROW]})
with client_for(deps_at(tmp_path, bot)) as client:
answer = client.get("/api/commands")
assert answer.status_code == 200
assert answer.json() == {"commands": [ROW]}
def test_a_put_is_forwarded_with_the_body_intact(tmp_path: Path) -> None:
seen: list[dict[str, object]] = []
def bot(request: httpx.Request) -> httpx.Response:
assert request.method == "PUT"
assert request.url.path == "/_commands/cat"
seen.append(json.loads(request.content))
return httpx.Response(200, json={**ROW, "enabled": False})
body = {"enabled": False, "disabled_until": None, "cooldown_seconds": 5}
with client_for(deps_at(tmp_path, bot)) as client:
answer = client.put("/api/commands/cat", json=body)
assert answer.status_code == 200
assert answer.json()["enabled"] is False
assert seen == [body]
def test_the_bots_404_and_503_are_passed_through_unchanged(tmp_path: Path) -> None:
def bot(request: httpx.Request) -> httpx.Response:
if request.method == "PUT":
return httpx.Response(404, json={"detail": "no command named 'nosuch'"})
return httpx.Response(503, json={"detail": {"status": "connecting"}})
with client_for(deps_at(tmp_path, bot)) as client:
put = client.put("/api/commands/nosuch", json={"enabled": False})
listed = client.get("/api/commands")
assert put.status_code == 404
assert "nosuch" in put.json()["detail"]
assert listed.status_code == 503
assert listed.json()["detail"]["status"] == "connecting"
def test_a_bot_that_does_not_answer_is_a_bad_gateway(tmp_path: Path) -> None:
with client_for(deps_at(tmp_path, bot_is_down)) as client:
answer = client.get("/api/commands")
assert answer.status_code == 502

View File

@ -13,6 +13,7 @@ from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from signal_bot.command_settings import CommandSetting
from signal_bot.schema import (
CLEARED_ON_LEAVE,
PERMANENT_TABLES,
@ -44,6 +45,7 @@ def _seed(store: Store, group_id: str = GROUP) -> None:
store.set_meta(group_id, "last_reported_day", "2026-08-29")
store.suggestions.record_suggestion(group_id, "!pogoda", "na jutro", 1)
store.suggestions.record_unknown(group_id, "!cta", "!cta", 2)
store.command_settings.put(CommandSetting("cat", enabled=False))
# Both maps are spelled out rather than interpolated, for the reason
@ -67,6 +69,9 @@ COUNTS = {
"suggestions": "SELECT count(*) AS n FROM suggestions WHERE group_id = ?",
"command_events": "SELECT count(*) AS n FROM command_events WHERE group_id = ?",
"upstream_calls": "SELECT count(*) AS n FROM upstream_calls WHERE group_id = ?",
# Not keyed by group: a switch is thrown for the bot, not for a chat. The
# placeholder is there so every entry takes the same one argument.
"command_settings": "SELECT count(*) AS n FROM command_settings WHERE ? IS NOT NULL",
}
@ -162,6 +167,7 @@ def test_forget_group_leaves_the_permanent_tables_alone(store: Store) -> None:
store.forget_group(GROUP)
assert len(store.suggestions.filed()) == 1
assert len(store.suggestions.unknown()) == 1
assert not store.command_settings.get("cat").enabled
def test_a_version_bump_keeps_the_use_history(tmp_path: Path) -> None:
@ -183,3 +189,4 @@ def test_a_version_bump_keeps_the_use_history(tmp_path: Path) -> None:
assert _rows(reopened, "command_uses") == 1
assert _rows(reopened, "daily_uses") == 1
assert len(reopened.suggestions.filed()) == 1
assert not reopened.command_settings.get("cat").enabled

View File

@ -0,0 +1,42 @@
# 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)

View File

@ -32,9 +32,9 @@
"@testing-library/user-event": "14.6.7",
"@types/node": "26.4.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/react-dom": "19.2.7",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "4.1.11",
"@vitest/coverage-v8": "5.0.0",
"eslint": "10.9.1",
"eslint-plugin-perfectionist": "5.11.0",
"eslint-plugin-react-hooks": "7.1.1",
@ -44,6 +44,6 @@
"typescript": "6.0.3",
"typescript-eslint": "8.69.0",
"vite": "8.2.2",
"vitest": "4.1.11"
"vitest": "5.0.0"
}
}

View File

@ -38,10 +38,10 @@ importers:
version: 10.0.1(eslint@10.9.1(supports-color@7.2.0))
'@testing-library/jest-dom':
specifier: 7.0.1
version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)
version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0)
'@testing-library/react':
specifier: 16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/user-event':
specifier: 14.6.7
version: 14.6.7(@testing-library/dom@10.4.1)
@ -52,14 +52,14 @@ importers:
specifier: 19.2.18
version: 19.2.18
'@types/react-dom':
specifier: 19.2.5
version: 19.2.5(@types/react@19.2.18)
specifier: 19.2.7
version: 19.2.7(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 6.1.1
version: 6.1.1(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: 4.1.11
version: 4.1.11(vitest@4.1.11)
specifier: 5.0.0
version: 5.0.0(vitest@5.0.0)
eslint:
specifier: 10.9.1
version: 10.9.1(supports-color@7.2.0)
@ -88,8 +88,8 @@ importers:
specifier: 8.2.2
version: 8.2.2(@types/node@26.4.1)(yaml@2.9.0)
vitest:
specifier: 4.1.11
version: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
specifier: 5.0.0
version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
packages:
@ -514,9 +514,6 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -609,8 +606,8 @@ packages:
'@types/node@26.4.1':
resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==}
'@types/react-dom@19.2.5':
resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==}
'@types/react-dom@19.2.7':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
'@types/react': ^19.2.0
@ -729,20 +726,25 @@ packages:
oxc-transform-react:
optional: true
'@vitest/coverage-v8@4.1.11':
resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
'@vitest/coverage-v8@5.0.0':
resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==}
peerDependencies:
'@vitest/browser': 4.1.11
vitest: 4.1.11
'@vitest/browser': 5.0.0
vitest: 5.0.0
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
'@vitest/istanbul-lib-coverage@1.0.1':
resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==}
engines: {node: '>=22'}
'@vitest/mocker@4.1.11':
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
'@vitest/istanbul-lib-report@1.0.1':
resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==}
engines: {node: '>=22'}
'@vitest/mocker@5.0.0':
resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@ -752,20 +754,8 @@ packages:
vite:
optional: true
'@vitest/pretty-format@4.1.11':
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
'@vitest/runner@4.1.11':
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
'@vitest/snapshot@4.1.11':
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
'@vitest/spy@4.1.11':
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@ -1132,9 +1122,6 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
identifier-regex@1.1.0:
resolution: {integrity: sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==}
engines: {node: '>=18'}
@ -1185,18 +1172,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@ -1337,8 +1312,8 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.2.3:
resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==}
magicast@0.5.4:
resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
@ -1347,10 +1322,6 @@ packages:
resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
engines: {node: '>=18'}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
@ -1419,9 +1390,6 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1567,8 +1535,9 @@ packages:
resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==}
engines: {node: '>=12'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
@ -1688,23 +1657,23 @@ packages:
yaml:
optional: true
vitest@4.1.11:
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
vitest@5.0.0:
resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.11
'@vitest/browser-preview': 4.1.11
'@vitest/browser-webdriverio': 4.1.11
'@vitest/coverage-istanbul': 4.1.11
'@vitest/coverage-v8': 4.1.11
'@vitest/ui': 4.1.11
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.0
'@vitest/browser-preview': 5.0.0
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.0
'@vitest/coverage-v8': 5.0.0
'@vitest/ui': 5.0.0
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@ -2307,8 +2276,6 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@ -2320,7 +2287,7 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)':
'@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0)':
dependencies:
'@adobe/css-tools': 4.5.0
'@testing-library/dom': 10.4.1
@ -2330,9 +2297,9 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
optionalDependencies:
vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
'@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
@ -2340,7 +2307,7 @@ snapshots:
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.5(@types/react@19.2.18)
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)':
dependencies:
@ -2395,7 +2362,7 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/react-dom@19.2.5(@types/react@19.2.18)':
'@types/react-dom@19.2.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@ -2550,60 +2517,34 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.2(@types/node@26.4.1)(yaml@2.9.0)
'@vitest/coverage-v8@4.1.11(vitest@4.1.11)':
'@vitest/coverage-v8@5.0.0(vitest@5.0.0)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.11
'@vitest/istanbul-lib-coverage': 1.0.1
'@vitest/istanbul-lib-report': 1.0.1
ast-v8-to-istanbul: 1.0.5
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.4
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
'@vitest/expect@4.1.11':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
chai: 6.2.2
tinyrainbow: 3.1.1
'@vitest/istanbul-lib-coverage@1.0.1': {}
'@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))':
'@vitest/istanbul-lib-report@1.0.1':
dependencies:
'@vitest/spy': 4.1.11
'@vitest/istanbul-lib-coverage': 1.0.1
'@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 0.30.21
magic-string: 1.2.3
optionalDependencies:
vite: 8.2.2(@types/node@26.4.1)(yaml@2.9.0)
'@vitest/pretty-format@4.1.11':
dependencies:
tinyrainbow: 3.1.1
'@vitest/runner@4.1.11':
dependencies:
'@vitest/utils': 4.1.11
pathe: 2.0.3
'@vitest/snapshot@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.11
'@vitest/utils': 4.1.11
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.11': {}
'@vitest/utils@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.11
convert-source-map: 2.0.0
tinyrainbow: 3.1.1
'@vitest/spy@5.0.0': {}
acorn-jsx@5.3.2(acorn@8.18.0):
dependencies:
@ -2959,7 +2900,8 @@ snapshots:
globals@17.11.0: {}
has-flag@4.0.0: {}
has-flag@4.0.0:
optional: true
hermes-estree@0.25.1: {}
@ -2973,8 +2915,6 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
html-escaper@2.0.2: {}
identifier-regex@1.1.0:
dependencies:
reserved-identifiers: 1.2.0
@ -3010,19 +2950,6 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@ -3139,7 +3066,7 @@ snapshots:
lz-string@1.5.0: {}
magic-string@0.30.21:
magic-string@1.2.3:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@ -3155,10 +3082,6 @@ snapshots:
type-fest: 4.41.0
web-worker: 1.5.0
make-dir@4.0.0:
dependencies:
semver: 7.8.5
mdn-data@2.27.1: {}
mdn-data@2.29.0: {}
@ -3212,8 +3135,6 @@ snapshots:
path-key@3.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
@ -3342,6 +3263,7 @@ snapshots:
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
optional: true
symbol-tree@3.2.4: {}
@ -3349,7 +3271,7 @@ snapshots:
dependencies:
convert-hrtime: 5.0.0
tinybench@2.9.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
@ -3427,31 +3349,25 @@ snapshots:
fsevents: 2.3.3
yaml: 2.9.0
vitest@4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0)):
vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(yaml@2.9.0))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 0.30.21
magic-string: 1.2.3
obug: 2.1.4
pathe: 2.0.3
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 2.9.0
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
vite: 8.2.2(@types/node@26.4.1)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.4.1
'@vitest/coverage-v8': 4.1.11(vitest@4.1.11)
'@vitest/coverage-v8': 5.0.0(vitest@5.0.0)
jsdom: 30.0.1
transitivePeerDependencies:
- msw

View File

@ -25,6 +25,17 @@ describe("the api client", () => {
);
});
it("names the bot's reason when the detail is one of its own refusals", async () => {
// What the switchboard proxy forwards while the bot is still connecting.
serve({
"/api/commands": {
body: { detail: { reason: "attempt 3: connection refused", status: "connecting" } },
status: 503,
},
});
await expect(api.commands()).rejects.toThrow("attempt 3: connection refused");
});
it("falls back to the status text when there is no detail", async () => {
serve({ "/api/status": { body: { nothing: true }, status: 500 } });
await expect(api.status()).rejects.toBeInstanceOf(ApiError);
@ -48,6 +59,16 @@ describe("the api client", () => {
expect(init.method).toBe("POST");
});
it("puts one command's switches under its own name", async () => {
const fetcher = serve({ "/api/commands/cat": { body: { enabled: false, name: "cat" } } });
const body = { cooldown_seconds: 5, disabled_until: null, enabled: false };
await expect(api.saveCommand("cat", body)).resolves.toMatchObject({ enabled: false });
const [path, init] = fetcher.mock.calls[0] as [string, RequestInit];
expect(path).toBe("/api/commands/cat");
expect(init.method).toBe("PUT");
expect(init.body).toBe(JSON.stringify(body));
});
it("reads the album, the groups and the restart through the same wrapper", async () => {
serve({
"/api/album/state": { body: { files: [] } },

View File

@ -10,10 +10,13 @@ import type {
import type { Roster } from "./setup.ts";
import type {
AlbumState,
Commands,
CommandSetting,
EnvironmentView,
Export,
Group,
Restart,
SettingBody,
Status,
Suggestions,
} from "./types.ts";
@ -41,9 +44,15 @@ async function unwrap<T>(response: Response): Promise<T> {
// restart. Showing anything else would be inventing a second wording.
const detail =
typeof body === "object" && body !== null && "detail" in body
? String((body).detail)
? body.detail
: response.statusText;
throw new ApiError(detail, { status: response.status });
// The bot's own refusals are objects with a `reason` -- "connecting",
// and why -- forwarded as they are by the management app's proxies.
const reason =
typeof detail === "object" && detail !== null && "reason" in detail
? detail.reason
: detail;
throw new ApiError(String(reason), { status: response.status });
}
return body as T;
}
@ -84,6 +93,7 @@ export const api = {
get<Overview>(scoped("/api/analytics/overview", group)),
analyticsTime: async (group: null | string): Promise<Times> =>
get<Times>(scoped("/api/analytics/time", group)),
commands: async (): Promise<Commands> => get<Commands>("/api/commands"),
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/api/env"),
exportSuggestions: async (): Promise<Export> =>
send<Export>("/api/suggestions/export", "POST", {}),
@ -92,6 +102,8 @@ export const api = {
send<{ key: string }>("/api/album/person", "POST", { triggers }),
restart: async (): Promise<Restart> =>
send<Restart>("/api/restart-container", "POST", {}),
saveCommand: async (name: string, body: SettingBody): Promise<CommandSetting> =>
send<CommandSetting>(`/api/commands/${encodeURIComponent(name)}`, "PUT", body),
saveEnv: async (values: Record<string, string>): Promise<unknown> =>
send("/api/env", "PUT", { values }),
setup: async (groups: string[], roster: Roster): Promise<unknown> =>

View File

@ -89,3 +89,29 @@ export interface Export {
suggestions: number;
unknown: number;
}
// One row of the switchboard: what the operator decided about a command,
// joined with the live table so the trigger and description are the ones
// the bot is answering to right now.
export interface CommandSetting {
cooldown_seconds: null | number;
// The command's own cooldown before any override; null for the commands
// wiring registers without one, which the page shows as "no cooldown"
// rather than an input that would do nothing.
default_cooldown_seconds: null | number;
description: string;
disabled_until: null | number;
enabled: boolean;
name: string;
trigger: string;
}
export interface Commands {
commands: CommandSetting[];
}
export interface SettingBody {
cooldown_seconds: null | number;
disabled_until: null | number;
enabled: boolean;
}

View File

@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { App } from "./app.tsx";
import { ALBUM, ENV, HEALTHY } from "./test/fixtures.ts";
import { ALBUM, COMMANDS, ENV, HEALTHY } from "./test/fixtures.ts";
import { serve } from "./test/http.ts";
afterEach(() => {
@ -13,6 +13,7 @@ afterEach(() => {
function everythingAnswers(): void {
serve({
"/api/album/state": { body: ALBUM },
"/api/commands": { body: COMMANDS },
"/api/env": { body: ENV },
"/api/status": { body: HEALTHY },
});
@ -43,3 +44,12 @@ it("switches to settings and to the photos", async () => {
expect(screen.getByRole("img")).toBeInTheDocument();
});
});
it("switches to the commands switchboard", async () => {
everythingAnswers();
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Commands" }));
await waitFor(() => {
expect(screen.getByTestId("command-cat")).toBeInTheDocument();
});
});

View File

@ -2,12 +2,14 @@ import { useState } from "react";
import { Album } from "./views/album.tsx";
import { Analytics } from "./views/analytics.tsx";
import { Commands } from "./views/commands.tsx";
import { Health } from "./views/health.tsx";
import { Settings } from "./views/settings.tsx";
import { Suggestions } from "./views/suggestions.tsx";
const VIEWS = {
Analytics: Analytics,
Commands: Commands,
Health: Health,
Ideas: Suggestions,
Photos: Album,

View File

@ -5,6 +5,7 @@
// names here are letters of the NATO alphabet, as tests/pack_fixture.py does.
import type {
AlbumState,
Commands,
EnvironmentView as EnvironmentView,
Status,
Suggestions as SuggestionsView,
@ -86,3 +87,28 @@ export const NO_SUGGESTIONS: SuggestionsView = { suggestions: [], unknown: [] };
// Invented people and an invented group, like everything else here. Three
// days of history, because that is the shape the real page is in: enough to
// draw, not enough to claim a trend.
// Two rows: one ordinary command on its defaults, and the mute pair's
// representative, which wiring registers without a cooldown.
export const COMMANDS: Commands = {
commands: [
{
cooldown_seconds: null,
default_cooldown_seconds: 30,
description: "losowy kot",
disabled_until: null,
enabled: true,
name: "cat",
trigger: "!kot",
},
{
cooldown_seconds: null,
default_cooldown_seconds: null,
description: "cisza",
disabled_until: null,
enabled: true,
name: "mute",
trigger: "!cisza",
},
],
};

View File

@ -0,0 +1,41 @@
import { expect, it } from "vitest";
import { cooldownFrom, fromLocalInput, isOffAt, toLocalInput } from "./commands-time.ts";
it("round-trips a moment through the input's local wall-clock string", () => {
// Whole minutes, because the input has no seconds field to carry them.
const seconds = Math.floor(Date.now() / 60_000) * 60;
const text = toLocalInput(seconds);
expect(text).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
expect(fromLocalInput(text)).toBe(seconds);
});
it("shows nothing for no deadline, and reads nothing back as null", () => {
expect(toLocalInput(null)).toBe("");
expect(fromLocalInput("")).toBeNull();
});
it("treats a string the browser could not have produced as no deadline", () => {
expect(fromLocalInput("not a date")).toBeNull();
});
it("pads every field, so 9 May 07:05 does not become 9-5T7:5", () => {
const at = new Date(2026, 4, 9, 7, 5);
expect(toLocalInput(at.getTime() / 1000)).toBe("2026-05-09T07:05");
});
it("reads a cooldown as a number, and anything else as the default", () => {
expect(cooldownFrom("5")).toBe(5);
expect(cooldownFrom("0")).toBe(0);
expect(cooldownFrom(" ")).toBeNull();
expect(cooldownFrom("-1")).toBeNull();
expect(cooldownFrom("soon")).toBeNull();
});
it("calls a command off for the hard switch or a deadline still ahead", () => {
expect(isOffAt({ disabled_until: null, enabled: true }, 100)).toBe(false);
expect(isOffAt({ disabled_until: null, enabled: false }, 100)).toBe(true);
expect(isOffAt({ disabled_until: 101, enabled: true }, 100)).toBe(true);
// Exclusive at the deadline, as the bot reads it.
expect(isOffAt({ disabled_until: 100, enabled: true }, 100)).toBe(false);
});

View File

@ -0,0 +1,55 @@
// The "off until" field is a <input type="datetime-local">, which speaks
// "YYYY-MM-DDTHH:MM" in the browser's own zone, while the bot stores epoch
// seconds. These two are the whole translation, kept apart from the view so
// the arithmetic is tested on its own rather than through a form.
const MS_PER_SECOND = 1000;
function two(n: number): string {
return String(n).padStart(2, "0");
}
/**
Epoch seconds to the local wall-clock string the input wants; "" for none.
*/
export function toLocalInput(seconds: null | number): string {
if (seconds === null) {
return "";
}
const at = new Date(seconds * MS_PER_SECOND);
const date = `${String(at.getFullYear())}-${two(at.getMonth() + 1)}-${two(at.getDate())}`;
return `${date}T${two(at.getHours())}:${two(at.getMinutes())}`;
}
/**
The input's string back to epoch seconds; null for an empty or unparsable field.
*/
export function fromLocalInput(text: string): null | number {
if (text === "") {
return null;
}
const at = new Date(text);
const ms = at.getTime();
return Number.isNaN(ms) ? null : Math.floor(ms / MS_PER_SECOND);
}
/**
Whether a command is off at `nowSeconds`: the hard switch, or a deadline still ahead.
*/
export function isOffAt(
row: { disabled_until: null | number; enabled: boolean },
nowSeconds: number,
): boolean {
return !row.enabled || (row.disabled_until !== null && row.disabled_until > nowSeconds);
}
/**
A cooldown field's text to what is sent: null clears the override.
*/
export function cooldownFrom(text: string): null | number {
if (text.trim() === "") {
return null;
}
const value = Number(text);
return Number.isFinite(value) && value >= 0 ? value : null;
}

View File

@ -0,0 +1,133 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { COMMANDS } from "../test/fixtures.ts";
import { serve } from "../test/http.ts";
import { Commands } from "./commands.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
const [CAT] = COMMANDS.commands;
async function rendered(): Promise<void> {
render(<Commands />);
await waitFor(() => {
expect(screen.getByTestId("command-cat")).toBeInTheDocument();
});
}
it("says it is asking before the bot answers", () => {
serve({ "/api/commands": { body: COMMANDS } });
render(<Commands />);
expect(screen.getByText("Asking the bot…")).toBeInTheDocument();
});
it("lists every live command, on, with the default as the placeholder", async () => {
serve({ "/api/commands": { body: COMMANDS } });
await rendered();
expect(screen.getByTestId("command-cat")).toHaveTextContent("!kot");
expect(screen.getByTestId("command-cat")).toHaveAttribute("data-off", "false");
expect(screen.getByLabelText("!kot cooldown")).toHaveAttribute("placeholder", "30");
// No cooldown to override on the mute pair, and the field says so.
expect(screen.getByLabelText("!cisza cooldown")).toBeDisabled();
expect(screen.getByLabelText("!cisza cooldown")).toHaveAttribute("placeholder", "none");
});
it("shows an override the bot already holds, as the value rather than the placeholder", async () => {
serve({ "/api/commands": { body: { commands: [{ ...CAT, cooldown_seconds: 5 }] } } });
await rendered();
expect(screen.getByLabelText("!kot cooldown")).toHaveValue(5);
});
it("names the bot's reason when there is no live table yet", async () => {
serve({
"/api/commands": {
body: { detail: { reason: "attempt 2: refused", status: "connecting" } },
status: 503,
},
});
render(<Commands />);
await waitFor(() => {
expect(screen.getByTestId("commands-problem")).toHaveTextContent("attempt 2: refused");
});
});
it("falls back to a plain sentence when the failure is not the server's", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
render(<Commands />);
await waitFor(() => {
expect(screen.getByTestId("commands-problem")).toHaveTextContent("could not reach the bot");
});
});
it("saves one command's switches and shows the row the bot sent back", async () => {
const saved = { ...CAT, cooldown_seconds: 5, enabled: false };
const fetcher = serve({
"/api/commands": { body: COMMANDS },
"/api/commands/cat": { body: saved },
});
await rendered();
await userEvent.click(within(screen.getByTestId("command-cat")).getByRole("checkbox"));
await userEvent.type(screen.getByLabelText("!kot cooldown"), "5");
await userEvent.click(screen.getByRole("button", { name: "Save !kot" }));
await waitFor(() => {
expect(screen.getByTestId("note-cat")).toHaveTextContent("saved");
});
const [, init] = fetcher.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
cooldown_seconds: 5,
disabled_until: null,
enabled: false,
});
expect(screen.getByTestId("command-cat")).toHaveAttribute("data-off", "true");
});
it("sends the deadline as epoch seconds and never a cooldown for an ungated command", async () => {
const later = Math.floor(Date.now() / 60_000) * 60 + 3600;
const fetcher = serve({
"/api/commands": { body: COMMANDS },
"/api/commands/mute": { body: { ...COMMANDS.commands[1], disabled_until: later } },
});
await rendered();
const at = new Date(later * 1000);
const pad = (n: number): string => String(n).padStart(2, "0");
const text = `${String(at.getFullYear())}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
fireEvent.change(screen.getByLabelText("!cisza off until"), { target: { value: text } });
await userEvent.click(screen.getByRole("button", { name: "Save !cisza" }));
await waitFor(() => {
expect(screen.getByTestId("note-mute")).toHaveTextContent("saved");
});
const [, init] = fetcher.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual({
cooldown_seconds: null,
disabled_until: later,
enabled: true,
});
// A deadline still ahead marks the row off.
expect(screen.getByTestId("command-mute")).toHaveAttribute("data-off", "true");
});
it("shows the bot's own sentence when a save is refused", async () => {
serve({
"/api/commands": { body: COMMANDS },
"/api/commands/cat": { body: { detail: "no command named 'cat'" }, status: 404 },
});
await rendered();
await userEvent.click(screen.getByRole("button", { name: "Save !kot" }));
await waitFor(() => {
expect(screen.getByTestId("note-cat")).toHaveTextContent("no command named 'cat'");
});
});
it("says 'could not save' when the failure is not the server's", async () => {
serve({ "/api/commands": { body: COMMANDS } });
await rendered();
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
await userEvent.click(screen.getByRole("button", { name: "Save !kot" }));
await waitFor(() => {
expect(screen.getByTestId("note-cat")).toHaveTextContent("could not save");
});
});

151
web/src/views/commands.tsx Normal file
View File

@ -0,0 +1,151 @@
import { useCallback, useEffect, useState } from "react";
import type { CommandSetting } from "../api/types.ts";
import { api, ApiError } from "../api/client.ts";
import { cooldownFrom, fromLocalInput, isOffAt, toLocalInput } from "./commands-time.ts";
// A row as shown: the bot's answer plus whether it is off *now*. Decided
// when the answer arrives rather than on render, which is what keeps the
// clock out of the render path.
interface Shown extends CommandSetting {
isOff: boolean;
}
function shown(row: CommandSetting): Shown {
return { ...row, isOff: isOffAt(row, Date.now() / 1000) };
}
// One row per live command, each with its own Save: a switch is thrown for
// one command at a time, and the bot reads it on the very next attempt, so
// there is nothing to batch and nothing to restart.
function Row({ initial }: Readonly<{ initial: Shown }>): React.JSX.Element {
const [row, setRow] = useState(initial);
const [isEnabled, setIsEnabled] = useState(initial.enabled);
const [until, setUntil] = useState(toLocalInput(initial.disabled_until));
const [cooldown, setCooldown] = useState(
initial.cooldown_seconds === null ? "" : String(initial.cooldown_seconds),
);
const [note, setNote] = useState("");
const [isBusy, setIsBusy] = useState(false);
const isUngated = row.default_cooldown_seconds === null;
const save = useCallback(() => {
setIsBusy(true);
void api
.saveCommand(row.name, {
cooldown_seconds: isUngated ? null : cooldownFrom(cooldown),
disabled_until: fromLocalInput(until),
enabled: isEnabled,
})
.then((saved) => {
setRow(shown(saved));
setNote("saved");
})
.catch((error: unknown) => {
setNote(error instanceof ApiError ? error.message : "could not save");
})
.finally(() => {
setIsBusy(false);
});
}, [cooldown, isEnabled, isUngated, row.name, until]);
return (
<tr data-off={row.isOff} data-testid={`command-${row.name}`}>
<td>
<code>{row.trigger}</code>
<br />
<small>{row.description}</small>
</td>
<td>
<label>
<input
checked={isEnabled}
onChange={(event) => {
setIsEnabled(event.target.checked);
}}
type="checkbox"
/>{" "}
on
</label>
</td>
<td>
<label>
off until{" "}
<input
aria-label={`${row.trigger} off until`}
onChange={(event) => {
setUntil(event.target.value);
}}
type="datetime-local"
value={until}
/>
</label>
</td>
<td>
<label>
cooldown{" "}
<input
aria-label={`${row.trigger} cooldown`}
disabled={isUngated}
min={0}
onChange={(event) => {
setCooldown(event.target.value);
}}
placeholder={isUngated ? "none" : String(row.default_cooldown_seconds)}
type="number"
value={cooldown}
/>{" "}
s
</label>
</td>
<td>
<button disabled={isBusy} onClick={save} type="button">
Save {row.trigger}
</button>{" "}
<span data-testid={`note-${row.name}`}>{note}</span>
</td>
</tr>
);
}
export function Commands(): React.JSX.Element {
const [rows, setRows] = useState<null | Shown[]>(null);
const [problem, setProblem] = useState("");
useEffect(() => {
void api
.commands()
.then((listing) => {
setRows(listing.commands.map((row) => shown(row)));
})
.catch((error: unknown) => {
// 503 while the bot is still connecting is the usual one: the list
// is the *live* table, and there is none until then.
setProblem(error instanceof ApiError ? error.message : "could not reach the bot");
});
}, []);
if (problem !== "") {
return <p data-testid="commands-problem">The bot did not answer: {problem}</p>;
}
if (rows === null) {
return <p>Asking the bot…</p>;
}
return (
<section>
<h2>Commands</h2>
<p>
Every switch here is live: the bot reads it on the next message, without a reload. A
command that is off answers <code>WYŁĄCZONE</code> so nobody thinks it broke. An empty
cooldown means the bot's own default.
</p>
<table>
<tbody>
{rows.map((row) => <Row initial={row} key={row.name} />)}
</tbody>
</table>
</section>
);
}