0.4.1.0.a2
- Docs site changes - Details brief no longer opens automatically on narrower devices - Implemented a counter that displays the number of executions for each command
This commit is contained in:
		
							parent
							
								
									fdd336fe91
								
							
						
					
					
						commit
						4e86eb43fc
					
				@ -385,7 +385,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
      <div class="meta">
 | 
					      <div class="meta">
 | 
				
			||||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
					        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
				
			||||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
					        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
				
			||||||
        ${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''}
 | 
					        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      ${usageBlockHTML(r.usage_prefix)}
 | 
					      ${usageBlockHTML(r.usage_prefix)}
 | 
				
			||||||
      ${usageBlockHTML(r.usage_slash)}
 | 
					      ${usageBlockHTML(r.usage_slash)}
 | 
				
			||||||
@ -454,7 +454,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
      <div class="meta">
 | 
					      <div class="meta">
 | 
				
			||||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
					        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
				
			||||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
					        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
				
			||||||
        ${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''}
 | 
					        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      ${usageHTML}
 | 
					      ${usageHTML}
 | 
				
			||||||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
					      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
				
			||||||
@ -485,18 +485,46 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
  const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
 | 
					  const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q)));
 | 
				
			||||||
  render(listEl, rows);
 | 
					  render(listEl, rows);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let sel=rows[0];
 | 
					  const isMobile = matchMedia('(max-width: 900px)').matches;
 | 
				
			||||||
    if(selectAnchorIfPresent && cmd){ const hit=rows.find(r=>rowAnchor(r)===cmd); if(hit) sel=hit;
 | 
					
 | 
				
			||||||
      const cardEl=document.getElementById('card-'+cmd); if(cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'}); }
 | 
					  // Selection logic:
 | 
				
			||||||
    if(sel) openDetails(sel);
 | 
					  // - If URL has an anchor, prefer that (scroll to it)
 | 
				
			||||||
 | 
					  // - Otherwise, ONLY auto-open first row on non-mobile
 | 
				
			||||||
 | 
					  let sel = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (selectAnchorIfPresent && cmd) {
 | 
				
			||||||
 | 
					    const hit = rows.find(r => rowAnchor(r) === cmd);
 | 
				
			||||||
 | 
					    if (hit) sel = hit;
 | 
				
			||||||
 | 
					    const cardEl = document.getElementById('card-'+cmd);
 | 
				
			||||||
 | 
					    if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
 | 
				
			||||||
 | 
					  } else if (!isMobile && rows.length) {
 | 
				
			||||||
 | 
					    sel = rows[0];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Do not auto-open on mobile
 | 
				
			||||||
 | 
					  if (sel && !isMobile) openDetails(sel);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function boot(){
 | 
					  async function boot(){
 | 
				
			||||||
    computeStickyTop();
 | 
					    computeStickyTop();
 | 
				
			||||||
    try{
 | 
					    try{
 | 
				
			||||||
      if(!data){ const res=await fetch('/api/commands',{cache:'no-store'}); if(!res.ok) throw new Error('HTTP '+res.status); data=await res.json(); }
 | 
					      async function loadData(){
 | 
				
			||||||
 | 
					        try{
 | 
				
			||||||
 | 
					          const res = await fetch('/api/commands', { cache:'no-store' });
 | 
				
			||||||
 | 
					          if(!res.ok) throw new Error('HTTP '+res.status);
 | 
				
			||||||
 | 
					          data = await res.json();
 | 
				
			||||||
 | 
					          return true;   // fetched fresh
 | 
				
			||||||
 | 
					        }catch(e){
 | 
				
			||||||
 | 
					          // fallback to inline bootstrap if fetch fails
 | 
				
			||||||
 | 
					          if(window.__DATA__){ data = window.__DATA__; return false; }
 | 
				
			||||||
 | 
					          throw e;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      addEventListener('hashchange', ()=>applyFilter(false));
 | 
					      addEventListener('hashchange', ()=>applyFilter(false));
 | 
				
			||||||
      qEl.addEventListener('input', ()=>applyFilter(false));
 | 
					      qEl.addEventListener('input', ()=>applyFilter(false));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await loadData();        // always refresh from backend
 | 
				
			||||||
      applyFilter(true);
 | 
					      applyFilter(true);
 | 
				
			||||||
    }catch{ document.getElementById('alerts').textContent='Failed to load.'; }
 | 
					    }catch{ document.getElementById('alerts').textContent='Failed to load.'; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -524,6 +552,12 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
				
			|||||||
    const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
 | 
					    const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60;
 | 
				
			||||||
    const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
 | 
					    const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setInterval(async ()=>{
 | 
				
			||||||
 | 
					    try{
 | 
				
			||||||
 | 
					      const gotFresh = await loadData();
 | 
				
			||||||
 | 
					      if(gotFresh) applyFilter(false);
 | 
				
			||||||
 | 
					    }catch{}
 | 
				
			||||||
 | 
					  }, 30000);
 | 
				
			||||||
  boot();
 | 
					  boot();
 | 
				
			||||||
})();
 | 
					})();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
				
			||||||
VERSION = "0.4.1.0.a1"
 | 
					VERSION = "0.4.1.0.a2"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Env loading ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					# data_manager.py
 | 
				
			||||||
import json
 | 
					import json
 | 
				
			||||||
import threading
 | 
					import threading
 | 
				
			||||||
import shutil
 | 
					import shutil
 | 
				
			||||||
@ -12,7 +13,7 @@ class DataManager:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _load(self):
 | 
					    def _load(self):
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            with open(self.json_path, 'r') as f:
 | 
					            with open(self.json_path, 'r', encoding='utf-8') as f:
 | 
				
			||||||
                return json.load(f)
 | 
					                return json.load(f)
 | 
				
			||||||
        except FileNotFoundError:
 | 
					        except FileNotFoundError:
 | 
				
			||||||
            default = {
 | 
					            default = {
 | 
				
			||||||
@ -34,17 +35,16 @@ class DataManager:
 | 
				
			|||||||
                'nick_claim_pending': [],
 | 
					                'nick_claim_pending': [],
 | 
				
			||||||
                'nick_reviews': [],
 | 
					                'nick_reviews': [],
 | 
				
			||||||
                'rr_msg_channels': [],
 | 
					                'rr_msg_channels': [],
 | 
				
			||||||
 | 
					                '_counters': {},
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            self._save(default)
 | 
					            self._save(default)
 | 
				
			||||||
            return default
 | 
					            return default
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _safe_write(self, data: dict):
 | 
					    def _safe_write(self, data: dict):
 | 
				
			||||||
        # ensure parent dir exists
 | 
					 | 
				
			||||||
        os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
 | 
					        os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
 | 
				
			||||||
        tmp = self.json_path + ".tmp"
 | 
					        tmp = self.json_path + ".tmp"
 | 
				
			||||||
        with open(tmp, 'w') as f:
 | 
					        with open(tmp, 'w', encoding='utf-8') as f:
 | 
				
			||||||
            json.dump(data, f, indent=4)
 | 
					            json.dump(data, f, indent=4)
 | 
				
			||||||
        # backup current file (best-effort)
 | 
					 | 
				
			||||||
        if os.path.exists(self.json_path):
 | 
					        if os.path.exists(self.json_path):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                shutil.copy2(self.json_path, self.json_path + ".bak")
 | 
					                shutil.copy2(self.json_path, self.json_path + ".bak")
 | 
				
			||||||
@ -53,9 +53,9 @@ class DataManager:
 | 
				
			|||||||
        os.replace(tmp, self.json_path)
 | 
					        os.replace(tmp, self.json_path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _save(self, data: dict):
 | 
					    def _save(self, data: dict):
 | 
				
			||||||
        # single place to write (atomic replace + rolling .bak)
 | 
					 | 
				
			||||||
        self._safe_write(data)
 | 
					        self._safe_write(data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ------------- existing list helpers -------------
 | 
				
			||||||
    def get(self, category: str):
 | 
					    def get(self, category: str):
 | 
				
			||||||
        with self.lock:
 | 
					        with self.lock:
 | 
				
			||||||
            return list(self._data.get(category, []))
 | 
					            return list(self._data.get(category, []))
 | 
				
			||||||
@ -72,15 +72,11 @@ class DataManager:
 | 
				
			|||||||
            self._save(self._data)
 | 
					            self._save(self._data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool:
 | 
					    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`."""
 | 
				
			||||||
        Atomically find one item in `category` matching predicate and update it with `updater`.
 | 
					 | 
				
			||||||
        Returns True if an item was updated, else False.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        with self.lock:
 | 
					        with self.lock:
 | 
				
			||||||
            arr = self._data.get(category, [])
 | 
					            arr = self._data.get(category, [])
 | 
				
			||||||
            for idx, item in enumerate(arr):
 | 
					            for idx, item in enumerate(arr):
 | 
				
			||||||
                if predicate(item):
 | 
					                if predicate(item):
 | 
				
			||||||
                    # Copy → mutate → save back
 | 
					 | 
				
			||||||
                    new_item = dict(item)
 | 
					                    new_item = dict(item)
 | 
				
			||||||
                    new_item = updater(new_item) or new_item
 | 
					                    new_item = updater(new_item) or new_item
 | 
				
			||||||
                    arr[idx] = new_item
 | 
					                    arr[idx] = new_item
 | 
				
			||||||
@ -88,3 +84,20 @@ class DataManager:
 | 
				
			|||||||
                    self._save(self._data)
 | 
					                    self._save(self._data)
 | 
				
			||||||
                    return True
 | 
					                    return True
 | 
				
			||||||
            return False
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ------------- NEW: tiny key→counter helpers -------------
 | 
				
			||||||
 | 
					    def incr_counter(self, key: str, by: int = 1) -> int:
 | 
				
			||||||
 | 
					        with self.lock:
 | 
				
			||||||
 | 
					            c = self._data.setdefault('_counters', {})
 | 
				
			||||||
 | 
					            c[key] = int(c.get(key, 0)) + int(by)
 | 
				
			||||||
 | 
					            self._save(self._data)
 | 
				
			||||||
 | 
					            return c[key]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_counter(self, key: str) -> int:
 | 
				
			||||||
 | 
					        with self.lock:
 | 
				
			||||||
 | 
					            return int(self._data.get('_counters', {}).get(key, 0))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_all_counters(self, prefix: str = "") -> dict[str, int]:
 | 
				
			||||||
 | 
					        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))}
 | 
				
			||||||
 | 
				
			|||||||
@ -276,11 +276,12 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
                    pass
 | 
					                    pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            usage_prefix = _command_usage_prefix(cmd, prefix)
 | 
					            usage_prefix = _command_usage_prefix(cmd, prefix)
 | 
				
			||||||
 | 
					            qn = cmd.qualified_name  # single source of truth for counters
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            row = {
 | 
					            row = {
 | 
				
			||||||
                "type": ctype,
 | 
					                "type": ctype,
 | 
				
			||||||
                "name": cmd.qualified_name,
 | 
					                "name": cmd.qualified_name,
 | 
				
			||||||
                "display_name": cmd.qualified_name,  # bare name
 | 
					                "display_name": cmd.qualified_name,
 | 
				
			||||||
                "help": (cmd.help or "").strip(),
 | 
					                "help": (cmd.help or "").strip(),
 | 
				
			||||||
                "brief": (cmd.brief or "").strip(),
 | 
					                "brief": (cmd.brief or "").strip(),
 | 
				
			||||||
                "usage": usage_prefix if not is_hybrid else None,
 | 
					                "usage": usage_prefix if not is_hybrid else None,
 | 
				
			||||||
@ -290,6 +291,9 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
                "module": getattr(getattr(cmd, "callback", None), "__module__", None),
 | 
					                "module": getattr(getattr(cmd, "callback", None), "__module__", None),
 | 
				
			||||||
                "moderator_only": bool(is_mod),
 | 
					                "moderator_only": bool(is_mod),
 | 
				
			||||||
                "required_permissions": perms,
 | 
					                "required_permissions": perms,
 | 
				
			||||||
 | 
					                # NEW: counter fields
 | 
				
			||||||
 | 
					                "counter_key": qn,
 | 
				
			||||||
 | 
					                "exec_count": _cmd_counter(bot, qn),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            key = ("px", row["name"])
 | 
					            key = ("px", row["name"])
 | 
				
			||||||
            if key not in seen:
 | 
					            if key not in seen:
 | 
				
			||||||
@ -332,7 +336,7 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
    seen_paths = set()
 | 
					    seen_paths = set()
 | 
				
			||||||
    for scope, leaf, path in collected:
 | 
					    for scope, leaf, path in collected:
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            canon = path.lstrip("/")  # power/restart
 | 
					            canon = path.lstrip("/")  # e.g., 'power/restart'
 | 
				
			||||||
            if canon in seen_paths:
 | 
					            if canon in seen_paths:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            seen_paths.add(canon)
 | 
					            seen_paths.add(canon)
 | 
				
			||||||
@ -345,6 +349,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
            options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
 | 
					            options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
 | 
				
			||||||
            usage_full = _command_usage_slash_like(display, options)
 | 
					            usage_full = _command_usage_slash_like(display, options)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Use leaf.qualified_name when available, it matches listener keys
 | 
				
			||||||
 | 
					            qn = getattr(leaf, "qualified_name", None) or display
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            row = {
 | 
					            row = {
 | 
				
			||||||
                "type": "slash",
 | 
					                "type": "slash",
 | 
				
			||||||
                "name": "/" + canon,
 | 
					                "name": "/" + canon,
 | 
				
			||||||
@ -360,6 +367,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
                "required_permissions": perms,
 | 
					                "required_permissions": perms,
 | 
				
			||||||
                "extras": _safe_extras(leaf),
 | 
					                "extras": _safe_extras(leaf),
 | 
				
			||||||
                "dm_permission": getattr(leaf, "dm_permission", None),
 | 
					                "dm_permission": getattr(leaf, "dm_permission", None),
 | 
				
			||||||
 | 
					                # NEW: counter fields
 | 
				
			||||||
 | 
					                "counter_key": qn,
 | 
				
			||||||
 | 
					                "exec_count": _cmd_counter(bot, qn),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            rows.append(row)
 | 
					            rows.append(row)
 | 
				
			||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
@ -367,6 +377,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
            continue
 | 
					            continue
 | 
				
			||||||
    return rows
 | 
					    return rows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _cmd_counter(bot, qualified_name: str) -> int:
 | 
				
			||||||
 | 
					    dm = getattr(bot, "data_manager", None)
 | 
				
			||||||
 | 
					    return dm.get_counter(f"cmd::{qualified_name}") if dm else 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					# =============================
 | 
				
			||||||
# Details loader & master JSON
 | 
					# Details loader & master JSON
 | 
				
			||||||
@ -562,6 +575,10 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
 | 
				
			|||||||
            h["brief_html"] = r["brief_html"]
 | 
					            h["brief_html"] = r["brief_html"]
 | 
				
			||||||
        if r.get("details_html") and not h.get("details_html"):
 | 
					        if r.get("details_html") and not h.get("details_html"):
 | 
				
			||||||
            h["details_html"] = r["details_html"]
 | 
					            h["details_html"] = r["details_html"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # NEW: sum exec_count from slash twin into the hybrid row
 | 
				
			||||||
 | 
					        h["exec_count"] = int(h.get("exec_count", 0) or 0) + int(r.get("exec_count", 0) or 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        to_remove.append(i)
 | 
					        to_remove.append(i)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for i in sorted(to_remove, reverse=True):
 | 
					    for i in sorted(to_remove, reverse=True):
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										0
									
								
								modules/usage/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								modules/usage/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										82
									
								
								modules/usage/usage_stats.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								modules/usage/usage_stats.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					# modules/usage/usage_stats.py
 | 
				
			||||||
 | 
					from __future__ import annotations
 | 
				
			||||||
 | 
					from discord.ext import commands
 | 
				
			||||||
 | 
					import discord
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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/attempt counters persisted in DataManager."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, bot: commands.Bot):
 | 
				
			||||||
 | 
					        self.bot = bot
 | 
				
			||||||
 | 
					        print("[usage] UsageStatsCog init")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ---- successful completions ----
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @commands.Cog.listener()
 | 
				
			||||||
 | 
					    async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command):
 | 
				
			||||||
 | 
					        dm = getattr(self.bot, "data_manager", None)
 | 
				
			||||||
 | 
					        if not dm:
 | 
				
			||||||
 | 
					            print("[usage] skip app (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))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @commands.Cog.listener()
 | 
				
			||||||
 | 
					    async def on_command_completion(self, ctx: commands.Context):
 | 
				
			||||||
 | 
					        dm = getattr(self.bot, "data_manager", None)
 | 
				
			||||||
 | 
					        if not dm:
 | 
				
			||||||
 | 
					            print("[usage] skip prefix (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))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ---- attempts that error (optional but useful while testing) ----
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @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))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @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))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async def setup(bot: commands.Bot):
 | 
				
			||||||
 | 
					    await bot.add_cog(UsageStatsCog(bot))
 | 
				
			||||||
 | 
					    print("[usage] UsageStatsCog loaded")
 | 
				
			||||||
		Loading…
	
		Reference in New Issue
	
	Block a user