From f3bc0ef670c910d71fdf40fc4afe1f64d244b6fd Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Mon, 25 Aug 2025 23:47:25 +0200 Subject: [PATCH] 0.5.1.2.a2 - Adjustments to reaction role - Ensures no one can hold the Fedaykin role without a head - Stores pending reviews in the absence of a head for later review when a new head is reinstated - *Thanks Kenny :P* --- bot.py | 2 +- modules/reaction_role/reaction_role.py | 185 ++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 4 deletions(-) diff --git a/bot.py b/bot.py index 3a5da6a..1104c86 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.5.1.2.a1" +VERSION = "0.5.1.2.a2" # ---------- Env loading ---------- load_dotenv() diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index b7fe841..0cdffc3 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -35,12 +35,19 @@ class _FedaykinApprovalView(discord.ui.View): self.req = req # dict persisted via data_manager async def interaction_check(self, interaction: discord.Interaction) -> bool: - # Only Field Mod or Admin may act + # Only Field Mod or Admin may act — and only if a Head exists member = interaction.user if not isinstance(member, discord.Member): await interaction.response.send_message("Server context required.", ephemeral=True) return False + if self.cog._fedaykin_headless or not (interaction.guild and self.cog._has_fedaykin_head(interaction.guild)): + await interaction.response.send_message( + "No **Fedaykin Head** is currently appointed. This request remains pending until one is appointed.", + ephemeral=True + ) + return False + allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id} if not any(r.id in allow_roles for r in getattr(member, "roles", [])): await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True) @@ -92,6 +99,9 @@ class ReactionRoleCog(commands.Cog): self._nick_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations + # Fedaykin head state (global) + self._fedaykin_headless: bool = False + # Apply initial snapshot and schedule restore self._refresh_settings(force=True) self.bot.loop.create_task(self._boot_restore()) @@ -197,6 +207,14 @@ class ReactionRoleCog(commands.Cog): except Exception: pass + # Evaluate head state at boot and act accordingly + try: + hg = cfg(self.bot).int('home_guild_id', 0) + guild = self.bot.get_guild(hg) if hg else (self.bot.guilds[0] if self.bot.guilds else None) + await self._maybe_transition_head_state(guild) + except Exception: + pass + async def _save_fedaykin_request(self, req: Dict[str, Any]): """Upsert by (guild_id, user_id).""" dm = self.bot.data_manager @@ -306,7 +324,10 @@ class ReactionRoleCog(commands.Cog): emb.set_footer(text=emb.footer.text + "\n" + note) else: emb.set_footer(text=note) - await msg.edit(embed=emb, view=None if clear_view else msg.components) + if clear_view: + await msg.edit(embed=emb, view=None) + else: + await msg.edit(embed=emb) # leave existing components untouched except Exception: pass @@ -398,7 +419,12 @@ class ReactionRoleCog(commands.Cog): pass async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool: - """Post the Fedaykin approval card; return True if posted somewhere.""" + """Post the Fedaykin approval card; return True if posted somewhere. While headless, queue pending only.""" + # If headless or effectively headless (no Head members): queue silently + if self._fedaykin_headless or not self._has_fedaykin_head(guild): + await self._queue_pending(guild, member, reason="headless_runtime") + return False + created_ts = datetime.now(timezone.utc).timestamp() emb = discord.Embed( title="Fedaykin Request", @@ -444,8 +470,143 @@ class ReactionRoleCog(commands.Cog): continue return False + # ---------- Fedaykin Head detection & transitions ---------- + + def _has_fedaykin_head(self, guild: discord.Guild) -> bool: + """True if any member currently has the Field Mod (Fedaykin Head) role.""" + if not guild or not self.field_mod_role_id: + return False + role = guild.get_role(self.field_mod_role_id) + return bool(role and role.members) + + async def _modlog_head_summary(self, guild: discord.Guild, *, head_present: bool, + revoked: int = 0, queued: int = 0, posted: int = 0): + """Brief summary in modlog on headless/head-found transitions.""" + chan = self.bot.get_channel(self.modlog_channel_id) + if not isinstance(chan, (discord.TextChannel, discord.Thread)): + return + colour = discord.Color.green() if head_present else discord.Color.orange() + title = "Fedaykin Head present — queued reviews sent" if head_present else "No Fedaykin Head — roles revoked & queued" + desc = ( + f"**Posted reviews:** {posted}" if head_present else + f"**Revoked roles:** {revoked}\n**Queued reviews:** {queued}" + ) + try: + emb = discord.Embed(title=title, description=desc, color=colour, + timestamp=datetime.now(timezone.utc)) + await chan.send(embed=emb) + except Exception: + pass + + async def _queue_pending(self, guild: discord.Guild, member: discord.Member, *, reason: str = "headless"): + """Upsert a silent pending request (no card).""" + dm = self.bot.data_manager + created_ts = datetime.now(timezone.utc).timestamp() + # Preserve an existing headless pending if one already exists (don’t duplicate) + existing = next((x for x in _as_list(dm.get('fedaykin_requests')) + if x.get("guild_id") == guild.id and x.get("user_id") == member.id + and x.get("status") == "pending" and int(x.get("review_message_id", 0)) == 0), None) + req = { + "guild_id": guild.id, + "user_id": member.id, + "status": "pending", + "created_ts": existing.get("created_ts", created_ts) if existing else created_ts, + "review_message_id": 0, + "review_channel_id": 0, + "reason": reason, + } + await self._save_fedaykin_request(req) + + async def _headless_revoke_and_queue(self, guild: discord.Guild) -> tuple[int, int]: + """When headless: remove Fedaykin role from all members and queue pending reviews.""" + revoked = queued = 0 + if not guild or not self.role_fedaykin_id: + return revoked, queued + role = guild.get_role(self.role_fedaykin_id) + if not role: + return revoked, queued + + for m in list(role.members): + if m.bot: + continue + try: + await m.remove_roles(role, reason="Fedaykin headless – role temporarily revoked") + revoked += 1 + except Exception: + pass + try: + await self._queue_pending(guild, m, reason="headless_boot") + queued += 1 + except Exception: + pass + return revoked, queued + + async def _flush_pending_to_cards(self, guild: discord.Guild) -> int: + """When a head exists: send cards for any queued pendings (review_message_id == 0).""" + dm = self.bot.data_manager + pending = [x for x in _as_list(dm.get('fedaykin_requests')) + if x.get("guild_id") == guild.id and x.get("status") == "pending" + and int(x.get("review_message_id", 0)) == 0] + posted = 0 + for req in pending: + member = await self._get_member(guild, int(req.get("user_id", 0))) + if not member: + continue + # This will upsert the request with live message/channel IDs + ok = await self._post_fedaykin_card(guild, member, self.crew_msg_id or 0) + if ok: + posted += 1 + return posted + + async def _maybe_transition_head_state(self, guild: Optional[discord.Guild]): + """Recompute head state; if it changes, perform the required mass action + log.""" + if guild is None: + return + + # Require a configured, resolvable Field Mod role to take global actions + if not self.field_mod_role_id: + return + head_role = guild.get_role(self.field_mod_role_id) + if head_role is None: + # Misconfigured: do not mass-revoke; other paths will still queue/block appropriately + return + + has_head_now = bool(head_role.members) + if has_head_now and self._fedaykin_headless: + # Head appeared — flush queued to cards + self._fedaykin_headless = False + posted = await self._flush_pending_to_cards(guild) + await self._modlog_head_summary(guild, head_present=True, posted=posted) + elif not has_head_now and not self._fedaykin_headless: + # Became headless — revoke all fedaykins and queue pendings + self._fedaykin_headless = True + revoked, queued = await self._headless_revoke_and_queue(guild) + await self._modlog_head_summary(guild, head_present=False, revoked=revoked, queued=queued) + # ------------------ listeners ------------------ + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Detect when someone gains/loses the Fedaykin Head role and transition state if needed.""" + try: + hg = cfg(self.bot).int('home_guild_id', 0) + if after.guild.id != (hg or after.guild.id): + return + except Exception: + pass + + # If the role IDs aren’t configured yet, just ignore + if not self.field_mod_role_id: + return + + had = any(r.id == self.field_mod_role_id for r in getattr(before, "roles", [])) + has = any(r.id == self.field_mod_role_id for r in getattr(after, "roles", [])) + if had == has: + return # no change on this member + + # Re-evaluate overall head presence and transition if the global state changes + await self._maybe_transition_head_state(after.guild) + @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): # hot-reload settings @@ -455,6 +616,15 @@ class ReactionRoleCog(commands.Cog): return guild = self.bot.get_guild(payload.guild_id) + + # Ensure head state stays accurate even if settings/users change at runtime + try: + hg = cfg(self.bot).int('home_guild_id', 0) + base_guild = self.bot.get_guild(hg) or guild + await self._maybe_transition_head_state(base_guild) + except Exception: + pass + member = await self._get_member(guild, payload.user_id) if not member or member.bot: return @@ -575,6 +745,15 @@ class ReactionRoleCog(commands.Cog): return guild = self.bot.get_guild(payload.guild_id) + + # Ensure head state stays accurate even if settings/users change at runtime + try: + hg = cfg(self.bot).int('home_guild_id', 0) + base_guild = self.bot.get_guild(hg) or guild + await self._maybe_transition_head_state(base_guild) + except Exception: + pass + member = await self._get_member(guild, payload.user_id) if not member or member.bot: return