# modules/reaction_role/reaction_role.py import discord from discord.ext import commands from discord import app_commands from modules.common.emoji_accept import is_accept from modules.common.settings import cfg # ENV-first helper CHECKMARK = '✅' class ReactionRoleCog(commands.Cog): """ Records agreements and manages Full Access. Nickname flow: • Add accept on nickname message -> atomically mark agreed + pending and open ONE review • Remove accept on nickname message -> clear only if user has no accept reactions left Full Access: granted when Rules ✅ + RoE ✅ + Nickname *claimed* (pending or verified). Revoked when any of the three is missing. """ def __init__(self, bot): self.bot = bot r = cfg(bot) # Message + role IDs from ENV/INI (default 0 if unset) self.rules_msg_id = r.int('rules_message_id', 0) self.engage_msg_id = r.int('engagement_message_id', 0) self.nick_msg_id = r.int('nickname_message_id', 0) self.rules_role = r.int('rules_role_id', 0) self.engage_role = r.int('engagement_role_id', 0) self.full_access_role = r.int('full_access_role_id', 0) # ---- helpers ---- def _has_rules(self, member_id: int) -> bool: return member_id in self.bot.data_manager.get('agreed_rules') def _has_engage(self, member_id: int) -> bool: return member_id in self.bot.data_manager.get('agreed_engagement') def _has_nick_claim(self, member_id: int) -> bool: """Claimed = agreed_nickname; pending/verified tracked separately.""" return member_id in self.bot.data_manager.get('agreed_nickname') async def maybe_apply_full_access(self, member: discord.Member): """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" guild = member.guild role = guild.get_role(self.full_access_role) if self.full_access_role else None if not role: return has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id) try: if has_all and role not in member.roles: await member.add_roles(role, reason="All agreements completed (nickname may be pending)") elif not has_all and role in member.roles: await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed") except discord.Forbidden: pass except Exception: pass # Best-effort: refresh user card cards = self.bot.get_cog('UserCardsCog') if cards: try: await cards.refresh_card(member) except Exception: pass async def _get_member(self, guild: discord.Guild, user_id: int): if not guild: return None m = guild.get_member(user_id) if m is None: try: m = await guild.fetch_member(user_id) except Exception: return None return m async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool: """Return True if the user still has at least one 'accept' reaction on the message.""" try: ch = guild.get_channel(channel_id) if not ch: return False msg = await ch.fetch_message(message_id) for rxn in msg.reactions: if is_accept(rxn.emoji): async for u in rxn.users(limit=None): if u.id == user_id: return True return False except Exception: return False # ---- commands (hybrid = prefix + slash) ---- @app_commands.command(name="nick_same", description="Claim that your global display name matches your in-game name (triggers mod review)") async def nick_same(self, interaction: discord.Interaction): if not interaction.guild or not isinstance(interaction.user, discord.Member): return await interaction.response.send_message("Use this in a server.", ephemeral=True) member: discord.Member = interaction.user nn = self.bot.get_cog('NickNudgeCog') if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): try: await nn.ensure_pending_and_maybe_open(interaction.guild, member, source="nick_same") except Exception: pass await self.maybe_apply_full_access(member) await interaction.response.send_message("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) # ---- listeners ---- @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): if not payload.guild_id or not is_accept(payload.emoji): return guild = self.bot.get_guild(payload.guild_id) member = await self._get_member(guild, payload.user_id) if not member or member.bot: return dm = self.bot.data_manager try: if payload.message_id == self.rules_msg_id: role = guild.get_role(self.rules_role) if role: await member.add_roles(role, reason="Agreed to rules") if member.id not in dm.get('agreed_rules'): dm.add('agreed_rules', int(member.id)) elif payload.message_id == self.engage_msg_id: role = guild.get_role(self.engage_role) if role: await member.add_roles(role, reason="Agreed to engagement") if member.id not in dm.get('agreed_engagement'): dm.add('agreed_engagement', int(member.id)) elif payload.message_id == self.nick_msg_id: # --- STALE-STATE CLEANUP --- # If no pending review exists for this user in this guild, make sure 'nick_claim_pending' # is cleared so the atomic method can transition and open a new review. has_pending_review = any( r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending' for r in dm.get('nick_reviews') ) if not has_pending_review: dm.remove('nick_claim_pending', lambda x: x == member.id) # Atomic claim -> ONE review only nn = self.bot.get_cog('NickNudgeCog') if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): try: await nn.ensure_pending_and_maybe_open(guild, member, source="claim") except Exception: pass else: return else: return except Exception: pass await self.maybe_apply_full_access(member) @commands.Cog.listener() async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): if not payload.guild_id or not is_accept(payload.emoji): return guild = self.bot.get_guild(payload.guild_id) member = await self._get_member(guild, payload.user_id) if not member or member.bot: return dm = self.bot.data_manager try: if payload.message_id == self.rules_msg_id: dm.remove('agreed_rules', lambda x: x == member.id) role = guild.get_role(self.rules_role) if role: await member.remove_roles(role, reason="Rules un-ticked") elif payload.message_id == self.engage_msg_id: dm.remove('agreed_engagement', lambda x: x == member.id) role = guild.get_role(self.engage_role) if role: await member.remove_roles(role, reason="Engagement un-ticked") elif payload.message_id == self.nick_msg_id: # Clear only if the user has NO accept reactions left on the message still_has_accept = await self._user_has_any_accept( guild, payload.channel_id, payload.message_id, member.id ) if not still_has_accept: dm.remove('agreed_nickname', lambda x: x == member.id) dm.remove('nick_claim_pending', lambda x: x == member.id) dm.remove('nick_verified', lambda x: x == member.id) else: return except Exception: pass await self.maybe_apply_full_access(member) async def setup(bot): await bot.add_cog(ReactionRoleCog(bot))