# 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 = """ {title}

{title}

Sections: All · Moderator · User

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

Moderator commands

""" class _DocsHandler(BaseHTTPRequestHandler): # We attach bot + title on the class at server startup bot: commands.Bot = None title: str = "ShaiWatcher Commands" def _set(self, code=200, content_type="text/html; charset=utf-8"): self.send_response(code) self.send_header("Content-Type", content_type) # Tiny bit of sanity for browser caches: self.send_header("Cache-Control", "no-store") self.end_headers() def log_message(self, fmt, *args): # Silence default noisy logs return def do_GET(self): try: path = urlparse(self.path).path if path == "/": self._set() self.wfile.write(_HTML.format(title=self.title).encode("utf-8")) return if path == "/healthz": self._set(200, "text/plain; charset=utf-8") self.wfile.write(b"ok") return if path == "/api/commands": schema = build_command_schema(self.bot) payload = json.dumps(schema, ensure_ascii=False, separators=(",", ":")).encode("utf-8") self._set(200, "application/json; charset=utf-8") self.wfile.write(payload) return self._set(404, "text/plain; charset=utf-8") self.wfile.write(b"not found") except Exception: try: self._set(500, "text/plain; charset=utf-8") self.wfile.write(b"internal error") except Exception: pass def _start_server(bot: commands.Bot, host: str, port: int, title: str): _DocsHandler.bot = bot _DocsHandler.title = title httpd = ThreadingHTTPServer((host, port), _DocsHandler) def _run(): try: httpd.serve_forever(poll_interval=0.5) except Exception: pass t = threading.Thread(target=_run, name="DocsSite", daemon=True) t.start() print(f"[DocsSite] Listening on http://{host}:{port} (title='{title}')") # ----------------------------- # Cog setup # ----------------------------- class DocsSite(commands.Cog): """Tiny Swagger‑like docs website for bot commands.""" def __init__(self, bot: commands.Bot): self.bot = bot r = cfg(bot) self.host = r.get("docs_host", "0.0.0.0") # SHAI_DOCS_HOST self.port = r.int("docs_port", 8910) # SHAI_DOCS_PORT self.title = r.get("docs_title", "ShaiWatcher Commands") # SHAI_DOCS_TITLE # Start server immediately; safe: ThreadingHTTPServer _start_server(self.bot, self.host, self.port, self.title) # No commands; this cog only serves HTTP. async def setup(bot: commands.Bot): await bot.add_cog(DocsSite(bot))