diff --git a/bot.py b/bot.py index 2372200..d552c2a 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.2.a10" +VERSION = "0.3.9.3.a1" # ---------- Env loading ---------- @@ -118,13 +118,6 @@ async def on_ready(): env_cfg = cfg_helper(bot) - # DEBUG: show what cfg resolves - for k in ("mod_channel_id", "modlog_channel_id", "pirates_list_channel_id"): - try: - print(f"[DEBUG cfg] {k}: str={env_cfg.get(k)} int={env_cfg.int(k, 0)}") - except Exception as e: - print(f"[DEBUG cfg] {k}: error={e!r}") - # Per-guild permission sanity checks (env-aware) await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds]) diff --git a/modules/common/boot_notice.py b/modules/common/boot_notice.py index dbe6117..5635f6e 100644 --- a/modules/common/boot_notice.py +++ b/modules/common/boot_notice.py @@ -5,7 +5,6 @@ import aiohttp import xml.etree.ElementTree as ET from modules.common.settings import cfg - async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | None]: """Best-effort: read latest commit subject + short sha from a Gitea RSS feed.""" try: @@ -26,7 +25,6 @@ async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | Non except Exception: return None, None - async def post_boot_notice(bot): """ Posts a boot status message to the configured modlog channel. @@ -46,14 +44,6 @@ async def post_boot_notice(bot): elif status == "cache_only_error": line = f"Successfully booted from cached version: v{new_v}. Program repository not accessible!" - # If wrapper didn’t set a status, optionally show latest commit subject from RSS (if provided) - if not line: - rss = os.getenv("SHAI_REPO_RSS", "").strip() - if rss: - subject, sha = await _fetch_latest_subject_sha(rss) - if subject and len(subject) > 5: - line = f"Booted (no BSM env). Latest commit: {subject}" + (f" ({sha})" if sha else "") - if not line: return # nothing to say diff --git a/modules/nick_nudge/nick_nudge.py b/modules/nick_nudge/nick_nudge.py index 740bdfd..3ce8dbf 100644 --- a/modules/nick_nudge/nick_nudge.py +++ b/modules/nick_nudge/nick_nudge.py @@ -2,6 +2,8 @@ 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 @@ -16,6 +18,9 @@ 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 .""" @@ -29,7 +34,7 @@ class NickNudgeCog(commands.Cog): Handles: • DM nickname nudge loop (optional; unchanged behavior) • Nickname *review* workflow for claims: - - On claim (via reaction or /nick_same): create a mod review in mod_channel with ✅/❌ + - 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'] """ @@ -83,20 +88,57 @@ class NickNudgeCog(commands.Cog): pass return None, None - # ---------- public API (called by ReactionRole cog) ---------- + # ---------- 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 (kept; called by ensure_pending...) ---------- async def start_nick_review(self, guild: discord.Guild, member: discord.Member, source: str = "claim"): """ - Create (or update) a nickname review entry for this member in the mod channel. - - source: "claim" or "nick_same" + 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 - # If a pending review already exists for this user in this guild, do nothing - for r in self.bot.data_manager.get('nick_reviews'): - if r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending': - return + mod_ch = guild.get_channel(self.mod_channel_id) if not mod_ch: return @@ -191,21 +233,12 @@ class NickNudgeCog(commands.Cog): member = guild.get_member(entry['user_id']) if guild else None if not member: return - # Treat as a claim: mark pending (idempotent) and open review only on first transition - dm = self.bot.data_manager - 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) - newly_pending = False - 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="nick_same") - except Exception: - pass + # 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) diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index 34f0fe9..d24a794 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -10,7 +10,7 @@ class ReactionRoleCog(commands.Cog): """ Records agreements and manages Full Access. Nickname flow: - • Add accept on nickname message -> mark agreed + pending (idempotent) and open ONE review + • 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. @@ -100,24 +100,13 @@ class ReactionRoleCog(commands.Cog): if not member or not ctx.guild: return await ctx.reply("Use this in a server.", ephemeral=True) - dm = self.bot.data_manager - if member.id not in dm.get('agreed_nickname'): - dm.add('agreed_nickname', int(member.id)) - # Mark pending (clear verified if present) - dm.remove('nick_verified', lambda x: x == member.id) - newly_pending = False - if member.id not in dm.get('nick_claim_pending'): - dm.add('nick_claim_pending', int(member.id)) - newly_pending = True - - # Open/refresh a review with NickNudge (only on first transition to pending) - if newly_pending: - nn = self.bot.get_cog('NickNudgeCog') - if nn and hasattr(nn, 'start_nick_review'): - try: - await nn.start_nick_review(ctx.guild, member, source="nick_same") - except Exception: - pass + # 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) @@ -149,23 +138,15 @@ class ReactionRoleCog(commands.Cog): dm.add('agreed_engagement', int(member.id)) elif payload.message_id == self.nick_msg_id: - # Claim nickname via reaction -> mark agreed + pending (idempotent) - newly_pending = False - 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 - - # Only open a review when we just transitioned to pending - if newly_pending: - nn = self.bot.get_cog('NickNudgeCog') - if nn and hasattr(nn, 'start_nick_review'): - try: - await nn.start_nick_review(guild, member, source="claim") - except Exception: - pass + # 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: