diff --git a/bot.py b/bot.py index e22ef4b..8e3c746 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.3.9.7.a5" +VERSION = "0.3.9.8.a1" # ---------- Env loading ---------- diff --git a/modules/docs_site/__init__.py b/modules/docs_site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/docs_site/docs_site.py b/modules/docs_site/docs_site.py new file mode 100644 index 0000000..f3b9214 --- /dev/null +++ b/modules/docs_site/docs_site.py @@ -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 = """ + + + + +{title} + + + +

{title}

+
+
+
+
+ +
Sections: All · Moderator · User
+
+
+
+
+
+
+

Notes

+
    +
  • No authentication; info is read-only and non-sensitive.
  • +
  • Moderator-only is inferred from checks (best-effort).
  • +
  • Slash commands show full path if grouped, e.g. /group/sub.
  • +
+

JSON: /api/commands · Health: /healthz

+
+
+

Moderator commands

+
+
+
+
+
+ + + +""" + +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)) diff --git a/modules/pirate_report/pirate_report.py b/modules/pirate_report/pirate_report.py index 3e6d198..5d00a8c 100644 --- a/modules/pirate_report/pirate_report.py +++ b/modules/pirate_report/pirate_report.py @@ -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 diff --git a/modules/status/status_rotator.py b/modules/status/status_rotator.py index b8ac36d..cb5ae37 100644 --- a/modules/status/status_rotator.py +++ b/modules/status/status_rotator.py @@ -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)