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:
parent
3d807e2fc2
commit
f3bc0ef670
2
bot.py
2
bot.py
@ -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; doesn’t trigger auto update)
|
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
||||||
VERSION = "0.5.1.2.a1"
|
VERSION = "0.5.1.2.a2"
|
||||||
|
|
||||||
# ---------- Env loading ----------
|
# ---------- Env loading ----------
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
@ -35,12 +35,19 @@ class _FedaykinApprovalView(discord.ui.View):
|
|||||||
self.req = req # dict persisted via data_manager
|
self.req = req # dict persisted via data_manager
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
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
|
member = interaction.user
|
||||||
if not isinstance(member, discord.Member):
|
if not isinstance(member, discord.Member):
|
||||||
await interaction.response.send_message("Server context required.", ephemeral=True)
|
await interaction.response.send_message("Server context required.", ephemeral=True)
|
||||||
return False
|
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}
|
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", [])):
|
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)
|
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_debounce: Dict[tuple[int, int], float] = {} # (guild_id, user_id) -> last_ts
|
||||||
self._nick_lock: set[tuple[int, int]] = set() # in-flight review creations
|
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
|
# Apply initial snapshot and schedule restore
|
||||||
self._refresh_settings(force=True)
|
self._refresh_settings(force=True)
|
||||||
self.bot.loop.create_task(self._boot_restore())
|
self.bot.loop.create_task(self._boot_restore())
|
||||||
@ -197,6 +207,14 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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]):
|
async def _save_fedaykin_request(self, req: Dict[str, Any]):
|
||||||
"""Upsert by (guild_id, user_id)."""
|
"""Upsert by (guild_id, user_id)."""
|
||||||
dm = self.bot.data_manager
|
dm = self.bot.data_manager
|
||||||
@ -306,7 +324,10 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
emb.set_footer(text=emb.footer.text + "\n" + note)
|
emb.set_footer(text=emb.footer.text + "\n" + note)
|
||||||
else:
|
else:
|
||||||
emb.set_footer(text=note)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -398,7 +419,12 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def _post_fedaykin_card(self, guild: discord.Guild, member: discord.Member, hub_id: int) -> bool:
|
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()
|
created_ts = datetime.now(timezone.utc).timestamp()
|
||||||
emb = discord.Embed(
|
emb = discord.Embed(
|
||||||
title="Fedaykin Request",
|
title="Fedaykin Request",
|
||||||
@ -444,8 +470,143 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
continue
|
continue
|
||||||
return False
|
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 (don’t 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 ------------------
|
# ------------------ 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 aren’t 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()
|
@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
|
# hot-reload settings
|
||||||
@ -455,6 +616,15 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
guild = self.bot.get_guild(payload.guild_id)
|
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)
|
member = await self._get_member(guild, payload.user_id)
|
||||||
if not member or member.bot:
|
if not member or member.bot:
|
||||||
return
|
return
|
||||||
@ -575,6 +745,15 @@ class ReactionRoleCog(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
guild = self.bot.get_guild(payload.guild_id)
|
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)
|
member = await self._get_member(guild, payload.user_id)
|
||||||
if not member or member.bot:
|
if not member or member.bot:
|
||||||
return
|
return
|
||||||
|
Loading…
Reference in New Issue
Block a user