diff --git a/assets/docs/cmd.html b/assets/docs/cmd.html
index 0cfe6e2..efd3572 100644
--- a/assets/docs/cmd.html
+++ b/assets/docs/cmd.html
@@ -385,7 +385,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
${r.cog?`cog: ${r.cog}`:''}
${r.module?`module: ${moduleSansPrefix(r)}`:''}
- ${r.required_permissions&&r.required_permissions.length?`perms: ${r.required_permissions.join(', ')}`:''}
+ runs: ${Number(r.exec_count||0).toLocaleString()}
${usageBlockHTML(r.usage_prefix)}
${usageBlockHTML(r.usage_slash)}
@@ -454,7 +454,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
${r.cog?`cog: ${r.cog}`:''}
${r.module?`module: ${moduleSansPrefix(r)}`:''}
- ${r.required_permissions&&r.required_permissions.length?`perms: ${r.required_permissions.join(', ')}`:''}
+ runs: ${Number(r.exec_count||0).toLocaleString()}
${usageHTML}
${helpSansMod(r)?`${helpSansMod(r)}
`:''}
@@ -475,28 +475,56 @@ fdbtn?.addEventListener('click', closeFullDetails);
function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); }
function applyFilter(selectAnchorIfPresent=true){
- if(!data) return;
- const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
- countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
+ if(!data) return;
+ const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[];
+ countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
- const q=(qEl.value||'').toLowerCase();
- const {filter, cmd}=getFilterFromHash();
- const src=filter==='moderator'?mods:(filter==='all'?all:users);
- 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);
+ const q=(qEl.value||'').toLowerCase();
+ const {filter, cmd}=getFilterFromHash();
+ const src=filter==='moderator'?mods:(filter==='all'?all:users);
+ 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);
- let sel=rows[0];
- 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'}); }
- if(sel) openDetails(sel);
+ const isMobile = matchMedia('(max-width: 900px)').matches;
+
+ // Selection logic:
+ // - 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(){
computeStickyTop();
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));
qEl.addEventListener('input', ()=>applyFilter(false));
+
+ await loadData(); // always refresh from backend
applyFilter(true);
}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 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();
})();
diff --git a/bot.py b/bot.py
index 8117f58..9b752bd 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.a1"
+VERSION = "0.4.1.0.a2"
# ---------- Env loading ----------
diff --git a/data_manager.py b/data_manager.py
index 453d7e4..3cc9053 100644
--- a/data_manager.py
+++ b/data_manager.py
@@ -1,3 +1,4 @@
+# data_manager.py
import json
import threading
import shutil
@@ -12,7 +13,7 @@ class DataManager:
def _load(self):
try:
- with open(self.json_path, 'r') as f:
+ with open(self.json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
default = {
@@ -34,17 +35,16 @@ class DataManager:
'nick_claim_pending': [],
'nick_reviews': [],
'rr_msg_channels': [],
+ '_counters': {},
}
self._save(default)
return default
def _safe_write(self, data: dict):
- # ensure parent dir exists
os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True)
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)
- # backup current file (best-effort)
if os.path.exists(self.json_path):
try:
shutil.copy2(self.json_path, self.json_path + ".bak")
@@ -53,9 +53,9 @@ class DataManager:
os.replace(tmp, self.json_path)
def _save(self, data: dict):
- # single place to write (atomic replace + rolling .bak)
self._safe_write(data)
+ # ------------- existing list helpers -------------
def get(self, category: str):
with self.lock:
return list(self._data.get(category, []))
@@ -72,15 +72,11 @@ 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` matching predicate and update it with `updater`.
- Returns True if an item was updated, else False.
- """
+ """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):
if predicate(item):
- # Copy → mutate → save back
new_item = dict(item)
new_item = updater(new_item) or new_item
arr[idx] = new_item
@@ -88,3 +84,20 @@ class DataManager:
self._save(self._data)
return True
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))}
diff --git a/modules/docs_site/docs_site.py b/modules/docs_site/docs_site.py
index 90d0340..8fe6379 100644
--- a/modules/docs_site/docs_site.py
+++ b/modules/docs_site/docs_site.py
@@ -276,11 +276,12 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
pass
usage_prefix = _command_usage_prefix(cmd, prefix)
+ qn = cmd.qualified_name # single source of truth for counters
row = {
"type": ctype,
"name": cmd.qualified_name,
- "display_name": cmd.qualified_name, # bare name
+ "display_name": cmd.qualified_name,
"help": (cmd.help or "").strip(),
"brief": (cmd.brief or "").strip(),
"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),
"moderator_only": bool(is_mod),
"required_permissions": perms,
+ # NEW: counter fields
+ "counter_key": qn,
+ "exec_count": _cmd_counter(bot, qn),
}
key = ("px", row["name"])
if key not in seen:
@@ -332,7 +336,7 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
seen_paths = set()
for scope, leaf, path in collected:
try:
- canon = path.lstrip("/") # power/restart
+ canon = path.lstrip("/") # e.g., 'power/restart'
if canon in seen_paths:
continue
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)
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 = {
"type": "slash",
"name": "/" + canon,
@@ -360,6 +367,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
"required_permissions": perms,
"extras": _safe_extras(leaf),
"dm_permission": getattr(leaf, "dm_permission", None),
+ # NEW: counter fields
+ "counter_key": qn,
+ "exec_count": _cmd_counter(bot, qn),
}
rows.append(row)
except Exception:
@@ -367,6 +377,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
continue
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
@@ -562,6 +575,10 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
h["brief_html"] = r["brief_html"]
if r.get("details_html") and not h.get("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)
for i in sorted(to_remove, reverse=True):
diff --git a/modules/usage/__init__.py b/modules/usage/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py
new file mode 100644
index 0000000..c907cd7
--- /dev/null
+++ b/modules/usage/usage_stats.py
@@ -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")