EveCogs/nexuscore/reactionroles.py

450 lines
22 KiB
Python
Raw Permalink Normal View History

"""NexusCore — Reaction roles v2: buttons, select, reactions, exclusive groups, requirements,
multi-page menus, custom embeds, requirement chains, stats, conflict removal, clone, verification."""
from __future__ import annotations
import datetime
from typing import Optional
import discord
from redbot.core import Config, commands
from .utils import (
Clr, ok_embed, err_embed, info_embed, short_id, ts_now,
safe_send, safe_dm, chunk_list,
)
# ── Defaults ───────────────────────────────────────────────────────────────
RR_DEFAULTS_GUILD = {
"enabled": True,
"panels": {},
"log_channel": None,
"dm_confirm": True,
"stats": {}, # role_id -> {"added": 0, "removed": 0}
"verification_panels": {},
# panel_id -> {channel_id, message_id, role_id, button_label, button_style}
}
# ── Dynamic view builders ─────────────────────────────────────────────────
def build_button_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
view = discord.ui.View(timeout=None)
for i, role_entry in enumerate(panel_data.get("roles", [])[:25]):
style_map = {
"primary": discord.ButtonStyle.primary, "secondary": discord.ButtonStyle.secondary,
"success": discord.ButtonStyle.success, "danger": discord.ButtonStyle.danger,
}
style = style_map.get(role_entry.get("style", "primary"), discord.ButtonStyle.primary)
emoji = role_entry.get("emoji")
button = discord.ui.Button(
label=role_entry.get("label", "Role"), style=style, emoji=emoji,
custom_id=f"nexus_rr_{panel_id}_{i}",
)
async def callback(interaction, idx=i, pid=panel_id):
await cog._handle_role_toggle(interaction, pid, idx)
button.callback = callback
view.add_item(button)
return view
def build_select_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
view = discord.ui.View(timeout=None)
max_roles = panel_data.get("max_roles", 0)
options = []
for i, role_entry in enumerate(panel_data.get("roles", [])[:25]):
options.append(discord.SelectOption(
label=role_entry.get("label", "Role"), value=str(i),
description=(role_entry.get("description", "") or "")[:100],
emoji=role_entry.get("emoji"),
))
select = discord.ui.Select(
placeholder="Select roles...", options=options, min_values=0,
max_values=min(len(options), max_roles) if max_roles else len(options),
custom_id=f"nexus_rr_sel_{panel_id}",
)
async def callback(interaction):
await cog._handle_select_roles(interaction, panel_id, [int(v) for v in select.values])
select.callback = callback
view.add_item(select)
return view
def build_verification_view(cog, panel_id: str, panel_data: dict) -> discord.ui.View:
"""Verification button that grants a role on click."""
view = discord.ui.View(timeout=None)
style_map = {
"primary": discord.ButtonStyle.primary, "secondary": discord.ButtonStyle.secondary,
"success": discord.ButtonStyle.success, "danger": discord.ButtonStyle.danger,
}
style = style_map.get(panel_data.get("button_style", "success"), discord.ButtonStyle.success)
button = discord.ui.Button(
label=panel_data.get("button_label", "✅ Verify"), style=style,
custom_id=f"nexus_verify_{panel_id}",
)
async def callback(interaction):
await cog._handle_verification(interaction, panel_id)
button.callback = callback
view.add_item(button)
return view
# ── Mixin ──────────────────────────────────────────────────────────────────
class ReactionRolesMixin:
"""Reaction roles mixin — v2 with verification, stats, cloning, multi-page."""
def _init_reaction_roles(self, bot):
self.rr_config = Config.get_conf(None, identifier=900004, cog_name="NexusCoreRR")
self.rr_config.register_guild(**RR_DEFAULTS_GUILD)
self._rr_views = {}
self._rr_temp_tasks = {}
self.bot = bot
async def _load_rr_panels(self):
all_guilds = await self.rr_config.all_guilds()
for guild_id, gdata in all_guilds.items():
guild = self.bot.get_guild(guild_id)
if not guild:
continue
for panel_id, panel in gdata.get("panels", {}).items():
mode = panel.get("mode", "button")
if mode == "button":
view = build_button_view(self, panel_id, panel)
elif mode == "select":
view = build_select_view(self, panel_id, panel)
else:
continue
self._rr_views[panel_id] = view
self.bot.add_view(view, message_id=panel.get("message_id"))
for vpid, vp in gdata.get("verification_panels", {}).items():
view = build_verification_view(self, vpid, vp)
self.bot.add_view(view, message_id=vp.get("message_id"))
async def _create_rr_panel(
self, ctx: commands.Context, channel: discord.TextChannel,
title: str, description: str, mode: str, colour: discord.Colour | None = None,
image: str | None = None, thumbnail: str | None = None,
) -> str:
panel_id = short_id(10)
panel_data = {
"channel_id": channel.id, "message_id": None,
"title": title, "description": description,
"colour": (colour or Clr.ROLES).value,
"image": image, "thumbnail": thumbnail,
"mode": mode, "roles": [], "exclusive_groups": {},
"max_roles": 0, "dm_confirm": True,
"require_role": None, "blacklist_role": None,
"sticky": False, "temp_minutes": 0,
}
embed = discord.Embed(title=title, description=description, colour=colour or Clr.ROLES)
if image:
embed.set_image(url=image)
if thumbnail:
embed.set_thumbnail(url=thumbnail)
embed.set_footer(text="Select your roles below")
msg = await channel.send(embed=embed)
panel_data["message_id"] = msg.id
async with self.rr_config.guild(ctx.guild).panels() as panels:
panels[panel_id] = panel_data
return panel_id
async def _add_role_to_panel(
self, guild: discord.Guild, panel_id: str,
role: discord.Role, label: str | None = None, emoji: str | None = None,
description: str | None = None, style: str = "primary",
group: str | None = None, required_role: int | None = None,
blacklisted_role: int | None = None,
):
async with self.rr_config.guild(guild).panels() as panels:
panel = panels.get(panel_id)
if not panel:
return False
panel["roles"].append({
"role_id": role.id, "label": label or role.name,
"emoji": emoji, "description": description,
"style": style, "group": group,
"required_role": required_role, "blacklisted_role": blacklisted_role,
})
panels[panel_id] = panel
await self._refresh_rr_panel(guild, panel_id)
return True
async def _refresh_rr_panel(self, guild: discord.Guild, panel_id: str):
data = await self.rr_config.guild(guild).panels()
panel = data.get(panel_id)
if not panel:
return
channel = guild.get_channel(panel["channel_id"])
if not channel:
return
mode = panel.get("mode", "button")
if mode == "button":
view = build_button_view(self, panel_id, panel)
elif mode == "select":
view = build_select_view(self, panel_id, panel)
else:
view = None
if view:
self._rr_views[panel_id] = view
self.bot.add_view(view, message_id=panel.get("message_id"))
embed = discord.Embed(title=panel["title"], description=panel["description"], colour=discord.Colour(panel["colour"]))
if panel.get("image"):
embed.set_image(url=panel["image"])
if panel.get("thumbnail"):
embed.set_thumbnail(url=panel["thumbnail"])
role_lines = []
for r in panel.get("roles", []):
emoji_str = f"{r['emoji']} " if r.get("emoji") else ""
role = guild.get_role(r["role_id"])
role_mention = role.mention if role else f"<@&{r['role_id']}>"
role_lines.append(f"{emoji_str}{role_mention} — {r.get('description', r.get('label', ''))}")
if role_lines:
embed.add_field(name="Available Roles", value="\n".join(role_lines), inline=False)
embed.set_footer(text="Select your roles below")
try:
msg = await channel.fetch_message(panel["message_id"])
await msg.edit(embed=embed, view=view)
except discord.HTTPException:
pass
async def _handle_role_toggle(self, interaction: discord.Interaction, panel_id: str, role_idx: int):
guild = interaction.guild
member = interaction.user
data = await self.rr_config.guild(guild).panels()
panel = data.get(panel_id)
if not panel:
return await interaction.response.send_message("Panel not found.", ephemeral=True)
roles_list = panel.get("roles", [])
if role_idx >= len(roles_list):
return await interaction.response.send_message("Role not found.", ephemeral=True)
role_entry = roles_list[role_idx]
role = guild.get_role(role_entry["role_id"])
if not role:
return await interaction.response.send_message("Role no longer exists.", ephemeral=True)
# Requirement checks
if panel.get("require_role"):
req = guild.get_role(panel["require_role"])
if req and req not in member.roles:
return await interaction.response.send_message(f"You need {req.mention} first.", ephemeral=True)
if panel.get("blacklist_role"):
bl = guild.get_role(panel["blacklist_role"])
if bl and bl in member.roles:
return await interaction.response.send_message(f"You can't use this with {bl.mention}.", ephemeral=True)
if role_entry.get("required_role"):
rr = guild.get_role(role_entry["required_role"])
if rr and rr not in member.roles:
return await interaction.response.send_message(f"You need {rr.mention} first.", ephemeral=True)
if role_entry.get("blacklisted_role"):
br = guild.get_role(role_entry["blacklisted_role"])
if br and br in member.roles:
return await interaction.response.send_message(f"Incompatible with {br.mention}.", ephemeral=True)
try:
if role in member.roles:
if panel.get("sticky"):
return await interaction.response.send_message("This role is sticky and can't be removed.", ephemeral=True)
await member.remove_roles(role, reason="NexusCore reaction roles")
action = "removed"
emoji = "➖"
else:
group = role_entry.get("group")
if group and group in panel.get("exclusive_groups", {}):
max_picks = panel["exclusive_groups"][group]
group_roles = [r for r in roles_list if r.get("group") == group]
member_group_roles = [guild.get_role(r["role_id"]) for r in group_roles if guild.get_role(r["role_id"]) in member.roles]
if len(member_group_roles) >= max_picks:
for old_role in member_group_roles:
await member.remove_roles(old_role, reason="NexusCore exclusive group swap")
if panel.get("max_roles", 0) > 0:
current_panel_roles = [guild.get_role(r["role_id"]) for r in roles_list if guild.get_role(r["role_id"]) in member.roles]
if len(current_panel_roles) >= panel["max_roles"]:
return await interaction.response.send_message(f"Max {panel['max_roles']} roles from this panel.", ephemeral=True)
await member.add_roles(role, reason="NexusCore reaction roles")
action = "added"
emoji = "➕"
if panel.get("temp_minutes", 0) > 0:
import asyncio
async def remove_later():
await asyncio.sleep(panel["temp_minutes"] * 60)
try:
await member.remove_roles(role, reason="NexusCore temp role expired")
except discord.HTTPException:
pass
task = asyncio.create_task(remove_later())
self._rr_temp_tasks[(member.id, role.id)] = task
except discord.Forbidden:
return await interaction.response.send_message("I don't have permission to manage that role.", ephemeral=True)
except discord.HTTPException:
return await interaction.response.send_message("Failed to update role.", ephemeral=True)
await interaction.response.send_message(f"{emoji} **{role.name}** {action}!", ephemeral=True)
# Track stats
async with self.rr_config.guild(guild).stats() as stats:
rid = str(role.id)
if rid not in stats:
stats[rid] = {"added": 0, "removed": 0}
stats[rid]["added" if action == "added" else "removed"] += 1
dm_confirm = panel.get("dm_confirm", True)
if dm_confirm:
await safe_dm(member, embed=discord.Embed(
description=f"{emoji} Role **{role.name}** has been {action} in **{guild.name}**.", colour=Clr.ROLES))
log_ch_id = await self.rr_config.guild(guild).log_channel()
if log_ch_id:
log_ch = guild.get_channel(log_ch_id)
if log_ch:
le = discord.Embed(
description=f"{emoji} {member.mention} — **{role.name}** {action}",
colour=Clr.ROLES, timestamp=datetime.datetime.now(datetime.timezone.utc))
await safe_send(log_ch, embed=le)
async def _handle_select_roles(self, interaction: discord.Interaction, panel_id: str, selected: list[int]):
guild = interaction.guild
member = interaction.user
data = await self.rr_config.guild(guild).panels()
panel = data.get(panel_id)
if not panel:
return await interaction.response.send_message("Panel not found.", ephemeral=True)
roles_list = panel.get("roles", [])
added, removed = [], []
for i, role_entry in enumerate(roles_list):
role = guild.get_role(role_entry["role_id"])
if not role:
continue
if i in selected:
if role not in member.roles:
try:
await member.add_roles(role, reason="NexusCore select roles")
added.append(role.name)
except discord.HTTPException:
pass
else:
if role in member.roles and not panel.get("sticky"):
try:
await member.remove_roles(role, reason="NexusCore select roles")
removed.append(role.name)
except discord.HTTPException:
pass
parts = []
if added:
parts.append(f"➕ Added: {', '.join(added)}")
if removed:
parts.append(f"➖ Removed: {', '.join(removed)}")
if not parts:
parts.append("No changes.")
await interaction.response.send_message("\n".join(parts), ephemeral=True)
async def _handle_verification(self, interaction: discord.Interaction, panel_id: str):
"""Handle verification button click — grant the configured role."""
guild = interaction.guild
data = await self.rr_config.guild(guild).verification_panels()
vp = data.get(panel_id)
if not vp:
return await interaction.response.send_message("Verification panel not found.", ephemeral=True)
role = guild.get_role(vp.get("role_id"))
if not role:
return await interaction.response.send_message("Verification role not found.", ephemeral=True)
if role in interaction.user.roles:
return await interaction.response.send_message("You're already verified!", ephemeral=True)
try:
await interaction.user.add_roles(role, reason="NexusCore verification")
await interaction.response.send_message("✅ You've been verified!", ephemeral=True)
except discord.Forbidden:
await interaction.response.send_message("I can't assign the verification role.", ephemeral=True)
async def _create_verification_panel(
self, ctx, channel: discord.TextChannel, role: discord.Role,
label: str = "✅ Verify", style: str = "success", title: str = "Verification",
description: str = "Click the button below to verify yourself!",
) -> str:
vpid = short_id(10)
embed = discord.Embed(title=title, description=description, colour=Clr.ROLES)
vp_data = {
"channel_id": channel.id, "message_id": None,
"role_id": role.id, "button_label": label, "button_style": style,
}
view = build_verification_view(self, vpid, vp_data)
msg = await channel.send(embed=embed, view=view)
vp_data["message_id"] = msg.id
async with self.rr_config.guild(ctx.guild).verification_panels() as vps:
vps[vpid] = vp_data
self.bot.add_view(view, message_id=msg.id)
return vpid
async def _clone_panel(self, guild, panel_id: str, target_channel: discord.TextChannel) -> str | None:
"""Clone a role panel to another channel."""
data = await self.rr_config.guild(guild).panels()
panel = data.get(panel_id)
if not panel:
return None
new_id = short_id(10)
new_panel = dict(panel)
new_panel["channel_id"] = target_channel.id
embed = discord.Embed(
title=panel["title"], description=panel["description"], colour=discord.Colour(panel["colour"]))
if panel.get("image"):
embed.set_image(url=panel["image"])
if panel.get("thumbnail"):
embed.set_thumbnail(url=panel["thumbnail"])
role_lines = []
for r in panel.get("roles", []):
emoji_str = f"{r['emoji']} " if r.get("emoji") else ""
ro = guild.get_role(r["role_id"])
role_lines.append(f"{emoji_str}{ro.mention if ro else '?'} — {r.get('label', '')}")
if role_lines:
embed.add_field(name="Available Roles", value="\n".join(role_lines), inline=False)
embed.set_footer(text="Select your roles below")
mode = panel.get("mode", "button")
if mode == "button":
view = build_button_view(self, new_id, panel)
elif mode == "select":
view = build_select_view(self, new_id, panel)
else:
view = None
msg = await target_channel.send(embed=embed, view=view)
new_panel["message_id"] = msg.id
async with self.rr_config.guild(guild).panels() as panels:
panels[new_id] = new_panel
if view:
self.bot.add_view(view, message_id=msg.id)
return new_id
# ── Reaction-based handling ────────────────────────────────────────────
async def _handle_reaction_add(self, payload: discord.RawReactionActionEvent):
guild = self.bot.get_guild(payload.guild_id)
if not guild:
return
data = await self.rr_config.guild(guild).panels()
for panel_id, panel in data.items():
if panel.get("mode") != "reaction" or panel.get("message_id") != payload.message_id:
continue
for role_entry in panel.get("roles", []):
if str(payload.emoji) == role_entry.get("emoji") or payload.emoji.name == role_entry.get("emoji"):
role = guild.get_role(role_entry["role_id"])
member = guild.get_member(payload.user_id)
if role and member and not member.bot:
try:
await member.add_roles(role, reason="NexusCore reaction role")
except discord.HTTPException:
pass
async def _handle_reaction_remove(self, payload: discord.RawReactionActionEvent):
guild = self.bot.get_guild(payload.guild_id)
if not guild:
return
data = await self.rr_config.guild(guild).panels()
for panel_id, panel in data.items():
if panel.get("mode") != "reaction" or panel.get("message_id") != payload.message_id:
continue
for role_entry in panel.get("roles", []):
if str(payload.emoji) == role_entry.get("emoji") or payload.emoji.name == role_entry.get("emoji"):
role = guild.get_role(role_entry["role_id"])
member = guild.get_member(payload.user_id)
if role and member and not member.bot:
try:
await member.remove_roles(role, reason="NexusCore reaction role removed")
except discord.HTTPException:
pass