Compare commits

..

2 Commits

Author SHA1 Message Date
Franz Rolfsvaag
3d807e2fc2 0.5.1.2.a1
- Added crew role reaction roles:
  - Harvester Crew - for carrier and crawler operators/owners
  - Escort Crew - for players protecting harvester crews
  - Fedaykin - for pirate hunters
- Minor bugfixes to other reaction role features, most notably duplicate reviews on auto-detect
2025-08-25 10:55:48 +02:00
b56ed48f8d Update requirements.txt 2025-08-16 06:25:50 +00:00
5 changed files with 607 additions and 117 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.1.a1" VERSION = "0.5.1.2.a1"
# ---------- Env loading ---------- # ---------- Env loading ----------
load_dotenv() load_dotenv()

View File

@ -163,6 +163,7 @@ 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."},
@ -172,6 +173,10 @@ SETTINGS_SCHEMA: Dict[str, Dict[str, Any]] = {
"field_mod_role_id": {"type": "int", "default": 0, "nonzero": True, "desc": "Field mod role ID."}, "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="Control the Deep Desert weekly loot updater") @app_commands.command(name="dd_update", description="[MOD] Control the Deep Desert weekly loot updater")
@app_commands.describe(action="stop/resume/start", reason="Optional reason") @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,71 +1,215 @@
# 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.settings import cfg # ENV-first helper
CHECKMARK = '' from modules.common.emoji_accept import is_accept
from modules.common.settings import cfg # dynamic settings helper
CHECKMARK = '' # kept for consistency with other flows
# ------------------ small helpers ------------------
def _fmt_dt(ts: float) -> str:
return discord.utils.format_dt(datetime.fromtimestamp(ts, tz=timezone.utc), style="f")
def _as_list(x: Optional[Iterable]) -> list:
return list(x) if isinstance(x, Iterable) else []
# ------------------ Fedaykin approval buttons ------------------
class _FedaykinApprovalView(discord.ui.View):
"""Approval buttons for a single Fedaykin request (persistent)."""
def __init__(self, cog: "ReactionRoleCog", req: Dict[str, Any], *, timeout: Optional[float] = None):
super().__init__(timeout=timeout)
self.cog = cog
self.req = req # dict persisted via data_manager
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Only Field Mod or Admin may act
member = interaction.user
if not isinstance(member, discord.Member):
await interaction.response.send_message("Server context required.", ephemeral=True)
return False
allow_roles = {self.cog.field_mod_role_id, self.cog.admin_role_id}
if not any(r.id in allow_roles for r in getattr(member, "roles", [])):
await interaction.response.send_message("You are not allowed to decide this request.", ephemeral=True)
return False
return True
async def _finish(self, interaction: discord.Interaction, status: str):
await self.cog._fedaykin_decide(
guild=interaction.guild,
actor=interaction.user if isinstance(interaction.user, discord.Member) else None,
req=self.req,
status=status,
edit_view=True,
)
try:
await interaction.response.send_message(f"{status.capitalize()}.", ephemeral=True)
except Exception:
pass
@discord.ui.button(label="Approve", style=discord.ButtonStyle.success, custom_id="fdk.approve")
async def _approve(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._finish(interaction, "approved")
@discord.ui.button(label="Reject", style=discord.ButtonStyle.danger, custom_id="fdk.reject")
async def _reject(self, interaction: discord.Interaction, button: discord.ui.Button):
await self._finish(interaction, "rejected")
# ------------------ Main cog ------------------
class ReactionRoleCog(commands.Cog): class ReactionRoleCog(commands.Cog):
""" """
Records agreements and manages Full Access. Original: Records agreements and manages Full Access.
Nickname flow: Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons.
Add accept on nickname message -> atomically mark agreed + pending and open ONE review - No debug slash commands.
Remove accept on nickname message -> clear only if user has no accept reactions left - Debounced nickname review to avoid duplicates when users add multiple accept emojis.
Full Access: granted when Rules + RoE + Nickname *claimed* (pending or verified). - Fedaykin role is removed when the user unreacts the Fedaykin emoji.
Revoked when any of the three is missing. - Settings are reloaded dynamically on each event (hot-apply without restart).
""" """
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
r = cfg(bot)
# Message + role IDs from ENV/INI (default 0 if unset) # Snapshot (will be refreshed dynamically on each event)
self.rules_msg_id = r.int('rules_message_id', 0) self._settings = {}
self.engage_msg_id = r.int('engagement_message_id', 0) self._hub_channel_id: Optional[int] = None # cache for crew hub channel
self.nick_msg_id = r.int('nickname_message_id', 0)
self.rules_role = r.int('rules_role_id', 0) # Debounce / locks to prevent duplicate nickname review on bursty reactions
self.engage_role = r.int('engagement_role_id', 0) self._nick_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts
self.full_access_role = r.int('full_access_role_id', 0) self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations
# ---- helpers ---- # Apply initial snapshot and schedule restore
def _has_rules(self, member_id: int) -> bool: self._refresh_settings(force=True)
return member_id in self.bot.data_manager.get('agreed_rules') self.bot.loop.create_task(self._boot_restore())
def _has_engage(self, member_id: int) -> bool: # ------------------ settings ------------------
return member_id in self.bot.data_manager.get('agreed_engagement')
def _has_nick_claim(self, member_id: int) -> bool: def _refresh_settings(self, *, force: bool = False):
"""Claimed = agreed_nickname; pending/verified tracked separately.""" """Hot-read settings on demand; cheap and avoids restart requirement."""
return member_id in self.bot.data_manager.get('agreed_nickname') r = cfg(self.bot)
s = {
# Message IDs
"rules_msg_id": r.int('rules_message_id', 0),
"engage_msg_id": r.int('engagement_message_id', 0),
"nick_msg_id": r.int('nickname_message_id', 0),
"crew_msg_id": r.int('crew_roles_message_id', 0),
async def maybe_apply_full_access(self, member: discord.Member): # Role IDs
"""Grant when Rules+RoE+Nickname *claimed*; revoke when any missing.""" "rules_role": r.int('rules_role_id', 0),
guild = member.guild "engage_role": r.int('engagement_role_id', 0),
role = guild.get_role(self.full_access_role) if self.full_access_role else None "full_access_role": r.int('full_access_role_id', 0),
if not role: "role_harvest_id": r.int('role_harvest_crew_id', 0),
return "role_escort_id": r.int('role_escort_crew_id', 0),
"role_fedaykin_id": r.int('role_fedaykin_id', 0),
has_all = self._has_rules(member.id) and self._has_engage(member.id) and self._has_nick_claim(member.id) # Approver roles
"field_mod_role_id": r.int('field_mod_role_id', 0),
"admin_role_id": r.int('admin_role_id', 0),
# Emojis
"emoji_harvest_id": r.int('emoji_harvester_crew', 0),
"emoji_escort_id": r.int('emoji_escort_crew', 0),
"emoji_fedaykin_id": r.int('emoji_fedaykin', 0),
# Channels
"report_channel_id": r.int('report_channel_id', 0),
"mod_channel_id": r.int('mod_channel_id', 0),
"modlog_channel_id": r.int('modlog_channel_id', 0),
}
self._settings = s
# handy properties (avoid tons of self._settings["x"] everywhere)
@property
def rules_msg_id(self) -> int: return self._settings.get("rules_msg_id", 0)
@property
def engage_msg_id(self) -> int: return self._settings.get("engage_msg_id", 0)
@property
def nick_msg_id(self) -> int: return self._settings.get("nick_msg_id", 0)
@property
def crew_msg_id(self) -> int: return self._settings.get("crew_msg_id", 0)
@property
def rules_role(self) -> int: return self._settings.get("rules_role", 0)
@property
def engage_role(self) -> int: return self._settings.get("engage_role", 0)
@property
def full_access_role(self) -> int: return self._settings.get("full_access_role", 0)
@property
def role_harvest_id(self) -> int: return self._settings.get("role_harvest_id", 0)
@property
def role_escort_id(self) -> int: return self._settings.get("role_escort_id", 0)
@property
def role_fedaykin_id(self) -> int: return self._settings.get("role_fedaykin_id", 0)
@property
def field_mod_role_id(self) -> int: return self._settings.get("field_mod_role_id", 0)
@property
def admin_role_id(self) -> int: return self._settings.get("admin_role_id", 0)
@property
def emoji_harvest_id(self) -> int: return self._settings.get("emoji_harvest_id", 0)
@property
def emoji_escort_id(self) -> int: return self._settings.get("emoji_escort_id", 0)
@property
def emoji_fedaykin_id(self) -> int: return self._settings.get("emoji_fedaykin_id", 0)
@property
def report_channel_id(self) -> int: return self._settings.get("report_channel_id", 0)
@property
def mod_channel_id(self) -> int: return self._settings.get("mod_channel_id", 0)
@property
def modlog_channel_id(self) -> int: return self._settings.get("modlog_channel_id", 0)
# ------------------ boot & persistence ------------------
async def _boot_restore(self):
await self.bot.wait_until_ready()
self._refresh_settings()
dm = self.bot.data_manager
# Ensure list key exists to be safe
if not isinstance(dm.get('fedaykin_requests'), list):
try:
dm.add('fedaykin_requests', {"_init": True})
dm.remove('fedaykin_requests', lambda x: isinstance(x, dict) and x.get("_init") is True)
except Exception:
pass
# Re-register views for any pending requests
try: try:
if has_all and role not in member.roles: for req in _as_list(dm.get('fedaykin_requests')):
await member.add_roles(role, reason="All agreements completed (nickname may be pending)") if req.get("status") == "pending" and req.get("review_message_id") and req.get("review_channel_id"):
elif not has_all and role in member.roles: self.bot.add_view(_FedaykinApprovalView(self, req), message_id=int(req["review_message_id"]))
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 async def _save_fedaykin_request(self, req: Dict[str, Any]):
cards = self.bot.get_cog('UserCardsCog') """Upsert by (guild_id, user_id)."""
if cards: dm = self.bot.data_manager
try: gid, uid = int(req["guild_id"]), int(req["user_id"])
await cards.refresh_card(member) try:
except Exception: for _ in list(_as_list(dm.get('fedaykin_requests'))):
pass 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:
@ -94,114 +238,453 @@ class ReactionRoleCog(commands.Cog):
except Exception: except Exception:
return False return False
# ---- commands (hybrid = prefix + slash) ---- async def maybe_apply_full_access(self, member: discord.Member):
@app_commands.command(name="nick_same", description="Claim that your global display name matches your in-game name (triggers mod review)") """Grant when Rules+RoE+Nickname *claimed*; revoke when any missing."""
async def nick_same(self, interaction: discord.Interaction): guild = member.guild
if not interaction.guild or not isinstance(interaction.user, discord.Member): role = guild.get_role(self.full_access_role) if self.full_access_role else None
return await interaction.response.send_message("Use this in a server.", ephemeral=True) if not role:
return
member: discord.Member = interaction.user dm = self.bot.data_manager
has_rules = member.id in _as_list(dm.get('agreed_rules'))
has_engage = member.id in _as_list(dm.get('agreed_engagement'))
has_nick_claim = member.id in _as_list(dm.get('agreed_nickname'))
has_all = has_rules and has_engage and has_nick_claim
nn = self.bot.get_cog('NickNudgeCog') try:
if nn and hasattr(nn, 'ensure_pending_and_maybe_open'): 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: try:
await nn.ensure_pending_and_maybe_open(interaction.guild, member, source="nick_same") await cards.refresh_card(member)
except Exception: except Exception:
pass pass
await self.maybe_apply_full_access(member) async def _find_hub_message(self, guild: discord.Guild) -> Tuple[Optional[discord.Message], Optional[int]]:
await interaction.response.send_message("Thanks — your nickname claim was sent for moderator review.", ephemeral=True) """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):
if not payload.guild_id or not is_accept(payload.emoji): # hot-reload settings
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 payload.message_id == self.rules_msg_id: if is_accept(payload.emoji):
role = guild.get_role(self.rules_role)
if role:
await member.add_roles(role, reason="Agreed to rules")
if member.id not in dm.get('agreed_rules'):
dm.add('agreed_rules', int(member.id))
elif payload.message_id == self.engage_msg_id: # RULES
role = guild.get_role(self.engage_role) if self.rules_msg_id and payload.message_id == self.rules_msg_id:
if role: role = guild.get_role(self.rules_role)
await member.add_roles(role, reason="Agreed to engagement") if role:
if member.id not in dm.get('agreed_engagement'): try:
dm.add('agreed_engagement', int(member.id)) await member.add_roles(role, reason="Agreed to rules")
except Exception:
pass
if member.id not in _as_list(dm.get('agreed_rules')):
dm.add('agreed_rules', int(member.id))
elif payload.message_id == self.nick_msg_id: # ENGAGEMENT
# --- STALE-STATE CLEANUP --- elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
# If no pending review exists for this user in this guild, make sure 'nick_claim_pending' role = guild.get_role(self.engage_role)
# is cleared so the atomic method can transition and open a new review. if role:
has_pending_review = any( try:
r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending' await member.add_roles(role, reason="Agreed to engagement")
for r in dm.get('nick_reviews') except Exception:
) pass
if not has_pending_review: if member.id not in _as_list(dm.get('agreed_engagement')):
dm.remove('nick_claim_pending', lambda x: x == member.id) dm.add('agreed_engagement', int(member.id))
# NICKNAME (debounced + idempotent)
elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
now = time.monotonic()
key = (guild.id, member.id)
last = self._nick_debounce.get(key, 0.0)
if now - last < 10.0:
return # debounce bursty multi-emoji reacts
self._nick_debounce[key] = now
# If any pending review already exists for this user, do nothing
has_pending_review = any(
r.get('guild_id') == guild.id and r.get('user_id') == member.id and r.get('status') == 'pending'
for r in _as_list(dm.get('nick_reviews'))
)
if has_pending_review:
return
# In-flight lock to avoid concurrent duplicate openings
if key in self._nick_lock:
return
self._nick_lock.add(key)
# Atomic claim -> ONE review only
nn = self.bot.get_cog('NickNudgeCog')
if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
try: try:
await nn.ensure_pending_and_maybe_open(guild, member, source="claim") # Clear stale marker if no active review, then open exactly one
except Exception: dm.remove('nick_claim_pending', lambda x: x == member.id)
pass nn = self.bot.get_cog('NickNudgeCog')
else: if nn and hasattr(nn, 'ensure_pending_and_maybe_open'):
return try:
else: await nn.ensure_pending_and_maybe_open(guild, member, source="claim")
return except Exception:
pass
finally:
self._nick_lock.discard(key)
except Exception: except Exception:
pass pass
# ----- Crew roles hub (custom emoji toggles + Fedaykin request) -----
try:
if self.crew_msg_id and payload.message_id == self.crew_msg_id and payload.emoji.id:
# Harvester / Escort
if payload.emoji.id == self.emoji_harvest_id and self.role_harvest_id:
role = guild.get_role(self.role_harvest_id)
if role:
try:
await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
except Exception:
pass
return
if payload.emoji.id == self.emoji_escort_id and self.role_escort_id:
role = guild.get_role(self.role_escort_id)
if role:
try:
await member.add_roles(role, reason=f"Reaction role via hub {self.crew_msg_id}")
except Exception:
pass
return
# Fedaykin -> open approval card (only one pending allowed)
if payload.emoji.id == self.emoji_fedaykin_id:
fed_role = guild.get_role(self.role_fedaykin_id) if self.role_fedaykin_id else None
if fed_role and fed_role in member.roles:
return # already has Fedaykin
pending = next((x for x in _as_list(dm.get('fedaykin_requests'))
if x.get("guild_id") == guild.id and x.get("user_id") == member.id and x.get("status") == "pending"), None)
if pending:
return
hub_msg, _ = await self._find_hub_message(guild)
hub_id = hub_msg.id if hub_msg else self.crew_msg_id
await self._post_fedaykin_card(guild, member, hub_id)
return
except Exception:
pass
# Only the original (agreements) flow affects Full Access
await self.maybe_apply_full_access(member) 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):
if not payload.guild_id or not is_accept(payload.emoji): # hot-reload settings
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 payload.message_id == self.rules_msg_id: if is_accept(payload.emoji):
dm.remove('agreed_rules', lambda x: x == member.id) # RULES
role = guild.get_role(self.rules_role) if self.rules_msg_id and payload.message_id == self.rules_msg_id:
if role: dm.remove('agreed_rules', lambda x: x == member.id)
await member.remove_roles(role, reason="Rules un-ticked") role = guild.get_role(self.rules_role)
if role and role in member.roles:
try:
await member.remove_roles(role, reason="Rules un-ticked")
except Exception:
pass
elif payload.message_id == self.engage_msg_id: # ENGAGEMENT
dm.remove('agreed_engagement', lambda x: x == member.id) elif self.engage_msg_id and payload.message_id == self.engage_msg_id:
role = guild.get_role(self.engage_role) dm.remove('agreed_engagement', lambda x: x == member.id)
if role: role = guild.get_role(self.engage_role)
await member.remove_roles(role, reason="Engagement un-ticked") if role and role in member.roles:
try:
await member.remove_roles(role, reason="Engagement un-ticked")
except Exception:
pass
elif payload.message_id == self.nick_msg_id: # NICKNAME: clear only if no other accept left
# Clear only if the user has NO accept reactions left on the message elif self.nick_msg_id and payload.message_id == self.nick_msg_id:
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
) )
if not still_has_accept: if not still_has_accept:
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)
else:
return
except Exception: except Exception:
pass 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) await self.maybe_apply_full_access(member)

View File

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