- 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
331 lines
13 KiB
Python
331 lines
13 KiB
Python
# modules/status/status_rotator.py
|
||
import random
|
||
import asyncio
|
||
import time
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
import discord
|
||
from discord.ext import commands, tasks
|
||
|
||
from modules.common.settings import cfg
|
||
|
||
# ============== Tunables / lists you can expand freely ==============
|
||
|
||
# Long list of expressive unicode emojis (safe for all clients)
|
||
EMOTES = [
|
||
"❤️","🧡","💛","💚","💙","💜","🤎","🖤","🤍","✨","🌟","⭐","🎉","🎊","🔥","💫","⚡","🌈",
|
||
"😄","😁","😆","😊","🙂","😉","😎","🤩","🥳","🤗","🙌","👏","👍","🤝","🫶","🙏","🫡","🤘","💪",
|
||
"👀","🤔","🧐","😼","😹","😏","😌","😇","😴","🤖","👾","🧠","🫠",
|
||
"🏜️","🌵","🐪","🐛","🪱","🧂","🧪","🗡️","⚔️","🛡️","🚁","🛩️","🚀","🧭","🌪️"
|
||
]
|
||
|
||
DUNE_PHRASES = [
|
||
"Arrakis. Dune. Desert Planet.",
|
||
"Shai-Hulud stirs beneath the sands.",
|
||
"The spice must flow.",
|
||
"Bene Gesserit whispers in the dark.",
|
||
"Kwisatz Haderach foretold.",
|
||
"House Atreides rises.",
|
||
"House Harkonnen plots.",
|
||
"Fremen walk without rhythm.",
|
||
"Crysknife unsheathed.",
|
||
"Sietch Tabr stands strong.",
|
||
"CHOAM counts its profits.",
|
||
"The Spacing Guild navigates the void.",
|
||
"Water is life.",
|
||
"Fear is the mind-killer.",
|
||
"Gom Jabbar at the throat.",
|
||
"Stillsuits conserve every drop.",
|
||
"Ornithopters in the storm.",
|
||
"Sardaukar march.",
|
||
"Prescience veils the future.",
|
||
"Fedāykin watchful in the dunes.",
|
||
"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"
|
||
]
|
||
|
||
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.",
|
||
"Melange extends life, heightens awareness, and enables prescient navigation.",
|
||
"Fremen ‘walking without rhythm’ avoids attracting sandworms.",
|
||
"The crysknife is made from a sandworm’s tooth and must never be sheathed unblooded.",
|
||
"Spacing Guild Navigators require spice to safely fold space.",
|
||
"The Litany Against Fear is recited to focus and master one’s emotions.",
|
||
"Bene Gesserit use prana-bindu training to control every muscle fiber.",
|
||
"Sietch means a Fremen community cave complex.",
|
||
"Stillsuits can reclaim over 90% of the body’s moisture.",
|
||
"The Imperial throne balances the Landsraad, CHOAM, and the Guild.",
|
||
"Kanly refers to a formal vendetta between noble houses.",
|
||
"Arrakis once had open water—long before the events of Dune.",
|
||
"‘Shai-Hulud’ is the Fremen name for the grand sandworm.",
|
||
"A lasgun-shield interaction can cause a subatomic chain reaction.",
|
||
"Mentats are human computers trained to replace forbidden thinking machines.",
|
||
"Duncan Idaho appears across eras via ghola rebirths.",
|
||
"The Water of Life is a deadly spice exhalation—surviving it transforms the Reverend Mother.",
|
||
"Fedaykin are the Fremen elite commandos sworn to their leader.",
|
||
"Sandtrout are juvenile forms of the great sandworms.",
|
||
"The Butlerian Jihad outlawed thinking machines, reshaping human society.",
|
||
"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.",
|
||
"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):
|
||
"""
|
||
Cycles bot presence every N minutes with randomized status entries:
|
||
- Member count (excluding bots)
|
||
- Pirates count
|
||
- Encounters count
|
||
- Random shout-out: {emoji} {display_name} (members with SHAI_FULL_ACCESS_ROLE_ID)
|
||
- Count of fully initiated members (role members)
|
||
- Random Dune phrase
|
||
- Random Dune fun fact (#n -> fact)
|
||
- Top threat pirate
|
||
- Latest report (pending/most recent)
|
||
- Encounters in last 24h
|
||
- Uptime
|
||
- Current running version
|
||
"""
|
||
|
||
def __init__(self, bot: commands.Bot):
|
||
self.bot = bot
|
||
r = cfg(bot)
|
||
self.home_gid = r.int('home_guild_id', 0)
|
||
self.full_access_role_id = r.int('full_access_role_id', 0) # SHAI_FULL_ACCESS_ROLE_ID
|
||
self.interval_min = r.int('status_interval_min', 5) # SHAI_STATUS_INTERVAL_MIN (optional)
|
||
self.max_len = 120 # clip safety for PC clients (keeps presence tidy)
|
||
|
||
# Build generator list (enable/disable here if needed)
|
||
self._generators = [
|
||
self._gen_members_count,
|
||
self._gen_pirates_count,
|
||
self._gen_encounters_count,
|
||
self._gen_random_shoutout,
|
||
self._gen_initiated_count,
|
||
self._gen_random_phrase,
|
||
self._gen_random_fun_fact,
|
||
self._gen_top_threat,
|
||
self._gen_latest_report,
|
||
self._gen_encounters_last_24h,
|
||
self._gen_uptime,
|
||
self._gen_running_version,
|
||
]
|
||
self._queue = []
|
||
|
||
# ---- lifecycle ----
|
||
|
||
@commands.Cog.listener()
|
||
async def on_ready(self):
|
||
# ensure loop interval reflects config
|
||
try:
|
||
if self.rotate_status.is_running():
|
||
self.rotate_status.change_interval(minutes=max(1, self.interval_min))
|
||
else:
|
||
self.rotate_status.change_interval(minutes=max(1, self.interval_min))
|
||
self.rotate_status.start()
|
||
except Exception as e:
|
||
print("[status] failed to start/change loop:", repr(e))
|
||
|
||
def cog_unload(self):
|
||
try:
|
||
self.rotate_status.cancel()
|
||
except Exception:
|
||
pass
|
||
|
||
# ---- the loop ----
|
||
|
||
@tasks.loop(minutes=5)
|
||
async def rotate_status(self):
|
||
await self.bot.wait_until_ready()
|
||
guild = self._resolve_guild()
|
||
if not guild:
|
||
return
|
||
|
||
text = await self._next_status_text(guild)
|
||
if not text:
|
||
return
|
||
|
||
# Enforce Custom Status; if it fails for any reason, fall back to Watching.
|
||
try:
|
||
await self.bot.change_presence(activity=discord.CustomActivity(name=text))
|
||
except Exception as e:
|
||
print("[status] custom presence failed, falling back to Watching:", repr(e))
|
||
try:
|
||
await self.bot.change_presence(
|
||
activity=discord.Activity(type=discord.ActivityType.watching, name=text)
|
||
)
|
||
except Exception as e2:
|
||
print("[status] change_presence failed:", repr(e2))
|
||
|
||
# ---- helpers ----
|
||
|
||
def _resolve_guild(self) -> discord.Guild | None:
|
||
if self.home_gid:
|
||
g = self.bot.get_guild(self.home_gid)
|
||
if g:
|
||
return g
|
||
return self.bot.guilds[0] if self.bot.guilds else None
|
||
|
||
async def _next_status_text(self, guild: discord.Guild) -> str | None:
|
||
# Try up to len(generators) times to get a non-empty status
|
||
for _ in range(len(self._generators)):
|
||
if not self._queue:
|
||
self._queue = random.sample(self._generators, k=len(self._generators))
|
||
gen = self._queue.pop(0)
|
||
try:
|
||
s = await gen(guild)
|
||
s = self._clip(s)
|
||
if s:
|
||
return s
|
||
except Exception as e:
|
||
print(f"[status] generator {gen.__name__} failed:", repr(e))
|
||
continue
|
||
return None
|
||
|
||
def _clip(self, s: str | None) -> str | None:
|
||
if not s:
|
||
return None
|
||
s = s.strip()
|
||
return (s[: self.max_len - 1] + "…") if len(s) > self.max_len else s
|
||
|
||
@staticmethod
|
||
def _fmt_duration(seconds: float) -> str:
|
||
seconds = int(max(0, seconds))
|
||
d, rem = divmod(seconds, 86400)
|
||
h, rem = divmod(rem, 3600)
|
||
m, _ = divmod(rem, 60)
|
||
if d > 0:
|
||
return f"{d}d {h}h {m}m"
|
||
if h > 0:
|
||
return f"{h}h {m}m"
|
||
return f"{m}m"
|
||
|
||
# ---- individual generators (originals) ----
|
||
|
||
async def _gen_members_count(self, guild: discord.Guild) -> str:
|
||
count = sum(1 for m in guild.members if not m.bot)
|
||
return f"{count} server members"
|
||
|
||
async def _gen_pirates_count(self, guild: discord.Guild) -> str:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
n = len(dm.get('pirates')) if dm else 0
|
||
return f"{n} pirates reported"
|
||
|
||
async def _gen_encounters_count(self, guild: discord.Guild) -> str:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
n = len(dm.get('encounters')) if dm else 0
|
||
return f"{n} reported encounters"
|
||
|
||
async def _gen_random_shoutout(self, guild: discord.Guild) -> str | None:
|
||
if not self.full_access_role_id:
|
||
return None
|
||
role = guild.get_role(self.full_access_role_id)
|
||
if not role:
|
||
return None
|
||
candidates = [m for m in role.members if not m.bot]
|
||
if not candidates:
|
||
return None
|
||
member = random.choice(candidates)
|
||
em = random.choice(EMOTES)
|
||
name = member.display_name
|
||
return f"{em} {name}"
|
||
|
||
async def _gen_initiated_count(self, guild: discord.Guild) -> str | None:
|
||
if not self.full_access_role_id:
|
||
return None
|
||
role = guild.get_role(self.full_access_role_id)
|
||
if not role:
|
||
return None
|
||
n = sum(1 for m in role.members if not m.bot)
|
||
return f"{n} fully initiated members"
|
||
|
||
async def _gen_random_phrase(self, guild: discord.Guild) -> str:
|
||
return random.choice(DUNE_PHRASES)
|
||
|
||
async def _gen_random_fun_fact(self, guild: discord.Guild) -> str:
|
||
idx = random.randrange(len(FUN_FACTS)) # 0-based
|
||
num = idx + 1
|
||
fact = FUN_FACTS[idx]
|
||
return f"Fun fact #{num} ->\n{fact}"
|
||
|
||
async def _gen_top_threat(self, guild: discord.Guild) -> str | None:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
pirates = dm.get('pirates') if dm else []
|
||
if not pirates:
|
||
return None
|
||
# sort by threat_level desc, then encounter_count desc, then newest added
|
||
pirates_sorted = sorted(
|
||
pirates,
|
||
key=lambda p: (
|
||
int(p.get('threat_level', 0)),
|
||
int(p.get('encounter_count', 0)),
|
||
float(p.get('added_ts', 0.0))
|
||
),
|
||
reverse=True
|
||
)
|
||
top = pirates_sorted[0]
|
||
tl = int(top.get('threat_level', 0))
|
||
name = top.get('character_name') or top.get('account_name') or "Unknown"
|
||
return f"Top threat: {name} ({tl}%)"
|
||
|
||
async def _gen_latest_report(self, guild: discord.Guild) -> str | None:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
reports = dm.get('reports') if dm else []
|
||
if not reports:
|
||
return None
|
||
latest = max(reports, key=lambda r: float(r.get('ts', 0.0)))
|
||
char = latest.get('character_name') or "Unknown"
|
||
acct = latest.get('account_name') or ""
|
||
# keep brief
|
||
return f"Latest report: {char}" + (f" ({acct})" if acct else "")
|
||
|
||
async def _gen_encounters_last_24h(self, guild: discord.Guild) -> str | None:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
encs = dm.get('encounters') if dm else []
|
||
if not encs:
|
||
return "0 encounters last 24h"
|
||
since = time.time() - 86400
|
||
n = sum(1 for e in encs if float(e.get('timestamp', 0.0)) >= since)
|
||
return f"{n} encounters last 24h"
|
||
|
||
async def _gen_uptime(self, guild: discord.Guild) -> str | None:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
|
||
boot_ts = float(st.get('last_boot_ts', 0.0))
|
||
if boot_ts <= 0:
|
||
return None
|
||
dur = self._fmt_duration(time.time() - boot_ts)
|
||
return f"Uptime {dur}"
|
||
|
||
async def _gen_running_version(self, guild: discord.Guild) -> str | None:
|
||
dm = getattr(self.bot, "data_manager", None)
|
||
st = (dm.get('boot_state') or [{}])[-1] if (dm and dm.get('boot_state')) else {}
|
||
ver = st.get('last_version')
|
||
if not ver:
|
||
return None
|
||
# Occasionally include version (kept as its own generator for randomness)
|
||
return f"Running v{ver}"
|
||
|
||
# ============== setup() ==============
|
||
|
||
async def setup(bot: commands.Bot):
|
||
cog = StatusRotatorCog(bot)
|
||
await bot.add_cog(cog)
|
||
print("[status] StatusRotatorCog loaded; rotating presence every",
|
||
max(1, cfg(bot).int('status_interval_min', 5)), "minute(s).") |