EveCogs/support/support.py
Omega Launcher 814b0e79e9 fix(support): remove core commands in setup before cog loads
Remove Red's built-in contact and dm commands in setup() before the Support
cog loads to prevent command registration conflicts. Commands are stored and
restored in teardown() when the cog unloads.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-06-12 17:49:07 +01:00

600 lines
27 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""Support — Modernized DM/contact system with categories, channels, and customization.
Overrides Red's built-in `contact` and `dm` commands with a more feature-rich system.
"""
from __future__ import annotations
import asyncio
import datetime
from typing import Optional, Literal
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.utils.chat_formatting import box, pagify
# ── Config Defaults ──────────────────────────────────────────────────────────
DEFAULT_GUILD = {
"enabled": False,
"categories": {}, # category_name -> {channel_id, description, emoji, roles, greeting}
"default_category": None,
"log_channel": None,
"mod_roles": [],
"reply_with_embed": True,
"show_author_info": True,
"anonymous_mode": False,
"thread_mode": False,
"dm_on_reply": True,
"active_threads": {}, # channel_id -> {user_id, category, started_at, message_count}
"thread_timeout": 3600, # Close thread after 1hr inactivity
"custom_greeting": "",
"require_category": False,
}
DEFAULT_USER = {
"blocked": False,
"preference_category": None,
"last_contact": 0,
}
# ── Utilities ────────────────────────────────────────────────────────────────
def success_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"✅ {msg}", color=0x43B581)
def error_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"❌ {msg}", color=0xF04747)
def info_embed(msg: str) -> discord.Embed:
return discord.Embed(description=f"ℹ️ {msg}", color=0x5865F2)
def timestamp_now() -> int:
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
# ── Views ────────────────────────────────────────────────────────────────────
class CategorySelectView(discord.ui.View):
"""Category selection dropdown for support messages."""
def __init__(self, categories: dict, timeout: float = 180):
super().__init__(timeout=timeout)
self.selected_category: Optional[str] = None
options = []
for name, data in list(categories.items())[:25]:
emoji = data.get("emoji", "📂")
description = data.get("description", "")[:100] or "Support category"
options.append(discord.SelectOption(
label=name.title(),
value=name,
description=description,
emoji=emoji,
))
select = discord.ui.Select(
placeholder="Choose a category...",
options=options,
custom_id="support_category_select"
)
select.callback = self.on_select
self.add_item(select)
async def on_select(self, interaction: discord.Interaction):
self.selected_category = interaction.data["values"][0]
await interaction.response.send_message(
embed=success_embed(f"Category selected: **{self.selected_category.title()}**"),
ephemeral=True
)
self.stop()
class SupportControlView(discord.ui.View):
"""Control buttons for staff managing support threads."""
def __init__(self, cog: "Support"):
super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="Close Thread", style=discord.ButtonStyle.danger, emoji="🔒", custom_id="support_close")
async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._close_thread(interaction)
@discord.ui.button(label="Block User", style=discord.ButtonStyle.secondary, emoji="🚫", custom_id="support_block")
async def block_button(self, interaction: discord.Interaction, button: discord.ui.Button):
await self.cog._block_user_from_thread(interaction)
class ReplyModal(discord.ui.Modal):
"""Modal for staff to reply to support messages."""
def __init__(self, cog: "Support", user: discord.User, category: str):
super().__init__(title=f"Reply to {user.name}")
self.cog = cog
self.user = user
self.category = category
self.message_input = discord.ui.TextInput(
label="Your Reply",
style=discord.TextStyle.paragraph,
placeholder="Type your response here...",
required=True,
max_length=1900,
)
self.add_item(self.message_input)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
await self.cog._send_reply_to_user(
interaction.guild,
interaction.user,
self.user,
self.message_input.value,
self.category
)
await interaction.followup.send(
embed=success_embed(f"Reply sent to {self.user.mention}"),
ephemeral=True
)
# ── Main Cog ─────────────────────────────────────────────────────────────────
class Support(commands.Cog):
"""Modernized support/contact system with categories and customization."""
def __init__(self, bot: Red):
self.bot = bot
self.config = Config.get_conf(self, identifier=900010, force_registration=True)
self.config.register_guild(**DEFAULT_GUILD)
self.config.register_user(**DEFAULT_USER)
self._control_view = SupportControlView(self)
self.bot.add_view(self._control_view)
self._cleanup_task: Optional[asyncio.Task] = None
async def cog_load(self):
"""Start background tasks on cog load."""
self._cleanup_task = asyncio.create_task(self._thread_cleanup_loop())
async def cog_unload(self):
"""Clean up on unload."""
if self._cleanup_task:
self._cleanup_task.cancel()
# ── Data Management ──────────────────────────────────────────────────────
async def red_delete_data_for_user(self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int):
"""Handle data deletion requests."""
await self.config.user_from_id(user_id).clear()
# ── User Commands ────────────────────────────────────────────────────────
@commands.command(name="contact")
@commands.dm_only()
async def contact(self, ctx: commands.Context, *, message: str):
"""Contact server staff with a message.
Usage: `[p]contact <message>`
This replaces Red's built-in contact command with enhanced features.
"""
user_conf = await self.config.user(ctx.author).all()
if user_conf["blocked"]:
return await ctx.send(embed=error_embed("You are blocked from contacting staff."))
guilds_data = await self.config.all_guilds()
available_guilds = []
for guild_id, gdata in guilds_data.items():
if not gdata["enabled"]:
continue
guild = self.bot.get_guild(guild_id)
if guild and guild.get_member(ctx.author.id):
available_guilds.append((guild, gdata))
if not available_guilds:
return await ctx.send(embed=error_embed("No servers have support enabled where you are a member."))
if len(available_guilds) == 1:
guild, gdata = available_guilds[0]
await self._process_contact(ctx, guild, gdata, message, user_conf)
else:
embed = discord.Embed(
title="Multiple Servers Available",
description="Please use the server's contact command directly or specify which server.",
color=0x5865F2
)
for guild, _ in available_guilds:
embed.add_field(name=guild.name, value=f"ID: `{guild.id}`", inline=False)
await ctx.send(embed=embed)
async def _process_contact(self, ctx: commands.Context, guild: discord.Guild, gdata: dict, message: str, user_conf: dict):
"""Process a contact message."""
categories = gdata["categories"]
selected_category = user_conf.get("preference_category") or gdata.get("default_category")
if gdata["require_category"] and not selected_category and categories:
view = CategorySelectView(categories)
prompt = await ctx.send(
embed=info_embed("Please select a category for your message:"),
view=view
)
await view.wait()
if view.selected_category:
selected_category = view.selected_category
try:
await prompt.delete()
except discord.HTTPException:
pass
category_data = categories.get(selected_category, {}) if selected_category else {}
channel_id = category_data.get("channel_id") or (list(categories.values())[0]["channel_id"] if categories else None)
if not channel_id:
return await ctx.send(embed=error_embed("No support channel configured. Contact an administrator."))
channel = guild.get_channel(channel_id)
if not channel:
return await ctx.send(embed=error_embed("Support channel not found. Contact an administrator."))
await self._send_to_staff(ctx.author, guild, channel, message, selected_category, gdata)
greeting = category_data.get("greeting") or gdata.get("custom_greeting") or \
"Your message has been sent to staff. They will respond shortly."
await ctx.send(embed=success_embed(greeting))
async with self.config.user(ctx.author).all() as user_data:
user_data["last_contact"] = timestamp_now()
@commands.command(name="dm")
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def dm_command(self, ctx: commands.Context, user: discord.User, *, message: str):
"""Send a DM to a user from the server.
Usage: `[p]dm <user> <message>`
This replaces Red's built-in dm command with enhanced features.
"""
gdata = await self.config.guild(ctx.guild).all()
if not gdata["enabled"]:
return await ctx.send(embed=error_embed("Support system is not enabled."))
try:
if gdata["reply_with_embed"]:
embed = discord.Embed(
title=f"Message from {ctx.guild.name}",
description=message,
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
if gdata["show_author_info"] and not gdata["anonymous_mode"]:
embed.set_footer(text=f"From: {ctx.author}", icon_url=ctx.author.display_avatar.url)
else:
embed.set_footer(text=f"From: {ctx.guild.name} Staff")
await user.send(embed=embed)
else:
prefix = f"**Message from {ctx.guild.name}**\n" if gdata["anonymous_mode"] else \
f"**{ctx.author}** from **{ctx.guild.name}**:\n"
await user.send(f"{prefix}{message}")
await ctx.send(embed=success_embed(f"Message sent to {user.mention}"))
log_channel_id = gdata["log_channel"]
if log_channel_id:
log_channel = ctx.guild.get_channel(log_channel_id)
if log_channel:
log_embed = discord.Embed(
title="📤 Staff DM Sent",
color=0x43B581,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
log_embed.add_field(name="Staff", value=ctx.author.mention, inline=True)
log_embed.add_field(name="User", value=user.mention, inline=True)
log_embed.add_field(name="Message", value=message[:1024], inline=False)
await log_channel.send(embed=log_embed)
except discord.Forbidden:
await ctx.send(embed=error_embed(f"Cannot send DM to {user.mention}. They may have DMs disabled."))
except discord.HTTPException as e:
await ctx.send(embed=error_embed(f"Failed to send DM: {e}"))
# ── Admin Commands ───────────────────────────────────────────────────────
@commands.group(name="supportset", aliases=["sset"])
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def supportset(self, ctx: commands.Context):
"""Configure the support system."""
if ctx.invoked_subcommand is None:
await ctx.send_help(ctx.command)
@supportset.command(name="enable")
async def supportset_enable(self, ctx: commands.Context):
"""Enable the support system for this server."""
await self.config.guild(ctx.guild).enabled.set(True)
await ctx.send(embed=success_embed("Support system enabled!"))
@supportset.command(name="disable")
async def supportset_disable(self, ctx: commands.Context):
"""Disable the support system for this server."""
await self.config.guild(ctx.guild).enabled.set(False)
await ctx.send(embed=success_embed("Support system disabled."))
@supportset.command(name="addcategory", aliases=["addcat"])
async def supportset_addcategory(self, ctx: commands.Context, name: str, channel: discord.TextChannel, *, description: str = ""):
"""Add a support category.
Usage: `[p]supportset addcategory <name> <channel> [description]`
"""
async with self.config.guild(ctx.guild).categories() as categories:
categories[name.lower()] = {
"channel_id": channel.id,
"description": description,
"emoji": "📂",
"roles": [],
"greeting": "",
}
await ctx.send(embed=success_embed(f"Category **{name}** created with channel {channel.mention}"))
@supportset.command(name="removecategory", aliases=["removecat", "delcat"])
async def supportset_removecategory(self, ctx: commands.Context, name: str):
"""Remove a support category."""
async with self.config.guild(ctx.guild).categories() as categories:
if name.lower() in categories:
del categories[name.lower()]
await ctx.send(embed=success_embed(f"Category **{name}** removed."))
else:
await ctx.send(embed=error_embed(f"Category **{name}** not found."))
@supportset.command(name="setemoji")
async def supportset_setemoji(self, ctx: commands.Context, category: str, emoji: str):
"""Set an emoji for a category."""
async with self.config.guild(ctx.guild).categories() as categories:
if category.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{category}** not found."))
categories[category.lower()]["emoji"] = emoji
await ctx.send(embed=success_embed(f"Emoji for **{category}** set to {emoji}"))
@supportset.command(name="setgreeting")
async def supportset_setgreeting(self, ctx: commands.Context, category: str, *, greeting: str):
"""Set a custom greeting for a category."""
async with self.config.guild(ctx.guild).categories() as categories:
if category.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{category}** not found."))
categories[category.lower()]["greeting"] = greeting
await ctx.send(embed=success_embed(f"Greeting set for **{category}**"))
@supportset.command(name="defaultcategory", aliases=["defaultcat"])
async def supportset_defaultcategory(self, ctx: commands.Context, name: str):
"""Set the default category for support messages."""
categories = await self.config.guild(ctx.guild).categories()
if name.lower() not in categories:
return await ctx.send(embed=error_embed(f"Category **{name}** not found."))
await self.config.guild(ctx.guild).default_category.set(name.lower())
await ctx.send(embed=success_embed(f"Default category set to **{name}**"))
@supportset.command(name="logchannel")
async def supportset_logchannel(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Set or clear the log channel for support messages."""
if channel:
await self.config.guild(ctx.guild).log_channel.set(channel.id)
await ctx.send(embed=success_embed(f"Log channel set to {channel.mention}"))
else:
await self.config.guild(ctx.guild).log_channel.set(None)
await ctx.send(embed=success_embed("Log channel cleared."))
@supportset.command(name="anonymous")
async def supportset_anonymous(self, ctx: commands.Context, enabled: bool):
"""Toggle anonymous mode for staff replies."""
await self.config.guild(ctx.guild).anonymous_mode.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Anonymous mode {status}."))
@supportset.command(name="requirecategory")
async def supportset_requirecategory(self, ctx: commands.Context, enabled: bool):
"""Toggle whether users must select a category."""
await self.config.guild(ctx.guild).require_category.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Category requirement {status}."))
@supportset.command(name="embed")
async def supportset_embed(self, ctx: commands.Context, enabled: bool):
"""Toggle using embeds for replies."""
await self.config.guild(ctx.guild).reply_with_embed.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Embed replies {status}."))
@supportset.command(name="showinfo")
async def supportset_showinfo(self, ctx: commands.Context, enabled: bool):
"""Toggle showing author info in replies."""
await self.config.guild(ctx.guild).show_author_info.set(enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(embed=success_embed(f"Author info {status}."))
@supportset.command(name="list")
async def supportset_list(self, ctx: commands.Context):
"""List all support categories and settings."""
gdata = await self.config.guild(ctx.guild).all()
embed = discord.Embed(
title=f"Support System — {ctx.guild.name}",
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
status = "✅ Enabled" if gdata["enabled"] else "❌ Disabled"
embed.add_field(name="Status", value=status, inline=True)
if gdata["default_category"]:
embed.add_field(name="Default Category", value=gdata["default_category"].title(), inline=True)
if gdata["log_channel"]:
log_ch = ctx.guild.get_channel(gdata["log_channel"])
embed.add_field(name="Log Channel", value=log_ch.mention if log_ch else "Not found", inline=True)
settings = []
if gdata["anonymous_mode"]:
settings.append("🔒 Anonymous Mode")
if gdata["require_category"]:
settings.append("📋 Require Category")
if gdata["reply_with_embed"]:
settings.append("📝 Embed Replies")
if gdata["show_author_info"]:
settings.append("👤 Show Author Info")
if settings:
embed.add_field(name="Settings", value="\n".join(settings), inline=False)
categories = gdata["categories"]
if categories:
cat_list = []
for name, data in categories.items():
channel = ctx.guild.get_channel(data["channel_id"])
emoji = data.get("emoji", "📂")
cat_list.append(f"{emoji} **{name.title()}** → {channel.mention if channel else 'Missing'}")
embed.add_field(name="Categories", value="\n".join(cat_list[:10]), inline=False)
else:
embed.add_field(name="Categories", value="None configured", inline=False)
await ctx.send(embed=embed)
@supportset.command(name="block")
async def supportset_block(self, ctx: commands.Context, user: discord.User):
"""Block a user from using the support system."""
await self.config.user(user).blocked.set(True)
await ctx.send(embed=success_embed(f"{user.mention} has been blocked from support."))
@supportset.command(name="unblock")
async def supportset_unblock(self, ctx: commands.Context, user: discord.User):
"""Unblock a user from using the support system."""
await self.config.user(user).blocked.set(False)
await ctx.send(embed=success_embed(f"{user.mention} has been unblocked from support."))
# ── Internal Methods ─────────────────────────────────────────────────────
async def _send_to_staff(self, user: discord.User, guild: discord.Guild, channel: discord.TextChannel,
message: str, category: Optional[str], gdata: dict):
"""Send a user's message to the staff channel."""
embed = discord.Embed(
title=f"📨 Support Message",
description=message,
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
embed.set_author(name=f"{user} ({user.id})", icon_url=user.display_avatar.url)
if category:
embed.add_field(name="Category", value=category.title(), inline=True)
member = guild.get_member(user.id)
if member:
embed.add_field(name="Joined", value=f"<t:{int(member.joined_at.timestamp())}:R>", inline=True)
roles = [r.mention for r in member.roles if r != guild.default_role]
if roles:
embed.add_field(name="Roles", value=", ".join(roles[:5]), inline=False)
embed.set_footer(text=f"User ID: {user.id}")
await channel.send(embed=embed)
log_channel_id = gdata["log_channel"]
if log_channel_id and log_channel_id != channel.id:
log_channel = guild.get_channel(log_channel_id)
if log_channel:
log_embed = discord.Embed(
title="📥 Support Message Received",
color=0x5865F2,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
log_embed.add_field(name="User", value=user.mention, inline=True)
log_embed.add_field(name="Channel", value=channel.mention, inline=True)
if category:
log_embed.add_field(name="Category", value=category.title(), inline=True)
log_embed.add_field(name="Message", value=message[:1024], inline=False)
await log_channel.send(embed=log_embed)
async def _send_reply_to_user(self, guild: discord.Guild, staff_member: discord.Member,
user: discord.User, message: str, category: Optional[str]):
"""Send a staff reply to a user."""
gdata = await self.config.guild(guild).all()
try:
if gdata["reply_with_embed"]:
embed = discord.Embed(
title=f"Reply from {guild.name}",
description=message,
color=0x43B581,
timestamp=datetime.datetime.now(datetime.timezone.utc)
)
if gdata["show_author_info"] and not gdata["anonymous_mode"]:
embed.set_footer(text=f"From: {staff_member}", icon_url=staff_member.display_avatar.url)
else:
embed.set_footer(text=f"From: {guild.name} Staff")
await user.send(embed=embed)
else:
prefix = f"**Reply from {guild.name}**\n" if gdata["anonymous_mode"] else \
f"**{staff_member}** from **{guild.name}**:\n"
await user.send(f"{prefix}{message}")
except discord.Forbidden:
pass
async def _close_thread(self, interaction: discord.Interaction):
"""Close an active support thread."""
ch_id = str(interaction.channel.id)
async with self.config.guild(interaction.guild).active_threads() as threads:
if ch_id in threads:
del threads[ch_id]
await interaction.response.send_message(
embed=success_embed("Support thread closed."),
ephemeral=True
)
async def _block_user_from_thread(self, interaction: discord.Interaction):
"""Block a user from an active thread."""
ch_id = str(interaction.channel.id)
threads = await self.config.guild(interaction.guild).active_threads()
thread = threads.get(ch_id)
if not thread:
return await interaction.response.send_message(
embed=error_embed("No active thread in this channel."),
ephemeral=True
)
user_id = thread.get("user_id")
if user_id:
await self.config.user_from_id(user_id).blocked.set(True)
await interaction.response.send_message(
embed=success_embed(f"User <@{user_id}> blocked from support."),
ephemeral=True
)
async def _thread_cleanup_loop(self):
"""Background task to clean up inactive threads."""
await self.bot.wait_until_ready()
while True:
try:
await asyncio.sleep(300) # Check every 5 minutes
for guild in self.bot.guilds:
gdata = await self.config.guild(guild).all()
if not gdata["enabled"]:
continue
timeout = gdata["thread_timeout"]
now = timestamp_now()
async with self.config.guild(guild).active_threads() as threads:
to_remove = []
for ch_id, thread in threads.items():
started = thread.get("started_at", now)
if now - started > timeout:
to_remove.append(ch_id)
for ch_id in to_remove:
del threads[ch_id]
except asyncio.CancelledError:
break
except Exception:
pass