0.4.0.0.a3
- Added linkable and sharable commands - Added copy-buttons to command fields
This commit is contained in:
		
							parent
							
								
									66447865f5
								
							
						
					
					
						commit
						87bcc61a1a
					
				
							
								
								
									
										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.4.0.0.a2"
 | 
			
		||||
VERSION = "0.4.0.0.a3"
 | 
			
		||||
 | 
			
		||||
# ---------- Env loading ----------
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -50,28 +50,6 @@ def _to_primitive(obj: Any, depth: int = 0) -> Any:
 | 
			
		||||
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
 | 
			
		||||
@ -95,7 +73,6 @@ def _read_version_from_file() -> Optional[str]:
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def _get_version_from_botpy() -> Optional[str]:
 | 
			
		||||
    # Secondary fallback: import
 | 
			
		||||
    try:
 | 
			
		||||
        m = importlib.import_module("bot")
 | 
			
		||||
        v = getattr(m, "VERSION", None)
 | 
			
		||||
@ -203,7 +180,7 @@ def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
 | 
			
		||||
        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
 | 
			
		||||
        for chk in getattr(cmd, "_checks", []) or []:
 | 
			
		||||
            if _looks_like_mod_check(chk):
 | 
			
		||||
                is_mod = True
 | 
			
		||||
    except Exception:
 | 
			
		||||
@ -248,6 +225,30 @@ def _command_usage_slash(cmd: app_commands.Command) -> str:
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return f"/{cmd.name}"
 | 
			
		||||
 | 
			
		||||
def _iter_all_app_commands(bot: commands.Bot):
 | 
			
		||||
    """Yield (scope_tag, top_level_command) including global and per-guild trees."""
 | 
			
		||||
    out = []
 | 
			
		||||
    # Global
 | 
			
		||||
    try:
 | 
			
		||||
        for cmd in bot.tree.get_commands():
 | 
			
		||||
            out.append(("", cmd))
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
    # Per-guild
 | 
			
		||||
    for g in list(getattr(bot, "guilds", []) or []):
 | 
			
		||||
        try:
 | 
			
		||||
            cmds = bot.tree.get_commands(guild=g)
 | 
			
		||||
        except TypeError:
 | 
			
		||||
            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))
 | 
			
		||||
    return out
 | 
			
		||||
 | 
			
		||||
def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
    rows: List[Dict[str, Any]] = []
 | 
			
		||||
    seen = set()
 | 
			
		||||
@ -282,7 +283,7 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
            row = {
 | 
			
		||||
                "type": ctype,
 | 
			
		||||
                "name": cmd.qualified_name,
 | 
			
		||||
                "display_name": cmd.qualified_name,  # bare name, no slash/prefix
 | 
			
		||||
                "display_name": cmd.qualified_name,  # bare name
 | 
			
		||||
                "help": (cmd.help or "").strip(),
 | 
			
		||||
                "brief": (cmd.brief or "").strip(),
 | 
			
		||||
                "usage": usage_prefix if not is_hybrid else None,
 | 
			
		||||
@ -323,22 +324,18 @@ def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
            canon = path.lstrip("/")  # power/restart
 | 
			
		||||
            if canon in seen_paths:
 | 
			
		||||
                continue
 | 
			
		||||
            seen_paths.add(canon)
 | 
			
		||||
@ -347,19 +344,14 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
            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)
 | 
			
		||||
            )
 | 
			
		||||
            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
 | 
			
		||||
                "name": "/" + canon,
 | 
			
		||||
                "display_name": "/" + display,
 | 
			
		||||
                "help": (getattr(leaf, "description", "") or "").strip(),
 | 
			
		||||
                "brief": "",
 | 
			
		||||
                "usage": usage_full,
 | 
			
		||||
@ -376,7 +368,6 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
			
		||||
        except Exception:
 | 
			
		||||
            traceback.print_exc()
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -399,7 +390,7 @@ def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
 | 
			
		||||
    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(str(row.get("name", "")).lstrip("/"))
 | 
			
		||||
    keys.append(base)
 | 
			
		||||
    return keys
 | 
			
		||||
 | 
			
		||||
@ -535,35 +526,6 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
 | 
			
		||||
    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
 | 
			
		||||
@ -608,6 +570,30 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# Static asset serving
 | 
			
		||||
# =============================
 | 
			
		||||
 | 
			
		||||
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")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# =============================
 | 
			
		||||
# HTTP + UI
 | 
			
		||||
# =============================
 | 
			
		||||
@ -630,7 +616,7 @@ _HTML = """<!doctype html>
 | 
			
		||||
  .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; }
 | 
			
		||||
  .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
 | 
			
		||||
  .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; }
 | 
			
		||||
@ -638,25 +624,40 @@ _HTML = """<!doctype html>
 | 
			
		||||
  .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; }
 | 
			
		||||
  .usage { position:relative; 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; }
 | 
			
		||||
  .btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
 | 
			
		||||
  .btn-row { display:flex; gap:8px; align-items:center; }
 | 
			
		||||
  .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) */
 | 
			
		||||
  /* Copy modal */
 | 
			
		||||
  #copyModal { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; z-index: 10; }
 | 
			
		||||
  #copyModal.open { display:flex; }
 | 
			
		||||
  #copyModal .overlay { position:absolute; inset:0; background:rgba(0,0,0,.4); }
 | 
			
		||||
  #copyModal .sheet { position:relative; z-index:1; min-width: min(520px, 92vw); background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; box-shadow: 0 20px 60px rgba(0,0,0,.45); }
 | 
			
		||||
  #copyModal .close { position:absolute; right:10px; top:10px; }
 | 
			
		||||
 | 
			
		||||
  /* Copy button inside usage */
 | 
			
		||||
  .usage .copybtn { position:absolute; right:6px; top:6px; }
 | 
			
		||||
  @media (max-width: 900px) {
 | 
			
		||||
    .usage .copybtn { right:6px; top:6px; }
 | 
			
		||||
    .btn-icon { width:auto; padding:4px 10px; } /* larger tap target on mobile */
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /* Blur veil under the toolbar */
 | 
			
		||||
  .veil {
 | 
			
		||||
    position: sticky;
 | 
			
		||||
    top: var(--header-h);
 | 
			
		||||
    height: var(--veil-h, 16px);
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    z-index: 2; /* just under toolbar */
 | 
			
		||||
    z-index: 2;
 | 
			
		||||
    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));
 | 
			
		||||
  }
 | 
			
		||||
@ -722,9 +723,21 @@ _HTML = """<!doctype html>
 | 
			
		||||
  <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>
 | 
			
		||||
 | 
			
		||||
  <!-- Copy/share modal -->
 | 
			
		||||
  <div id="copyModal" aria-hidden="true">
 | 
			
		||||
    <div class="overlay" data-close="1"></div>
 | 
			
		||||
    <div class="sheet">
 | 
			
		||||
      <button class="btn btn-icon close" title="Close" data-close="1">✕</button>
 | 
			
		||||
      <div id="copyText" style="word-break:break-all; font-family: ui-monospace, monospace;"></div>
 | 
			
		||||
      <div style="margin-top:10px" class="btn-row">
 | 
			
		||||
        <button id="copyAction" class="btn">Copy</button>
 | 
			
		||||
        <span id="copyHint" style="font-size:12px;color:#9ca3af">Tap outside to dismiss.</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div id="backdrop"></div>
 | 
			
		||||
</main>
 | 
			
		||||
<script>
 | 
			
		||||
@ -732,13 +745,10 @@ 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
 | 
			
		||||
  const veilH = 16;
 | 
			
		||||
  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
 | 
			
		||||
  const stickyTop = headerH + toolbarH + 8;
 | 
			
		||||
  document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px');
 | 
			
		||||
  const veil = document.getElementById('veil');
 | 
			
		||||
  if (veil) {
 | 
			
		||||
@ -778,6 +788,64 @@ function renderMD(src) {
 | 
			
		||||
  return s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --------- Linking helpers ---------
 | 
			
		||||
function rowAnchor(r) {
 | 
			
		||||
  // Stable anchor: "<cog-or-nocog>-<path with slashes -> dashes>"
 | 
			
		||||
  const cog = (r.cog || 'nocog').toLowerCase();
 | 
			
		||||
  let base = (r.name || '').toLowerCase();
 | 
			
		||||
  base = base.replace(/^\\//, '');      // remove leading "/"
 | 
			
		||||
  base = base.replace(/\\s+/g, '-');    // spaces to dash
 | 
			
		||||
  base = base.replace(/\\//g, '-');     // slashes to dash
 | 
			
		||||
  return `${cog}-${base}`;
 | 
			
		||||
}
 | 
			
		||||
function getFilterFromHash() {
 | 
			
		||||
  const h = (location.hash || '#user').slice(1);
 | 
			
		||||
  // support legacy "cmd=" in hash too
 | 
			
		||||
  const parts = h.split('&').map(s => s.trim());
 | 
			
		||||
  const filt = parts.find(p => p === 'user' || p === 'moderator' || p === 'all') || 'user';
 | 
			
		||||
  let cmd = null;
 | 
			
		||||
  const cmdPart = parts.find(p => p.startsWith('cmd='));
 | 
			
		||||
  if (cmdPart) cmd = cmdPart.slice(4);
 | 
			
		||||
  // also check query
 | 
			
		||||
  const sp = new URLSearchParams(location.search);
 | 
			
		||||
  const qcmd = sp.get('cmd');
 | 
			
		||||
  if (!cmd && qcmd) cmd = qcmd;
 | 
			
		||||
  return { filter: filt, cmd };
 | 
			
		||||
}
 | 
			
		||||
function buildLink(anchor) {
 | 
			
		||||
  const url = new URL(window.location.href);
 | 
			
		||||
  url.searchParams.set('cmd', anchor);
 | 
			
		||||
  url.hash = location.hash || '#user';
 | 
			
		||||
  return url.toString();
 | 
			
		||||
}
 | 
			
		||||
function replaceURLFor(anchor) {
 | 
			
		||||
  try {
 | 
			
		||||
    const url = new URL(window.location.href);
 | 
			
		||||
    url.searchParams.set('cmd', anchor);
 | 
			
		||||
    history.replaceState(null, '', url.toString());
 | 
			
		||||
  } catch {}
 | 
			
		||||
}
 | 
			
		||||
async function copyText(s) {
 | 
			
		||||
  try {
 | 
			
		||||
    await navigator.clipboard.writeText(s);
 | 
			
		||||
    return true;
 | 
			
		||||
  } catch {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
function openCopyModal(text) {
 | 
			
		||||
  const modal = document.getElementById('copyModal');
 | 
			
		||||
  const sheet = modal.querySelector('.sheet');
 | 
			
		||||
  document.getElementById('copyText').textContent = text;
 | 
			
		||||
  const action = document.getElementById('copyAction');
 | 
			
		||||
  const closeables = modal.querySelectorAll('[data-close]');
 | 
			
		||||
  const close = () => { modal.classList.remove('open'); action.onclick = null; closeables.forEach(el => el.onclick = null); };
 | 
			
		||||
  closeables.forEach(el => el.onclick = close);
 | 
			
		||||
  action.onclick = async () => { const ok = await copyText(text); if (ok) close(); };
 | 
			
		||||
  modal.classList.add('open');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// --------- App ---------
 | 
			
		||||
(function() {
 | 
			
		||||
  if (!location.hash) location.hash = '#user';
 | 
			
		||||
  let data = (window.__DATA__ || null);
 | 
			
		||||
@ -790,32 +858,67 @@ function renderMD(src) {
 | 
			
		||||
 | 
			
		||||
  function shownName(r) {
 | 
			
		||||
    const n = (r.display_name || r.name || '');
 | 
			
		||||
    return n.replace(/^\\//, ''); // strip leading '/'
 | 
			
		||||
    return n.replace(/^\\//, '');
 | 
			
		||||
  }
 | 
			
		||||
  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.'
 | 
			
		||||
    return m.replace(/^modules?\\./, '');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function shareFor(r) {
 | 
			
		||||
    const anchor = rowAnchor(r);
 | 
			
		||||
    const url = buildLink(anchor);
 | 
			
		||||
    const ok = await copyText(url);
 | 
			
		||||
    if (!ok) openCopyModal(url);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function usageBlockHTML(text) {
 | 
			
		||||
    if (!text) return '';
 | 
			
		||||
    const esc = String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
 | 
			
		||||
    return `
 | 
			
		||||
      <div class="usage">
 | 
			
		||||
        ${esc}
 | 
			
		||||
        <button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function wireUsageCopy(container) {
 | 
			
		||||
    container.querySelectorAll('[data-copy]').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('click', async (ev) => {
 | 
			
		||||
        ev.stopPropagation();
 | 
			
		||||
        const t = btn.getAttribute('data-copy').replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
 | 
			
		||||
        const ok = await copyText(t);
 | 
			
		||||
        if (!ok) openCopyModal(t);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function openDetails(r) {
 | 
			
		||||
    const anchor = rowAnchor(r);
 | 
			
		||||
    replaceURLFor(anchor); // keep URL synced
 | 
			
		||||
 | 
			
		||||
    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>
 | 
			
		||||
        <div class="btn-row">
 | 
			
		||||
          <button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
 | 
			
		||||
          <button class="btn" id="closeDetails" style="display:none">Close</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </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>` : ''}
 | 
			
		||||
      ${usageBlockHTML(r.usage_prefix)}
 | 
			
		||||
      ${usageBlockHTML(r.usage_slash)}
 | 
			
		||||
      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
			
		||||
    `;
 | 
			
		||||
    if (r.details_md) {
 | 
			
		||||
@ -830,6 +933,11 @@ function renderMD(src) {
 | 
			
		||||
    }
 | 
			
		||||
    detailsEl.innerHTML = html;
 | 
			
		||||
 | 
			
		||||
    // Wire share + copy buttons
 | 
			
		||||
    const shareBtn = document.getElementById('shareDetails');
 | 
			
		||||
    if (shareBtn) shareBtn.onclick = (e) => { e.stopPropagation(); shareFor(r); };
 | 
			
		||||
    wireUsageCopy(detailsEl);
 | 
			
		||||
 | 
			
		||||
    // Mobile sheet open
 | 
			
		||||
    if (window.matchMedia('(max-width: 900px)').matches) {
 | 
			
		||||
      detailsEl.classList.add('open');
 | 
			
		||||
@ -850,32 +958,32 @@ function renderMD(src) {
 | 
			
		||||
 | 
			
		||||
  function card(r) {
 | 
			
		||||
    const c = document.createElement('div');
 | 
			
		||||
    const anchor = rowAnchor(r);
 | 
			
		||||
    c.className = 'card';
 | 
			
		||||
    c.id = 'card-' + anchor;
 | 
			
		||||
    c.dataset.anchor = anchor;
 | 
			
		||||
    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>` : '';
 | 
			
		||||
    })();
 | 
			
		||||
    const usageHTML = (r.type === 'hybrid')
 | 
			
		||||
      ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}`
 | 
			
		||||
      : `${usageBlockHTML(r.usage)}`;
 | 
			
		||||
 | 
			
		||||
    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>
 | 
			
		||||
        <div class="btn-row">
 | 
			
		||||
          <button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
 | 
			
		||||
          <button class="btn" data-details="1">Details</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </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}
 | 
			
		||||
      ${usageHTML}
 | 
			
		||||
      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
@ -883,7 +991,14 @@ function renderMD(src) {
 | 
			
		||||
      ev.stopPropagation();
 | 
			
		||||
      openDetails(r);
 | 
			
		||||
    });
 | 
			
		||||
    c.querySelector('[data-share]').addEventListener('click', async (ev) => {
 | 
			
		||||
      ev.stopPropagation();
 | 
			
		||||
      await shareFor(r);
 | 
			
		||||
    });
 | 
			
		||||
    c.addEventListener('click', () => openDetails(r));
 | 
			
		||||
 | 
			
		||||
    // Wire usage copy buttons within card
 | 
			
		||||
    wireUsageCopy(c);
 | 
			
		||||
    return c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -892,7 +1007,7 @@ function renderMD(src) {
 | 
			
		||||
    rows.forEach(r => target.appendChild(card(r)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function applyFilter() {
 | 
			
		||||
  function applyFilter(selectAnchorIfPresent=true) {
 | 
			
		||||
    if (!data) return;
 | 
			
		||||
    const all = data.all || [];
 | 
			
		||||
    const mods = (data.sections && data.sections.moderator) || [];
 | 
			
		||||
@ -903,14 +1018,24 @@ function renderMD(src) {
 | 
			
		||||
    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 { filter, cmd } = getFilterFromHash();
 | 
			
		||||
    const src = filter === 'moderator' ? mods : (filter === '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
 | 
			
		||||
 | 
			
		||||
    // Auto-select first or anchor target
 | 
			
		||||
    let sel = rows[0];
 | 
			
		||||
    if (selectAnchorIfPresent && cmd) {
 | 
			
		||||
      const hit = rows.find(r => rowAnchor(r) === cmd);
 | 
			
		||||
      if (hit) sel = hit;
 | 
			
		||||
      // Also scroll the specific card into view smoothly
 | 
			
		||||
      const cardEl = document.getElementById('card-' + cmd);
 | 
			
		||||
      if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
 | 
			
		||||
    }
 | 
			
		||||
    if (sel) openDetails(sel);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function boot() {
 | 
			
		||||
@ -921,9 +1046,9 @@ function renderMD(src) {
 | 
			
		||||
        if (!res.ok) throw new Error('HTTP ' + res.status);
 | 
			
		||||
        data = await res.json();
 | 
			
		||||
      }
 | 
			
		||||
      window.addEventListener('hashchange', applyFilter);
 | 
			
		||||
      qEl.addEventListener('input', applyFilter);
 | 
			
		||||
      applyFilter();
 | 
			
		||||
      window.addEventListener('hashchange', () => applyFilter(false));
 | 
			
		||||
      qEl.addEventListener('input', () => applyFilter(false));
 | 
			
		||||
      applyFilter(true);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      document.getElementById('alerts').textContent = 'Failed to load.';
 | 
			
		||||
    }
 | 
			
		||||
@ -988,33 +1113,17 @@ class _DocsHandler(BaseHTTPRequestHandler):
 | 
			
		||||
                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/...
 | 
			
		||||
            # Serve static assets from /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():
 | 
			
		||||
@ -1035,6 +1144,18 @@ class _DocsHandler(BaseHTTPRequestHandler):
 | 
			
		||||
                    self.wfile.write(b"internal error")
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
            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
 | 
			
		||||
 | 
			
		||||
            if path == "/api/status":
 | 
			
		||||
                payload = _status_payload(self.bot)
 | 
			
		||||
                self._set(200, "application/json; charset=utf-8")
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user