2026-06-12 17:45:13 +01:00
|
|
|
|
"""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):
|
2026-06-12 17:49:07 +01:00
|
|
|
|
"""Start background tasks on cog load."""
|
2026-06-12 17:45:13 +01:00
|
|
|
|
self._cleanup_task = asyncio.create_task(self._thread_cleanup_loop())
|
|
|
|
|
|
|
|
|
|
|
|
async def cog_unload(self):
|
2026-06-12 17:49:07 +01:00
|
|
|
|
"""Clean up on unload."""
|
2026-06-12 17:45:13 +01:00
|
|
|
|
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
|