shaiwatcher/modules/reaction_role/reaction_role.py
Franz Rolfsvaag ac9953fed6 0.5.1.2.a4
- Minor patch to prevent non-initiated members from claiming crew roles
2025-08-26 00:07:51 +02:00

942 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (dont 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 arent 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))