# 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 = [ "โค๏ธ","๐Ÿงก","๐Ÿ’›","๐Ÿ’š","๐Ÿ’™","๐Ÿ’œ","๐ŸคŽ","๐Ÿ–ค","๐Ÿค","โœจ","๐ŸŒŸ","โญ","๐ŸŽ‰","๐ŸŽŠ","๐Ÿ”ฅ","๐Ÿ’ซ","โšก","๐ŸŒˆ", "๐Ÿ˜„","๐Ÿ˜","๐Ÿ˜†","๐Ÿ˜Š","๐Ÿ™‚","๐Ÿ˜‰","๐Ÿ˜Ž","๐Ÿคฉ","๐Ÿฅณ","๐Ÿค—","๐Ÿ™Œ","๐Ÿ‘","๐Ÿ‘","๐Ÿค","๐Ÿซถ","๐Ÿ™","๐Ÿซก","๐Ÿค˜","๐Ÿ’ช", "๐Ÿ‘€","๐Ÿค”","๐Ÿง","๐Ÿ˜ผ","๐Ÿ˜น","๐Ÿ˜","๐Ÿ˜Œ","๐Ÿ˜‡","๐Ÿ˜ด","๐Ÿค–","๐Ÿ‘พ","๐Ÿง ","๐Ÿซ ", "๐Ÿœ๏ธ","๐ŸŒต","๐Ÿช","๐Ÿ›","๐Ÿชฑ","๐Ÿง‚","๐Ÿงช","๐Ÿ—ก๏ธ","โš”๏ธ","๐Ÿ›ก๏ธ","๐Ÿš","๐Ÿ›ฉ๏ธ","๐Ÿš€","๐Ÿงญ","๐ŸŒช๏ธ" ] # Short Dune phrases / lore snippets (kept short for presence) 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.", "Made with โค๏ธ in ๐Ÿ‡ณ๐Ÿ‡ด" ] # 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.", "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." ] # ============== 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} 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).")