EveCogs/nexuscore/reactionroles.py
everestmcarthur 84459daf63 NexusCore v2.0.0 — 10 modules, 100+ commands, 8,132 lines
Major expansion:
- All 8 modules fully expanded with advanced features
- New: Embed Builder (webhook-based, templates, scheduling, variables)
- New: Dashboard Integration (web config for all modules)
- Serverlog: 30+ event types, invite tracking, per-event channels
- Moderation: quarantine, reputation, warning decay, staff leaderboard,
  anti-raid, anti-nuke, 6 automod modules, appeals, cross-server bans
- Economy: roulette, dice, fishing, mining, crafting, achievements,
  income roles, auction house, pets (6 types), heists, gambling stats
- Tickets: auto-close, tags, HTML transcripts, staff tracking, reopen
- Applications: staff voting, bulk actions, webhook notifications, templates
- Suggestions: edit, merge, 10 statuses, tag system, auto-approve/deny
- Reaction Roles: verification panels, exclusive groups, sticky/temp roles
- Giveaways: drop mode, bonus entries, templates, DM notifications
- All views use custom_id for persistence across restarts
- Full syntax verification on all 13 Python files
2026-05-08 02:38:34 +00:00

449 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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