diff --git a/bot.py b/bot.py index 9b752bd..e167c77 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.a2" +VERSION = "0.4.1.0.a3" # ---------- Env loading ---------- diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py index c907cd7..4397310 100644 --- a/modules/usage/usage_stats.py +++ b/modules/usage/usage_stats.py @@ -2,6 +2,7 @@ from __future__ import annotations from discord.ext import commands import discord +from collections import OrderedDict COUNTER_KEY_PREFIX = "cmd::" @@ -17,66 +18,96 @@ def _key_from_ctx(ctx: commands.Context) -> str: return f"{COUNTER_KEY_PREFIX}{name}" class UsageStatsCog(commands.Cog): - """Lightweight command run/attempt counters persisted in DataManager.""" + """Lightweight command run counters with de-dup + hybrid guards.""" 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") - # ---- successful completions ---- + # ----- 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 ----- @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] skip app (no data_manager)") + 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}") except Exception as e: - print("[usage] incr app failed:", repr(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 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})") + return + dm = getattr(self.bot, "data_manager", None) if not dm: - print("[usage] skip prefix (no data_manager)") + 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}") except Exception as e: - print("[usage] incr prefix failed:", repr(e)) + print("[usage] px !! incr failed:", repr(e)) - # ---- attempts that error (optional but useful while testing) ---- + # ----- attempts that error (optional, keep while validating) ----- @commands.Cog.listener() async def on_app_command_error(self, interaction: discord.Interaction, error: Exception): - # Count attempts (separate key) so you can confirm the listener fires at all. - dm = getattr(self.bot, "data_manager", None) - cmd = getattr(interaction, "command", None) - if dm and isinstance(cmd, discord.app_commands.Command): - try: - key = _key_from_app(cmd).replace("cmd::", "cmd_attempt::") - newv = dm.incr_counter(key, 1) - print(f"[usage] app !! {key} -> {newv} ({type(error).__name__})") - except Exception as e: - print("[usage] app err incr failed:", repr(e)) + # 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}") @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: Exception): - dm = getattr(self.bot, "data_manager", None) - if dm and getattr(ctx, "command", None): - try: - key = _key_from_ctx(ctx).replace("cmd::", "cmd_attempt::") - newv = dm.incr_counter(key, 1) - print(f"[usage] px !! {key} -> {newv} ({type(error).__name__})") - except Exception as e: - print("[usage] px err incr failed:", repr(e)) + mid = getattr(getattr(ctx, "message", None), "id", None) + print(f"[usage] px ** error ({type(error).__name__}) mid={mid}") async def setup(bot: commands.Bot): + # Prevent duplicate registration if extensions are reloaded / auto-discovered twice + if getattr(bot, "_usage_stats_loaded", False): + print("[usage] UsageStatsCog already loaded; skipping duplicate add") + return await bot.add_cog(UsageStatsCog(bot)) + bot._usage_stats_loaded = True print("[usage] UsageStatsCog loaded")