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
This commit is contained in:
		
							parent
							
								
									aab931b543
								
							
						
					
					
						commit
						21a79194dd
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								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 ----------
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								modules/docs_site/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								modules/docs_site/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										468
									
								
								modules/docs_site/docs_site.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										468
									
								
								modules/docs_site/docs_site.py
									
									
									
									
									
										Normal file
									
								
							@ -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 = """<!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))
 | 
			
		||||
@ -551,7 +551,7 @@ class PirateReportCog(commands.Cog):
 | 
			
		||||
        await interaction.response.send_modal(EncounterModal(self))
 | 
			
		||||
 | 
			
		||||
    # ---- Migration: convert encounter identifiers to accounts (mod-only) ----
 | 
			
		||||
    @commands.hybrid_command(name='encounters_migrate_ids', description='Migrate encounter identifiers to account names')
 | 
			
		||||
    @commands.hybrid_command(name='encounters_migrate_ids', description='[MOD] Migrate encounter identifiers to account names')
 | 
			
		||||
    async def encounters_migrate_ids(self, ctx: commands.Context):
 | 
			
		||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ EMOTES = [
 | 
			
		||||
    "🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
# Short Dune phrases / lore snippets (kept short for presence)
 | 
			
		||||
DUNE_PHRASES = [
 | 
			
		||||
    "Arrakis. Dune. Desert Planet.",
 | 
			
		||||
    "Shai-Hulud stirs beneath the sands.",
 | 
			
		||||
@ -41,10 +40,15 @@ DUNE_PHRASES = [
 | 
			
		||||
    "Sardaukar march.",
 | 
			
		||||
    "Prescience veils the future.",
 | 
			
		||||
    "Fedāykin watchful in the dunes.",
 | 
			
		||||
    "Made with ❤️ in 🇳🇴"
 | 
			
		||||
    "Shields hum under the sun.",
 | 
			
		||||
    "Kanly declared.",
 | 
			
		||||
    "Desert winds whisper secrets.",
 | 
			
		||||
    "Muad’Dib walks the golden path.",
 | 
			
		||||
    "Sandtrout seal the desert’s fate.",
 | 
			
		||||
    "Made with ❤️ in 🇳🇴",
 | 
			
		||||
    "DD Reset: Tuesday 03:00 UTC"
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
# Concise fun facts (will be numbered). Keep them reasonably short.
 | 
			
		||||
FUN_FACTS = [
 | 
			
		||||
    "Frank Herbert conceived Dune after reporting on sand dune stabilization in the Oregon coast.",
 | 
			
		||||
    "‘Muad’Dib’ is a small desert mouse whose footprints taught Paul the sandwalk.",
 | 
			
		||||
@ -70,9 +74,16 @@ FUN_FACTS = [
 | 
			
		||||
    "Caladan is the Atreides ocean world before their move to Arrakis.",
 | 
			
		||||
    "The Harkonnen homeworld is Giedi Prime, an industrialized, harsh planet.",
 | 
			
		||||
    "‘He who controls the spice controls the universe.’",
 | 
			
		||||
    "The Weirding Way is a Bene Gesserit martial art emphasizing speed and economy."
 | 
			
		||||
    "The Weirding Way is a Bene Gesserit martial art emphasizing speed and economy.",
 | 
			
		||||
    "Sardaukar troops are trained from birth on the prison planet Salusa Secundus.",
 | 
			
		||||
    "Ornithopters mimic bird flight to navigate harsh desert storms.",
 | 
			
		||||
    "The Fremen call offworlders ‘water-fat’ as an insult.",
 | 
			
		||||
    "Spice blows are natural melange eruptions from the deep desert.",
 | 
			
		||||
    "A ~30 year old male weighing ~75kg will consist of around 45L of water.",
 | 
			
		||||
    "A simple interactive DD map can be found at https://dune.gaming.tools/deep-desert."
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ============== Cog implementation ==============
 | 
			
		||||
 | 
			
		||||
class StatusRotatorCog(commands.Cog):
 | 
			
		||||
@ -242,7 +253,7 @@ class StatusRotatorCog(commands.Cog):
 | 
			
		||||
        if not role:
 | 
			
		||||
            return None
 | 
			
		||||
        n = sum(1 for m in role.members if not m.bot)
 | 
			
		||||
        return f"{n} initiated members"
 | 
			
		||||
        return f"{n} fully initiated members"
 | 
			
		||||
 | 
			
		||||
    async def _gen_random_phrase(self, guild: discord.Guild) -> str:
 | 
			
		||||
        return random.choice(DUNE_PHRASES)
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user