mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
One row per live command on a new Commands tab of the management page,
each with an on/off switch, an optional "off until" moment and a cooldown
that overrides the bot's default for that command alone. Every switch is
live: the bot keeps them in its own SQLite (command_settings, a permanent
table) and reads the row on every attempt, so nothing goes through
/_reload and nothing restarts.
A command that is off answers "BEEP BOOP !kot WYŁĄCZONE." (with "DO HH:MM"
for a timed switch) rather than going quiet, so nobody reports it broken;
the refusal is filed as refused_off, which the analytics count with the
other refusals, and it neither starts the cooldown nor counts as a use.
The bot grows GET /_commands and PUT /_commands/{name}; the management app
proxies them under /api/commands and passes the bot's 404/503 through
unwrapped. !cisza and !odcisz carry no cooldown and the page says so
instead of accepting a number the bot would ignore (test_wiring_ungated
keeps that set honest against the real table).
Proven end to end: scripts/e2e.sh now brackets a PUT with switch_off.yaml
and switch_on.yaml, the same actor refused and then served, with no reload
between them. Also bumps vitest and @vitest/coverage-v8 to 5.0.0 and
@types/react-dom to 19.2.7, which the freshness gate asked for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Reusable gates that wrap a command handler.
|
|
|
|
These are the mechanisms salvaged from the unmerged vote and rate-limit
|
|
branches, detached from the command they were originally written for.
|
|
They only decide whether a handler may run; recording a successful use is the
|
|
dispatcher's job, so stacking two gates cannot double-count one invocation.
|
|
|
|
A gate that refuses raises ``CommandRefusedError`` rather than returning, which
|
|
is what tells the dispatcher no use took place. Returning quietly used to look
|
|
identical to a successful run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import logging
|
|
import math
|
|
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.registry import CommandRefusedError, Handler
|
|
from signal_bot.stats.text import plural
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.context import CommandContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
Decorator = Callable[[Handler], Handler]
|
|
|
|
# Which gate said no. These are the analytics outcomes minus their prefix, so
|
|
# a refusal names the gate that caused it rather than arriving as one
|
|
# undifferentiated "refused" -- a command whose cooldown bites half the time
|
|
# is mis-tuned, and one whose vote never carries is unwanted, and the raw
|
|
# count cannot tell those apart.
|
|
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
|
|
# bot lying; a number that is a second long costs nothing.
|
|
DISPLAY_MARGIN_SECONDS = 2
|
|
|
|
|
|
def wait_display(remaining: float) -> tuple[int, str]:
|
|
"""Round a remaining wait up (plus margin) and pick its Polish noun form.
|
|
|
|
Three forms, not two: 1 sekundę, 2-4 sekundy, 5+ sekund -- and the rule
|
|
reads the last two digits, so 12-14 take the many-form and 22-24 do not.
|
|
"""
|
|
seconds = math.ceil(remaining) + DISPLAY_MARGIN_SECONDS
|
|
return seconds, plural(seconds, "SEKUNDĘ", "SEKUNDY", "SEKUND")
|
|
|
|
|
|
def cooldown(seconds: float, message: str) -> Decorator:
|
|
"""Refuse a command until `seconds` have passed since that user's last use.
|
|
|
|
The original bot shared one cooldown window across every user and every
|
|
command, so one person's cat picture silenced everybody.
|
|
"""
|
|
|
|
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 < 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(
|
|
"cooldown refused %s for %s: told them %d %s",
|
|
ctx.command_name,
|
|
ctx.message.source_name,
|
|
shown,
|
|
unit,
|
|
)
|
|
await ctx.reply(message.format(seconds=shown, unit=unit))
|
|
raise CommandRefusedError(GATE_COOLDOWN)
|
|
await handler(ctx)
|
|
|
|
return wrapper
|
|
|
|
return decorate
|
|
|
|
|
|
def daily_limit(max_uses: int, message: str) -> Decorator:
|
|
"""Allow a command at most `max_uses` times per user per calendar day."""
|
|
|
|
def decorate(handler: Handler) -> Handler:
|
|
@functools.wraps(handler)
|
|
async def wrapper(ctx: CommandContext) -> None:
|
|
used = ctx.store.uses_today(
|
|
ctx.group_id, ctx.message.source_uuid, ctx.command_name, ctx.day
|
|
)
|
|
if used >= max_uses:
|
|
await ctx.reply(message.format(max_uses=max_uses, used=used))
|
|
raise CommandRefusedError(GATE_DAILY)
|
|
await handler(ctx)
|
|
|
|
return wrapper
|
|
|
|
return decorate
|
|
|
|
|
|
def vote_gated(required: int, window_seconds: float, message: str) -> Decorator:
|
|
"""Run a command only once `required` distinct people ask within a window.
|
|
|
|
A repeat vote from the same person is ignored, and the tally is cleared
|
|
once the command fires so the next run starts from zero.
|
|
"""
|
|
|
|
def decorate(handler: Handler) -> Handler:
|
|
@functools.wraps(handler)
|
|
async def wrapper(ctx: CommandContext) -> None:
|
|
ctx.store.add_vote(ctx.group_id, ctx.command_name, ctx.message.source_uuid, ctx.now)
|
|
votes = ctx.store.count_votes(ctx.group_id, ctx.command_name, ctx.now - window_seconds)
|
|
if votes < required:
|
|
await ctx.reply(message.format(votes=votes, required=required))
|
|
raise CommandRefusedError(GATE_VOTE)
|
|
ctx.store.clear_votes(ctx.group_id, ctx.command_name)
|
|
await handler(ctx)
|
|
|
|
return wrapper
|
|
|
|
return decorate
|