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:
Franz Rolfsvaag 2025-08-13 08:58:56 +02:00
parent aab931b543
commit 21a79194dd
5 changed files with 486 additions and 7 deletions

2
bot.py
View File

@ -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; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.3.9.7.a5" VERSION = "0.3.9.8.a1"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

View 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
# Besteffort: 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 arent trivial to enumerate without ._params.
# Do a safe besteffort:
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 Swaggerlike 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))

View File

@ -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

View File

@ -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.",
"MuadDib walks the golden path.",
"Sandtrout seal the deserts 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.",
"MuadDib is a small desert mouse whose footprints taught Paul the sandwalk.", "MuadDib 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)