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*
This commit is contained in:
		
							parent
							
								
									3d807e2fc2
								
							
						
					
					
						commit
						f3bc0ef670
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# 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 ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
load_dotenv()
 | 
					load_dotenv()
 | 
				
			||||||
 | 
				
			|||||||
@ -35,12 +35,19 @@ class _FedaykinApprovalView(discord.ui.View):
 | 
				
			|||||||
        self.req = req  # dict persisted via data_manager
 | 
					        self.req = req  # dict persisted via data_manager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def interaction_check(self, interaction: discord.Interaction) -> bool:
 | 
					    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
 | 
					        member = interaction.user
 | 
				
			||||||
        if not isinstance(member, discord.Member):
 | 
					        if not isinstance(member, discord.Member):
 | 
				
			||||||
            await interaction.response.send_message("Server context required.", ephemeral=True)
 | 
					            await interaction.response.send_message("Server context required.", ephemeral=True)
 | 
				
			||||||
            return False
 | 
					            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}
 | 
					        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", [])):
 | 
					        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)
 | 
					            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_debounce: Dict[tuple[int, int], float] = {}  # (guild_id, user_id) -> last_ts
 | 
				
			||||||
        self._nick_lock: set[tuple[int, int]] = set()           # in-flight review creations
 | 
					        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
 | 
					        # Apply initial snapshot and schedule restore
 | 
				
			||||||
        self._refresh_settings(force=True)
 | 
					        self._refresh_settings(force=True)
 | 
				
			||||||
        self.bot.loop.create_task(self._boot_restore())
 | 
					        self.bot.loop.create_task(self._boot_restore())
 | 
				
			||||||
@ -197,6 +207,14 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
            pass
 | 
					            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]):
 | 
					    async def _save_fedaykin_request(self, req: Dict[str, Any]):
 | 
				
			||||||
        """Upsert by (guild_id, user_id)."""
 | 
					        """Upsert by (guild_id, user_id)."""
 | 
				
			||||||
        dm = self.bot.data_manager
 | 
					        dm = self.bot.data_manager
 | 
				
			||||||
@ -306,7 +324,10 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
                    emb.set_footer(text=emb.footer.text + "\n" + note)
 | 
					                    emb.set_footer(text=emb.footer.text + "\n" + note)
 | 
				
			||||||
                else:
 | 
					                else:
 | 
				
			||||||
                    emb.set_footer(text=note)
 | 
					                    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:
 | 
					        except Exception:
 | 
				
			||||||
            pass
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -398,7 +419,12 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
            pass
 | 
					            pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool:
 | 
					    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()
 | 
					        created_ts = datetime.now(timezone.utc).timestamp()
 | 
				
			||||||
        emb = discord.Embed(
 | 
					        emb = discord.Embed(
 | 
				
			||||||
            title="Fedaykin Request",
 | 
					            title="Fedaykin Request",
 | 
				
			||||||
@ -444,8 +470,143 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
                continue
 | 
					                continue
 | 
				
			||||||
        return False
 | 
					        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 ------------------
 | 
					    # ------------------ 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()
 | 
					    @commands.Cog.listener()
 | 
				
			||||||
    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
					    async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
 | 
				
			||||||
        # hot-reload settings
 | 
					        # hot-reload settings
 | 
				
			||||||
@ -455,6 +616,15 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
					        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)
 | 
					        member = await self._get_member(guild, payload.user_id)
 | 
				
			||||||
        if not member or member.bot:
 | 
					        if not member or member.bot:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
@ -575,6 +745,15 @@ class ReactionRoleCog(commands.Cog):
 | 
				
			|||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        guild = self.bot.get_guild(payload.guild_id)
 | 
					        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)
 | 
					        member = await self._get_member(guild, payload.user_id)
 | 
				
			||||||
        if not member or member.bot:
 | 
					        if not member or member.bot:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user