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:
# 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 ----------
@ -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])

View File

@ -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 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:
return # nothing to say

View File

@ -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 <t:12345:R>."""
@ -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)

View File

@ -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: