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:
 | 
			
		||||
# 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()
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user