From d13ae425903f239a7f4691c7cb05f90346959bb7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 4 Sep 2026 15:55:56 +0200 Subject: [PATCH] A switchboard: any command off, off until, or on its own cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DOCS-commands.md | 7 + DOCS-reload.md | 15 +- DOCS-web-ui.md | 23 +++ fake_signal/proofs/switch_off.yaml | 9 ++ fake_signal/proofs/switch_on.yaml | 9 ++ scripts/e2e.sh | 1 + scripts/e2e_proofs.sh | 22 +++ signal_bot/analytics/recorder.py | 4 +- signal_bot/analytics/uses.py | 1 + signal_bot/app.py | 4 + signal_bot/app_commands.py | 91 +++++++++++ signal_bot/command_settings.py | 103 ++++++++++++ signal_bot/command_switch.py | 59 +++++++ signal_bot/decorators.py | 12 +- signal_bot/dispatcher.py | 3 +- signal_bot/manage/app.py | 2 + signal_bot/manage/routes_commands.py | 80 ++++++++++ signal_bot/schema.py | 14 ++ signal_bot/store.py | 6 +- signal_bot/wiring.py | 12 +- tests/test_app_commands.py | 76 +++++++++ tests/test_command_settings.py | 62 ++++++++ tests/test_command_switch.py | 131 +++++++++++++++ tests/test_dispatcher_recording.py | 5 +- tests/test_manage_commands.py | 73 +++++++++ tests/test_schema_tables.py | 7 + tests/test_wiring_ungated.py | 42 +++++ web/package.json | 6 +- web/pnpm-lock.yaml | 228 +++++++++------------------ web/src/api/client.test.ts | 21 +++ web/src/api/client.ts | 16 +- web/src/api/types.ts | 26 +++ web/src/app.test.tsx | 12 +- web/src/app.tsx | 2 + web/src/test/fixtures.ts | 26 +++ web/src/views/commands-time.test.ts | 41 +++++ web/src/views/commands-time.ts | 55 +++++++ web/src/views/commands.test.tsx | 133 ++++++++++++++++ web/src/views/commands.tsx | 151 ++++++++++++++++++ 39 files changed, 1417 insertions(+), 173 deletions(-) create mode 100644 fake_signal/proofs/switch_off.yaml create mode 100644 fake_signal/proofs/switch_on.yaml create mode 100644 signal_bot/app_commands.py create mode 100644 signal_bot/command_settings.py create mode 100644 signal_bot/command_switch.py create mode 100644 signal_bot/manage/routes_commands.py create mode 100644 tests/test_app_commands.py create mode 100644 tests/test_command_settings.py create mode 100644 tests/test_command_switch.py create mode 100644 tests/test_manage_commands.py create mode 100644 tests/test_wiring_ungated.py create mode 100644 web/src/views/commands-time.test.ts create mode 100644 web/src/views/commands-time.ts create mode 100644 web/src/views/commands.test.tsx create mode 100644 web/src/views/commands.tsx diff --git a/DOCS-commands.md b/DOCS-commands.md index e66f921..ce19dda 100644 --- a/DOCS-commands.md +++ b/DOCS-commands.md @@ -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` diff --git a/DOCS-reload.md b/DOCS-reload.md index 811f1a7..cfdf550 100644 --- a/DOCS-reload.md +++ b/DOCS-reload.md @@ -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. diff --git a/DOCS-web-ui.md b/DOCS-web-ui.md index 08621d1..c076286 100644 --- a/DOCS-web-ui.md +++ b/DOCS-web-ui.md @@ -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**. | diff --git a/fake_signal/proofs/switch_off.yaml b/fake_signal/proofs/switch_off.yaml new file mode 100644 index 0000000..ac91b4c --- /dev/null +++ b/fake_signal/proofs/switch_off.yaml @@ -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" } diff --git a/fake_signal/proofs/switch_on.yaml b/fake_signal/proofs/switch_on.yaml new file mode 100644 index 0000000..ea59732 --- /dev/null +++ b/fake_signal/proofs/switch_on.yaml @@ -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 } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2b02a15..d186115 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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" } diff --git a/scripts/e2e_proofs.sh b/scripts/e2e_proofs.sh index 4f12661..b6fd3b7 100755 --- a/scripts/e2e_proofs.sh +++ b/scripts/e2e_proofs.sh @@ -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; diff --git a/signal_bot/analytics/recorder.py b/signal_bot/analytics/recorder.py index ed5c6ac..aad14d8 100644 --- a/signal_bot/analytics/recorder.py +++ b/signal_bot/analytics/recorder.py @@ -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, } diff --git a/signal_bot/analytics/uses.py b/signal_bot/analytics/uses.py index 9d4314b..5d38b69 100644 --- a/signal_bot/analytics/uses.py +++ b/signal_bot/analytics/uses.py @@ -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" diff --git a/signal_bot/app.py b/signal_bot/app.py index 79cb2d6..3b07d24 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -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") diff --git a/signal_bot/app_commands.py b/signal_bot/app_commands.py new file mode 100644 index 0000000..d6342dc --- /dev/null +++ b/signal_bot/app_commands.py @@ -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) diff --git a/signal_bot/command_settings.py b/signal_bot/command_settings.py new file mode 100644 index 0000000..bc34f60 --- /dev/null +++ b/signal_bot/command_settings.py @@ -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), + ) diff --git a/signal_bot/command_switch.py b/signal_bot/command_switch.py new file mode 100644 index 0000000..9f9517e --- /dev/null +++ b/signal_bot/command_switch.py @@ -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) diff --git a/signal_bot/decorators.py b/signal_bot/decorators.py index 63aedf0..f67a6b8 100644 --- a/signal_bot/decorators.py +++ b/signal_bot/decorators.py @@ -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( diff --git a/signal_bot/dispatcher.py b/signal_bot/dispatcher.py index f76b53e..8c7e690 100644 --- a/signal_bot/dispatcher.py +++ b/signal_bot/dispatcher.py @@ -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( diff --git a/signal_bot/manage/app.py b/signal_bot/manage/app.py index 69df394..731ffe0 100644 --- a/signal_bot/manage/app.py +++ b/signal_bot/manage/app.py @@ -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 diff --git a/signal_bot/manage/routes_commands.py b/signal_bot/manage/routes_commands.py new file mode 100644 index 0000000..b5b8496 --- /dev/null +++ b/signal_bot/manage/routes_commands.py @@ -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 diff --git a/signal_bot/schema.py b/signal_bot/schema.py index e48c88a..65bbf33 100644 --- a/signal_bot/schema.py +++ b/signal_bot/schema.py @@ -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 diff --git a/signal_bot/store.py b/signal_bot/store.py index 090cddc..34b0cef 100644 --- a/signal_bot/store.py +++ b/signal_bot/store.py @@ -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. diff --git a/signal_bot/wiring.py b/signal_bot/wiring.py index ba11d60..e953897 100644 --- a/signal_bot/wiring.py +++ b/signal_bot/wiring.py @@ -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. diff --git a/tests/test_app_commands.py b/tests/test_app_commands.py new file mode 100644 index 0000000..b1dda30 --- /dev/null +++ b/tests/test_app_commands.py @@ -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 diff --git a/tests/test_command_settings.py b/tests/test_command_settings.py new file mode 100644 index 0000000..2549e78 --- /dev/null +++ b/tests/test_command_settings.py @@ -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 diff --git a/tests/test_command_switch.py b/tests/test_command_switch.py new file mode 100644 index 0000000..35d5644 --- /dev/null +++ b/tests/test_command_switch.py @@ -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" diff --git a/tests/test_dispatcher_recording.py b/tests/test_dispatcher_recording.py index 89bf718..5a1ca38 100644 --- a/tests/test_dispatcher_recording.py +++ b/tests/test_dispatcher_recording.py @@ -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, ) diff --git a/tests/test_manage_commands.py b/tests/test_manage_commands.py new file mode 100644 index 0000000..9e5cc18 --- /dev/null +++ b/tests/test_manage_commands.py @@ -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 diff --git a/tests/test_schema_tables.py b/tests/test_schema_tables.py index 8c8fd2f..4b5b6ff 100644 --- a/tests/test_schema_tables.py +++ b/tests/test_schema_tables.py @@ -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 diff --git a/tests/test_wiring_ungated.py b/tests/test_wiring_ungated.py new file mode 100644 index 0000000..8428db4 --- /dev/null +++ b/tests/test_wiring_ungated.py @@ -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..decorate..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) diff --git a/web/package.json b/web/package.json index cf60680..3778fcf 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 69beef9..fa329d2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 2580fa7..9febdfd 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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: [] } }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2c13b4e..69d2461 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(response: Response): Promise { // 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(scoped("/api/analytics/overview", group)), analyticsTime: async (group: null | string): Promise => get(scoped("/api/analytics/time", group)), + commands: async (): Promise => get("/api/commands"), env: async (): Promise => get("/api/env"), exportSuggestions: async (): Promise => send("/api/suggestions/export", "POST", {}), @@ -92,6 +102,8 @@ export const api = { send<{ key: string }>("/api/album/person", "POST", { triggers }), restart: async (): Promise => send("/api/restart-container", "POST", {}), + saveCommand: async (name: string, body: SettingBody): Promise => + send(`/api/commands/${encodeURIComponent(name)}`, "PUT", body), saveEnv: async (values: Record): Promise => send("/api/env", "PUT", { values }), setup: async (groups: string[], roster: Roster): Promise => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index f7ae863..29333d9 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; +} diff --git a/web/src/app.test.tsx b/web/src/app.test.tsx index 731d340..9aff353 100644 --- a/web/src/app.test.tsx +++ b/web/src/app.test.tsx @@ -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(); + await userEvent.click(screen.getByRole("button", { name: "Commands" })); + await waitFor(() => { + expect(screen.getByTestId("command-cat")).toBeInTheDocument(); + }); +}); diff --git a/web/src/app.tsx b/web/src/app.tsx index 642db76..38bab01 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -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, diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index c1ad3c3..c1f3bf3 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -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", + }, + ], +}; diff --git a/web/src/views/commands-time.test.ts b/web/src/views/commands-time.test.ts new file mode 100644 index 0000000..040d2f1 --- /dev/null +++ b/web/src/views/commands-time.test.ts @@ -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); +}); diff --git a/web/src/views/commands-time.ts b/web/src/views/commands-time.ts new file mode 100644 index 0000000..cff9445 --- /dev/null +++ b/web/src/views/commands-time.ts @@ -0,0 +1,55 @@ +// The "off until" field is a , 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; +} diff --git a/web/src/views/commands.test.tsx b/web/src/views/commands.test.tsx new file mode 100644 index 0000000..0f118a0 --- /dev/null +++ b/web/src/views/commands.test.tsx @@ -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 { + render(); + await waitFor(() => { + expect(screen.getByTestId("command-cat")).toBeInTheDocument(); + }); +} + +it("says it is asking before the bot answers", () => { + serve({ "/api/commands": { body: COMMANDS } }); + render(); + 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(); + 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(); + 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"); + }); +}); diff --git a/web/src/views/commands.tsx b/web/src/views/commands.tsx new file mode 100644 index 0000000..996a4aa --- /dev/null +++ b/web/src/views/commands.tsx @@ -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 ( + + + {row.trigger} +
+ {row.description} + + + + + + + + + + + + {" "} + {note} + + + ); +} + +export function Commands(): React.JSX.Element { + const [rows, setRows] = useState(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

The bot did not answer: {problem}

; + } + if (rows === null) { + return

Asking the bot…

; + } + return ( +
+

Commands

+

+ Every switch here is live: the bot reads it on the next message, without a reload. A + command that is off answers WYŁĄCZONE so nobody thinks it broke. An empty + cooldown means the bot's own default. +

+ + + {rows.map((row) => )} + +
+
+ ); +}