Notes
+-
+
- No authentication; info is read-only and non-sensitive. +
- Moderator-only is inferred from checks (best-effort). +
- Slash commands show full path if grouped, e.g.
/group/sub
.
+
JSON: /api/commands
· Health: /healthz
diff --git a/bot.py b/bot.py index e22ef4b..8e3c746 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.3.9.7.a5" +VERSION = "0.3.9.8.a1" # ---------- Env loading ---------- diff --git a/modules/docs_site/__init__.py b/modules/docs_site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/docs_site/docs_site.py b/modules/docs_site/docs_site.py new file mode 100644 index 0000000..f3b9214 --- /dev/null +++ b/modules/docs_site/docs_site.py @@ -0,0 +1,468 @@ +# modules/docs_site/docs_site.py +import json +import threading +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +from typing import Any, Dict, List, Optional, Tuple + +import discord +from discord.ext import commands +from discord import app_commands + +from modules.common.settings import cfg +import mod_perms # for name/qualname detection only (no calls) + +# ----------------------------- +# Helpers: command introspection +# ----------------------------- + +def _is_perm_check(fn) -> Tuple[bool, List[str]]: + """ + Detects if a prefix/hybrid command check enforces guild/admin permissions. + Returns (has_perm_check, [perm_names]). + """ + try: + name = getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "") + except Exception: + name = "" + + # discord.py wraps has_permissions/has_guild_permissions checks; they carry attrs sometimes. + perms = [] + try: + # Some check predicates store desired perms on closure/cell vars; best-effort: + code = getattr(fn, "__code__", None) + if code and code.co_freevars and getattr(fn, "__closure__", None): + for cell in fn.__closure__ or []: + val = getattr(cell, "cell_contents", None) + if isinstance(val, dict): + # likely {'manage_messages': True, ...} + for k, v in val.items(): + if v: + perms.append(str(k)) + except Exception: + pass + + has_perm = any(p for p in perms) + return has_perm, perms + + +def _looks_like_mod_check(fn) -> bool: + """ + Heuristic: identify checks referring to your moderator helpers. + """ + try: + qn = getattr(fn, "__qualname__", "") or "" + mod_names = ( + "is_moderator_member", + "is_moderator_userid", + "require_mod_ctx", + "require_mod_interaction", + ) + if any(m in qn for m in mod_names): + return True + # Also check module path/name: + mod = getattr(fn, "__module__", "") or "" + if "mod_perms" in mod: + return True + except Exception: + pass + return False + + +def _is_mod_command_prefix(cmd: commands.Command) -> Tuple[bool, List[str]]: + """ + For prefix/hybrid commands: inspect .checks for moderator checks or admin perms. + """ + is_mod = False + perms: List[str] = [] + try: + for chk in getattr(cmd, "checks", []) or []: + m = _looks_like_mod_check(chk) + p_flag, p_list = _is_perm_check(chk) + if m: + is_mod = True + if p_flag: + perms.extend(p_list) + except Exception: + pass + + # If command declares directly required perms (rare), capture them too: + # No official attribute on commands.Command; we rely on checks only. + return is_mod, sorted(set(perms)) + + +def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]: + """ + For slash commands: inspect .checks (app_commands) if present. + """ + is_mod = False + perms: List[str] = [] + try: + for chk in getattr(cmd, "checks", []) or []: + m = _looks_like_mod_check(chk) + # app_commands has no built-in has_permissions; users often wrap their own. + if m: + is_mod = True + except Exception: + pass + # Best‑effort: look into ._checks (private) if available + try: + for chk in getattr(cmd, "_checks", []) or []: + if _looks_like_mod_check(chk): + is_mod = True + except Exception: + pass + return is_mod, perms + + +def _command_usage_prefix(cmd: commands.Command) -> str: + # Prefer .usage, else generate "/help"-like usage. + if cmd.usage: + return cmd.usage + try: + # Build basic placeholder usage + params = [] + for k, p in cmd.clean_params.items(): + if p.kind.name.lower().startswith("var"): + params.append(f"[{k}...]") + elif p.default is p.empty: + params.append(f"<{k}>") + else: + params.append(f"[{k}]") + if params: + return f"!{cmd.qualified_name} " + " ".join(params) + return f"!{cmd.qualified_name}" + except Exception: + return f"!{cmd.name}" + + +def _command_usage_slash(cmd: app_commands.Command) -> str: + # Slash usage is mostly the path; options aren’t trivial to enumerate without ._params. + # Do a safe best‑effort: + try: + parts = [f"/{cmd.name}"] + # options (private API in discord.py); keep it safe and minimal: + opts = [] + for opt in getattr(cmd, "options", []) or []: + n = getattr(opt, "name", "arg") + req = getattr(opt, "required", False) + opts.append(f"<{n}>" if req else f"[{n}]") + if opts: + parts.append(" " + " ".join(opts)) + return "".join(parts) + except Exception: + return f"/{cmd.name}" + + +def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + seen = set() + for cmd in bot.commands: + # Skip hidden + if getattr(cmd, "hidden", False): + continue + + ctype = "hybrid" if isinstance(cmd, commands.HybridCommand) else "prefix" + is_mod, perms = _is_mod_command_prefix(cmd) + row = { + "type": ctype, + "name": cmd.qualified_name, + "help": (cmd.help or "").strip(), + "brief": (cmd.brief or "").strip(), + "usage": _command_usage_prefix(cmd), + "cog": getattr(cmd.cog, "qualified_name", None) if getattr(cmd, "cog", None) else None, + "module": getattr(cmd.callback, "__module__", None), + "moderator_only": bool(is_mod), + "required_permissions": perms, + } + key = ("px", row["name"]) + if key not in seen: + rows.append(row) + seen.add(key) + return rows + + +def _walk_app_tree(node: Any, prefix: str = "") -> List[Tuple[str, app_commands.Command]]: + """ + Flatten app command tree (includes groups/subcommands) into (path, command) pairs. + """ + out: List[Tuple[str, app_commands.Command]] = [] + if isinstance(node, app_commands.Command): + out.append((f"{prefix}/{node.name}", node)) + return out + if isinstance(node, app_commands.Group): + base = f"{prefix}/{node.name}" + # Subcommands or nested groups + for sub in list(getattr(node, "commands", []) or []): + out.extend(_walk_app_tree(sub, base)) + return out + + +def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + try: + # Local view of registered commands (no network call) + cmds = bot.tree.get_commands() + except Exception: + cmds = [] + + for cmd in cmds: + for path, leaf in _walk_app_tree(cmd, prefix=""): + is_mod, perms = _is_mod_command_slash(leaf) + row = { + "type": "slash", + "name": path, # includes group path + "help": (leaf.description or "").strip(), + "brief": "", + "usage": _command_usage_slash(leaf), + "cog": getattr(leaf, "binding", None).__class__.__name__ if getattr(leaf, "binding", None) else None, + "module": getattr(leaf.callback, "__module__", None) if getattr(leaf, "callback", None) else None, + "moderator_only": bool(is_mod), + "required_permissions": perms, + # Extras (discord.py 2.x): curation knobs per-command if authors set them + "extras": dict(getattr(leaf, "extras", {}) or {}), + # DM permission when available (discord.py exposes default permissions in command tree) + "dm_permission": getattr(leaf, "dm_permission", None), + } + rows.append(row) + return rows + + +def build_command_schema(bot: commands.Bot) -> Dict[str, Any]: + px = _gather_prefix_and_hybrid(bot) + sl = _gather_slash(bot) + all_rows = px + sl + + # Derive “moderator” flag if extras hint exists (in case checks missed it) + for row in all_rows: + try: + ex = row.get("extras") or {} + if isinstance(ex, dict) and ex.get("category", "").lower() in {"mod", "moderator", "staff"}: + row["moderator_only"] = True + except Exception: + pass + + # Basic sections + mods = [r for r in all_rows if r.get("moderator_only")] + users = [r for r in all_rows if not r.get("moderator_only")] + + return { + "title": "ShaiWatcher Commands", + "count": len(all_rows), + "sections": { + "user": users, + "moderator": mods, + }, + "all": all_rows, + } + + +# ----------------------------- +# HTTP server + request handler +# ----------------------------- + +_HTML = """ + +
+ + +/group/sub
.JSON: /api/commands
· Health: /healthz