diff --git a/bot.py b/bot.py index 57a7bfe..6bb10a1 100644 --- a/bot.py +++ b/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.6.a5" +VERSION = "0.3.9.7.a1" # ---------- Env loading ---------- diff --git a/modules/status/__init__.py b/modules/status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/status/status_rotator.py b/modules/status/status_rotator.py new file mode 100644 index 0000000..39d0609 --- /dev/null +++ b/modules/status/status_rotator.py @@ -0,0 +1,318 @@ +# 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." +] + +# 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 + + atype = random.choice([ + discord.ActivityType.watching, + discord.ActivityType.playing, + discord.ActivityType.listening + ]) + + try: + await self.bot.change_presence(activity=discord.Activity(type=atype, name=text)) + except Exception as e: + print("[status] change_presence failed:", repr(e)) + + # ---- 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 {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).")