From 87bcc61a1a4faff9578f725a79d2fd42b1c888ea Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 13 Aug 2025 12:47:34 +0200 Subject: [PATCH] 0.4.0.0.a3 - Added linkable and sharable commands - Added copy-buttons to command fields --- bot.py | 2 +- modules/docs_site/docs_site.py | 361 ++++++++++++++++++++++----------- 2 files changed, 242 insertions(+), 121 deletions(-) diff --git a/bot.py b/bot.py index bf81453..322aad4 100644 --- a/bot.py +++ b/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 ---------- diff --git a/modules/docs_site/docs_site.py b/modules/docs_site/docs_site.py index 7283d32..eadf235 100644 --- a/modules/docs_site/docs_site.py +++ b/modules/docs_site/docs_site.py @@ -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 = """ .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 = """ .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 = """ + + +
") - 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("", f"") + 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")