0.4.1.0.a4
- Correction to earlier patch. *Testing on live server is a pain, but I'm too lazy to fire up a dev server..*
This commit is contained in:
		
							parent
							
								
									730d479e2d
								
							
						
					
					
						commit
						73175bbecd
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								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 ----------
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import json
 | 
			
		||||
import threading
 | 
			
		||||
import shutil
 | 
			
		||||
import os
 | 
			
		||||
import time
 | 
			
		||||
from typing import Callable, Any
 | 
			
		||||
 | 
			
		||||
class DataManager:
 | 
			
		||||
@ -11,12 +12,8 @@ class DataManager:
 | 
			
		||||
        self.lock = threading.Lock()
 | 
			
		||||
        self._data = self._load()
 | 
			
		||||
 | 
			
		||||
    def _load(self):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.json_path, 'r', encoding='utf-8') as f:
 | 
			
		||||
                return json.load(f)
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            default = {
 | 
			
		||||
    def _default_payload(self):
 | 
			
		||||
        return {
 | 
			
		||||
            'agreed_rules': [],
 | 
			
		||||
            'agreed_engagement': [],
 | 
			
		||||
            'agreed_nickname': [],
 | 
			
		||||
@ -35,8 +32,31 @@ class DataManager:
 | 
			
		||||
            'nick_claim_pending': [],
 | 
			
		||||
            'nick_reviews': [],
 | 
			
		||||
            'rr_msg_channels': [],
 | 
			
		||||
                '_counters': {},
 | 
			
		||||
            '_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:
 | 
			
		||||
                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 = 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]
 | 
			
		||||
 | 
			
		||||
@ -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}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user