shaiwatcher/modules/docs_site/docs_site.py
Franz Rolfsvaag 21a79194dd 0.3.9.8.a1
- 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
2025-08-13 08:58:56 +02:00

469 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
# Besteffort: 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 arent trivial to enumerate without ._params.
# Do a safe besteffort:
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 Swaggerlike 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))