mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
A switchboard: any command off, off until, or on its own cooldown
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:
parent
41df308393
commit
d13ae42590
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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**. |
|
||||
|
||||
9
fake_signal/proofs/switch_off.yaml
Normal file
9
fake_signal/proofs/switch_off.yaml
Normal 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" }
|
||||
9
fake_signal/proofs/switch_on.yaml
Normal file
9
fake_signal/proofs/switch_on.yaml
Normal 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 }
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
91
signal_bot/app_commands.py
Normal file
91
signal_bot/app_commands.py
Normal 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)
|
||||
103
signal_bot/command_settings.py
Normal file
103
signal_bot/command_settings.py
Normal 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),
|
||||
)
|
||||
59
signal_bot/command_switch.py
Normal file
59
signal_bot/command_switch.py
Normal 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)
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
80
signal_bot/manage/routes_commands.py
Normal file
80
signal_bot/manage/routes_commands.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
76
tests/test_app_commands.py
Normal file
76
tests/test_app_commands.py
Normal 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
|
||||
62
tests/test_command_settings.py
Normal file
62
tests/test_command_settings.py
Normal 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
|
||||
131
tests/test_command_switch.py
Normal file
131
tests/test_command_switch.py
Normal 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"
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
73
tests/test_manage_commands.py
Normal file
73
tests/test_manage_commands.py
Normal 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
|
||||
@ -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
|
||||
|
||||
42
tests/test_wiring_ungated.py
Normal file
42
tests/test_wiring_ungated.py
Normal 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)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [] } },
|
||||
|
||||
@ -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> =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
41
web/src/views/commands-time.test.ts
Normal file
41
web/src/views/commands-time.test.ts
Normal 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);
|
||||
});
|
||||
55
web/src/views/commands-time.ts
Normal file
55
web/src/views/commands-time.ts
Normal 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;
|
||||
}
|
||||
133
web/src/views/commands.test.tsx
Normal file
133
web/src/views/commands.test.tsx
Normal 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
151
web/src/views/commands.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user