0.3.9.3.a1
Fixes duplicate nickname reviews when users react with multiple emojis
This commit is contained in:
parent
4e77cddc92
commit
268966a4ae
9
bot.py
9
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])
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user