diff --git a/bot.py b/bot.py index e167c77..74c4857 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update) -VERSION = "0.4.1.0.a3" +VERSION = "0.4.1.0.a4" # ---------- Env loading ---------- diff --git a/data_manager.py b/data_manager.py index 3cc9053..6a6b556 100644 --- a/data_manager.py +++ b/data_manager.py @@ -3,6 +3,7 @@ import json import threading import shutil import os +import time from typing import Callable, Any class DataManager: @@ -11,32 +12,51 @@ class DataManager: self.lock = threading.Lock() self._data = self._load() + def _default_payload(self): + return { + 'agreed_rules': [], + 'agreed_engagement': [], + 'agreed_nickname': [], + 'nick_same_confirmed': [], + 'nick_nudged': [], + 'nick_dm_map': [], + 'pirates': [], + 'modlog': [], + 'reports': [], + 'encounters': [], + 'vc_channels': [], + 'user_cards': [], + 'pirates_list_posts': [], + 'spicepay_prefs': [], + 'nick_verified': [], + 'nick_claim_pending': [], + 'nick_reviews': [], + 'rr_msg_channels': [], + '_counters': {}, # key -> int (metrics) + '_events_seen': {}, # stamp-key -> unix_ts (dedup window) + } + def _load(self): try: with open(self.json_path, 'r', encoding='utf-8') as f: - return json.load(f) + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("root is not an object") + data.setdefault('_counters', {}) + data.setdefault('_events_seen', {}) + return data except FileNotFoundError: - default = { - 'agreed_rules': [], - 'agreed_engagement': [], - 'agreed_nickname': [], - 'nick_same_confirmed': [], - 'nick_nudged': [], - 'nick_dm_map': [], - 'pirates': [], - 'modlog': [], - 'reports': [], - 'encounters': [], - 'vc_channels': [], - 'user_cards': [], - 'pirates_list_posts': [], - 'spicepay_prefs': [], - 'nick_verified': [], - 'nick_claim_pending': [], - 'nick_reviews': [], - 'rr_msg_channels': [], - '_counters': {}, - } + default = self._default_payload() + self._save(default) + return default + except Exception: + # Backup the broken file if it exists, then start fresh + try: + if os.path.exists(self.json_path): + shutil.copy2(self.json_path, self.json_path + ".corrupt.bak") + except Exception: + pass + default = self._default_payload() self._save(default) return default @@ -44,7 +64,7 @@ class DataManager: os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True) tmp = self.json_path + ".tmp" with open(tmp, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=4) + json.dump(data, f, indent=4, ensure_ascii=False) if os.path.exists(self.json_path): try: shutil.copy2(self.json_path, self.json_path + ".bak") @@ -55,7 +75,7 @@ class DataManager: def _save(self, data: dict): self._safe_write(data) - # ------------- existing list helpers ------------- + # ------------- list helpers ------------- def get(self, category: str): with self.lock: return list(self._data.get(category, [])) @@ -72,7 +92,6 @@ class DataManager: self._save(self._data) def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool: - """Atomically find one item in `category` and update it with `updater`.""" with self.lock: arr = self._data.get(category, []) for idx, item in enumerate(arr): @@ -85,7 +104,7 @@ class DataManager: return True return False - # ------------- NEW: tiny key→counter helpers ------------- + # ------------- counters ------------- def incr_counter(self, key: str, by: int = 1) -> int: with self.lock: c = self._data.setdefault('_counters', {}) @@ -101,3 +120,37 @@ class DataManager: with self.lock: c = dict(self._data.get('_counters', {})) return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))} + + # ------------- NEW: once-per-event increment with TTL dedup ------------- + def incr_counter_once(self, counter_key: str, event_key: str, window_sec: float = 3.0, namespace: str = "cmd") -> int | None: + """ + Atomically: if `event_key` wasn't seen within `window_sec`, mark it seen and increment `counter_key`. + Returns new counter value, or None if this event is a duplicate. + """ + now = time.time() + stamp = f"{namespace}:{event_key}" + with self.lock: + seen = self._data.setdefault('_events_seen', {}) + last = float(seen.get(stamp, 0.0)) + if now - last <= float(window_sec): + # duplicate; ignore + return None + + # mark seen + seen[stamp] = now + + # opportunistic pruning to keep file small + if len(seen) > 5000: + cutoff = now - (window_sec * 2) + for k in list(seen.keys()): + try: + if float(seen.get(k, 0.0)) < cutoff: + seen.pop(k, None) + except Exception: + seen.pop(k, None) + + # increment + counters = self._data.setdefault('_counters', {}) + counters[counter_key] = int(counters.get(counter_key, 0)) + 1 + self._save(self._data) + return counters[counter_key] diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py index 4397310..7b25fb1 100644 --- a/modules/usage/usage_stats.py +++ b/modules/usage/usage_stats.py @@ -2,81 +2,50 @@ from __future__ import annotations from discord.ext import commands import discord -from collections import OrderedDict COUNTER_KEY_PREFIX = "cmd::" def _key_from_app(cmd: discord.app_commands.Command) -> str: - # app command qualified_name is "group sub" or "name" name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown") return f"{COUNTER_KEY_PREFIX}{name}" def _key_from_ctx(ctx: commands.Context) -> str: - # prefix/hybrid qualified_name is "group sub" or "name" c = getattr(ctx, "command", None) name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown") return f"{COUNTER_KEY_PREFIX}{name}" class UsageStatsCog(commands.Cog): - """Lightweight command run counters with de-dup + hybrid guards.""" + """Lightweight command run counters with storage-level dedup.""" def __init__(self, bot: commands.Bot): self.bot = bot - # Simple LRU caches for seen events (avoid double/quad increments) - self._seen_interactions: OrderedDict[int, int] = OrderedDict() # slash - self._seen_messages: OrderedDict[int, int] = OrderedDict() # prefix/hybrid print("[usage] UsageStatsCog init") - # ----- small LRU helpers ----- - @staticmethod - def _lru_mark_seen(lru: OrderedDict, key, cap: int) -> bool: - """Return True if already seen; otherwise record and trim to cap.""" - if key is None: - return False - if key in lru: - # refresh order - lru.move_to_end(key, last=True) - return True - lru[key] = 1 - # trim if needed - while len(lru) > cap: - lru.popitem(last=False) - return False - - # ----- successful completions ----- + # ---- successful completions ---- @commands.Cog.listener() async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command): - # De-dup by interaction id - iid = getattr(interaction, "id", None) - if self._lru_mark_seen(self._seen_interactions, iid, cap=2048): - print(f"[usage] app ~~ dup ignored (iid={iid})") - return - dm = getattr(self.bot, "data_manager", None) if not dm: print("[usage] app ~~ skip (no data_manager)") return try: - key = _key_from_app(command) - newv = dm.incr_counter(key, 1) - print(f"[usage] app ++ {key} -> {newv}") + # Use the interaction id as the dedup event key + event_key = str(getattr(interaction, "id", None) or "no-iid") + counter_key = _key_from_app(command) + newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="app") + if newv is not None: + print(f"[usage] app ++ {counter_key} -> {newv}") + else: + print(f"[usage] app ~~ dup ignored ({event_key})") except Exception as e: print("[usage] app !! incr failed:", repr(e)) @commands.Cog.listener() async def on_command_completion(self, ctx: commands.Context): - # If a HybridCommand was invoked via slash, some setups can still fire this. - # Guard: only count here when NOT an interaction-backed invoke. + # If a HybridCommand was invoked via slash, skip here (the app listener handled it). if isinstance(getattr(ctx, "command", None), commands.HybridCommand) and getattr(ctx, "interaction", None): - print("[usage] px ~~ hybrid-as-slash; ignore here (app listener handled it)") - return - - # De-dup by message id - msg = getattr(ctx, "message", None) - mid = getattr(msg, "id", None) - if self._lru_mark_seen(self._seen_messages, mid, cap=4096): - print(f"[usage] px ~~ dup ignored (mid={mid})") + print("[usage] px ~~ hybrid-as-slash; ignore here") return dm = getattr(self.bot, "data_manager", None) @@ -84,17 +53,26 @@ class UsageStatsCog(commands.Cog): print("[usage] px ~~ skip (no data_manager)") return try: - key = _key_from_ctx(ctx) - newv = dm.incr_counter(key, 1) - print(f"[usage] px ++ {key} -> {newv}") + # Prefer interaction id when present (hybrid-as-slash defense), else message id + event_key = None + if getattr(ctx, "interaction", None): + event_key = str(getattr(ctx.interaction, "id", None) or "") + if not event_key: + msg = getattr(ctx, "message", None) + event_key = str(getattr(msg, "id", None) or "no-mid") + + counter_key = _key_from_ctx(ctx) + newv = dm.incr_counter_once(counter_key=counter_key, event_key=event_key, window_sec=3.0, namespace="px") + if newv is not None: + print(f"[usage] px ++ {counter_key} -> {newv}") + else: + print(f"[usage] px ~~ dup ignored ({event_key})") except Exception as e: print("[usage] px !! incr failed:", repr(e)) - # ----- attempts that error (optional, keep while validating) ----- - + # ---- error visibility (optional) ---- @commands.Cog.listener() async def on_app_command_error(self, interaction: discord.Interaction, error: Exception): - # We won't increment exec counters on error; this is only for debug visibility. iid = getattr(interaction, "id", None) print(f"[usage] app ** error ({type(error).__name__}) iid={iid}")