0.5.1.2.a2

- Adjustments to reaction role
  - Ensures no one can hold the Fedaykin role without a head
  - Stores pending reviews in the absence of a head for later review when a new head is reinstated
  - *Thanks Kenny :P*
This commit is contained in:
Franz Rolfsvaag 2025-08-25 23:47:25 +02:00
parent 3d807e2fc2
commit f3bc0ef670
2 changed files with 183 additions and 4 deletions

2
bot.py
View File

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

View File

@ -35,12 +35,19 @@ class _FedaykinApprovalView(discord.ui.View):
self.req = req # dict persisted via data_manager
async def interaction_check(self, interaction: discord.Interaction) -> bool:
# Only Field Mod or Admin may act
# 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)
@ -92,6 +99,9 @@ class ReactionRoleCog(commands.Cog):
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())
@ -197,6 +207,14 @@ class ReactionRoleCog(commands.Cog):
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
async def _save_fedaykin_request(self, req: Dict[str, Any]):
"""Upsert by (guild_id, user_id)."""
dm = self.bot.data_manager
@ -306,7 +324,10 @@ class ReactionRoleCog(commands.Cog):
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)
if clear_view:
await msg.edit(embed=emb, view=None)
else:
await msg.edit(embed=emb) # leave existing components untouched
except Exception:
pass
@ -398,7 +419,12 @@ class ReactionRoleCog(commands.Cog):
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."""
"""Post the Fedaykin approval card; return True if posted somewhere. While headless, queue pending only."""
# 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",
@ -444,8 +470,143 @@ class ReactionRoleCog(commands.Cog):
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._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)
# ------------------ 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
@ -455,6 +616,15 @@ class ReactionRoleCog(commands.Cog):
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
@ -575,6 +745,15 @@ class ReactionRoleCog(commands.Cog):
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