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:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# 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 ----------
 | 
					# ---------- 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))
 | 
					        await interaction.response.send_modal(EncounterModal(self))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # ---- Migration: convert encounter identifiers to accounts (mod-only) ----
 | 
					    # ---- 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):
 | 
					    async def encounters_migrate_ids(self, ctx: commands.Context):
 | 
				
			||||||
        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
					        if not await require_mod_ctx(ctx, "This command is restricted to moderators."):
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
				
			|||||||
@ -19,7 +19,6 @@ EMOTES = [
 | 
				
			|||||||
    "🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
 | 
					    "🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Short Dune phrases / lore snippets (kept short for presence)
 | 
					 | 
				
			||||||
DUNE_PHRASES = [
 | 
					DUNE_PHRASES = [
 | 
				
			||||||
    "Arrakis. Dune. Desert Planet.",
 | 
					    "Arrakis. Dune. Desert Planet.",
 | 
				
			||||||
    "Shai-Hulud stirs beneath the sands.",
 | 
					    "Shai-Hulud stirs beneath the sands.",
 | 
				
			||||||
@ -41,10 +40,15 @@ DUNE_PHRASES = [
 | 
				
			|||||||
    "Sardaukar march.",
 | 
					    "Sardaukar march.",
 | 
				
			||||||
    "Prescience veils the future.",
 | 
					    "Prescience veils the future.",
 | 
				
			||||||
    "Fedāykin watchful in the dunes.",
 | 
					    "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 = [
 | 
					FUN_FACTS = [
 | 
				
			||||||
    "Frank Herbert conceived Dune after reporting on sand dune stabilization in the Oregon coast.",
 | 
					    "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.",
 | 
					    "‘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.",
 | 
					    "Caladan is the Atreides ocean world before their move to Arrakis.",
 | 
				
			||||||
    "The Harkonnen homeworld is Giedi Prime, an industrialized, harsh planet.",
 | 
					    "The Harkonnen homeworld is Giedi Prime, an industrialized, harsh planet.",
 | 
				
			||||||
    "‘He who controls the spice controls the universe.’",
 | 
					    "‘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 ==============
 | 
					# ============== Cog implementation ==============
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StatusRotatorCog(commands.Cog):
 | 
					class StatusRotatorCog(commands.Cog):
 | 
				
			||||||
@ -242,7 +253,7 @@ class StatusRotatorCog(commands.Cog):
 | 
				
			|||||||
        if not role:
 | 
					        if not role:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        n = sum(1 for m in role.members if not m.bot)
 | 
					        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:
 | 
					    async def _gen_random_phrase(self, guild: discord.Guild) -> str:
 | 
				
			||||||
        return random.choice(DUNE_PHRASES)
 | 
					        return random.choice(DUNE_PHRASES)
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user