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:
|
# Version consists of:
|
||||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
# 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 ----------
|
# ---------- 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])
|
||||||
|
|
||||||
|
@ -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 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:
|
if not line:
|
||||||
return # nothing to say
|
return # nothing to say
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
Loading…
Reference in New Issue
Block a user