shaiwatcher/modules/status/status_rotator.py
Franz Rolfsvaag 21a79194dd 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
2025-08-13 08:58:56 +02:00

331 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.",
"MuadDib walks the golden path.",
"Sandtrout seal the deserts 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.",
"MuadDib 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 sandworms 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 ones 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 bodys 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).")