""" control_limiter — M3.4: Rate limiting + cooldown for Matrix control channel. Protection layers: 1. Per-room sliding window — CONTROL_ROOM_RPM (default 60) 2. Per-operator sliding window — CONTROL_OPERATOR_RPM (default 30) 3. Per-run sliding window — CONTROL_RUN_NEXT_RPM (default 20, only !runbook next) 4. Per-operator cooldown — CONTROL_COOLDOWN_S (default 2s, anti-double-click) All state is in-memory (lost on restart), which is intentional — limits reset with the bridge. Thread safety: not needed (asyncio single-threaded event loop). """ from __future__ import annotations import time from collections import defaultdict, deque from typing import Dict, Tuple # Sentinel value for "unlimited" (rpm == 0 → skip check) _UNLIMITED = 0 class ControlRateLimiter: """ Sliding-window rate limiter + cooldown for the Matrix control channel. All rpm values are requests-per-minute over a 60-second rolling window. cooldown_s is a per-{operator, verb, subcommand} debounce window (anti-double-click). """ def __init__( self, room_rpm: int = 60, operator_rpm: int = 30, run_next_rpm: int = 20, cooldown_s: float = 2.0, ) -> None: self.room_rpm = room_rpm self.operator_rpm = operator_rpm self.run_next_rpm = run_next_rpm self.cooldown_s = cooldown_s # Sliding-window storage: key → deque[float] (monotonic timestamps) self._room_windows: Dict[str, deque] = defaultdict(deque) self._op_windows: Dict[str, deque] = defaultdict(deque) self._run_windows: Dict[str, deque] = defaultdict(deque) # Cooldown: (sender_hash, verb, subcommand) → last accepted timestamp self._cooldown_times: Dict[str, float] = {} # ── Sliding window helpers ───────────────────────────────────────────────── @staticmethod def _check_window( windows: Dict[str, deque], key: str, rpm: int, ) -> Tuple[bool, float]: """ Sliding-window check over a 60-second window. Returns (allowed, retry_after_seconds). If rpm == 0, always allowed. """ if rpm == _UNLIMITED: return True, 0.0 now = time.monotonic() window = windows[key] cutoff = now - 60.0 # Evict expired entries while window and window[0] < cutoff: window.popleft() if len(window) >= rpm: # Time until oldest entry expires retry_after = max(0.0, 60.0 - (now - window[0])) return False, retry_after window.append(now) return True, 0.0 # ── Public check methods ─────────────────────────────────────────────────── def check_room(self, room_id: str) -> Tuple[bool, float]: """Per-room rate limit check. Returns (allowed, retry_after_s).""" return self._check_window(self._room_windows, room_id, self.room_rpm) def check_operator(self, sender_hash: str) -> Tuple[bool, float]: """Per-operator rate limit check. Returns (allowed, retry_after_s).""" return self._check_window(self._op_windows, sender_hash, self.operator_rpm) def check_run_next(self, run_id: str) -> Tuple[bool, float]: """ Per-run rate limit for !runbook next — prevents rapid-fire advancement. Returns (allowed, retry_after_s). """ return self._check_window(self._run_windows, run_id, self.run_next_rpm) def check_cooldown( self, sender_hash: str, verb: str, subcommand: str, ) -> Tuple[bool, float]: """ Anti-double-click cooldown per (operator, verb, subcommand). Returns (allowed, wait_s). On first call → records timestamp and allows. On subsequent calls within cooldown_s → blocks and returns remaining wait. """ if self.cooldown_s <= 0: return True, 0.0 key = f"{sender_hash}:{verb}:{subcommand}" now = time.monotonic() last = self._cooldown_times.get(key) if last is not None: elapsed = now - last if elapsed < self.cooldown_s: return False, self.cooldown_s - elapsed self._cooldown_times[key] = now return True, 0.0 # ── Summary ─────────────────────────────────────────────────────────────── def as_health_dict(self) -> dict: return { "room_rpm": self.room_rpm, "operator_rpm": self.operator_rpm, "run_next_rpm": self.run_next_rpm, "cooldown_s": self.cooldown_s, }