# 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 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 — and only if a Head exists member = interaction.user if not isinstance(member, discord.Member): await interaction.response.send_message("Server context required.", ephemeral=True) return False if self.cog._fedaykin_headless or not (interaction.guild and self.cog._has_fedaykin_head(interaction.guild)): await interaction.response.send_message( "No **Fedaykin Head** is currently appointed. This request remains pending until one is appointed.", 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): """ 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). - NEW: Only users with Full Access may claim/request crew roles. """ def __init__(self, bot): self.bot = bot # Snapshot (will be refreshed dynamically on each event) self._settings = {} self._hub_channel_id: Optional[int] = None # cache for crew hub channel # 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 # Fedaykin head state (global) self._fedaykin_headless: bool = False # Apply initial snapshot and schedule restore self._refresh_settings(force=True) self.bot.loop.create_task(self._boot_restore()) # ------------------ settings ------------------ 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), # 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), # 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: 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 # Evaluate head state at boot and act accordingly try: hg = cfg(self.bot).int('home_guild_id', 0) guild = self.bot.get_guild(hg) if hg else (self.bot.guilds[0] if self.bot.guilds else None) await self._maybe_transition_head_state(guild) except Exception: pass try: if guild: await self._repair_orphaned_pending_cards(guild) 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: return None m = guild.get_member(user_id) if m is None: try: m = await guild.fetch_member(user_id) except Exception: return None return m async def _user_has_any_accept(self, guild: discord.Guild, channel_id: int, message_id: int, user_id: int) -> bool: """Return True if the user still has at least one 'accept' reaction on the message.""" try: ch = guild.get_channel(channel_id) if not ch: return False msg = await ch.fetch_message(message_id) for rxn in msg.reactions: if is_accept(rxn.emoji): async for u in rxn.users(limit=None): if u.id == user_id: return True return False except Exception: return False def _has_full_initiated(self, member: discord.Member) -> bool: """User must have Full Access role to claim/request crew roles.""" if not member or not isinstance(member.guild, discord.Guild): return False role = member.guild.get_role(self.full_access_role) if self.full_access_role else None return bool(role and role in member.roles) async def _remove_reaction_silent(self, guild: discord.Guild, channel_id: int, message_id: int, emoji: discord.PartialEmoji | discord.Emoji | str, member: discord.Member): """Best-effort: remove a reaction without messaging the user.""" try: ch = guild.get_channel(channel_id) if not isinstance(ch, (discord.TextChannel, discord.Thread)): return msg = await ch.fetch_message(message_id) await msg.remove_reaction(emoji, member) except Exception: pass 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 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 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 cards.refresh_card(member) except Exception: pass 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) if clear_view: await msg.edit(embed=emb, view=None) else: await msg.edit(embed=emb) # leave existing components untouched 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. While headless, queue pending only.""" # Require Full Access to even request Fedaykin if not self._has_full_initiated(member): return False # If headless or effectively headless (no Head members): queue silently if self._fedaykin_headless or not self._has_fedaykin_head(guild): await self._queue_pending(guild, member, reason="headless_runtime") return False 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 # ---------- Fedaykin Head detection & transitions ---------- def _has_fedaykin_head(self, guild: discord.Guild) -> bool: """True if any member currently has the Field Mod (Fedaykin Head) role.""" if not guild or not self.field_mod_role_id: return False role = guild.get_role(self.field_mod_role_id) return bool(role and role.members) async def _modlog_head_summary(self, guild: discord.Guild, *, head_present: bool, revoked: int = 0, queued: int = 0, posted: int = 0): """Brief summary in modlog on headless/head-found transitions.""" chan = self.bot.get_channel(self.modlog_channel_id) if not isinstance(chan, (discord.TextChannel, discord.Thread)): return colour = discord.Color.green() if head_present else discord.Color.orange() title = "Fedaykin Head present — queued reviews sent" if head_present else "No Fedaykin Head — roles revoked & queued" desc = ( f"**Posted reviews:** {posted}" if head_present else f"**Revoked roles:** {revoked}\n**Queued reviews:** {queued}" ) try: emb = discord.Embed(title=title, description=desc, color=colour, timestamp=datetime.now(timezone.utc)) await chan.send(embed=emb) except Exception: pass async def _queue_pending(self, guild: discord.Guild, member: discord.Member, *, reason: str = "headless"): """Upsert a silent pending request (no card).""" dm = self.bot.data_manager created_ts = datetime.now(timezone.utc).timestamp() # Preserve an existing headless pending if one already exists (don’t duplicate) existing = 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" and int(x.get("review_message_id", 0)) == 0), None) req = { "guild_id": guild.id, "user_id": member.id, "status": "pending", "created_ts": existing.get("created_ts", created_ts) if existing else created_ts, "review_message_id": 0, "review_channel_id": 0, "reason": reason, } await self._save_fedaykin_request(req) async def _headless_revoke_and_queue(self, guild: discord.Guild) -> tuple[int, int]: """When headless: remove Fedaykin role from all members and queue pending reviews.""" revoked = queued = 0 if not guild or not self.role_fedaykin_id: return revoked, queued role = guild.get_role(self.role_fedaykin_id) if not role: return revoked, queued for m in list(role.members): if m.bot: continue try: await m.remove_roles(role, reason="Fedaykin headless – role temporarily revoked") revoked += 1 except Exception: pass try: await self._queue_pending(guild, m, reason="headless_boot") queued += 1 except Exception: pass return revoked, queued async def _flush_pending_to_cards(self, guild: discord.Guild) -> int: """When a head exists: send cards for any queued pendings (review_message_id == 0).""" dm = self.bot.data_manager pending = [x for x in _as_list(dm.get('fedaykin_requests')) if x.get("guild_id") == guild.id and x.get("status") == "pending" and int(x.get("review_message_id", 0)) == 0] posted = 0 for req in pending: member = await self._get_member(guild, int(req.get("user_id", 0))) if not member: continue # This will upsert the request with live message/channel IDs ok = await self._post_fedaykin_card(guild, member, self.crew_msg_id or 0) if ok: posted += 1 return posted async def _maybe_transition_head_state(self, guild: Optional[discord.Guild]): """Recompute head state; if it changes, perform the required mass action + log.""" if guild is None: return # Require a configured, resolvable Field Mod role to take global actions if not self.field_mod_role_id: return head_role = guild.get_role(self.field_mod_role_id) if head_role is None: # Misconfigured: do not mass-revoke; other paths will still queue/block appropriately return has_head_now = bool(head_role.members) if has_head_now and self._fedaykin_headless: # Head appeared — flush queued to cards self._fedaykin_headless = False posted = await self._flush_pending_to_cards(guild) await self._repair_orphaned_pending_cards(guild) await self._modlog_head_summary(guild, head_present=True, posted=posted) elif not has_head_now and not self._fedaykin_headless: # Became headless — revoke all fedaykins and queue pendings self._fedaykin_headless = True revoked, queued = await self._headless_revoke_and_queue(guild) await self._modlog_head_summary(guild, head_present=False, revoked=revoked, queued=queued) # inside ReactionRoleCog class async def _repair_orphaned_pending_cards(self, guild: discord.Guild) -> int: """Detect pending requests whose review message was deleted; reset them so they can be re-posted.""" dm = self.bot.data_manager repaired = 0 for req in list(_as_list(dm.get('fedaykin_requests'))): if int(req.get("guild_id", 0)) != guild.id or req.get("status") != "pending": continue mid = int(req.get("review_message_id", 0) or 0) cid = int(req.get("review_channel_id", 0) or 0) if not mid or not cid: continue # already queued (no card) ch = self.bot.get_channel(cid) exists = False if isinstance(ch, (discord.TextChannel, discord.Thread)): try: await ch.fetch_message(mid) exists = True except (discord.NotFound, discord.Forbidden): exists = False except Exception: exists = False if not exists: # reset to "queued" so normal flow can re-post req["review_message_id"] = 0 req["review_channel_id"] = 0 await self._save_fedaykin_request(req) repaired += 1 # If we repaired and a head exists, immediately flush to new cards if repaired and not self._fedaykin_headless and self._has_fedaykin_head(guild): await self._flush_pending_to_cards(guild) return repaired # ------------------ listeners ------------------ @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """Detect when someone gains/loses the Fedaykin Head role and transition state if needed.""" try: hg = cfg(self.bot).int('home_guild_id', 0) if after.guild.id != (hg or after.guild.id): return except Exception: pass # If the role IDs aren’t configured yet, just ignore if not self.field_mod_role_id: return had = any(r.id == self.field_mod_role_id for r in getattr(before, "roles", [])) has = any(r.id == self.field_mod_role_id for r in getattr(after, "roles", [])) if had == has: return # no change on this member # Re-evaluate overall head presence and transition if the global state changes await self._maybe_transition_head_state(after.guild) @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): # hot-reload settings self._refresh_settings() if not payload.guild_id: return guild = self.bot.get_guild(payload.guild_id) # Ensure head state stays accurate even if settings/users change at runtime try: hg = cfg(self.bot).int('home_guild_id', 0) base_guild = self.bot.get_guild(hg) or guild await self._maybe_transition_head_state(base_guild) except Exception: pass 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 is_accept(payload.emoji): # 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)) # 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) try: # 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: # Gate: must have Full Access to claim/request crew roles if not self._has_full_initiated(member): await self._remove_reaction_silent(guild, payload.channel_id, payload.message_id, payload.emoji, member) return # 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): # hot-reload settings self._refresh_settings() if not payload.guild_id: return guild = self.bot.get_guild(payload.guild_id) # Ensure head state stays accurate even if settings/users change at runtime try: hg = cfg(self.bot).int('home_guild_id', 0) base_guild = self.bot.get_guild(hg) or guild await self._maybe_transition_head_state(base_guild) except Exception: pass 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 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 # 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 # 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) async def setup(bot): await bot.add_cog(ReactionRoleCog(bot))