From c09f36162dfb55c6115044c94bcb2081102a9929 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 10 Aug 2025 23:53:16 +0200 Subject: [PATCH] 0.3.9.3.a2 Fixes nick review lock, preventing re-reviews to be sent out despite genuine --- bot.py | 2 +- modules/nick_nudge/nick_nudge.py | 106 ++++++++++++++++--------- modules/reaction_role/reaction_role.py | 10 +++ 3 files changed, 78 insertions(+), 40 deletions(-) diff --git a/bot.py b/bot.py index d552c2a..0ebba2b 100644 --- a/bot.py +++ b/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.3.9.3.a1" +VERSION = "0.3.9.3.a2" # ---------- Env loading ---------- diff --git a/modules/nick_nudge/nick_nudge.py b/modules/nick_nudge/nick_nudge.py index 3ce8dbf..f622903 100644 --- a/modules/nick_nudge/nick_nudge.py +++ b/modules/nick_nudge/nick_nudge.py @@ -31,23 +31,25 @@ def _ts_rel(ts: Optional[float] = None) -> str: class NickNudgeCog(commands.Cog): """ - Handles: - • DM nickname nudge loop (optional; unchanged behavior) - • Nickname *review* workflow for claims: - - Atomic transition to pending + open exactly ONE review - - Mods react: ✅ -> mark verified; ❌ -> clear claim and revoke Full Access - • Stores review mapping in data_manager['nick_reviews'] + 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) - # Config via helper (ENV -> optional INI fallback) self.modlog_channel_id = r.int('modlog_channel_id', 0) - self.mod_channel_id = r.int('mod_channel_id', 0) # same review channel as pirate reports + self.mod_channel_id = r.int('mod_channel_id', 0) - # Optional DM nudge loop retained self.loop_enabled = r.bool('nick_nudge_loop_enabled', False) self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None @@ -72,10 +74,6 @@ class NickNudgeCog(commands.Cog): 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]]: - """ - Best-effort: look up last nickname change via audit logs. - Returns (before_nick, after_nick) or (None, None) if not found/allowed. - """ 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: @@ -129,13 +127,9 @@ class NickNudgeCog(commands.Cog): except Exception: pass - # ---------- public API (kept; called by ensure_pending...) ---------- + # ---------- public API ---------- async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"): - """ - Create a nickname review entry for this member in the mod channel. - Stores in data_manager['nick_reviews'] a record keyed by the review message_id. - """ if not guild or not self.mod_channel_id: return @@ -143,10 +137,9 @@ class NickNudgeCog(commands.Cog): if not mod_ch: return - before_n, after_n = await self._find_last_nick_change(guild, member) + before_n, _ = await self._find_last_nick_change(guild, member) now_ts = int(time.time()) - # Compose review text title = "📝 **Nickname Verification Request**" who = f"User: {member.mention} (`{member.id}`)" change = f"Claimed {_ts_rel(now_ts)}" @@ -162,7 +155,6 @@ class NickNudgeCog(commands.Cog): except Exception: return - # Persist review mapping self.bot.data_manager.add('nick_reviews', { 'message_id': int(msg.id), 'guild_id': int(guild.id), @@ -174,7 +166,6 @@ class NickNudgeCog(commands.Cog): 'ts': now_ts }) - # Log to modlog await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.") # ---------- DM nudge loop (unchanged) ---------- @@ -190,7 +181,6 @@ class NickNudgeCog(commands.Cog): continue if (now_t - member.joined_at.timestamp()) < 24 * 3600: continue - # If they already have a server nick OR already claimed/verified, skip nudging dm = self.bot.data_manager if (member.nick and member.nick.strip()): continue @@ -218,10 +208,52 @@ class NickNudgeCog(commands.Cog): pass except Exception: pass - await asyncio.sleep(1800) # every 30 minutes + 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) @@ -234,16 +266,13 @@ class NickNudgeCog(commands.Cog): if not member: return - # Atomic claim path try: await self.ensure_pending_and_maybe_open(guild, member, source="nick_same") except Exception: pass - # Clean map entry self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id) - # Refresh card and maybe full access (pending does NOT block full access) rr = self.bot.get_cog('ReactionRoleCog') if rr: try: @@ -265,11 +294,9 @@ class NickNudgeCog(commands.Cog): guild = self.bot.get_guild(payload.guild_id) if not guild: return - # Only moderators can act if not is_moderator_userid(guild, payload.user_id, self.bot): return - # Is this a review message? 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': @@ -277,11 +304,9 @@ class NickNudgeCog(commands.Cog): member = guild.get_member(int(review['user_id'])) if not member: - # mark closed missing self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'closed_missing'}), r)[1]) return - # Fetch and edit the review message content (best-effort) try: ch = self.bot.get_channel(payload.channel_id) msg = await ch.fetch_message(payload.message_id) @@ -293,17 +318,25 @@ class NickNudgeCog(commands.Cog): approver = f"<@{payload.user_id}>" if str(payload.emoji) == CHECK: - # Approve: mark verified, clear pending, ensure agreed flag set 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)) - # Update review record + 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]) - # Edit the review message if msg: try: await msg.clear_reactions() @@ -314,10 +347,8 @@ class NickNudgeCog(commands.Cog): except Exception: pass - # Modlog await self._modlog(guild, f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}.") - # Refresh roles / card rr = self.bot.get_cog('ReactionRoleCog') if rr: try: @@ -332,12 +363,10 @@ class NickNudgeCog(commands.Cog): pass else: - # Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access 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) - # Update review record 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: @@ -352,7 +381,6 @@ class NickNudgeCog(commands.Cog): await self._modlog(guild, f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}.") - # Refresh roles / card rr = self.bot.get_cog('ReactionRoleCog') if rr: try: diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index d24a794..84b80fd 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -138,6 +138,16 @@ class ReactionRoleCog(commands.Cog): 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'):