shaiwatcher/modules/reaction_role/reaction_role.py
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

693 lines
30 KiB
Python

# 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
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):
"""
Original: Records agreements and manages Full Access.
Added: Crew reaction roles (Harvester/Escort toggles) + Fedaykin approval flow via buttons.
- No debug slash commands.
- Debounced nickname review to avoid duplicates when users add multiple accept emojis.
- Fedaykin role is removed when the user unreacts the Fedaykin emoji.
- Settings are reloaded dynamically on each event (hot-apply without restart).
"""
def __init__(self, bot):
self.bot = bot
# 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
# 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
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
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)
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 ------------------
@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)
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:
# 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)
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))