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:
|
||||
# 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 ----------
|
||||
|
||||
|
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))
|
||||
|
||||
# ---- 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
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user