mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:54:04 +01:00
Some checks are pending
CI / check (push) Waiting to run
The vote and rate-limit gates were salvaged from an old command that is gone; the docstring still named it, and test_store.py used its name as the arbitrary string for the vote-dedup fixture. Neither reference means anything now, and the pre-rewrite history that did contain it has been dropped, so these were the only two left in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
82 lines
2.9 KiB
Python
82 lines
2.9 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
from collections.abc import Callable
|
|
|
|
from signal_bot.registry import CommandContext, Handler
|
|
|
|
Decorator = Callable[[Handler], Handler]
|
|
|
|
|
|
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:
|
|
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:
|
|
remaining = seconds - (ctx.now - last)
|
|
await ctx.reply(message.format(seconds=int(remaining) + 1))
|
|
return
|
|
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))
|
|
return
|
|
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))
|
|
return
|
|
ctx.store.clear_votes(ctx.group_id, ctx.command_name)
|
|
await handler(ctx)
|
|
|
|
return wrapper
|
|
|
|
return decorate
|