signal-bot/signal_bot/decorators.py
Krzysztof kuhy Rudnicki 5d76adf4db feat: watch several groups, and answer only where you were asked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:03:24 +02:00

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 NSFW 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