Compare commits

..

No commits in common. "3d807e2fc220d0bff47d5ad54e7659148a88256d" and "23e122c08a0bc833c2b9d0e2af79c032d637b4fb" have entirely different histories.

5 changed files with 116 additions and 606 deletions

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of: # Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.5.1.2.a1" VERSION = "0.5.1.1.a1"
# ---------- Env loading ---------- # ---------- Env loading ----------
load_dotenv() load_dotenv()

View File

@ -163,7 +163,6 @@ SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
"report_channel_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Reports/approvals channel."}, "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."}, "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."}, "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) # Roles (IDs)
"rules_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."}, "rules_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules-agreed role ID."},
@ -173,10 +172,6 @@ SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
"field_mod_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Field mod role ID."}, "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."}, "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."}, "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 # Message IDs
"rules_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules message ID."}, "rules_message_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Rules message ID."},

View File

@ -731,7 +731,7 @@ class DDLootTableCog(commands.Cog):
# ---- command ---- # ---- command ----
@app_commands.command(name="dd_update", description="[MOD] Control the Deep Desert weekly loot updater") @app_commands.command(name="dd_update", description="Control the Deep Desert weekly loot updater")
@app_commands.describe(action="stop/resume/start", reason="Optional reason") @app_commands.describe(action="stop/resume/start", reason="Optional reason")
async def dd_update(self, interaction: discord.Interaction, async def dd_update(self, interaction: discord.Interaction,
action: Literal["stop", "resume", "start"], action: Literal["stop", "resume", "start"],

View File

@ -1,216 +1,72 @@
# modules/reaction_role/reaction_role.py # 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 import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands
from modules.common.emoji_accept import is_accept from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # dynamic settings helper from modules.common.settings import cfg # ENV-first helper
CHECKMARK = '' # kept for consistency with other flows CHECKMARK = ''
# ------------------ 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): class ReactionRoleCog(commands.Cog):
""" """
Original: Records agreements and manages Full Access. Records agreements and manages Full Access.
Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons. Nickname flow:
- No debug slash commands. Add accept on nickname message -> atomically mark agreed + pending and open ONE review
- Debounced nickname review to avoid duplicates when users add multiple accept emojis. Remove accept on nickname message -> clear only if user has no accept reactions left
- Fedaykin role is removed when the user unreacts the Fedaykin emoji. Full Access: granted when Rules + RoE + Nickname *claimed* (pending or verified).
- Settings are reloaded dynamically on each event (hot-apply without restart). Revoked when any of the three is missing.
""" """
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
r = cfg(bot)
# Snapshot (will be refreshed dynamically on each event) # Message + role IDs from ENV/INI (default 0 if unset)
self._settings = {} self.rules_msg_id = r.int('rules_message_id', 0)
self._hub_channel_id: Optional[int] = None # cache for crew hub channel self.engage_msg_id = r.int('engagement_message_id', 0)
self.nick_msg_id = r.int('nickname_message_id', 0)
# Debounce / locks to prevent duplicate nickname review on bursty reactions self.rules_role = r.int('rules_role_id', 0)
self._nick_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts self.engage_role = r.int('engagement_role_id', 0)
self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations self.full_access_role = r.int('full_access_role_id', 0)
# Apply initial snapshot and schedule restore # ---- helpers ----
self._refresh_settings(force=True) def _has_rules(self, member_id: int) -> bool:
self.bot.loop.create_task(self._boot_restore()) return member_id in self.bot.data_manager.get('agreed_rules')
# ------------------ settings ------------------ def _has_engage(self, member_id: int) -> bool:
return member_id in self.bot.data_manager.get('agreed_engagement')
def _refresh_settings(self, *, force: bool = False): def _has_nick_claim(self, member_id: int) -> bool:
"""Hot-read settings on demand; cheap and avoids restart requirement.""" """Claimed = agreed_nickname; pending/verified tracked separately."""
r = cfg(self.bot) return member_id in self.bot.data_manager.get('agreed_nickname')
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 async def maybe_apply_full_access(self, member: discord.Member):
"rules_role": r.int('rules_role_id', 0), """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing."""
"engage_role": r.int('engagement_role_id', 0), guild = member.guild
"full_access_role": r.int('full_access_role_id', 0), role = guild.get_role(self.full_access_role) if self.full_access_role else None
"role_harvest_id": r.int('role_harvest_crew_id', 0), if not role:
"role_escort_id": r.int('role_escort_crew_id', 0), return
"role_fedaykin_id": r.int('role_fedaykin_id', 0),
# Approver roles has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id)
"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: try:
dm.add('fedaykin_requests', {"_init": True}) if has_all and role not in member.roles:
dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True) 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: except Exception:
pass pass
# Re-register views for any pending requests # Best-effort: refresh user card
cards = self.bot.get_cog('UserCardsCog')
if cards:
try: try:
for req in _as_list(dm.get('fedaykin_requests')): await cards.refresh_card(member)
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: except Exception:
pass 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): async def _get_member(self, guild: discord.Guild, user_id: int):
if not guild: if not guild:
return None return None
@ -238,374 +94,102 @@ class ReactionRoleCog(commands.Cog):
except Exception: except Exception:
return False return False
async def maybe_apply_full_access(self, member: discord.Member): # ---- commands (hybrid = prefix + slash) ----
"""Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" @app_commands.command(name="nick_same", description="Claim that your global display name matches your in-game name (triggers mod review)")
guild = member.guild async def nick_same(self, interaction: discord.Interaction):
role = guild.get_role(self.full_access_role) if self.full_access_role else None if not interaction.guild or not isinstance(interaction.user, discord.Member):
if not role: return await interaction.response.send_message("Use this in a server.", ephemeral=True)
return
dm = self.bot.data_manager member: discord.Member = interaction.user
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: try:
if has_all and role not in member.roles: await nn.ensure_pending_and_maybe_open(interaction.guild, member, source="nick_same")
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: except Exception:
pass pass
# Best-effort: refresh user card await self.maybe_apply_full_access(member)
cards = self.bot.get_cog('UserCardsCog') await interaction.response.send_message("Thanks — your nickname claim was sent for moderator review.", ephemeral=True)
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)
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() @commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
# hot-reload settings if not payload.guild_id or not is_accept(payload.emoji):
self._refresh_settings()
if not payload.guild_id:
return return
guild = self.bot.get_guild(payload.guild_id) guild = self.bot.get_guild(payload.guild_id)
member = await self._get_member(guild, payload.user_id) member = await self._get_member(guild, payload.user_id)
if not member or member.bot: if not member or member.bot:
return return
dm = self.bot.data_manager dm = self.bot.data_manager
# ----- Original accept-based flows -----
try: try:
if is_accept(payload.emoji): if payload.message_id == self.rules_msg_id:
# RULES
if self.rules_msg_id and payload.message_id == self.rules_msg_id:
role = guild.get_role(self.rules_role) role = guild.get_role(self.rules_role)
if role: if role:
try:
await member.add_roles(role, reason="Agreed to rules") await member.add_roles(role, reason="Agreed to rules")
except Exception: if member.id not in dm.get('agreed_rules'):
pass
if member.id not in _as_list(dm.get('agreed_rules')):
dm.add('agreed_rules', int(member.id)) dm.add('agreed_rules', int(member.id))
# ENGAGEMENT elif payload.message_id == self.engage_msg_id:
elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
role = guild.get_role(self.engage_role) role = guild.get_role(self.engage_role)
if role: if role:
try:
await member.add_roles(role, reason="Agreed to engagement") await member.add_roles(role, reason="Agreed to engagement")
except Exception: if member.id not in dm.get('agreed_engagement'):
pass
if member.id not in _as_list(dm.get('agreed_engagement')):
dm.add('agreed_engagement', int(member.id)) dm.add('agreed_engagement', int(member.id))
# NICKNAME (debounced + idempotent) elif payload.message_id == self.nick_msg_id:
elif self.nick_msg_id and payload.message_id == self.nick_msg_id: # --- STALE-STATE CLEANUP ---
now = time.monotonic() # If no pending review exists for this user in this guild, make sure 'nick_claim_pending'
key = (guild.id, member.id) # is cleared so the atomic method can transition and open a new review.
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( has_pending_review = any(
r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending' 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')) for r in dm.get('nick_reviews')
) )
if has_pending_review: if not 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) dm.remove('nick_claim_pending', lambda x: x == member.id)
# Atomic claim -> ONE review only
nn = self.bot.get_cog('NickNudgeCog') nn = self.bot.get_cog('NickNudgeCog')
if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
try: try:
await nn.ensure_pending_and_maybe_open(guild, member, source="claim") await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
except Exception: except Exception:
pass pass
finally: else:
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 return
else:
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 return
except Exception: except Exception:
pass pass
# Only the original (agreements) flow affects Full Access
await self.maybe_apply_full_access(member) await self.maybe_apply_full_access(member)
@commands.Cog.listener() @commands.Cog.listener()
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
# hot-reload settings if not payload.guild_id or not is_accept(payload.emoji):
self._refresh_settings()
if not payload.guild_id:
return return
guild = self.bot.get_guild(payload.guild_id) guild = self.bot.get_guild(payload.guild_id)
member = await self._get_member(guild, payload.user_id) member = await self._get_member(guild, payload.user_id)
if not member or member.bot: if not member or member.bot:
return return
dm = self.bot.data_manager dm = self.bot.data_manager
# ----- Original accept-based flows -----
try: try:
if is_accept(payload.emoji): if payload.message_id == self.rules_msg_id:
# RULES
if self.rules_msg_id and payload.message_id == self.rules_msg_id:
dm.remove('agreed_rules', lambda x: x == member.id) dm.remove('agreed_rules', lambda x: x == member.id)
role = guild.get_role(self.rules_role) role = guild.get_role(self.rules_role)
if role and role in member.roles: if role:
try:
await member.remove_roles(role, reason="Rules un-ticked") await member.remove_roles(role, reason="Rules un-ticked")
except Exception:
pass
# ENGAGEMENT elif payload.message_id == self.engage_msg_id:
elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
dm.remove('agreed_engagement', lambda x: x == member.id) dm.remove('agreed_engagement', lambda x: x == member.id)
role = guild.get_role(self.engage_role) role = guild.get_role(self.engage_role)
if role and role in member.roles: if role:
try:
await member.remove_roles(role, reason="Engagement un-ticked") await member.remove_roles(role, reason="Engagement un-ticked")
except Exception:
pass
# NICKNAME: clear only if no other accept left elif payload.message_id == self.nick_msg_id:
elif self.nick_msg_id and 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( still_has_accept = await self._user_has_any_accept(
guild, payload.channel_id, payload.message_id, member.id guild, payload.channel_id, payload.message_id, member.id
) )
@ -613,78 +197,11 @@ class ReactionRoleCog(commands.Cog):
dm.remove('agreed_nickname', lambda x: x == member.id) dm.remove('agreed_nickname', lambda x: x == member.id)
dm.remove('nick_claim_pending', lambda x: x == member.id) dm.remove('nick_claim_pending', lambda x: x == member.id)
dm.remove('nick_verified', 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: 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 return
except Exception: except Exception:
pass pass
# Only the original (agreements) flow affects Full Access
await self.maybe_apply_full_access(member) await self.maybe_apply_full_access(member)

View File

@ -1,4 +1,2 @@
discord.py>=2.5.2 discord.py>=2.3.2
python-dotenv>=1.0.1 python-dotenv
aiohttp>=3.9,<4
playwright==1.45.0