From 0387750800ebef408d6af58321029730e7eadca8 Mon Sep 17 00:00:00 2001 From: frarol96 Date: Sun, 10 Aug 2025 15:12:20 +0000 Subject: [PATCH] Delete modules/user_cards/user_cards.py --- modules/user_cards/user_cards.py | 510 ------------------------------- 1 file changed, 510 deletions(-) delete mode 100644 modules/user_cards/user_cards.py diff --git a/modules/user_cards/user_cards.py b/modules/user_cards/user_cards.py deleted file mode 100644 index 287be69..0000000 --- a/modules/user_cards/user_cards.py +++ /dev/null @@ -1,510 +0,0 @@ -import asyncio -import time -from typing import Optional, Set, Tuple -import discord -from discord.ext import commands -from modules.common.emoji_accept import is_accept - -CHECK = '✅' # verified -CROSS = '❌' # not done -PENDING = '✔️' # claimed / pending review -ACCEPT = {CHECK, '🫡'} -NO_MENTIONS = discord.AllowedMentions.none() - -class UserCardsCog(commands.Cog): - """ - Per-user status cards with live reconcile and offline review triggers. - Title = Nickname → Display → Global → Username (adds '*' if NO server nickname) - Fields: Rules / RoE = ✅/❌, Nickname = ❌ | ✔️(pending) | ✅(verified) - Colors: - - Green: Rules✅ & RoE✅ & Nickname✅ - - Blue: Rules✅ & RoE✅ & Nickname✔️ (pending) - - Yellow: Some/partial - - Red: None - """ - - def __init__(self, bot): - self.bot = bot - cfg = bot.config['DEFAULT'] - self.userslist_channel_id = int(cfg['userslist_channel_id']) - self.modlog_channel_id = int(cfg['modlog_channel_id']) - self.mod_channel_id = int(cfg.get('mod_channel_id', '0') or 0) - - # reaction-role authoritative messages/roles - self.rules_msg_id = int(cfg['rules_message_id']) - self.engage_msg_id = int(cfg['engagement_message_id']) - self.nick_msg_id = int(cfg['nickname_message_id']) - self.rules_role_id = int(cfg['rules_role_id']) - self.engage_role_id = int(cfg['engagement_role_id']) - self.full_access_role_id = int(cfg['full_access_role_id']) - self._refresh_locks = {} # per-user locks to avoid racey double-posts - - # Optional periodic refresh (twice a day) - try: - self.cron_enabled = cfg.getboolean('user_cards_cron_enabled') - except Exception: - self.cron_enabled = False - - self._cron_task = asyncio.create_task(self._periodic_refresh()) if self.cron_enabled else None - self._startup_task = asyncio.create_task(self._startup_reconcile()) - - def cog_unload(self): - for t in (self._cron_task, self._startup_task): - if t: - try: t.cancel() - except Exception: pass - - # ---------- status helpers ---------- - - def _rules_ok(self, uid: int) -> bool: - return uid in self.bot.data_manager.get('agreed_rules') - - def _engage_ok(self, uid: int) -> bool: - return uid in self.bot.data_manager.get('agreed_engagement') - - def _nick_status(self, uid: int) -> str: - dm = self.bot.data_manager - if uid in dm.get('nick_verified'): - return 'verified' - if uid in dm.get('nick_claim_pending') or uid in dm.get('agreed_nickname'): - return 'pending' - return 'none' - - def _primary_name(self, m: discord.Member) -> str: - has_nick = bool(m.nick and m.nick.strip()) - base = m.nick or m.display_name or m.global_name or m.name - if not has_nick: - base = f"{base} *" - return base - - def _secondary_name(self, m: discord.Member) -> str: - return m.global_name or m.name - - def _card_color(self, uid: int) -> discord.Color: - r = self._rules_ok(uid) - e = self._engage_ok(uid) - n = self._nick_status(uid) - if r and e and n == 'verified': - return discord.Color.green() - if r and e and n == 'pending': - return discord.Color.blurple() - if r or e or n != 'none': - return discord.Color.yellow() - return discord.Color.red() - - # ---------- persistence ---------- - - def _get_card_record(self, user_id: int): - for r in self.bot.data_manager.get('user_cards'): - if r.get('user_id') == user_id: - return r - return None - - async def _build_embed(self, member: discord.Member) -> discord.Embed: - uid = member.id - rules = CHECK if self._rules_ok(uid) else CROSS - roe = CHECK if self._engage_ok(uid) else CROSS - ns = self._nick_status(uid) - if ns == 'verified': - nick_val = CHECK - elif ns == 'pending': - nick_val = PENDING - else: - nick_val = CROSS - - title_left = self._primary_name(member) - title_right = self._secondary_name(member) - title = f"{title_left} ({title_right})" - - join_val = f"" if member.joined_at else "Unknown" - - embed = discord.Embed(title=title, color=self._card_color(uid)) - embed.add_field(name="Rules", value=rules) - embed.add_field(name="RoE", value=roe) - embed.add_field(name="Nickname Set", value=nick_val) - embed.add_field(name="Joined", value=join_val, inline=False) - if member.avatar: - embed.set_thumbnail(url=member.avatar.url) - - # NEW: stable identity so we can find/edit the right card later - embed.set_footer(text=f"UID:{member.id}") - return embed - - async def refresh_card(self, member: discord.Member): - """ - Idempotent refresh: - 1) Try to edit the tracked message. - 2) If not found, search the channel by footer marker and edit that. - 3) If still not found, post a new one, then delete any stragglers with the same marker. - """ - if not member or not member.guild: - return - - async with self._lock_for(member.id): - dm = self.bot.data_manager - channel: discord.TextChannel = member.guild.get_channel(self.userslist_channel_id) - if not channel: - return - - record = self._get_card_record(member.id) - embed = await self._build_embed(member) - - async def _fetch_in(ch_id: int, mid: int) -> Optional[discord.Message]: - ch = member.guild.get_channel(ch_id) - if not ch: - return None - try: - return await ch.fetch_message(mid) - except Exception: - return None - - # 1) Attempt to edit the exact tracked message (configured channel, then stored channel) - msg = None - stored_ch_id = int(record.get('channel_id')) if record and record.get('channel_id') else None - if record and record.get('message_id'): - mid = int(record['message_id']) - msg = await _fetch_in(channel.id, mid) - if not msg and stored_ch_id and stored_ch_id != channel.id: - msg = await _fetch_in(stored_ch_id, mid) - - # 2) If we couldn’t find by record, try to find by footer marker in channel history - if not msg: - msg = await self._find_existing_card(channel, member.id) - - # 3) Edit in place if found - if msg: - try: - await msg.edit(embed=embed, allowed_mentions=NO_MENTIONS) - except Exception: - pass - # upsert record with the definitive location - dm.remove('user_cards', lambda r: r.get('user_id') == member.id) - dm.add('user_cards', {'user_id': member.id, 'message_id': msg.id, 'channel_id': msg.channel.id}) - return - - # 4) Post fresh card - new_msg = await channel.send(embed=embed, allowed_mentions=NO_MENTIONS) - - # 5) Clean up any other messages that look like this user's card - try: - # Find any *other* occurrences with the same footer marker and delete them - marker = f"UID:{member.id}" - async for m in channel.history(limit=400, oldest_first=False): - if m.id == new_msg.id or m.author.id != self.bot.user.id or not m.embeds: - continue - foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else "" - if foot == marker: - try: - await m.delete() - except Exception: - pass - except Exception: - pass - - # 6) Persist mapping - dm.remove('user_cards', lambda r: r.get('user_id') == member.id) - dm.add('user_cards', {'user_id': member.id, 'message_id': new_msg.id, 'channel_id': new_msg.channel.id}) - - def _lock_for(self, user_id: int) -> asyncio.Lock: - lk = self._refresh_locks.get(user_id) - if not lk: - lk = asyncio.Lock() - self._refresh_locks[user_id] = lk - return lk - - async def _find_existing_card(self, channel: discord.TextChannel, user_id: int) -> Optional[discord.Message]: - """Search recent history for a card we posted for this user (by footer marker).""" - marker = f"UID:{user_id}" - try: - async for m in channel.history(limit=400, oldest_first=False): - if m.author.id != self.bot.user.id: - continue - if not m.embeds: - continue - foot = (m.embeds[0].footer.text or "") if m.embeds[0].footer else "" - if foot == marker: - return m - except Exception: - pass - return None - - async def _log(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', {'guild_id': guild.id, 'content': content}) - - # ---------- RR message lookup & reactor collection ---------- - - def _get_cached_msg_channel_id(self, guild_id: int, message_id: int) -> Optional[int]: - for r in self.bot.data_manager.get('rr_msg_channels'): - if r.get('guild_id') == guild_id and r.get('message_id') == message_id: - return int(r.get('channel_id')) - return None - - def _cache_msg_channel_id(self, guild_id: int, message_id: int, channel_id: int): - dm = self.bot.data_manager - dm.remove('rr_msg_channels', lambda r: r.get('guild_id') == guild_id and r.get('message_id') == message_id) - dm.add('rr_msg_channels', {'guild_id': guild_id, 'message_id': int(message_id), 'channel_id': int(channel_id)}) - - async def _get_message_by_id(self, guild: discord.Guild, message_id: int) -> Optional[discord.Message]: - ch_id = self._get_cached_msg_channel_id(guild.id, message_id) - if ch_id: - ch = guild.get_channel(ch_id) - if ch: - try: - return await ch.fetch_message(message_id) - except Exception: - pass - for ch in guild.text_channels: - try: - msg = await ch.fetch_message(message_id) - self._cache_msg_channel_id(guild.id, message_id, ch.id) - return msg - except (discord.Forbidden, discord.NotFound, discord.HTTPException): - continue - return None - - async def _collect_reactors(self, message: Optional[discord.Message]) -> Set[int]: - ids: Set[int] = set() - if not message: - return ids - for rxn in message.reactions: - if is_accept(rxn.emoji): - async for u in rxn.users(limit=None): - if not u.bot: - ids.add(u.id) - return ids - - # ---------- reconcile (creates reviews for offline nickname claims) ---------- - - async def _open_review_fallback(self, guild: discord.Guild, member: discord.Member, source: str): - """If NickNudgeCog.start_nick_review is missing, post a simple review to mod channel.""" - if not self.mod_channel_id: - return - ch = guild.get_channel(self.mod_channel_id) - if not ch: - return - try: - await ch.send( - f"📝 **Nickname claim review (fallback)**\n" - f"User: {member.mention} (`{member.id}`)\n" - f"Source: `{source}`\n" - f"Current name: **{self._primary_name(member)}**\n" - f"Please verify and handle manually.", - allowed_mentions=NO_MENTIONS - ) - except Exception: - pass - - async def _reconcile_agreements(self, guild: discord.Guild) -> Tuple[int, int, int, int]: - """ - Reconcile from live reaction messages. - Returns (rules_changed, engage_changed, nick_added_claims, nick_removed_claims). - Also opens nickname reviews for any **unreviewed** claimers (startup/offline). - """ - dm = self.bot.data_manager - - rules_msg = await self._get_message_by_id(guild, self.rules_msg_id) - engage_msg = await self._get_message_by_id(guild, self.engage_msg_id) - nick_msg = await self._get_message_by_id(guild, self.nick_msg_id) - - new_rules = await self._collect_reactors(rules_msg) - new_engage = await self._collect_reactors(engage_msg) - new_nick = await self._collect_reactors(nick_msg) - - old_rules = set(dm.get('agreed_rules')) - old_engage = set(dm.get('agreed_engagement')) - old_nick = set(dm.get('agreed_nickname')) - - rules_added, rules_removed = new_rules - old_rules, old_rules - new_rules - engage_added, engage_removed = new_engage - old_engage, old_engage - new_engage - nick_added, nick_removed = new_nick - old_nick, old_nick - new_nick - - # Persist live truth for rules/engagement/nickname claim (reaction path) - if old_rules != new_rules: - dm.remove('agreed_rules', lambda _x: True) - for uid in new_rules: - dm.add('agreed_rules', int(uid)) - - if old_engage != new_engage: - dm.remove('agreed_engagement', lambda _x: True) - for uid in new_engage: - dm.add('agreed_engagement', int(uid)) - - if old_nick != new_nick: - dm.remove('agreed_nickname', lambda _x: True) - for uid in new_nick: - dm.add('agreed_nickname', int(uid)) - - # Apply/repair roles for Rules/Engagement - rules_role = guild.get_role(self.rules_role_id) - engage_role = guild.get_role(self.engage_role_id) - for uid in (rules_added | rules_removed | engage_added | engage_removed): - m = guild.get_member(uid) - if not m or m.bot: - continue - if rules_role: - try: - if uid in new_rules and rules_role not in m.roles: - await m.add_roles(rules_role, reason="Reconcile: reacted to Rules ✅") - elif uid not in new_rules and rules_role in m.roles: - await m.remove_roles(rules_role, reason="Reconcile: removed Rules ✅") - except Exception: - pass - if engage_role: - try: - if uid in new_engage and engage_role not in m.roles: - await m.add_roles(engage_role, reason="Reconcile: reacted to RoE ✅") - elif uid not in new_engage and engage_role in m.roles: - await m.remove_roles(engage_role, reason="Reconcile: removed RoE ✅") - except Exception: - pass - - # --- New part: open reviews for *any* unreviewed claimers (startup/offline) --- - nn = self.bot.get_cog('NickNudgeCog') - - verified_set = set(dm.get('nick_verified')) - pending_set = set(dm.get('nick_claim_pending')) - open_review_user_ids = { - int(r['user_id']) - for r in dm.get('nick_reviews') - if r.get('guild_id') == guild.id and r.get('status') == 'pending' - } - - # Anyone who currently claims (reacted) but isn't verified/pending/under review - need_review = [ - uid for uid in new_nick - if uid not in verified_set and uid not in pending_set and uid not in open_review_user_ids - ] - - for uid in need_review: - dm.add('nick_claim_pending', int(uid)) - m = guild.get_member(uid) - if m and not m.bot: - if nn and hasattr(nn, 'start_nick_review'): - try: - await nn.start_nick_review(guild, m, source="claim_offline") - except Exception: - pass - else: - await self._open_review_fallback(guild, m, source="claim_offline") - - # Anyone who *lost* their reaction -> clear pending/verified - for uid in nick_removed: - dm.remove('nick_claim_pending', lambda x: x == uid) - dm.remove('nick_verified', lambda x: x == uid) - - # Nudge roles/full access via ReactionRoleCog for anyone impacted - rr = self.bot.get_cog('ReactionRoleCog') - impacted = (rules_added | rules_removed | engage_added | engage_removed | - set(need_review) | nick_removed) - if rr: - for uid in impacted: - m = guild.get_member(uid) - if m and not m.bot: - try: - await rr.maybe_apply_full_access(m) - except Exception: - pass - else: - for uid in impacted: - m = guild.get_member(uid) - if m and not m.bot: - try: - await self.refresh_card(m) - except Exception: - pass - - return ( - len(rules_added) + len(rules_removed), - len(engage_added) + len(engage_removed), - len(nick_added), - len(nick_removed), - ) - - # ---------- listeners ---------- - - @commands.Cog.listener() - async def on_member_join(self, member: discord.Member): - await self._log(member.guild, f"📝 User joined: {member.mention} (ID: {member.id})") - await self.refresh_card(member) - - @commands.Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member): - if before.nick != after.nick or before.roles != after.roles: - await self.refresh_card(after) - - @commands.Cog.listener() - async def on_user_update(self, before: discord.User, after: discord.User): - if before.global_name == after.global_name and before.name == after.name: - return - for g in self.bot.guilds: - m = g.get_member(after.id) - if m: - try: await self.refresh_card(m) - except Exception: pass - - # ---------- periodic + startup ---------- - - async def _startup_reconcile(self): - await self.bot.wait_until_ready() - for g in list(self.bot.guilds): - try: - await self._reconcile_agreements(g) - except Exception: - pass - for g in list(self.bot.guilds): - for m in g.members: - if not m.bot: - try: await self.refresh_card(m) - except Exception: pass - - async def _periodic_refresh(self): - await self.bot.wait_until_ready() - while not self.bot.is_closed(): - try: - for g in self.bot.guilds: - try: await self._reconcile_agreements(g) - except Exception: pass - for m in g.members: - if not m.bot: - await self.refresh_card(m) - except Exception: - pass - await asyncio.sleep(12 * 60 * 60) # twice a day - - # ---------- mod command: rescan + live reconcile ---------- - - @commands.hybrid_command( - name="usercards_rescan", - description="Re-check all users and refresh cards (mod-only)" - ) - @commands.has_permissions(manage_guild=True) - async def usercards_rescan(self, ctx: commands.Context): - g = ctx.guild - if not g: - return await ctx.reply("Use this in a server.", ephemeral=True) - - rchg, echg, nadd, nrem = await self._reconcile_agreements(g) - - # Rebuild cards - updated = 0 - for m in g.members: - if not m.bot: - try: - await self.refresh_card(m) - updated += 1 - except Exception: - pass - - await ctx.reply( - f"Reconciled from messages. Changes — Rules: **{rchg}**, RoE: **{echg}**, " - f"Nickname (added): **{nadd}**, Nickname (removed): **{nrem}**. " - f"Refreshed cards for **{updated}** members.", - ephemeral=True - ) - -async def setup(bot): - await bot.add_cog(UserCardsCog(bot))