- Bugfix for commands not fully populating docs site - Deployed version uses global commands. The docs site should now pick these up as well
1098 lines
39 KiB
Python
1098 lines
39 KiB
Python
# modules/docs_site/docs_site.py
|
||
import json
|
||
import threading
|
||
import traceback
|
||
import os
|
||
import re
|
||
import time
|
||
import importlib
|
||
from pathlib import Path
|
||
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
|
||
|
||
_START_TS = time.time()
|
||
|
||
# =============================
|
||
# Safe JSON helpers
|
||
# =============================
|
||
|
||
_PRIMITIVES = (str, int, float, bool, type(None))
|
||
|
||
def _to_primitive(obj: Any, depth: int = 0) -> Any:
|
||
if depth > 6:
|
||
return str(obj)
|
||
if isinstance(obj, _PRIMITIVES):
|
||
return obj
|
||
if isinstance(obj, (list, tuple, set)):
|
||
return [_to_primitive(x, depth + 1) for x in obj]
|
||
if isinstance(obj, dict):
|
||
out = {}
|
||
for k, v in obj.items():
|
||
try:
|
||
out[str(k)] = _to_primitive(v, depth + 1)
|
||
except Exception:
|
||
out[str(k)] = str(v)
|
||
return out
|
||
if hasattr(obj, "value"):
|
||
try:
|
||
return _to_primitive(getattr(obj, "value"), depth + 1)
|
||
except Exception:
|
||
pass
|
||
return str(obj)
|
||
|
||
def _json_dumps_safe(payload: Any) -> bytes:
|
||
return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
|
||
# =============================
|
||
# File serving helpers
|
||
# =============================
|
||
|
||
def _static_root() -> Path:
|
||
return _project_root() / "assets" / "docs"
|
||
|
||
def _guess_mime(p: Path) -> str:
|
||
ext = p.suffix.lower()
|
||
return {
|
||
".svg": "image/svg+xml; charset=utf-8",
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".webp": "image/webp",
|
||
".gif": "image/gif",
|
||
".css": "text/css; charset=utf-8",
|
||
".js": "application/javascript; charset=utf-8",
|
||
".json": "application/json; charset=utf-8",
|
||
".md": "text/markdown; charset=utf-8",
|
||
".txt": "text/plain; charset=utf-8",
|
||
}.get(ext, "application/octet-stream")
|
||
|
||
# =============================
|
||
# Version / uptime helpers
|
||
# =============================
|
||
|
||
def _project_root() -> Path:
|
||
return Path(__file__).resolve().parents[2]
|
||
|
||
def _read_version_from_file() -> Optional[str]:
|
||
"""Strictly parse VERSION from bot.py: VERSION = "x.y.z"."""
|
||
try:
|
||
bot_py = _project_root() / "bot.py"
|
||
if not bot_py.is_file():
|
||
return None
|
||
text = bot_py.read_text(encoding="utf-8", errors="ignore")
|
||
m = re.search(r'^\s*VERSION\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
||
if m:
|
||
return m.group(1)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def _get_version_from_botpy() -> Optional[str]:
|
||
# Secondary fallback: import
|
||
try:
|
||
m = importlib.import_module("bot")
|
||
v = getattr(m, "VERSION", None)
|
||
if isinstance(v, (str, int, float)):
|
||
return str(v)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _get_boot_state(bot: commands.Bot) -> Dict[str, Any]:
|
||
dm = getattr(bot, "data_manager", None)
|
||
if dm and dm.get("boot_state"):
|
||
try:
|
||
return (dm.get("boot_state") or [{}])[-1]
|
||
except Exception:
|
||
return {}
|
||
return {}
|
||
|
||
def _status_payload(bot: commands.Bot) -> Dict[str, Any]:
|
||
st = _get_boot_state(bot)
|
||
now = time.time()
|
||
boot_ts = float(st.get("last_boot_ts", 0.0)) if st else 0.0
|
||
if boot_ts > 0:
|
||
uptime = max(0, int(now - boot_ts))
|
||
else:
|
||
uptime = max(0, int(now - _START_TS))
|
||
|
||
ver = st.get("last_version") if st else None
|
||
if not ver:
|
||
ver = _read_version_from_file() or _get_version_from_botpy() or os.getenv("SHAI_VERSION", "unknown")
|
||
return {"uptime_seconds": uptime, "version": str(ver)}
|
||
|
||
|
||
# =============================
|
||
# Mod detection (heuristics)
|
||
# =============================
|
||
|
||
def _looks_like_mod_check(fn) -> bool:
|
||
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
|
||
mod = getattr(fn, "__module__", "") or ""
|
||
if "mod_perms" in mod:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _is_perm_check(fn) -> Tuple[bool, List[str]]:
|
||
perms = []
|
||
try:
|
||
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):
|
||
for k, v in val.items():
|
||
if v:
|
||
perms.append(str(k))
|
||
except Exception:
|
||
pass
|
||
return (len(perms) > 0), perms
|
||
|
||
|
||
# =============================
|
||
# Introspection
|
||
# =============================
|
||
|
||
def _bot_prefix(bot: commands.Bot) -> str:
|
||
p = getattr(bot, "command_prefix", "!")
|
||
try:
|
||
if callable(p):
|
||
val = p(bot, None)
|
||
if isinstance(val, (list, tuple)) and val:
|
||
return str(val[0])
|
||
return str(val)
|
||
return str(p)
|
||
except Exception:
|
||
return "!"
|
||
|
||
def _is_mod_command_prefix(cmd: commands.Command) -> Tuple[bool, List[str]]:
|
||
is_mod = False
|
||
perms: List[str] = []
|
||
try:
|
||
for chk in getattr(cmd, "checks", []) or []:
|
||
if _looks_like_mod_check(chk):
|
||
is_mod = True
|
||
p_flag, p_list = _is_perm_check(chk)
|
||
if p_flag:
|
||
perms.extend(p_list)
|
||
except Exception:
|
||
pass
|
||
return is_mod, sorted(set(perms))
|
||
|
||
def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
|
||
is_mod = False
|
||
try:
|
||
for chk in getattr(cmd, "checks", []) or []:
|
||
if _looks_like_mod_check(chk):
|
||
is_mod = True
|
||
for chk in getattr(cmd, "_checks", []) or []: # private best-effort
|
||
if _looks_like_mod_check(chk):
|
||
is_mod = True
|
||
except Exception:
|
||
pass
|
||
return is_mod, []
|
||
|
||
def _command_usage_prefix(cmd: commands.Command, prefix: str) -> str:
|
||
if cmd.usage:
|
||
return cmd.usage
|
||
try:
|
||
params = []
|
||
for k, p in (cmd.clean_params or {}).items():
|
||
if getattr(p, "kind", None) and str(p.kind).lower().startswith("var"):
|
||
params.append(f"[{k}...]")
|
||
elif p.default is p.empty:
|
||
params.append(f"<{k}>")
|
||
else:
|
||
params.append(f"[{k}]")
|
||
return f"{prefix}{cmd.qualified_name}" + ((" " + " ".join(params)) if params else "")
|
||
except Exception:
|
||
return f"{prefix}{cmd.name}"
|
||
|
||
def _command_usage_slash_like(cmd_name: str, options: Optional[List[Any]] = None) -> str:
|
||
try:
|
||
parts = [f"/{cmd_name}"]
|
||
opts = []
|
||
seq = options or []
|
||
for opt in seq:
|
||
n = getattr(opt, "name", None) or getattr(opt, "display_name", None) or "arg"
|
||
req = bool(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 _command_usage_slash(cmd: app_commands.Command) -> str:
|
||
try:
|
||
options = getattr(cmd, "options", None) or getattr(cmd, "parameters", None) or getattr(cmd, "_params", None)
|
||
return _command_usage_slash_like((cmd.name or "").replace("/", " "), options)
|
||
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()
|
||
prefix = _bot_prefix(bot)
|
||
|
||
for cmd in getattr(bot, "commands", []) or []:
|
||
try:
|
||
if getattr(cmd, "hidden", False):
|
||
continue
|
||
is_hybrid = isinstance(cmd, commands.HybridCommand)
|
||
ctype = "hybrid" if is_hybrid else "prefix"
|
||
|
||
is_mod, perms = _is_mod_command_prefix(cmd)
|
||
|
||
usage_slash = None
|
||
if is_hybrid:
|
||
try:
|
||
app_cmd = getattr(cmd, "app_command", None)
|
||
if isinstance(app_cmd, app_commands.Command):
|
||
usage_slash = _command_usage_slash(app_cmd)
|
||
s_mod, _ = _is_mod_command_slash(app_cmd)
|
||
if s_mod:
|
||
is_mod = True
|
||
desc = (getattr(app_cmd, "description", "") or "")
|
||
if "[mod]" in desc.lower():
|
||
is_mod = True
|
||
except Exception:
|
||
pass
|
||
|
||
usage_prefix = _command_usage_prefix(cmd, prefix)
|
||
|
||
row = {
|
||
"type": ctype,
|
||
"name": cmd.qualified_name,
|
||
"display_name": cmd.qualified_name, # bare name, no slash/prefix
|
||
"help": (cmd.help or "").strip(),
|
||
"brief": (cmd.brief or "").strip(),
|
||
"usage": usage_prefix if not is_hybrid else None,
|
||
"usage_prefix": usage_prefix,
|
||
"usage_slash": usage_slash,
|
||
"cog": getattr(cmd.cog, "qualified_name", None) if getattr(cmd, "cog", None) else None,
|
||
"module": getattr(getattr(cmd, "callback", None), "__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)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
continue
|
||
return rows
|
||
|
||
def _walk_app_tree(node: Any, prefix: str = "") -> List[Tuple[str, app_commands.Command]]:
|
||
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}"
|
||
for sub in list(getattr(node, "commands", []) or []):
|
||
out.extend(_walk_app_tree(sub, base))
|
||
return out
|
||
|
||
def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
|
||
d = getattr(obj, "extras", None)
|
||
if not d:
|
||
return None
|
||
if not isinstance(d, dict):
|
||
return {"value": _to_primitive(d)}
|
||
return _to_primitive(d)
|
||
|
||
def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||
rows: List[Dict[str, Any]] = []
|
||
|
||
# Collect paths from all scopes (global + each guild)
|
||
collected: List[Tuple[str, app_commands.Command, str]] = []
|
||
try:
|
||
for scope, top in _iter_all_app_commands(bot):
|
||
# walk each top-level into leaves
|
||
for path, leaf in _walk_app_tree(top, prefix=""):
|
||
collected.append((scope, leaf, path))
|
||
except Exception:
|
||
traceback.print_exc()
|
||
|
||
# De-dupe by canonical path (e.g. "power/restart"), regardless of scope
|
||
seen_paths = set()
|
||
for scope, leaf, path in collected:
|
||
try:
|
||
canon = path.lstrip("/") # e.g. "power/restart"
|
||
if canon in seen_paths:
|
||
continue
|
||
seen_paths.add(canon)
|
||
|
||
is_mod, perms = _is_mod_command_slash(leaf)
|
||
binding = getattr(leaf, "binding", None)
|
||
callback = getattr(leaf, "callback", None)
|
||
|
||
# UI shows "power restart" (title), but usage keeps "/power restart ..."
|
||
display = canon.replace("/", " ")
|
||
options = (
|
||
getattr(leaf, "options", None)
|
||
or getattr(leaf, "parameters", None)
|
||
or getattr(leaf, "_params", None)
|
||
)
|
||
usage_full = _command_usage_slash_like(display, options)
|
||
|
||
row = {
|
||
"type": "slash",
|
||
"name": "/" + canon, # canonical with leading slash
|
||
"display_name": "/" + display, # shown without the leading slash in UI
|
||
"help": (getattr(leaf, "description", "") or "").strip(),
|
||
"brief": "",
|
||
"usage": usage_full,
|
||
"usage_prefix": None,
|
||
"usage_slash": usage_full,
|
||
"cog": binding.__class__.__name__ if binding else None,
|
||
"module": getattr(callback, "__module__", None) if callback else None,
|
||
"moderator_only": bool(is_mod),
|
||
"required_permissions": perms,
|
||
"extras": _safe_extras(leaf),
|
||
"dm_permission": getattr(leaf, "dm_permission", None),
|
||
}
|
||
rows.append(row)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
continue
|
||
|
||
return rows
|
||
|
||
|
||
# =============================
|
||
# Details loader & master JSON
|
||
# =============================
|
||
|
||
_DETAILS_DIR = _project_root() / "assets" / "docs" / "commands"
|
||
_MASTER_PATH = _DETAILS_DIR / "__commands__.json"
|
||
|
||
_DOCS_CACHE: Dict[str, Any] = {"map": {}, "sig": ""}
|
||
|
||
def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
|
||
c = str(row.get("cog") or "").strip()
|
||
if row.get("type") == "slash":
|
||
base = str(row.get("name", "")).lstrip("/").split("/")[-1]
|
||
else:
|
||
base = str(row.get("name", "")).split(" ")[0]
|
||
keys = []
|
||
if c:
|
||
keys.append(f"{c}.{base}")
|
||
if row.get("type") == "slash":
|
||
keys.append(str(row.get("name", "")).lstrip("/")) # e.g., power/restart
|
||
keys.append(base)
|
||
return keys
|
||
|
||
def _scan_md_files() -> Dict[str, Dict[str, Any]]:
|
||
out: Dict[str, Dict[str, Any]] = {}
|
||
if not _DETAILS_DIR.is_dir():
|
||
return out
|
||
for p in _DETAILS_DIR.glob("*.md"):
|
||
if p.name.startswith("_"):
|
||
continue
|
||
try:
|
||
out[p.stem] = {"details_md": p.read_text(encoding="utf-8")}
|
||
except Exception:
|
||
traceback.print_exc()
|
||
continue
|
||
return out
|
||
|
||
def _load_master_json() -> Dict[str, Dict[str, Any]]:
|
||
if not _MASTER_PATH.is_file():
|
||
return {}
|
||
try:
|
||
raw_text = _MASTER_PATH.read_text(encoding="utf-8")
|
||
if not raw_text.strip():
|
||
return {}
|
||
raw_any = json.loads(raw_text) or {}
|
||
out: Dict[str, Dict[str, Any]] = {}
|
||
for k, v in raw_any.items():
|
||
if isinstance(v, dict):
|
||
out[str(k)] = {kk: vv for kk, vv in v.items()}
|
||
return out
|
||
except Exception:
|
||
traceback.print_exc()
|
||
return {}
|
||
|
||
def _write_master_json(mapping: Dict[str, Dict[str, Any]]) -> None:
|
||
try:
|
||
_DETAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||
with _MASTER_PATH.open("w", encoding="utf-8") as f:
|
||
json.dump(mapping, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
|
||
def _dir_signature() -> str:
|
||
parts = []
|
||
if _DETAILS_DIR.is_dir():
|
||
for p in sorted(_DETAILS_DIR.glob("*")):
|
||
try:
|
||
parts.append(f"{p.name}:{int(p.stat().st_mtime)}")
|
||
except Exception:
|
||
continue
|
||
try:
|
||
if _MASTER_PATH.exists():
|
||
parts.append(f"__master__:{int(_MASTER_PATH.stat().st_mtime)}")
|
||
except Exception:
|
||
pass
|
||
return "|".join(parts)
|
||
|
||
def _load_external_docs() -> Dict[str, Dict[str, Any]]:
|
||
sig = _dir_signature()
|
||
if _DOCS_CACHE.get("sig") == sig:
|
||
return _DOCS_CACHE["map"]
|
||
|
||
master = _load_master_json()
|
||
md_map = _scan_md_files()
|
||
|
||
merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in master.items()}
|
||
for k, v in md_map.items():
|
||
if k not in merged:
|
||
merged[k] = {}
|
||
merged[k]["details_md"] = v.get("details_md", "")
|
||
|
||
_write_master_json(merged)
|
||
_DOCS_CACHE["map"] = merged
|
||
_DOCS_CACHE["sig"] = sig
|
||
return merged
|
||
|
||
def _augment_with_external_docs(rows: List[Dict[str, Any]]) -> None:
|
||
mapping = _load_external_docs()
|
||
for r in rows:
|
||
if not r.get("details_md") and isinstance(r.get("extras"), dict):
|
||
dm = r["extras"].get("details_md")
|
||
if isinstance(dm, str) and dm.strip():
|
||
r["details_md"] = dm
|
||
if not r.get("details_md"):
|
||
for key in _row_key_candidates(r):
|
||
if key in mapping and isinstance(mapping[key], dict):
|
||
dm = mapping[key].get("details_md")
|
||
if isinstance(dm, str) and dm.strip():
|
||
r["details_md"] = dm
|
||
meta = {kk: vv for kk, vv in mapping[key].items() if kk != "details_md"}
|
||
if meta:
|
||
r["doc_meta"] = meta
|
||
break
|
||
|
||
|
||
# =============================
|
||
# Merge hybrids with their slash twins
|
||
# =============================
|
||
|
||
def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
|
||
idx_by_hybrid: Dict[Tuple[str, str], int] = {}
|
||
for i, r in enumerate(rows):
|
||
if r.get("type") == "hybrid":
|
||
cog = (r.get("cog") or "").strip()
|
||
base = str(r.get("name") or "").split(" ")[-1].lower()
|
||
idx_by_hybrid[(cog, base)] = i
|
||
|
||
to_remove: List[int] = []
|
||
for i, r in enumerate(rows):
|
||
if r.get("type") != "slash":
|
||
continue
|
||
base = str(r.get("name") or "").lstrip("/").split("/")[-1].lower()
|
||
cog = (r.get("cog") or "").strip()
|
||
key = (cog, base)
|
||
hi = idx_by_hybrid.get(key)
|
||
if hi is None:
|
||
continue
|
||
h = rows[hi]
|
||
if not h.get("usage_slash") and r.get("usage_slash"):
|
||
h["usage_slash"] = r["usage_slash"]
|
||
if not h.get("help") and r.get("help"):
|
||
h["help"] = r["help"]
|
||
if r.get("moderator_only"):
|
||
h["moderator_only"] = True
|
||
if r.get("required_permissions"):
|
||
h["required_permissions"] = sorted(set((h.get("required_permissions") or []) + r["required_permissions"]))
|
||
if not h.get("extras") and r.get("extras"):
|
||
h["extras"] = r["extras"]
|
||
if r.get("details_md") and not h.get("details_md"):
|
||
h["details_md"] = r["details_md"]
|
||
to_remove.append(i)
|
||
|
||
for i in sorted(to_remove, reverse=True):
|
||
rows.pop(i)
|
||
|
||
# =============================
|
||
# Global commands helper
|
||
# =============================
|
||
|
||
def _iter_all_app_commands(bot: commands.Bot):
|
||
"""Yield (path, app_commands.Command) for global and per-guild trees."""
|
||
out = []
|
||
# Global
|
||
try:
|
||
for cmd in bot.tree.get_commands():
|
||
out.append(("", cmd)) # empty scope tag
|
||
except Exception:
|
||
pass
|
||
|
||
# Per-guild (guild-specific commands live here)
|
||
for g in list(getattr(bot, "guilds", []) or []):
|
||
try:
|
||
cmds = bot.tree.get_commands(guild=g)
|
||
except TypeError:
|
||
# older d.py variants accept Snowflake-like instead of Guild
|
||
try:
|
||
cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
|
||
except Exception:
|
||
cmds = []
|
||
except Exception:
|
||
cmds = []
|
||
for cmd in cmds or []:
|
||
out.append((str(g.id), cmd)) # scope tag = guild id as string
|
||
return out
|
||
|
||
# =============================
|
||
# Schema builder
|
||
# =============================
|
||
|
||
def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
|
||
px = _gather_prefix_and_hybrid(bot)
|
||
sl = _gather_slash(bot)
|
||
all_rows = px + sl
|
||
|
||
# Additional moderator checks & sanitization
|
||
for row in all_rows:
|
||
try:
|
||
helptext = f"{row.get('help') or ''} {row.get('brief') or ''}"
|
||
if "[mod]" in helptext.lower():
|
||
row["moderator_only"] = True
|
||
except Exception:
|
||
pass
|
||
if row.get("required_permissions"):
|
||
row["moderator_only"] = True
|
||
try:
|
||
ex = row.get("extras") or {}
|
||
if isinstance(ex, dict) and str(ex.get("category", "")).lower() in {"mod", "moderator", "staff"}:
|
||
row["moderator_only"] = True
|
||
except Exception:
|
||
pass
|
||
|
||
_augment_with_external_docs(all_rows)
|
||
_merge_hybrid_slash(all_rows)
|
||
|
||
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 + UI
|
||
# =============================
|
||
|
||
_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; --sticky-top: 8px; --header-h: 56px; }
|
||
* { 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:3; }
|
||
header h1 { margin:0; font-size:20px; }
|
||
main { max-width:1200px; margin:20px auto; padding:0 16px 40px; }
|
||
.row { display:flex; gap:16px; flex-wrap:wrap; }
|
||
.col { flex:1 1 560px; min-width:320px; }
|
||
.panel { background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; }
|
||
.toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
|
||
.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; transition: filter .2s ease; }
|
||
.card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; }
|
||
.name { font-weight:600; display:flex; align-items:center; gap:8px; }
|
||
.meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
|
||
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; 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; }
|
||
.btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
|
||
.btn:hover { background:#0f172a; }
|
||
.detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
|
||
.flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; }
|
||
footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
|
||
footer .line { margin:4px 0; }
|
||
|
||
/* Blur veil between header and toolbar (gradually increases up to toolbar) */
|
||
.veil {
|
||
position: sticky;
|
||
top: var(--header-h);
|
||
height: var(--veil-h, 16px);
|
||
pointer-events: none;
|
||
z-index: 2; /* just under toolbar */
|
||
backdrop-filter: blur(4px);
|
||
-webkit-backdrop-filter: blur(4px);
|
||
/* Gradual mask: 0 blur visible at top, full blur at bottom near the toolbar */
|
||
mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
|
||
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
|
||
}
|
||
|
||
/* Mobile sheet behavior */
|
||
@media (max-width: 900px) {
|
||
.row { display:block; }
|
||
.detailsbox {
|
||
position: fixed;
|
||
right: 0; top: var(--sticky-top);
|
||
width: 96vw; max-width: 720px;
|
||
height: calc(100vh - var(--sticky-top) - 16px);
|
||
overflow:auto;
|
||
transform: translateX(100%);
|
||
box-shadow: -12px 0 30px rgba(0,0,0,.4);
|
||
z-index: 4;
|
||
}
|
||
.detailsbox.open { transform: translateX(0); }
|
||
#backdrop {
|
||
position: fixed; inset: var(--sticky-top) 0 0 0;
|
||
background: rgba(0,0,0,.35);
|
||
display:none; z-index:3;
|
||
}
|
||
#backdrop.open { display:block; }
|
||
#list.blur { filter: blur(2px); }
|
||
#backdrop::before {
|
||
content: "‹";
|
||
position:absolute; left: 10px; top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 48px; line-height:1; color: #e5e7eb; opacity:.8;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><h1>__TITLE__</h1></header>
|
||
<main>
|
||
<!-- Top toolbar -->
|
||
<div class="panel toolbar" id="toolbar">
|
||
<input id="q" class="search" placeholder="Search name/description…">
|
||
<div style="margin-top:8px; font-size:12px; color:var(--muted)">
|
||
Sections: <a href="#user">User</a> · <a href="#moderator">Moderator</a> · <a href="#all">All</a>
|
||
<span id="counts" style="margin-left:10px"></span>
|
||
<span id="alerts" style="margin-left:10px; color:#fbbf24"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gradual blur veil under the toolbar -->
|
||
<div class="veil" id="veil"></div>
|
||
|
||
<div class="row">
|
||
<!-- Left: command list -->
|
||
<div class="col">
|
||
<div id="list" class="list"></div>
|
||
</div>
|
||
|
||
<!-- Right: details panel (sticky / mobile sheet) -->
|
||
<div class="col">
|
||
<div class="panel detailsbox" id="details"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer id="footer">
|
||
<div class="line" id="copyright"></div>
|
||
<div class="line" id="statusline">Uptime: — · Version: v—</div>
|
||
<div class="line" id="coffee"><a href="https://throne.com/ookamikuntv/item/39590391-c582-4c5d-8795-fe6f1925eaae">Buy me a ☕</a></div>
|
||
</footer>
|
||
|
||
<div id="backdrop"></div>
|
||
</main>
|
||
<script>
|
||
function computeStickyTop() {
|
||
const header = document.querySelector('header');
|
||
const toolbar = document.getElementById('toolbar');
|
||
const headerH = header ? header.offsetHeight : 0;
|
||
// Set how much veil space we want between header bottom and toolbar top.
|
||
const veilH = 16; // px
|
||
// Toolbar sticks immediately under header
|
||
document.documentElement.style.setProperty('--header-h', headerH + 'px');
|
||
// Details panel sticks under header + toolbar + small rhythm spacing
|
||
const toolbarH = toolbar ? toolbar.offsetHeight : 0;
|
||
const stickyTop = headerH + toolbarH + 8; // rhythm
|
||
document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px');
|
||
const veil = document.getElementById('veil');
|
||
if (veil) {
|
||
veil.style.top = headerH + 'px';
|
||
veil.style.height = veilH + 'px';
|
||
}
|
||
}
|
||
|
||
window.addEventListener('resize', computeStickyTop);
|
||
window.addEventListener('load', computeStickyTop);
|
||
|
||
window.addEventListener('error', function(e){
|
||
var list = document.getElementById('alerts');
|
||
if (list) list.textContent = 'JS error: ' + (e && e.message);
|
||
});
|
||
window.addEventListener('unhandledrejection', function(e){
|
||
var list = document.getElementById('alerts');
|
||
var msg = e && (e.reason && (e.reason.message || e.reason) || e);
|
||
if (list) list.textContent = 'Promise error: ' + msg;
|
||
});
|
||
|
||
// Minimal markdown renderer
|
||
function renderMD(src) {
|
||
if (!src) return "";
|
||
let s = String(src).replace(/\\r\\n/g, "\\n");
|
||
s = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
s = s.replace(/```([\\s\\S]*?)```/g, (m, p1) => "<pre><code>" + p1 + "</code></pre>");
|
||
s = s.replace(/^###\\s+(.*)$/gm, "<h3>$1</h3>");
|
||
s = s.replace(/^##\\s+(.*)$/gm, "<h2>$1</h2>");
|
||
s = s.replace(/^#\\s+(.*)$/gm, "<h1>$1</h1>");
|
||
s = s.replace(/\\*\\*(.+?)\\*\\*/g, "<strong>$1</strong>");
|
||
s = s.replace(/\\*(.+?)\\*/g, "<em>$1</em>");
|
||
s = s.replace(/`([^`]+?)`/g, "<code>$1</code>");
|
||
s = s.replace(/^(\\s*)-\\s+(.+)$/gm, "$1<li>$2</li>");
|
||
s = s.replace(/(<li>.*<\\/li>\\n?)+/g, (m) => "<ul>" + m + "</ul>");
|
||
s = s.replace(/^(?!<h\\d|<ul>|<pre>|<li>)([^\\n][^\\n]*)$/gm, "<p>$1</p>");
|
||
return s;
|
||
}
|
||
|
||
(function() {
|
||
if (!location.hash) location.hash = '#user';
|
||
let data = (window.__DATA__ || null);
|
||
|
||
const listEl = document.getElementById('list');
|
||
const qEl = document.getElementById('q');
|
||
const countsEl = document.getElementById('counts');
|
||
const detailsEl = document.getElementById('details');
|
||
const backdrop = document.getElementById('backdrop');
|
||
|
||
function shownName(r) {
|
||
const n = (r.display_name || r.name || '');
|
||
return n.replace(/^\\//, ''); // strip leading '/'
|
||
}
|
||
function helpSansMod(r) {
|
||
return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, '');
|
||
}
|
||
function moduleSansPrefix(r) {
|
||
const m = r.module || '';
|
||
return m.replace(/^modules?\\./, ''); // 'modules.' or 'module.'
|
||
}
|
||
|
||
function openDetails(r) {
|
||
let html = `
|
||
<div class="name" style="margin-bottom:6px">
|
||
<span class="pill ${r.type}">${r.type}</span>
|
||
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
|
||
<span>${shownName(r)}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn" id="closeDetails" style="display:none">Close</button>
|
||
</div>
|
||
<div class="meta">
|
||
${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
|
||
${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
|
||
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
|
||
</div>
|
||
${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
|
||
${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
|
||
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
|
||
`;
|
||
if (r.details_md) {
|
||
html += `<div class="md" style="margin-top:10px">${renderMD(r.details_md)}</div>`;
|
||
} else {
|
||
html += `
|
||
<div class="md" style="margin-top:10px">
|
||
<p style="color:#9ca3af;font-size:14px;margin:0 0 6px 0">No custom documentation yet.</p>
|
||
<details><summary>Extras</summary><pre style="white-space:pre-wrap">${r.extras ? JSON.stringify(r.extras, null, 2) : '—'}</pre></details>
|
||
</div>
|
||
`;
|
||
}
|
||
detailsEl.innerHTML = html;
|
||
|
||
// Mobile sheet open
|
||
if (window.matchMedia('(max-width: 900px)').matches) {
|
||
detailsEl.classList.add('open');
|
||
listEl.classList.add('blur');
|
||
backdrop.classList.add('open');
|
||
const closeBtn = document.getElementById('closeDetails');
|
||
if (closeBtn) closeBtn.style.display = 'inline-block';
|
||
backdrop.onclick = closeDetails;
|
||
closeBtn && (closeBtn.onclick = closeDetails);
|
||
}
|
||
}
|
||
|
||
function closeDetails() {
|
||
detailsEl.classList.remove('open');
|
||
listEl.classList.remove('blur');
|
||
backdrop.classList.remove('open');
|
||
}
|
||
|
||
function card(r) {
|
||
const c = document.createElement('div');
|
||
c.className = 'card';
|
||
c.dataset.search = [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""].join(' ').toLowerCase();
|
||
|
||
const usageBlock = (() => {
|
||
if (r.type === 'hybrid') {
|
||
return `
|
||
${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
|
||
${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
|
||
`;
|
||
}
|
||
return r.usage ? `<div class="usage">${r.usage}</div>` : '';
|
||
})();
|
||
|
||
c.innerHTML = `
|
||
<div class="name">
|
||
<span class="pill ${r.type}">${r.type}</span>
|
||
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
|
||
<span>${shownName(r)}</span>
|
||
<button class="btn" data-details="1">Details</button>
|
||
</div>
|
||
<div class="meta">
|
||
${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
|
||
${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
|
||
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
|
||
</div>
|
||
${usageBlock}
|
||
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
|
||
`;
|
||
|
||
c.querySelector('[data-details]').addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
openDetails(r);
|
||
});
|
||
c.addEventListener('click', () => openDetails(r));
|
||
return c;
|
||
}
|
||
|
||
function render(target, rows) {
|
||
target.innerHTML = '';
|
||
rows.forEach(r => target.appendChild(card(r)));
|
||
}
|
||
|
||
function applyFilter() {
|
||
if (!data) return;
|
||
const all = data.all || [];
|
||
const mods = (data.sections && data.sections.moderator) || [];
|
||
const users = (data.sections && data.sections.user) || [];
|
||
|
||
// Counts order: User · Moderator · Total
|
||
const ct = `User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`;
|
||
countsEl.textContent = ct;
|
||
|
||
const q = (qEl.value || '').toLowerCase();
|
||
const hash = (location.hash || '#user').slice(1);
|
||
const src = hash === 'moderator' ? mods : (hash === '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 || ""]
|
||
.join(' ').toLowerCase().includes(q)
|
||
));
|
||
render(listEl, rows);
|
||
if (rows.length) openDetails(rows[0]); // auto-select first
|
||
}
|
||
|
||
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();
|
||
}
|
||
window.addEventListener('hashchange', applyFilter);
|
||
qEl.addEventListener('input', applyFilter);
|
||
applyFilter();
|
||
} catch (e) {
|
||
document.getElementById('alerts').textContent = 'Failed to load.';
|
||
}
|
||
|
||
// Footer
|
||
const fromYear = 2025;
|
||
const now = new Date();
|
||
const flagSvg = '<img class="flag-emoji" alt="NO" src="/assets/docs/no.svg">';
|
||
document.getElementById('copyright').innerHTML =
|
||
`© OokamiKunTV ${fromYear}–${now.getFullYear()} — Made in ${flagSvg} with ❤️`;
|
||
|
||
try {
|
||
const s = await fetch('/api/status', {cache:'no-store'});
|
||
if (s.ok) {
|
||
const js = await s.json();
|
||
document.getElementById('statusline').textContent =
|
||
`Uptime: ${fmtDuration(js.uptime_seconds||0)} · Version: v${js.version||'unknown'}`;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function fmtDuration(s) {
|
||
s = Math.max(0, Math.floor(+s||0));
|
||
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(' ');
|
||
}
|
||
|
||
boot();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
class _DocsHandler(BaseHTTPRequestHandler):
|
||
bot: commands.Bot = None
|
||
title: str = "ShaiWatcher Commands"
|
||
force_ready: bool = False
|
||
|
||
def _set(self, code=200, content_type="text/html; charset=utf-8"):
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
|
||
def log_message(self, fmt, *args):
|
||
return
|
||
|
||
def do_GET(self):
|
||
try:
|
||
ready = bool(self.force_ready) or (getattr(self, "bot", None) and self.bot.is_ready())
|
||
if not ready:
|
||
self._set(503, "text/plain; charset=utf-8")
|
||
self.wfile.write(b"warming up")
|
||
return
|
||
|
||
path = urlparse(self.path).path
|
||
if path == "/":
|
||
self._set()
|
||
html = _HTML.replace("__TITLE__", self.title)
|
||
try:
|
||
schema = build_command_schema(self.bot)
|
||
inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
|
||
html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
|
||
except Exception:
|
||
traceback.print_exc()
|
||
self.wfile.write(html.encode("utf-8"))
|
||
return
|
||
|
||
path = urlparse(self.path).path
|
||
|
||
# Static assets: /assets/docs/...
|
||
if path.startswith("/assets/docs/"):
|
||
try:
|
||
root = _static_root().resolve()
|
||
rel = path[len("/assets/docs/"):]
|
||
fs_path = (root / rel).resolve()
|
||
# Prevent path traversal
|
||
try:
|
||
# Python 3.10+: Path.is_relative_to
|
||
if not fs_path.is_relative_to(root):
|
||
raise ValueError("outside root")
|
||
except AttributeError:
|
||
# Fallback for very old Python (not needed on 3.10+)
|
||
if str(root) not in str(fs_path):
|
||
raise ValueError("outside root")
|
||
if fs_path.is_file():
|
||
mime = _guess_mime(fs_path)
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", mime)
|
||
self.send_header("Cache-Control", "public, max-age=86400, immutable")
|
||
self.end_headers()
|
||
with fs_path.open("rb") as f:
|
||
self.wfile.write(f.read())
|
||
return
|
||
self._set(404, "text/plain; charset=utf-8")
|
||
self.wfile.write(b"not found")
|
||
return
|
||
except Exception:
|
||
traceback.print_exc()
|
||
self._set(500, "text/plain; charset=utf-8")
|
||
self.wfile.write(b"internal error")
|
||
return
|
||
|
||
if path == "/api/status":
|
||
payload = _status_payload(self.bot)
|
||
self._set(200, "application/json; charset=utf-8")
|
||
self.wfile.write(_json_dumps_safe(payload))
|
||
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_safe(schema)
|
||
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:
|
||
traceback.print_exc()
|
||
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
|
||
_DocsHandler.force_ready = os.getenv("SHAI_OFFLINE", "").lower() in {"1", "true", "yes"}
|
||
httpd = ThreadingHTTPServer((host, port), _DocsHandler)
|
||
|
||
def _run():
|
||
try:
|
||
httpd.serve_forever(poll_interval=0.5)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
|
||
t = threading.Thread(target=_run, name="DocsSite", daemon=True)
|
||
t.start()
|
||
print(f"[DocsSite] Listening on http://{host}:{port} (title='{title}', offline={_DocsHandler.force_ready})")
|
||
|
||
class DocsSite(commands.Cog):
|
||
"""Tiny Swagger-like docs site 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(self.bot, self.host, self.port, self.title)
|
||
|
||
def force_ready(self, value: bool = True):
|
||
_DocsHandler.force_ready = bool(value)
|
||
|
||
async def setup(bot: commands.Bot):
|
||
await bot.add_cog(DocsSite(bot))
|