330 lines
14 KiB
Python
330 lines
14 KiB
Python
import asyncio
|
|
import time
|
|
from typing import Optional, Tuple
|
|
import discord
|
|
from discord.ext import commands
|
|
from discord import app_commands
|
|
|
|
from mod_perms import is_moderator_userid
|
|
|
|
CHECK = '✅' # approved/verified
|
|
CROSS = '❌' # reject / no
|
|
PENDING = '✔️' # heavy check mark = pending claim
|
|
ACCEPT = {CHECK, '🫡'}
|
|
NO_MENTIONS = discord.AllowedMentions.none()
|
|
|
|
def _ts_rel(ts: Optional[float] = None) -> str:
|
|
"""Discord relative timestamp like <t:12345:R>."""
|
|
if ts is None:
|
|
ts = time.time()
|
|
return f"<t:{int(ts)}:R>"
|
|
|
|
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 ✅/❌
|
|
- Mods react: ✅ -> mark verified; ❌ -> clear claim and revoke Full Access
|
|
• Stores review mapping in data_manager['nick_reviews']
|
|
"""
|
|
|
|
def __init__(self, bot):
|
|
self.bot = bot
|
|
cfg = bot.config['DEFAULT']
|
|
self.modlog_channel_id = int(cfg['modlog_channel_id'])
|
|
self.mod_channel_id = int(cfg['mod_channel_id']) # same review channel as pirate reports
|
|
# Optional DM nudge loop retained
|
|
try:
|
|
self.loop_enabled = cfg.getboolean('nick_nudge_loop_enabled')
|
|
except Exception:
|
|
self.loop_enabled = False
|
|
self._task = asyncio.create_task(self._nudge_loop()) if self.loop_enabled else None
|
|
|
|
# ---------- utils ----------
|
|
|
|
def cog_unload(self):
|
|
try:
|
|
if self._task:
|
|
self._task.cancel()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _modlog(self, guild: discord.Guild, content: str):
|
|
ch = guild.get_channel(self.modlog_channel_id)
|
|
if ch:
|
|
try:
|
|
await ch.send(content, allowed_mentions=NO_MENTIONS)
|
|
except Exception:
|
|
pass
|
|
self.bot.data_manager.add('modlog', {'ts': time.time(), 'guild_id': guild.id, 'content': content})
|
|
|
|
async def _find_last_nick_change(self, guild: discord.Guild, member: discord.Member) -> Tuple[Optional[str], Optional[str]]:
|
|
"""
|
|
Best-effort: look up last nickname change via audit logs.
|
|
Returns (before_nick, after_nick) or (None, None) if not found/allowed.
|
|
"""
|
|
try:
|
|
async for entry in guild.audit_logs(limit=10, action=discord.AuditLogAction.member_update):
|
|
if entry.target.id != member.id or not entry.changes:
|
|
continue
|
|
before_n = getattr(entry.changes.before, 'get', lambda *_: None)('nick')
|
|
after_n = getattr(entry.changes.after, 'get', lambda *_: None)('nick')
|
|
if before_n is not None or after_n is not None:
|
|
return before_n, after_n
|
|
except Exception:
|
|
pass
|
|
return None, None
|
|
|
|
# ---------- public API (called by ReactionRole cog) ----------
|
|
|
|
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"
|
|
Stores in data_manager['nick_reviews'] a record keyed by the review message_id.
|
|
"""
|
|
if not guild:
|
|
return
|
|
mod_ch = guild.get_channel(self.mod_channel_id)
|
|
if not mod_ch:
|
|
return
|
|
|
|
before_n, after_n = await self._find_last_nick_change(guild, member)
|
|
now_ts = int(time.time())
|
|
|
|
# Compose review text
|
|
title = "📝 **Nickname Verification Request**"
|
|
who = f"User: {member.mention} (`{member.id}`)"
|
|
change = f"Claimed {_ts_rel(now_ts)}"
|
|
from_to = f"From: {repr(before_n) if before_n is not None else 'unknown'} → To: {repr(member.nick) if member.nick else 'None'}"
|
|
method = f"Method: {'/nick_same' if source == 'nick_same' else 'reaction'}"
|
|
instructions = "Moderators: react ✅ to **approve** or ❌ to **reject**."
|
|
content = f"{title}\n{who}\n{from_to}\n{method}\n{change}\n\n{instructions}"
|
|
|
|
try:
|
|
msg = await mod_ch.send(content, allowed_mentions=NO_MENTIONS)
|
|
await msg.add_reaction(CHECK)
|
|
await msg.add_reaction(CROSS)
|
|
except Exception:
|
|
return
|
|
|
|
# Persist review mapping
|
|
self.bot.data_manager.add('nick_reviews', {
|
|
'message_id': int(msg.id),
|
|
'guild_id': int(guild.id),
|
|
'user_id': int(member.id),
|
|
'before_nick': before_n if before_n is None or isinstance(before_n, str) else str(before_n),
|
|
'claimed_nick': member.nick if member.nick else None,
|
|
'status': 'pending',
|
|
'source': source,
|
|
'ts': now_ts
|
|
})
|
|
|
|
# Log to modlog
|
|
await self._modlog(guild, f"🔎 Nickname review opened for {member.mention} — {method} — {_ts_rel(now_ts)}.")
|
|
|
|
# ---------- DM nudge loop (unchanged) ----------
|
|
|
|
async def _nudge_loop(self):
|
|
await self.bot.wait_until_ready()
|
|
while not self.bot.is_closed():
|
|
try:
|
|
now = time.time()
|
|
for guild in self.bot.guilds:
|
|
for member in guild.members:
|
|
if member.bot or not member.joined_at:
|
|
continue
|
|
if (now - member.joined_at.timestamp()) < 24*3600:
|
|
continue
|
|
# If they already have a server nick OR already claimed/verified, skip nudging
|
|
dm = self.bot.data_manager
|
|
if (member.nick and member.nick.strip()):
|
|
continue
|
|
if member.id in dm.get('nick_verified') or member.id in dm.get('nick_claim_pending'):
|
|
continue
|
|
if member.id in dm.get('nick_nudged'):
|
|
continue
|
|
try:
|
|
dmchan = await member.create_dm()
|
|
msg = await dmchan.send(
|
|
"Hey! On this server we require your **server nickname** to match your in-game character name.\n\n"
|
|
"If your default Discord display name is already identical to your in-game name, react ✅ **or 🫡** below "
|
|
"or run `/nick_same`. Otherwise, please set your **server nickname** to your in-game name and react ✅ **or 🫡**."
|
|
)
|
|
await msg.add_reaction(CHECK)
|
|
self.bot.data_manager.add('nick_dm_map', {
|
|
'message_id': int(msg.id),
|
|
'user_id': int(member.id),
|
|
'guild_id': int(guild.id),
|
|
'ts': now
|
|
})
|
|
self.bot.data_manager.add('nick_nudged', int(member.id))
|
|
await self._modlog(guild, f"📨 Sent nickname nudge to {member.mention}")
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(1800) # every 30 minutes
|
|
|
|
# ---------- listeners ----------
|
|
|
|
@commands.Cog.listener()
|
|
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
|
# 1) Handle DM nudge confirmations (user reacts ✅ in DM)
|
|
if payload.guild_id is None and str(payload.emoji) in ACCEPT and payload.user_id != self.bot.user.id:
|
|
entry = next((m for m in self.bot.data_manager.get('nick_dm_map') if m['message_id'] == payload.message_id), None)
|
|
if not entry:
|
|
return
|
|
guild = self.bot.get_guild(entry['guild_id'])
|
|
member = guild.get_member(entry['user_id']) if guild else None
|
|
if not member:
|
|
return
|
|
# Treat as a claim: mark pending + create review
|
|
dm = self.bot.data_manager
|
|
if member.id not in dm.get('agreed_nickname'):
|
|
dm.add('agreed_nickname', int(member.id))
|
|
# Always (re)mark pending & clear verified
|
|
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))
|
|
|
|
# Create review
|
|
try:
|
|
await self.start_nick_review(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)
|
|
|
|
# Refresh card and maybe full access (pending does NOT block full access)
|
|
rr = self.bot.get_cog('ReactionRoleCog')
|
|
if rr:
|
|
try:
|
|
await rr.maybe_apply_full_access(member)
|
|
except Exception:
|
|
pass
|
|
cards = self.bot.get_cog('UserCardsCog')
|
|
if cards:
|
|
try:
|
|
await cards.refresh_card(member)
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
# 2) Handle moderator review reactions in mod channel
|
|
if payload.guild_id and str(payload.emoji) in (CHECK, CROSS) and payload.user_id != self.bot.user.id:
|
|
if payload.channel_id != self.mod_channel_id:
|
|
return
|
|
guild = self.bot.get_guild(payload.guild_id)
|
|
if not guild:
|
|
return
|
|
# Only moderators can act
|
|
if not is_moderator_userid(guild, payload.user_id, self.bot):
|
|
return
|
|
|
|
# Is this a review message?
|
|
reviews = self.bot.data_manager.get('nick_reviews')
|
|
review = next((r for r in reviews if r.get('message_id') == payload.message_id and r.get('guild_id') == guild.id), None)
|
|
if not review or review.get('status') != 'pending':
|
|
return
|
|
|
|
member = guild.get_member(int(review['user_id']))
|
|
if not member:
|
|
# mark closed missing
|
|
self.bot.data_manager.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status':'closed_missing'}), r)[1])
|
|
return
|
|
|
|
# Fetch and edit the review message content (best-effort)
|
|
try:
|
|
ch = self.bot.get_channel(payload.channel_id)
|
|
msg = await ch.fetch_message(payload.message_id)
|
|
except Exception:
|
|
msg = None
|
|
|
|
dm = self.bot.data_manager
|
|
now_ts = int(time.time())
|
|
approver = f"<@{payload.user_id}>"
|
|
|
|
if str(payload.emoji) == CHECK:
|
|
# Approve: mark verified, clear pending, ensure agreed flag set
|
|
if member.id not in dm.get('agreed_nickname'):
|
|
dm.add('agreed_nickname', int(member.id))
|
|
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
|
if member.id not in dm.get('nick_verified'):
|
|
dm.add('nick_verified', int(member.id))
|
|
|
|
# Update review record
|
|
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'approved', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
|
|
|
|
# Edit the review message
|
|
if msg:
|
|
try:
|
|
await msg.clear_reactions()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await msg.edit(content=f"✅ **Nickname Approved** for {member.mention} by {approver} — {_ts_rel(now_ts)}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Modlog
|
|
await self._modlog(guild,
|
|
f"✅ Nickname **verified** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
|
|
)
|
|
|
|
# Refresh roles / card
|
|
rr = self.bot.get_cog('ReactionRoleCog')
|
|
if rr:
|
|
try:
|
|
await rr.maybe_apply_full_access(member)
|
|
except Exception:
|
|
pass
|
|
cards = self.bot.get_cog('UserCardsCog')
|
|
if cards:
|
|
try:
|
|
await cards.refresh_card(member)
|
|
except Exception:
|
|
pass
|
|
|
|
else:
|
|
# Reject: clear all nickname flags; Full Access should be revoked by maybe_apply_full_access
|
|
dm.remove('agreed_nickname', lambda x: x == member.id)
|
|
dm.remove('nick_claim_pending', lambda x: x == member.id)
|
|
dm.remove('nick_verified', lambda x: x == member.id)
|
|
|
|
# Update review record
|
|
dm.update('nick_reviews', lambda r: r is review, lambda r: (r.update({'status': 'rejected', 'decided_ts': now_ts, 'moderator_id': int(payload.user_id)}), r)[1])
|
|
|
|
if msg:
|
|
try:
|
|
await msg.clear_reactions()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await msg.edit(content=f"❌ **Nickname Rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}")
|
|
except Exception:
|
|
pass
|
|
|
|
await self._modlog(guild,
|
|
f"❌ Nickname **rejected** for {member.mention} by {approver} — {_ts_rel(now_ts)}."
|
|
)
|
|
|
|
# Refresh roles / card
|
|
rr = self.bot.get_cog('ReactionRoleCog')
|
|
if rr:
|
|
try:
|
|
await rr.maybe_apply_full_access(member)
|
|
except Exception:
|
|
pass
|
|
cards = self.bot.get_cog('UserCardsCog')
|
|
if cards:
|
|
try:
|
|
await cards.refresh_card(member)
|
|
except Exception:
|
|
pass
|
|
|
|
async def setup(bot):
|
|
await bot.add_cog(NickNudgeCog(bot))
|