# modules/nick_nudge/nick_nudge.py import asyncio import time from typing import Optional, Tuple from collections import defaultdict import discord from discord.ext import commands from discord import app_commands from mod_perms import is_moderator_userid, require_mod_interaction from modules.common.emoji_accept import is_accept from modules.common.settings import cfg # ENV-first config helper CHECK = 'โœ…' # approved/verified CROSS = 'โŒ' # reject / no PENDING = 'โœ”๏ธ' # heavy check mark = pending claim ACCEPT = {CHECK, '๐Ÿซก'} NO_MENTIONS = discord.AllowedMentions.none() # Per-user in-process lock to prevent duplicate reviews from concurrent reactions _user_locks = defaultdict(asyncio.Lock) def _ts_rel(ts: Optional[float] = None) -> str: """Discord relative timestamp like .""" if ts is None: ts = time.time() return f"" class NickNudgeCog(commands.Cog): """ Nickname review flow: - Atomic transition to 'pending' and open exactly ONE review. - Mods: โœ… approve -> mark verified; โŒ reject -> clear claim. - If a verified user changes their nickname, verification is revoked automatically. Data keys used in data_manager: โ€ข agreed_nickname: [user_id] โ€ข nick_claim_pending: [user_id] โ€ข nick_verified: [user_id] โ€ข nick_reviews: [{ message_id, guild_id, user_id, status, ... }] โ€ข nick_verified_name: [{ guild_id, user_id, nick, ts }] """ def __init__(self, bot): self.bot = bot r = cfg(bot) self.modlog_channel_id = r.int('modlog_channel_id', 0) self.mod_channel_id = r.int('mod_channel_id', 0) self.loop_enabled = r.bool('nick_nudge_loop_enabled', False) self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None # ---------- utils ---------- def cog_unload(self): try: if self._task: self._task.cancel() except Exception: pass async def _modlog(self, guild: discord.Guild, content: str): if not self.modlog_channel_id: return ch = guild.get_channel(self.modlog_channel_id) if ch: try: await ch.send(content, allowed_mentions=NO_MENTIONS) except Exception: pass self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content}) async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]: try: async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update): if entry.target.id != member.id or not entry.changes: continue before_n = getattr(entry.changes.before, 'get', lambda *_: None)('nick') after_n = getattr(entry.changes.after, 'get', lambda *_: None)('nick') if before_n is not None or after_n is not None: return before_n, after_n except Exception: pass return None, None # ---------- atomic entry point used by all claim sources ---------- async def ensure_pending_and_maybe_open(self, guild: discord.Guild, member: discord.Member, source: str): """ Atomically: - set pending (idempotent) - open ONE review if this is the first transition to pending Prevents duplicate reviews when multiple reactions/commands fire. """ if not guild or not self.mod_channel_id or member.bot: return newly_pending = False lock = _user_locks[member.id] async with lock: dm = self.bot.data_manager # If a pending review already exists, bail out for r in dm.get('nick_reviews'): if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending': return # Mark "agreed" and flip to pending if not already if member.id not in dm.get('agreed_nickname'): dm.add('agreed_nickname', int(member.id)) dm.remove('nick_verified', lambda x: x == member.id) if member.id not in dm.get('nick_claim_pending'): dm.add('nick_claim_pending', int(member.id)) newly_pending = True if newly_pending: try: await self.start_nick_review(guild, member, source=source) except Exception: # Roll back pending on failure so the user can try again try: dm = self.bot.data_manager dm.remove('nick_claim_pending', lambda x: x == member.id) except Exception: pass # ---------- public API ---------- async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"): if not guild or not self.mod_channel_id: return mod_ch = guild.get_channel(self.mod_channel_id) if not mod_ch: return before_n, _ = await self._find_last_nick_change(guild, member) now_ts = int(time.time()) title = "๐Ÿ“ **Nickname Verification Request**" who = f"User: {member.mention} (`{member.id}`)" change = f"Claimed {_ts_rel(now_ts)}" from_to = f"From: {repr(before_n) if before_n is not None else 'unknown'} โ†’ To: {repr(member.nick) if member.nick else 'None'}" method = f"Method: {'/nick_same' if source == 'nick_same' else 'reaction'}" instructions = "Moderators: react โœ… to **approve** or โŒ to **reject**." content = f"{title}\n{who}\n{from_to}\n{method}\n{change}\n\n{instructions}" try: msg = await mod_ch.send(content, allowed_mentions=NO_MENTIONS) await msg.add_reaction(CHECK) await msg.add_reaction(CROSS) except Exception: return self.bot.data_manager.add('nick_reviews', { 'message_id': int(msg.id), 'guild_id': int(guild.id), 'user_id': int(member.id), 'before_nick': before_n if before_n is None or isinstance(before_n, str) else str(before_n), 'claimed_nick': member.nick if member.nick else None, 'status': 'pending', 'source': source, 'ts': now_ts }) await self._modlog(guild, f"๐Ÿ”Ž Nickname review opened for {member.mention} โ€” {method} โ€” {_ts_rel(now_ts)}.") # ---------- DM nudge loop ---------- async def _nudge_loop(self): await self.bot.wait_until_ready() while not self.bot.is_closed(): try: now_t = time.time() for guild in self.bot.guilds: for member in guild.members: if member.bot or not member.joined_at: continue if (now_t - member.joined_at.timestamp()) < 24 * 3600: continue dm = self.bot.data_manager if (member.nick and member.nick.strip()): continue if member.id in dm.get('nick_verified') or member.id in dm.get('nick_claim_pending'): continue if member.id in dm.get('nick_nudged'): continue try: dmchan = await member.create_dm() msg = await dmchan.send( "Hey! On this server we require your **server nickname** to match your in-game character name.\n\n" "If your default Discord display name is already identical to your in-game name, react โœ… **or ๐Ÿซก** below " "or run `/nick_same`. Otherwise, please set your **server nickname** to your in-game name and react โœ… **or ๐Ÿซก**." ) await msg.add_reaction(CHECK) self.bot.data_manager.add('nick_dm_map', { 'message_id': int(msg.id), 'user_id': int(member.id), 'guild_id': int(guild.id), 'ts': now_t }) self.bot.data_manager.add('nick_nudged', int(member.id)) await self._modlog(guild, f"๐Ÿ“จ Sent nickname nudge to {member.mention}") except Exception: pass except Exception: pass await asyncio.sleep(1800) # ---------- listeners ---------- @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """Revoke verification if a verified user changes their nick to anything.""" if before.bot or before.guild != after.guild: return if before.nick == after.nick: return dm = self.bot.data_manager # Only act if the user is currently verified if before.id in dm.get('nick_verified'): dm.remove('nick_verified', lambda x: x == before.id) dm.remove('nick_claim_pending', lambda x: x == before.id) try: dm.remove('nick_verified_name', lambda r: r.get('guild_id') == before.guild.id and r.get('user_id') == before.id) except Exception: pass dm.add('nick_verified_name', { 'guild_id': int(before.guild.id), 'user_id': int(before.id), 'nick': after.nick if after.nick else None, 'ts': int(time.time()) }) try: await self._modlog(after.guild, f"โš ๏ธ {after.mention} changed nickname; **verification revoked**. They must re-claim for a new review.") except Exception: pass rr = self.bot.get_cog('ReactionRoleCog') if rr: try: await rr.maybe_apply_full_access(after) except Exception: pass cards = self.bot.get_cog('UserCardsCog') if cards: try: await cards.refresh_card(after) except Exception: pass @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): # 1) Handle DM nudge confirmations (user reacts with an accept in DM) if payload.guild_id is None and is_accept(payload.emoji) and payload.user_id != self.bot.user.id: entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None) if not entry: return guild = self.bot.get_guild(entry['guild_id']) member = guild.get_member(entry['user_id']) if guild else None if not member: return try: await self.ensure_pending_and_maybe_open(guild, member, source="nick_same") except Exception: pass self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id) rr = self.bot.get_cog('ReactionRoleCog') if rr: try: await rr.maybe_apply_full_access(member) except Exception: pass cards = self.bot.get_cog('UserCardsCog') if cards: try: await cards.refresh_card(member) except Exception: pass return # 2) Handle moderator review reactions in mod channel if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id: if payload.channel_id != self.mod_channel_id or not self.mod_channel_id: return guild = self.bot.get_guild(payload.guild_id) if not guild: return if not is_moderator_userid(guild, payload.user_id, self.bot): return reviews = self.bot.data_manager.get('nick_reviews') review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None) if not review or review.get('status') != 'pending': return member = guild.get_member(int(review['user_id'])) if not member: self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1]) return try: ch = self.bot.get_channel(payload.channel_id) msg = await ch.fetch_message(payload.message_id) except Exception: msg = None dm = self.bot.data_manager now_ts = int(time.time()) approver = f"<@{payload.user_id}>" if str(payload.emoji) == CHECK: if member.id not in dm.get('agreed_nickname'): dm.add('agreed_nickname', int(member.id)) dm.remove('nick_claim_pending', lambda x: x == member.id) if member.id not in dm.get('nick_verified'): dm.add('nick_verified', int(member.id)) try: dm.remove('nick_verified_name', lambda r: r.get('guild_id') == guild.id and r.get('user_id') == member.id) except Exception: pass dm.add('nick_verified_name', { 'guild_id': int(guild.id), 'user_id': int(member.id), 'nick': member.nick if member.nick else None, 'ts': now_ts }) dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1]) if msg: try: await msg.clear_reactions() except Exception: pass try: await msg.edit(content=f"โœ… **Nickname Approved** for {member.mention} by {approver} โ€” {_ts_rel(now_ts)}") except Exception: pass await self._modlog(guild, f"โœ… Nickname **verified** for {member.mention} by {approver} โ€” {_ts_rel(now_ts)}.") rr = self.bot.get_cog('ReactionRoleCog') if rr: try: await rr.maybe_apply_full_access(member) except Exception: pass cards = self.bot.get_cog('UserCardsCog') if cards: try: await cards.refresh_card(member) except Exception: pass else: 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) dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1]) if msg: try: await msg.clear_reactions() except Exception: pass try: await msg.edit(content=f"โŒ **Nickname Rejected** for {member.mention} by {approver} โ€” {_ts_rel(now_ts)}") except Exception: pass await self._modlog(guild, f"โŒ Nickname **rejected** for {member.mention} by {approver} โ€” {_ts_rel(now_ts)}.") rr = self.bot.get_cog('ReactionRoleCog') if rr: try: await rr.maybe_apply_full_access(member) except Exception: pass cards = self.bot.get_cog('UserCardsCog') if cards: try: await cards.refresh_card(member) except Exception: pass # ---------- Mod command to clear pending reviews from datafile ---------- @app_commands.command(name="clear_nick_reviews", description="Delete all PENDING nickname review records for this server.") async def clear_nick_reviews(self, interaction: discord.Interaction): """Moderator-only. Clears all 'pending' entries in data_manager['nick_reviews'] for this guild.""" # Must be used in a guild if not interaction.guild: return await interaction.response.send_message("Use this in a server.", ephemeral=True) # Moderator permission check (your existing gate) if not await require_mod_interaction(interaction): return # require_mod_interaction already responded dm = self.bot.data_manager # Count pending records for this guild pending = [ r for r in dm.get('nick_reviews') if r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending' ] count = len(pending) # Remove pending records if count: dm.remove( 'nick_reviews', lambda r: r.get('guild_id') == interaction.guild.id and r.get('status') == 'pending' ) # Modlog + ephemeral confirmation try: await self._modlog( interaction.guild, f"๐Ÿงน {interaction.user.mention} cleared **{count}** pending nickname review(s)." ) except Exception: pass await interaction.response.send_message( f"Cleared **{count}** pending nickname review{'s' if count != 1 else ''}.", ephemeral=True ) async def setup(bot): await bot.add_cog(NickNudgeCog(bot))