shaiwatcher/modules/reaction_role/reaction_role.py
Franz Rolfsvaag c09f36162d 0.3.9.3.a2
Fixes nick review lock, preventing re-reviews to be sent out despite genuine
2025-08-10 23:53:16 +02:00

209 lines
8.5 KiB
Python

# modules/reaction_role/reaction_role.py
import discord
from discord.ext import 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) ----
@commands.hybrid_command(name='nick_same', description='Claim that your global display name matches your in-game name (triggers mod review)')
async def nick_same(self, ctx: commands.Context):
member = ctx.author if isinstance(ctx.author, discord.Member) else None
if not member or not ctx.guild:
return await ctx.reply("Use this in a server.", ephemeral=True)
# Atomic path handled inside NickNudge
nn = self.bot.get_cog('NickNudgeCog')
if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
try:
await nn.ensure_pending_and_maybe_open(ctx.guild, member, source="nick_same")
except Exception:
pass
await self.maybe_apply_full_access(member)
await ctx.reply("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))