0.3.9.3.a1

Fixes duplicate nickname reviews when users react with multiple emojis
This commit is contained in:
Franz Rolfsvaag 2025-08-10 23:37:14 +02:00
parent 4e77cddc92
commit 268966a4ae
4 changed files with 73 additions and 76 deletions

9
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of: # Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.3.9.2.a10" VERSION = "0.3.9.3.a1"
# ---------- Env loading ---------- # ---------- Env loading ----------
@ -118,13 +118,6 @@ async def on_ready():
env_cfg = cfg_helper(bot) 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) # Per-guild permission sanity checks (env-aware)
await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds]) await asyncio.gather(*[_guild_selfcheck(g, env_cfg) for g in bot.guilds])

View File

@ -5,7 +5,6 @@ import aiohttp
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from modules.common.settings import cfg from modules.common.settings import cfg
async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | None]: 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.""" """Best-effort: read latest commit subject + short sha from a Gitea RSS feed."""
try: try:
@ -26,7 +25,6 @@ async def _fetch_latest_subject_sha(rss_url: str) -> tuple[str | None, str | Non
except Exception: except Exception:
return None, None return None, None
async def post_boot_notice(bot): async def post_boot_notice(bot):
""" """
Posts a boot status message to the configured modlog channel. 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": elif status == "cache_only_error":
line = f"Successfully booted from cached version: v{new_v}. Program repository not accessible!" line = f"Successfully booted from cached version: v{new_v}. Program repository not accessible!"
# If wrapper didnt 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: if not line:
return # nothing to say return # nothing to say

View File

@ -2,6 +2,8 @@
import asyncio import asyncio
import time import time
from typing import Optional, Tuple from typing import Optional, Tuple
from collections import defaultdict
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands from discord import app_commands
@ -16,6 +18,9 @@ PENDING = '✔️' # heavy check mark = pending claim
ACCEPT = {CHECK, '🫡'} ACCEPT = {CHECK, '🫡'}
NO_MENTIONS = discord.AllowedMentions.none() 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: def _ts_rel(ts: Optional[float] = None) -> str:
"""Discord relative timestamp like <t:12345:R>.""" """Discord relative timestamp like <t:12345:R>."""
@ -29,7 +34,7 @@ class NickNudgeCog(commands.Cog):
Handles: Handles:
DM nickname nudge loop (optional; unchanged behavior) DM nickname nudge loop (optional; unchanged behavior)
Nickname *review* workflow for claims: 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 - Mods react: -> mark verified; -> clear claim and revoke Full Access
Stores review mapping in data_manager['nick_reviews'] Stores review mapping in data_manager['nick_reviews']
""" """
@ -83,20 +88,57 @@ class NickNudgeCog(commands.Cog):
pass pass
return None, None 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"): 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. Create a nickname review entry for this member in the mod channel.
- source: "claim" or "nick_same"
Stores in data_manager['nick_reviews'] a record keyed by the review message_id. Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
""" """
if not guild or not self.mod_channel_id: if not guild or not self.mod_channel_id:
return 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) mod_ch = guild.get_channel(self.mod_channel_id)
if not mod_ch: if not mod_ch:
return return
@ -191,21 +233,12 @@ class NickNudgeCog(commands.Cog):
member = guild.get_member(entry['user_id']) if guild else None member = guild.get_member(entry['user_id']) if guild else None
if not member: if not member:
return 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: # Atomic claim path
try: try:
await self.start_nick_review(guild, member, source="nick_same") await self.ensure_pending_and_maybe_open(guild, member, source="nick_same")
except Exception: except Exception:
pass pass
# Clean map entry # Clean map entry
self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id) self.bot.data_manager.remove('nick_dm_map', lambda m: m['message_id'] == payload.message_id)

View File

@ -10,7 +10,7 @@ class ReactionRoleCog(commands.Cog):
""" """
Records agreements and manages Full Access. Records agreements and manages Full Access.
Nickname flow: 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 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). Full Access: granted when Rules + RoE + Nickname *claimed* (pending or verified).
Revoked when any of the three is missing. Revoked when any of the three is missing.
@ -100,24 +100,13 @@ class ReactionRoleCog(commands.Cog):
if not member or not ctx.guild: if not member or not ctx.guild:
return await ctx.reply("Use this in a server.", ephemeral=True) return await ctx.reply("Use this in a server.", ephemeral=True)
dm = self.bot.data_manager # Atomic path handled inside NickNudge
if member.id not in dm.get('agreed_nickname'): nn = self.bot.get_cog('NickNudgeCog')
dm.add('agreed_nickname', int(member.id)) if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
# Mark pending (clear verified if present) try:
dm.remove('nick_verified', lambda x: x == member.id) await nn.ensure_pending_and_maybe_open(ctx.guild, member, source="nick_same")
newly_pending = False except Exception:
if member.id not in dm.get('nick_claim_pending'): pass
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
await self.maybe_apply_full_access(member) await self.maybe_apply_full_access(member)
await ctx.reply("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) 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)) dm.add('agreed_engagement', int(member.id))
elif payload.message_id == self.nick_msg_id: elif payload.message_id == self.nick_msg_id:
# Claim nickname via reaction -> mark agreed + pending (idempotent) # Atomic claim -> ONE review only
newly_pending = False nn = self.bot.get_cog('NickNudgeCog')
if member.id not in dm.get('agreed_nickname'): if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
dm.add('agreed_nickname', int(member.id)) try:
dm.remove('nick_verified', lambda x: x == member.id) await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
if member.id not in dm.get('nick_claim_pending'): except Exception:
dm.add('nick_claim_pending', int(member.id)) pass
newly_pending = True else:
return
# 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
else: else:
return return
except Exception: except Exception: