diff --git a/bot.py b/bot.py index 55314d1..3a5da6a 100644 --- a/bot.py +++ b/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.5.1.1.a1" +VERSION = "0.5.1.2.a1" # ---------- Env loading ---------- load_dotenv() diff --git a/modules/common/settings.py b/modules/common/settings.py index c58ffe5..ef6e2f2 100644 --- a/modules/common/settings.py +++ b/modules/common/settings.py @@ -163,6 +163,7 @@ SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = { "report_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Reports/approvals channel."}, "userslist_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Users list channel."}, "trigger_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Trigger channel for Auto VC."}, + "crew_roles_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Message ID for crew reaction roles hub."}, # Roles (IDs) "rules_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."}, @@ -172,6 +173,10 @@ SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = { "field_mod_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Field mod role ID."}, "engagement_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Engagement role ID."}, "admin_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Admin role ID."}, + # Role IDs for crew groups + "role_harvest_crew_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Harvest Crew"}, + "role_escort_crew_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Escort Crew"}, + "role_fedaykin_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Role: Fedaykin"}, # Message IDs "rules_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules message ID."}, diff --git a/modules/dd/dd_loot_table.py b/modules/dd/dd_loot_table.py index 6217f47..557d153 100644 --- a/modules/dd/dd_loot_table.py +++ b/modules/dd/dd_loot_table.py @@ -731,7 +731,7 @@ class DDLootTableCog(commands.Cog): # ---- command ---- - @app_commands.command(name="dd_update", description="Control the Deep Desert weekly loot updater") + @app_commands.command(name="dd_update", description="[MOD] Control the Deep Desert weekly loot updater") @app_commands.describe(action="stop/resume/start", reason="Optional reason") async def dd_update(self, interaction: discord.Interaction, action: Literal["stop", "resume", "start"], diff --git a/modules/reaction_role/reaction_role.py b/modules/reaction_role/reaction_role.py index 269a7ef..b7fe841 100644 --- a/modules/reaction_role/reaction_role.py +++ b/modules/reaction_role/reaction_role.py @@ -1,71 +1,215 @@ # modules/reaction_role/reaction_role.py +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Iterable, Tuple +import time + import discord from discord.ext import commands -from discord import app_commands -from modules.common.emoji_accept import is_accept -from modules.common.settings import cfg # ENV-first helper -CHECKMARK = '✅' +from modules.common.emoji_accept import is_accept +from modules.common.settings import cfg # dynamic settings helper + +CHECKMARK = '✅' # kept for consistency with other flows + + +# ------------------ small helpers ------------------ + +def _fmt_dt(ts: float) -> str: + return discord.utils.format_dt(datetime.fromtimestamp(ts, tz=timezone.utc), style="f") + + +def _as_list(x: Optional[Iterable]) -> list: + return list(x) if isinstance(x, Iterable) else [] + + +# ------------------ Fedaykin approval buttons ------------------ + +class _FedaykinApprovalView(discord.ui.View): + """Approval buttons for a single Fedaykin request (persistent).""" + + def __init__(self, cog: "ReactionRoleCog", req: Dict[str, Any], *, timeout: Optional[float] = None): + super().__init__(timeout=timeout) + self.cog = cog + self.req = req # dict persisted via data_manager + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + # Only Field Mod or Admin may act + member = interaction.user + if not isinstance(member, discord.Member): + await interaction.response.send_message("Server context required.", ephemeral=True) + return False + + allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id} + if not any(r.id in allow_roles for r in getattr(member, "roles", [])): + await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True) + return False + return True + + async def _finish(self, interaction: discord.Interaction, status: str): + await self.cog._fedaykin_decide( + guild=interaction.guild, + actor=interaction.user if isinstance(interaction.user, discord.Member) else None, + req=self.req, + status=status, + edit_view=True, + ) + try: + await interaction.response.send_message(f"{status.capitalize()}.", ephemeral=True) + except Exception: + pass + + @discord.ui.button(label="Approve", style=discord.ButtonStyle.success, custom_id="fdk.approve") + async def _approve(self, interaction: discord.Interaction, button: discord.ui.Button): + await self._finish(interaction, "approved") + + @discord.ui.button(label="Reject", style=discord.ButtonStyle.danger, custom_id="fdk.reject") + async def _reject(self, interaction: discord.Interaction, button: discord.ui.Button): + await self._finish(interaction, "rejected") + + +# ------------------ Main cog ------------------ class ReactionRoleCog(commands.Cog): """ - Records agreements and manages Full Access. - Nickname flow: - • 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. + Original: Records agreements and manages Full Access. + Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons. + - No debug slash commands. + - Debounced nickname review to avoid duplicates when users add multiple accept emojis. + - Fedaykin role is removed when the user unreacts the Fedaykin emoji. + - Settings are reloaded dynamically on each event (hot-apply without restart). """ def __init__(self, bot): self.bot = bot - r = cfg(bot) - # Message + role IDs from ENV/INI (default 0 if unset) - self.rules_msg_id = r.int('rules_message_id', 0) - self.engage_msg_id = r.int('engagement_message_id', 0) - self.nick_msg_id = r.int('nickname_message_id', 0) + # Snapshot (will be refreshed dynamically on each event) + self._settings = {} + self._hub_channel_id: Optional[int] = None # cache for crew hub channel - self.rules_role = r.int('rules_role_id', 0) - self.engage_role = r.int('engagement_role_id', 0) - self.full_access_role = r.int('full_access_role_id', 0) + # Debounce / locks to prevent duplicate nickname review on bursty reactions + self._nick_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts + self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations - # ---- helpers ---- - def _has_rules(self, member_id: int) -> bool: - return member_id in self.bot.data_manager.get('agreed_rules') + # Apply initial snapshot and schedule restore + self._refresh_settings(force=True) + self.bot.loop.create_task(self._boot_restore()) - def _has_engage(self, member_id: int) -> bool: - return member_id in self.bot.data_manager.get('agreed_engagement') + # ------------------ settings ------------------ - def _has_nick_claim(self, member_id: int) -> bool: - """Claimed = agreed_nickname; pending/verified tracked separately.""" - return member_id in self.bot.data_manager.get('agreed_nickname') + def _refresh_settings(self, *, force: bool = False): + """Hot-read settings on demand; cheap and avoids restart requirement.""" + r = cfg(self.bot) + s = { + # Message IDs + "rules_msg_id": r.int('rules_message_id', 0), + "engage_msg_id": r.int('engagement_message_id', 0), + "nick_msg_id": r.int('nickname_message_id', 0), + "crew_msg_id": r.int('crew_roles_message_id', 0), - async def maybe_apply_full_access(self, member: discord.Member): - """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" - guild = member.guild - role = guild.get_role(self.full_access_role) if self.full_access_role else None - if not role: - return + # Role IDs + "rules_role": r.int('rules_role_id', 0), + "engage_role": r.int('engagement_role_id', 0), + "full_access_role": r.int('full_access_role_id', 0), + "role_harvest_id": r.int('role_harvest_crew_id', 0), + "role_escort_id": r.int('role_escort_crew_id', 0), + "role_fedaykin_id": r.int('role_fedaykin_id', 0), - has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id) + # Approver roles + "field_mod_role_id": r.int('field_mod_role_id', 0), + "admin_role_id": r.int('admin_role_id', 0), + + # Emojis + "emoji_harvest_id": r.int('emoji_harvester_crew', 0), + "emoji_escort_id": r.int('emoji_escort_crew', 0), + "emoji_fedaykin_id": r.int('emoji_fedaykin', 0), + + # Channels + "report_channel_id": r.int('report_channel_id', 0), + "mod_channel_id": r.int('mod_channel_id', 0), + "modlog_channel_id": r.int('modlog_channel_id', 0), + } + self._settings = s + + # handy properties (avoid tons of self._settings["x"] everywhere) + @property + def rules_msg_id(self) -> int: return self._settings.get("rules_msg_id", 0) + @property + def engage_msg_id(self) -> int: return self._settings.get("engage_msg_id", 0) + @property + def nick_msg_id(self) -> int: return self._settings.get("nick_msg_id", 0) + @property + def crew_msg_id(self) -> int: return self._settings.get("crew_msg_id", 0) + + @property + def rules_role(self) -> int: return self._settings.get("rules_role", 0) + @property + def engage_role(self) -> int: return self._settings.get("engage_role", 0) + @property + def full_access_role(self) -> int: return self._settings.get("full_access_role", 0) + @property + def role_harvest_id(self) -> int: return self._settings.get("role_harvest_id", 0) + @property + def role_escort_id(self) -> int: return self._settings.get("role_escort_id", 0) + @property + def role_fedaykin_id(self) -> int: return self._settings.get("role_fedaykin_id", 0) + + @property + def field_mod_role_id(self) -> int: return self._settings.get("field_mod_role_id", 0) + @property + def admin_role_id(self) -> int: return self._settings.get("admin_role_id", 0) + + @property + def emoji_harvest_id(self) -> int: return self._settings.get("emoji_harvest_id", 0) + @property + def emoji_escort_id(self) -> int: return self._settings.get("emoji_escort_id", 0) + @property + def emoji_fedaykin_id(self) -> int: return self._settings.get("emoji_fedaykin_id", 0) + + @property + def report_channel_id(self) -> int: return self._settings.get("report_channel_id", 0) + @property + def mod_channel_id(self) -> int: return self._settings.get("mod_channel_id", 0) + @property + def modlog_channel_id(self) -> int: return self._settings.get("modlog_channel_id", 0) + + # ------------------ boot & persistence ------------------ + + async def _boot_restore(self): + await self.bot.wait_until_ready() + self._refresh_settings() + dm = self.bot.data_manager + + # Ensure list key exists to be safe + if not isinstance(dm.get('fedaykin_requests'), list): + try: + dm.add('fedaykin_requests', {"_init": True}) + dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True) + except Exception: + pass + + # Re-register views for any pending requests try: - if has_all and role not in member.roles: - await member.add_roles(role, reason="All agreements completed (nickname may be pending)") - elif not has_all and role in member.roles: - await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed") - except discord.Forbidden: - pass + for req in _as_list(dm.get('fedaykin_requests')): + if req.get("status") == "pending" and req.get("review_message_id") and req.get("review_channel_id"): + self.bot.add_view(_FedaykinApprovalView(self, req), message_id=int(req["review_message_id"])) except Exception: pass - # Best-effort: refresh user card - cards = self.bot.get_cog('UserCardsCog') - if cards: - try: - await cards.refresh_card(member) - except Exception: - pass + async def _save_fedaykin_request(self, req: Dict[str, Any]): + """Upsert by (guild_id, user_id).""" + dm = self.bot.data_manager + gid, uid = int(req["guild_id"]), int(req["user_id"]) + try: + for _ in list(_as_list(dm.get('fedaykin_requests'))): + dm.remove('fedaykin_requests', lambda x, gid=gid, uid=uid: + int(x.get("guild_id", 0)) == gid and int(x.get("user_id", 0)) == uid) + dm.add('fedaykin_requests', dict(req)) + except Exception: + pass + + # ------------------ shared helpers ------------------ async def _get_member(self, guild: discord.Guild, user_id: int): if not guild: @@ -94,114 +238,453 @@ class ReactionRoleCog(commands.Cog): except Exception: return False - # ---- commands (hybrid = prefix + slash) ---- - @app_commands.command(name="nick_same", description="Claim that your global display name matches your in-game name (triggers mod review)") - async def nick_same(self, interaction: discord.Interaction): - if not interaction.guild or not isinstance(interaction.user, discord.Member): - return await interaction.response.send_message("Use this in a server.", ephemeral=True) + async def maybe_apply_full_access(self, member: discord.Member): + """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" + guild = member.guild + role = guild.get_role(self.full_access_role) if self.full_access_role else None + if not role: + return - member: discord.Member = interaction.user + dm = self.bot.data_manager + has_rules = member.id in _as_list(dm.get('agreed_rules')) + has_engage = member.id in _as_list(dm.get('agreed_engagement')) + has_nick_claim = member.id in _as_list(dm.get('agreed_nickname')) + has_all = has_rules and has_engage and has_nick_claim - nn = self.bot.get_cog('NickNudgeCog') - if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): + try: + if has_all and role not in member.roles: + await member.add_roles(role, reason="All agreements completed (nickname may be pending)") + elif not has_all and role in member.roles: + await member.remove_roles(role, reason="Agreements incomplete or nickname unclaimed") + except discord.Forbidden: + pass + except Exception: + pass + + # Best-effort: refresh user card + cards = self.bot.get_cog('UserCardsCog') + if cards: try: - await nn.ensure_pending_and_maybe_open(interaction.guild, member, source="nick_same") + await cards.refresh_card(member) except Exception: pass - await self.maybe_apply_full_access(member) - await interaction.response.send_message("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) + async def _find_hub_message(self, guild: discord.Guild) -> Tuple[Optional[discord.Message], Optional[int]]: + """Return (message, channel_id) for the hub message, scanning text channels if needed.""" + if not self.crew_msg_id: + return None, None + + # Try cached channel + if self._hub_channel_id: + ch = guild.get_channel(self._hub_channel_id) + if isinstance(ch, discord.TextChannel): + try: + msg = await ch.fetch_message(self.crew_msg_id) + return msg, ch.id + except Exception: + pass + + # Scan + for ch in guild.text_channels: + try: + msg = await ch.fetch_message(self.crew_msg_id) + self._hub_channel_id = ch.id + return msg, ch.id + except (discord.NotFound, discord.Forbidden): + continue + except Exception: + continue + return None, None + + async def _edit_review_message_footer(self, req: Dict[str, Any], note: str, *, clear_view: bool = False): + try: + ch = self.bot.get_channel(int(req["review_channel_id"])) + if isinstance(ch, (discord.TextChannel, discord.Thread)): + msg = await ch.fetch_message(int(req["review_message_id"])) + emb = msg.embeds[0] if msg.embeds else discord.Embed(title="Fedaykin Request") + if emb.footer and emb.footer.text: + emb.set_footer(text=emb.footer.text + "\n" + note) + else: + emb.set_footer(text=note) + await msg.edit(embed=emb, view=None if clear_view else msg.components) + except Exception: + pass + + async def _modlog_decision(self, guild: discord.Guild, status: str, target: discord.Member, actor: discord.Member, req: Dict[str, Any]): + """Send an approval/rejection/revoke line to modlog channel.""" + chan = self.bot.get_channel(self.modlog_channel_id) + if not isinstance(chan, (discord.TextChannel, discord.Thread)): + return + color_map = { + "approved": discord.Color.green(), + "rejected": discord.Color.red(), + "withdrawn": discord.Color.orange(), + "revoked": discord.Color.orange() + } + colour = color_map.get(status, discord.Color.blurple()) + jump = f"https://discord.com/channels/{guild.id}/{req.get('review_channel_id')}/{req.get('review_message_id')}" \ + if req.get('review_channel_id') and req.get('review_message_id') else "" + created_ts = float(req.get("created_ts", datetime.now(timezone.utc).timestamp())) + decided_ts = float(req.get("decision_ts", datetime.now(timezone.utc).timestamp())) + emb = discord.Embed( + title=f"Fedaykin {status.capitalize()}", + description=(f"[View review card]({jump})" if jump else "No card link available."), + color=colour, + timestamp=datetime.fromtimestamp(decided_ts, tz=timezone.utc), + ) + emb.add_field(name="User", value=f"{target.mention} (`{target.id}`)", inline=True) + emb.add_field(name="By", value=f"{actor.mention} (`{actor.id}`)", inline=True) + emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True) + emb.add_field(name="Decision", value=_fmt_dt(decided_ts), inline=True) + try: + await chan.send(embed=emb) + except Exception: + pass + + async def _fedaykin_decide(self, guild: Optional[discord.Guild], actor: Optional[discord.Member], req: Dict[str, Any], status: str, *, edit_view: bool = False): + """Common decision path for approvals via buttons.""" + if guild is None or actor is None: + return + + # Idempotency + if req.get("status") != "pending" and status in ("approved", "rejected"): + return + + target = await self._get_member(guild, int(req["user_id"])) + if target is None: + return + + role = guild.get_role(self.role_fedaykin_id) + + if status == "approved": + if role is None: + await self._edit_review_message_footer( + req, f"**Decision attempt:** Approve by {actor.mention} failed — Fedaykin role not configured." + ) + return + try: + await target.add_roles(role, reason=f"Fedaykin approved by {actor} ({actor.id})") + except Exception: + pass + elif status == "rejected": + if role and role in target.roles: + try: + await target.remove_roles(role, reason=f"Fedaykin rejected by {actor} ({actor.id})") + except Exception: + pass + elif status in ("withdrawn", "revoked"): + # Both withdrawn (user cancels before approval) and revoked (user unreacts after approval) + if role and role in target.roles: + try: + await target.remove_roles(role, reason=f"Fedaykin {status} by user") + except Exception: + pass + + if status in ("approved", "rejected"): + req["status"] = status + req["approver_id"] = int(actor.id) + else: + req["status"] = status # withdrawn / revoked don't set approver + req["decision_ts"] = datetime.now(timezone.utc).timestamp() + await self._save_fedaykin_request(req) + + note = f"**Decision:** {status.capitalize()} by {actor.mention if actor else 'system'} at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}" + await self._edit_review_message_footer(req, note, clear_view=edit_view) + + # modlog + try: + await self._modlog_decision(guild, status, target, actor or guild.me, req) + except Exception: + pass + + async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool: + """Post the Fedaykin approval card; return True if posted somewhere.""" + created_ts = datetime.now(timezone.utc).timestamp() + emb = discord.Embed( + title="Fedaykin Request", + description=f"{member.mention} requested **Fedaykin** via reaction.", + color=discord.Color.orange(), + timestamp=datetime.fromtimestamp(created_ts, tz=timezone.utc), + ) + emb.add_field(name="User", value=f"{member} (`{member.id}`)", inline=True) + emb.add_field(name="Requested", value=_fmt_dt(created_ts), inline=True) + emb.set_footer(text=f"Hub message ID: {hub_id}") + + req = { + "guild_id": guild.id, + "user_id": member.id, + "status": "pending", + "created_ts": created_ts, + "review_message_id": 0, + "review_channel_id": 0, + } + view = _FedaykinApprovalView(self, req, timeout=None) + + # Primary: report_channel_id; fallback: mod_channel_id + targets = [] + chan = self.bot.get_channel(self.report_channel_id) + if isinstance(chan, (discord.TextChannel, discord.Thread)): + targets.append(chan) + mchan = self.bot.get_channel(self.mod_channel_id) + if isinstance(mchan, (discord.TextChannel, discord.Thread)): + targets.append(mchan) + + for ch in targets: + try: + msg = await ch.send(embed=emb, view=view) + req["review_message_id"] = int(msg.id) + req["review_channel_id"] = int(msg.channel.id) + await self._save_fedaykin_request(req) + try: + self.bot.add_view(_FedaykinApprovalView(self, req), message_id=msg.id) + except Exception: + pass + return True + except Exception: + continue + return False + + # ------------------ listeners ------------------ - # ---- listeners ---- @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - if not payload.guild_id or not is_accept(payload.emoji): + # hot-reload settings + self._refresh_settings() + + if not payload.guild_id: return + guild = self.bot.get_guild(payload.guild_id) member = await self._get_member(guild, payload.user_id) if not member or member.bot: return dm = self.bot.data_manager + + # ----- Original accept-based flows ----- try: - if payload.message_id == self.rules_msg_id: - role = guild.get_role(self.rules_role) - if role: - await member.add_roles(role, reason="Agreed to rules") - if member.id not in dm.get('agreed_rules'): - dm.add('agreed_rules', int(member.id)) + if is_accept(payload.emoji): - elif payload.message_id == self.engage_msg_id: - role = guild.get_role(self.engage_role) - if role: - await member.add_roles(role, reason="Agreed to engagement") - if member.id not in dm.get('agreed_engagement'): - dm.add('agreed_engagement', int(member.id)) + # RULES + if self.rules_msg_id and payload.message_id == self.rules_msg_id: + role = guild.get_role(self.rules_role) + if role: + try: + await member.add_roles(role, reason="Agreed to rules") + except Exception: + pass + if member.id not in _as_list(dm.get('agreed_rules')): + dm.add('agreed_rules', int(member.id)) - elif payload.message_id == self.nick_msg_id: - # --- STALE-STATE CLEANUP --- - # If no pending review exists for this user in this guild, make sure 'nick_claim_pending' - # is cleared so the atomic method can transition and open a new review. - has_pending_review = any( - r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending' - for r in dm.get('nick_reviews') - ) - if not has_pending_review: - dm.remove('nick_claim_pending', lambda x: x == member.id) + # ENGAGEMENT + elif self.engage_msg_id and payload.message_id == self.engage_msg_id: + role = guild.get_role(self.engage_role) + if role: + try: + await member.add_roles(role, reason="Agreed to engagement") + except Exception: + pass + if member.id not in _as_list(dm.get('agreed_engagement')): + dm.add('agreed_engagement', int(member.id)) + + # NICKNAME (debounced + idempotent) + elif self.nick_msg_id and payload.message_id == self.nick_msg_id: + now = time.monotonic() + key = (guild.id, member.id) + last = self._nick_debounce.get(key, 0.0) + if now - last < 10.0: + return # debounce bursty multi-emoji reacts + self._nick_debounce[key] = now + + # If any pending review already exists for this user, do nothing + has_pending_review = any( + r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending' + for r in _as_list(dm.get('nick_reviews')) + ) + if has_pending_review: + return + + # In-flight lock to avoid concurrent duplicate openings + if key in self._nick_lock: + return + self._nick_lock.add(key) - # 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 + # Clear stale marker if no active review, then open exactly one + dm.remove('nick_claim_pending', lambda x: x == member.id) + 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 + finally: + self._nick_lock.discard(key) except Exception: pass + # ----- Crew roles hub (custom emoji toggles + Fedaykin request) ----- + try: + if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id: + # Harvester / Escort + if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id: + role = guild.get_role(self.role_harvest_id) + if role: + try: + await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}") + except Exception: + pass + return + + if payload.emoji.id == self.emoji_escort_id and self.role_escort_id: + role = guild.get_role(self.role_escort_id) + if role: + try: + await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}") + except Exception: + pass + return + + # Fedaykin -> open approval card (only one pending allowed) + if payload.emoji.id == self.emoji_fedaykin_id: + fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None + if fed_role and fed_role in member.roles: + return # already has Fedaykin + + pending = next((x for x in _as_list(dm.get('fedaykin_requests')) + if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"), None) + if pending: + return + + hub_msg, _ = await self._find_hub_message(guild) + hub_id = hub_msg.id if hub_msg else self.crew_msg_id + await self._post_fedaykin_card(guild, member, hub_id) + return + except Exception: + pass + + # Only the original (agreements) flow affects Full Access await self.maybe_apply_full_access(member) @commands.Cog.listener() async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): - if not payload.guild_id or not is_accept(payload.emoji): + # hot-reload settings + self._refresh_settings() + + if not payload.guild_id: return + guild = self.bot.get_guild(payload.guild_id) member = await self._get_member(guild, payload.user_id) if not member or member.bot: return dm = self.bot.data_manager + + # ----- Original accept-based flows ----- try: - if payload.message_id == self.rules_msg_id: - dm.remove('agreed_rules', lambda x: x == member.id) - role = guild.get_role(self.rules_role) - if role: - await member.remove_roles(role, reason="Rules un-ticked") + if is_accept(payload.emoji): + # RULES + if self.rules_msg_id and payload.message_id == self.rules_msg_id: + dm.remove('agreed_rules', lambda x: x == member.id) + role = guild.get_role(self.rules_role) + if role and role in member.roles: + try: + await member.remove_roles(role, reason="Rules un-ticked") + except Exception: + pass - elif payload.message_id == self.engage_msg_id: - dm.remove('agreed_engagement', lambda x: x == member.id) - role = guild.get_role(self.engage_role) - if role: - await member.remove_roles(role, reason="Engagement un-ticked") + # ENGAGEMENT + elif self.engage_msg_id and payload.message_id == self.engage_msg_id: + dm.remove('agreed_engagement', lambda x: x == member.id) + role = guild.get_role(self.engage_role) + if role and role in member.roles: + try: + await member.remove_roles(role, reason="Engagement un-ticked") + except Exception: + pass - elif payload.message_id == self.nick_msg_id: - # Clear only if the user has NO accept reactions left on the message - still_has_accept = await self._user_has_any_accept( - guild, payload.channel_id, payload.message_id, member.id - ) - if not still_has_accept: - 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) - else: - return + # NICKNAME: clear only if no other accept left + elif self.nick_msg_id and payload.message_id == self.nick_msg_id: + still_has_accept = await self._user_has_any_accept( + guild, payload.channel_id, payload.message_id, member.id + ) + if not still_has_accept: + 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) except Exception: pass + # ----- Crew roles hub (custom emoji) ----- + try: + if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id: + # Harvester + if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id: + role = guild.get_role(self.role_harvest_id) + if role and role in member.roles: + try: + await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)") + except Exception: + pass + return + + # Escort + if payload.emoji.id == self.emoji_escort_id and self.role_escort_id: + role = guild.get_role(self.role_escort_id) + if role and role in member.roles: + try: + await member.remove_roles(role, reason=f"Reaction role via hub {self.crew_msg_id} (unreact)") + except Exception: + pass + return + + # Fedaykin unreact -> remove role + mark request accordingly + if payload.emoji.id == self.emoji_fedaykin_id: + fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None + + # Find a related request if any (prefer pending; else last approved) + req = None + pend = [x for x in _as_list(dm.get('fedaykin_requests')) + if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"] + if pend: + req = pend[-1] + req["status"] = "withdrawn" + else: + appr = [x for x in _as_list(dm.get('fedaykin_requests')) + if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "approved"] + if appr: + req = appr[-1] + req["status"] = "revoked" + + # Always remove the role on unreact (per requirement) + if fed_role and fed_role in member.roles: + try: + await member.remove_roles(fed_role, reason="Fedaykin reaction removed") + except Exception: + pass + + if req: + req["decision_ts"] = datetime.now(timezone.utc).timestamp() + await self._save_fedaykin_request(req) + actor = guild.me # system action + status = req["status"] + # Update card footer if we still have it + await self._edit_review_message_footer( + req, + f"**Decision:** {status.capitalize()} (user unreact) at {discord.utils.format_dt(datetime.now(timezone.utc), style='f')}", + clear_view=True, + ) + # Modlog + try: + await self._modlog_decision(guild, status, member, actor, req) + except Exception: + pass + return + except Exception: + pass + + # Only the original (agreements) flow affects Full Access await self.maybe_apply_full_access(member) diff --git a/requirements.txt b/requirements.txt index 1e7b898..61fadfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -discord.py>=2.3.2 -python-dotenv +discord.py>=2.5.2 +python-dotenv>=1.0.1 +aiohttp>=3.9,<4 +playwright==1.45.0