- Added an experimental small doc-site - Automatically fetches and displays command syntax and other details - Lightweight and secure with no edit functionality - Minor description changes for clarity - Added a few more status texts
469 lines
17 KiB
Python
469 lines
17 KiB
Python
# 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 = """<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width">
|
||
<title>{title}</title>
|
||
<style>
|
||
:root {{ --bg:#0b0f14; --panel:#121922; --muted:#6b7280; --fg:#e5e7eb; --accent:#60a5fa; --good:#34d399; --warn:#f59e0b; }}
|
||
* {{ box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, 'Inter', Arial; }}
|
||
body {{ margin:0; background:var(--bg); color:var(--fg); }}
|
||
header {{ padding:16px 20px; background:#0f172a; border-bottom:1px solid #223; position:sticky; top:0; z-index:2; }}
|
||
header h1 {{ margin:0; font-size:20px; }}
|
||
main {{ max-width:1100px; margin:20px auto; padding:0 16px 40px; }}
|
||
.row {{ display:flex; gap:16px; flex-wrap:wrap; }}
|
||
.col {{ flex:1 1 520px; min-width:320px; }}
|
||
.panel {{ background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; }}
|
||
.search {{ width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }}
|
||
.list {{ margin-top:12px; display:flex; flex-direction:column; gap:10px; }}
|
||
.card {{ border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; }}
|
||
.name {{ font-weight:600; }}
|
||
.meta {{ font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; }}
|
||
.pill {{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; margin-right:8px; border:1px solid #2b4; }}
|
||
.pill.mod {{ border-color:#ef4444; color:#fecaca; }}
|
||
.pill.slash {{ border-color:#60a5fa; }}
|
||
.pill.prefix {{ border-color:#f59e0b; }}
|
||
.pill.hybrid {{ border-color:#34d399; }}
|
||
.usage {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; }}
|
||
.help {{ margin-top:6px; color:#cbd5e1; }}
|
||
details {{ margin-top:6px; }}
|
||
summary {{ cursor:pointer; color:#93c5fd; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><h1>{title}</h1></header>
|
||
<main>
|
||
<div class="row">
|
||
<div class="col">
|
||
<div class="panel">
|
||
<input id="q" class="search" placeholder="Search name/description…">
|
||
<div style="margin-top:8px; font-size:12px; color:var(--muted)">Sections: <a href="#all">All</a> · <a href="#moderator">Moderator</a> · <a href="#user">User</a></div>
|
||
</div>
|
||
<div id="counts" style="margin:12px 0; color:var(--muted); font-size:13px;"></div>
|
||
<div id="list" class="list"></div>
|
||
</div>
|
||
<div class="col">
|
||
<div class="panel">
|
||
<h3 style="margin-top:0">Notes</h3>
|
||
<ul>
|
||
<li>No authentication; info is read-only and non-sensitive.</li>
|
||
<li>Moderator-only is inferred from checks (best-effort).</li>
|
||
<li>Slash commands show full path if grouped, e.g. <code>/group/sub</code>.</li>
|
||
</ul>
|
||
<p style="font-size:12px;color:var(--muted)">JSON: <code>/api/commands</code> · Health: <code>/healthz</code></p>
|
||
</div>
|
||
<div class="panel">
|
||
<h3 style="margin-top:0">Moderator commands</h3>
|
||
<div id="mods" class="list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<script>
|
||
(async function() {{
|
||
const res = await fetch('/api/commands', {{cache: 'no-store'}});
|
||
const data = await res.json();
|
||
const listEl = document.getElementById('list');
|
||
const modsEl = document.getElementById('mods');
|
||
const qEl = document.getElementById('q');
|
||
const countsEl = document.getElementById('counts');
|
||
|
||
const all = data.all || [];
|
||
const mods = data.sections?.moderator || [];
|
||
const users = data.sections?.user || [];
|
||
|
||
countsEl.textContent = `Total: ${all.length} · User: ${users.length} · Moderator: ${mods.length}`;
|
||
|
||
function card(r) {{
|
||
const c = document.createElement('div');
|
||
c.className = 'card';
|
||
c.dataset.search = [r.name, r.help, r.brief, r.usage, r.cog, r.module].join(' ').toLowerCase();
|
||
c.innerHTML = `
|
||
<div class="name">
|
||
<span class="pill ${r.type}">${r.type}</span>
|
||
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
|
||
<span>${r.name}</span>
|
||
</div>
|
||
<div class="meta">
|
||
${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
|
||
${r.module ? `<span>module: ${r.module}</span>` : '' }
|
||
${r.required_permissions?.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
|
||
</div>
|
||
${r.usage ? `<div class="usage">${r.usage}</div>` : ''}
|
||
${r.help ? `<div class="help">${r.help}</div>` : ''}
|
||
${r.extras ? `<details><summary>extras</summary><pre style="white-space:pre-wrap">${JSON.stringify(r.extras, null, 2)}</pre></details>` : '' }
|
||
`;
|
||
return c;
|
||
}}
|
||
|
||
function render(target, rows) {{
|
||
target.innerHTML = '';
|
||
rows.forEach(r => target.appendChild(card(r)));
|
||
}}
|
||
|
||
function applyFilter() {{
|
||
const q = (qEl.value || '').toLowerCase();
|
||
const hash = (location.hash || '#all').slice(1);
|
||
const src = hash === 'moderator' ? mods : (hash === 'user' ? users : all);
|
||
const rows = !q ? src : src.filter(r => (
|
||
[r.name, r.help, r.brief, r.usage, r.cog, r.module].join(' ').toLowerCase().includes(q)
|
||
));
|
||
render(listEl, rows);
|
||
}}
|
||
|
||
window.addEventListener('hashchange', applyFilter);
|
||
qEl.addEventListener('input', applyFilter);
|
||
|
||
applyFilter();
|
||
render(modsEl, mods);
|
||
}})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
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))
|