1274 lines
51 KiB
Python
1274 lines
51 KiB
Python
import discord
|
|
from discord.ext import commands
|
|
from discord import app_commands, ui
|
|
import asyncpg
|
|
import datetime
|
|
import logging
|
|
import json
|
|
from typing import Optional, List, Dict, Any, Union, Literal, Tuple
|
|
|
|
# Configure logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.INFO) # Ensure info messages are captured
|
|
|
|
# Application statuses
|
|
APPLICATION_STATUS = Literal["PENDING", "APPROVED", "REJECTED", "UNDER_REVIEW"]
|
|
|
|
# Database table creation query
|
|
CREATE_MOD_APPLICATIONS_TABLE = """
|
|
CREATE TABLE IF NOT EXISTS mod_applications (
|
|
application_id SERIAL PRIMARY KEY,
|
|
guild_id BIGINT NOT NULL,
|
|
user_id BIGINT NOT NULL,
|
|
submission_date TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
reviewer_id BIGINT NULL,
|
|
review_date TIMESTAMPTZ NULL,
|
|
form_data JSONB NOT NULL,
|
|
notes TEXT NULL,
|
|
UNIQUE(guild_id, user_id, status)
|
|
);
|
|
"""
|
|
|
|
CREATE_MOD_APPLICATION_SETTINGS_TABLE = """
|
|
CREATE TABLE IF NOT EXISTS mod_application_settings (
|
|
guild_id BIGINT PRIMARY KEY,
|
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
log_channel_id BIGINT NULL,
|
|
review_channel_id BIGINT NULL,
|
|
required_role_id BIGINT NULL,
|
|
reviewer_role_id BIGINT NULL,
|
|
custom_questions JSONB NULL,
|
|
cooldown_days INTEGER NOT NULL DEFAULT 30,
|
|
log_new_applications BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
"""
|
|
|
|
# Default application questions
|
|
DEFAULT_QUESTIONS = [
|
|
{
|
|
"id": "age",
|
|
"label": "How old are you?",
|
|
"style": discord.TextStyle.short,
|
|
"required": True,
|
|
"max_length": 10
|
|
},
|
|
{
|
|
"id": "experience",
|
|
"label": "Previous moderation experience?",
|
|
"style": discord.TextStyle.paragraph,
|
|
"required": True,
|
|
"max_length": 1000
|
|
},
|
|
{
|
|
"id": "time_available",
|
|
"label": "Hours per week available for moderation?",
|
|
"style": discord.TextStyle.short,
|
|
"required": True,
|
|
"max_length": 50
|
|
},
|
|
{
|
|
"id": "timezone",
|
|
"label": "What is your timezone?",
|
|
"style": discord.TextStyle.short,
|
|
"required": True,
|
|
"max_length": 50
|
|
},
|
|
{
|
|
"id": "why_mod",
|
|
"label": "Why do you want to be a moderator?",
|
|
"style": discord.TextStyle.paragraph,
|
|
"required": True,
|
|
"max_length": 1000
|
|
}
|
|
]
|
|
|
|
class ModApplicationModal(ui.Modal):
|
|
"""Modal for moderator application form"""
|
|
|
|
def __init__(self, cog, questions=None):
|
|
super().__init__(title="Moderator Application")
|
|
self.cog = cog
|
|
|
|
# Use default questions if none provided
|
|
questions = questions or DEFAULT_QUESTIONS
|
|
|
|
# Add form fields dynamically based on questions
|
|
for q in questions:
|
|
text_input = ui.TextInput(
|
|
label=q["label"],
|
|
style=q["style"],
|
|
required=q.get("required", True),
|
|
max_length=q.get("max_length", 1000),
|
|
placeholder=q.get("placeholder", "")
|
|
)
|
|
# Store the question ID as a custom attribute
|
|
text_input.custom_id = q["id"]
|
|
self.add_item(text_input)
|
|
|
|
async def on_submit(self, interaction: discord.Interaction):
|
|
"""Handle form submission"""
|
|
# Collect form data
|
|
form_data = {}
|
|
for child in self.children:
|
|
if isinstance(child, ui.TextInput):
|
|
form_data[child.custom_id] = child.value
|
|
|
|
# Submit application to database
|
|
success = await self.cog.submit_application(interaction.guild_id, interaction.user.id, form_data)
|
|
|
|
if success:
|
|
await interaction.response.send_message(
|
|
"✅ Your moderator application has been submitted successfully! You will be notified when it's reviewed.",
|
|
ephemeral=True
|
|
)
|
|
|
|
# Notify staff in the review channel
|
|
await self.cog.notify_new_application(interaction.guild, interaction.user, form_data)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ There was an error submitting your application. You may have an existing application pending review.",
|
|
ephemeral=True
|
|
)
|
|
|
|
class ApplicationReviewView(ui.View):
|
|
"""View for reviewing moderator applications"""
|
|
|
|
def __init__(self, cog, application_data):
|
|
super().__init__(timeout=None) # Persistent view
|
|
self.cog = cog
|
|
self.application_data = application_data
|
|
|
|
@ui.button(label="Approve", style=discord.ButtonStyle.green, custom_id="approve_application")
|
|
async def approve_button(self, interaction: discord.Interaction, button: ui.Button):
|
|
"""Approve the application"""
|
|
await self.cog.update_application_status(
|
|
self.application_data["application_id"],
|
|
"APPROVED",
|
|
interaction.user.id
|
|
)
|
|
|
|
# Update the message
|
|
await interaction.response.edit_message(
|
|
content=f"✅ Application approved by {interaction.user.mention}",
|
|
view=None
|
|
)
|
|
|
|
# Notify the applicant
|
|
await self.cog.notify_application_status_change(
|
|
interaction.guild,
|
|
self.application_data["user_id"],
|
|
"APPROVED"
|
|
)
|
|
|
|
@ui.button(label="Reject", style=discord.ButtonStyle.red, custom_id="reject_application")
|
|
async def reject_button(self, interaction: discord.Interaction, button: ui.Button):
|
|
"""Reject the application"""
|
|
# Show rejection reason modal
|
|
await interaction.response.send_modal(
|
|
RejectionReasonModal(self.cog, self.application_data)
|
|
)
|
|
|
|
@ui.button(label="Under Review", style=discord.ButtonStyle.blurple, custom_id="review_application")
|
|
async def review_button(self, interaction: discord.Interaction, button: ui.Button):
|
|
"""Mark application as under review"""
|
|
await self.cog.update_application_status(
|
|
self.application_data["application_id"],
|
|
"UNDER_REVIEW",
|
|
interaction.user.id
|
|
)
|
|
|
|
# Update the message
|
|
await interaction.response.edit_message(
|
|
content=f"🔍 Application marked as under review by {interaction.user.mention}",
|
|
view=self
|
|
)
|
|
|
|
# Notify the applicant
|
|
await self.cog.notify_application_status_change(
|
|
interaction.guild,
|
|
self.application_data["user_id"],
|
|
"UNDER_REVIEW"
|
|
)
|
|
|
|
class RejectionReasonModal(ui.Modal, title="Rejection Reason"):
|
|
"""Modal for providing rejection reason"""
|
|
|
|
reason = ui.TextInput(
|
|
label="Reason for rejection",
|
|
style=discord.TextStyle.paragraph,
|
|
required=True,
|
|
max_length=1000
|
|
)
|
|
|
|
def __init__(self, cog, application_data):
|
|
super().__init__()
|
|
self.cog = cog
|
|
self.application_data = application_data
|
|
|
|
async def on_submit(self, interaction: discord.Interaction):
|
|
"""Handle rejection reason submission"""
|
|
await self.cog.update_application_status(
|
|
self.application_data["application_id"],
|
|
"REJECTED",
|
|
interaction.user.id,
|
|
notes=self.reason.value
|
|
)
|
|
|
|
# Update the message
|
|
await interaction.response.edit_message(
|
|
content=f"❌ Application rejected by {interaction.user.mention}",
|
|
view=None
|
|
)
|
|
|
|
# Notify the applicant
|
|
await self.cog.notify_application_status_change(
|
|
interaction.guild,
|
|
self.application_data["user_id"],
|
|
"REJECTED",
|
|
reason=self.reason.value
|
|
)
|
|
|
|
class ModApplicationCog(commands.Cog):
|
|
"""Cog for handling moderator applications using Discord forms"""
|
|
|
|
def __init__(self, bot):
|
|
logger.info(f"ModApplicationCog __init__ called. Bot instance ID: {id(bot)}")
|
|
self.bot = bot
|
|
|
|
# Create the main command group for this cog
|
|
self.modapp_group = app_commands.Group(
|
|
name="modapp",
|
|
description="Moderator application system commands"
|
|
)
|
|
|
|
# Register commands
|
|
self.register_commands()
|
|
|
|
# Add command group to the bot's tree
|
|
self.bot.tree.add_command(self.modapp_group)
|
|
|
|
async def cog_load(self):
|
|
"""Setup database tables when cog is loaded"""
|
|
if hasattr(self.bot, 'pg_pool') and self.bot.pg_pool:
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
await conn.execute(CREATE_MOD_APPLICATIONS_TABLE)
|
|
await conn.execute(CREATE_MOD_APPLICATION_SETTINGS_TABLE)
|
|
logger.info("Moderator application tables created successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error creating moderator application tables: {e}")
|
|
else:
|
|
logger.warning("Database pool not available, skipping table creation")
|
|
|
|
def register_commands(self):
|
|
"""Register all commands for this cog"""
|
|
|
|
# --- Apply Command ---
|
|
apply_command = app_commands.Command(
|
|
name="apply",
|
|
description="Apply to become a moderator for this server",
|
|
callback=self.apply_callback,
|
|
parent=self.modapp_group
|
|
)
|
|
self.modapp_group.add_command(apply_command)
|
|
|
|
# --- List Applications Command ---
|
|
list_command = app_commands.Command(
|
|
name="list",
|
|
description="List all moderator applications",
|
|
callback=self.list_applications_callback,
|
|
parent=self.modapp_group
|
|
)
|
|
app_commands.describe(
|
|
status="Filter applications by status"
|
|
)(list_command)
|
|
self.modapp_group.add_command(list_command)
|
|
|
|
# --- View Application Command ---
|
|
view_command = app_commands.Command(
|
|
name="view",
|
|
description="View details of a specific application",
|
|
callback=self.view_application_callback,
|
|
parent=self.modapp_group
|
|
)
|
|
app_commands.describe(
|
|
application_id="The ID of the application to view"
|
|
)(view_command)
|
|
self.modapp_group.add_command(view_command)
|
|
|
|
# --- Settings Commands (Direct children of modapp_group) ---
|
|
|
|
# --- Enable/Disable Command ---
|
|
settings_toggle_command = app_commands.Command(
|
|
name="settings_toggle",
|
|
description="Enable or disable the application system",
|
|
callback=self.toggle_applications_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
enabled="Whether applications should be enabled or disabled"
|
|
)(settings_toggle_command)
|
|
self.modapp_group.add_command(settings_toggle_command)
|
|
|
|
# --- Set Review Channel Command ---
|
|
settings_review_channel_command = app_commands.Command(
|
|
name="settings_reviewchannel",
|
|
description="Set the channel where new applications will be posted for review",
|
|
callback=self.set_review_channel_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
channel="The channel where applications will be posted for review"
|
|
)(settings_review_channel_command)
|
|
self.modapp_group.add_command(settings_review_channel_command)
|
|
|
|
# --- Set Log Channel Command ---
|
|
settings_log_channel_command = app_commands.Command(
|
|
name="settings_logchannel",
|
|
description="Set the channel where application activity will be logged",
|
|
callback=self.set_log_channel_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
channel="The channel where application activity will be logged"
|
|
)(settings_log_channel_command)
|
|
self.modapp_group.add_command(settings_log_channel_command)
|
|
|
|
# --- Set Reviewer Role Command ---
|
|
settings_reviewer_role_command = app_commands.Command(
|
|
name="settings_reviewerrole",
|
|
description="Set the role that can review applications",
|
|
callback=self.set_reviewer_role_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
role="The role that can review applications"
|
|
)(settings_reviewer_role_command)
|
|
self.modapp_group.add_command(settings_reviewer_role_command)
|
|
|
|
# --- Set Required Role Command ---
|
|
settings_required_role_command = app_commands.Command(
|
|
name="settings_requiredrole",
|
|
description="Set the role required to apply (optional)",
|
|
callback=self.set_required_role_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
role="The role required to apply (or None to allow anyone)"
|
|
)(settings_required_role_command)
|
|
self.modapp_group.add_command(settings_required_role_command)
|
|
|
|
# --- Set Cooldown Command ---
|
|
settings_cooldown_command = app_commands.Command(
|
|
name="settings_cooldown",
|
|
description="Set the cooldown period between rejected applications",
|
|
callback=self.set_cooldown_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
days="Number of days a user must wait after rejection before applying again"
|
|
)(settings_cooldown_command)
|
|
self.modapp_group.add_command(settings_cooldown_command)
|
|
|
|
# --- Toggle Log New Applications Command ---
|
|
settings_log_new_apps_command = app_commands.Command(
|
|
name="settings_lognewapps",
|
|
description="Toggle whether new applications are automatically logged in the log channel",
|
|
callback=self.toggle_log_new_applications_callback,
|
|
parent=self.modapp_group # Direct child of modapp
|
|
)
|
|
app_commands.describe(
|
|
enabled="Whether new applications should be logged automatically"
|
|
)(settings_log_new_apps_command)
|
|
self.modapp_group.add_command(settings_log_new_apps_command)
|
|
|
|
# --- Command Callbacks ---
|
|
|
|
async def apply_callback(self, interaction: discord.Interaction):
|
|
"""Handle the /apply command"""
|
|
# Check if applications are enabled for this guild
|
|
settings = await self.get_application_settings(interaction.guild_id)
|
|
if not settings or not settings.get("enabled", False):
|
|
await interaction.response.send_message(
|
|
"❌ Moderator applications are currently disabled for this server.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Check if user has the required role (if set)
|
|
required_role_id = settings.get("required_role_id")
|
|
if required_role_id:
|
|
member = interaction.guild.get_member(interaction.user.id)
|
|
if not member or not any(role.id == required_role_id for role in member.roles):
|
|
required_role = interaction.guild.get_role(required_role_id)
|
|
role_name = required_role.name if required_role else "Required Role"
|
|
await interaction.response.send_message(
|
|
f"❌ You need the {role_name} role to apply for moderator.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Check if user has a pending or under review application
|
|
has_active_application = await self.check_active_application(interaction.guild_id, interaction.user.id)
|
|
if has_active_application:
|
|
await interaction.response.send_message(
|
|
"❌ You already have an application pending review. Please wait for it to be processed.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Check if user is on cooldown from a rejected application
|
|
on_cooldown, days_left = await self.check_application_cooldown(interaction.guild_id, interaction.user.id, settings.get("cooldown_days", 30))
|
|
if on_cooldown:
|
|
await interaction.response.send_message(
|
|
f"❌ You must wait {days_left} more days before submitting a new application.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Get custom questions if configured, otherwise use defaults
|
|
questions = settings.get("custom_questions", DEFAULT_QUESTIONS)
|
|
|
|
# Show the application form
|
|
await interaction.response.send_modal(ModApplicationModal(self, questions))
|
|
|
|
async def list_applications_callback(self, interaction: discord.Interaction, status: Optional[str] = None):
|
|
"""Handle the /modapp list command"""
|
|
# Check if user has permission to view applications
|
|
if not await self.check_reviewer_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to view applications.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Validate status parameter if provided
|
|
valid_statuses = ["PENDING", "APPROVED", "REJECTED", "UNDER_REVIEW"]
|
|
if status and status.upper() not in valid_statuses:
|
|
await interaction.response.send_message(
|
|
f"❌ Invalid status. Valid options are: {', '.join(valid_statuses)}",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Fetch applications from database
|
|
applications = await self.get_applications(interaction.guild_id, status.upper() if status else None)
|
|
|
|
if not applications:
|
|
await interaction.response.send_message(
|
|
f"No applications found{f' with status {status.upper()}' if status else ''}.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Create an embed to display the applications
|
|
embed = discord.Embed(
|
|
title=f"Moderator Applications{f' ({status.upper()})' if status else ''}",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.datetime.now()
|
|
)
|
|
|
|
for app in applications:
|
|
user = self.bot.get_user(app["user_id"]) or f"User ID: {app['user_id']}"
|
|
user_display = user.mention if isinstance(user, discord.User) else user
|
|
|
|
# Format submission date
|
|
submission_date = app["submission_date"].strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
# Add field for this application
|
|
embed.add_field(
|
|
name=f"Application #{app['application_id']} - {app['status']}",
|
|
value=f"From: {user_display}\nSubmitted: {submission_date}\nUse `/modapp view {app['application_id']}` to view details",
|
|
inline=False
|
|
)
|
|
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
|
|
async def view_application_callback(self, interaction: discord.Interaction, application_id: int):
|
|
"""Handle the /modapp view command"""
|
|
# Check if user has permission to view applications
|
|
if not await self.check_reviewer_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to view applications.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Fetch application from database
|
|
application = await self.get_application_by_id(application_id)
|
|
|
|
if not application or application["guild_id"] != interaction.guild_id:
|
|
await interaction.response.send_message(
|
|
"❌ Application not found.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Get user objects
|
|
applicant = self.bot.get_user(application["user_id"]) or f"User ID: {application['user_id']}"
|
|
applicant_display = applicant.mention if isinstance(applicant, discord.User) else applicant
|
|
|
|
reviewer = None
|
|
if application["reviewer_id"]:
|
|
reviewer = self.bot.get_user(application["reviewer_id"]) or f"User ID: {application['reviewer_id']}"
|
|
reviewer_display = reviewer.mention if isinstance(reviewer, discord.User) else reviewer or "None"
|
|
|
|
# Create an embed to display the application details
|
|
embed = discord.Embed(
|
|
title=f"Moderator Application #{application_id}",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.datetime.now()
|
|
)
|
|
|
|
# Add application metadata
|
|
embed.add_field(name="Applicant", value=applicant_display, inline=True)
|
|
embed.add_field(name="Status", value=application["status"], inline=True)
|
|
embed.add_field(name="Submitted", value=application["submission_date"].strftime("%Y-%m-%d %H:%M UTC"), inline=True)
|
|
|
|
if application["reviewer_id"]:
|
|
embed.add_field(name="Reviewed By", value=reviewer_display, inline=True)
|
|
embed.add_field(name="Review Date", value=application["review_date"].strftime("%Y-%m-%d %H:%M UTC") if application["review_date"] else "N/A", inline=True)
|
|
|
|
# Add application form data
|
|
embed.add_field(name="Application Responses", value="", inline=False)
|
|
|
|
form_data = application["form_data"]
|
|
for key, value in form_data.items():
|
|
# Try to find the question label from DEFAULT_QUESTIONS
|
|
question_label = next((q["label"] for q in DEFAULT_QUESTIONS if q["id"] == key), key)
|
|
embed.add_field(name=question_label, value=value, inline=False)
|
|
|
|
# Add notes if available
|
|
if application["notes"]:
|
|
embed.add_field(name="Notes", value=application["notes"], inline=False)
|
|
|
|
# Create view with action buttons if application is pending or under review
|
|
view = None
|
|
if application["status"] in ["PENDING", "UNDER_REVIEW"]:
|
|
view = ApplicationReviewView(self, application)
|
|
|
|
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
|
|
|
|
async def toggle_applications_callback(self, interaction: discord.Interaction, enabled: bool):
|
|
"""Handle the /modapp config toggle command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "enabled", enabled)
|
|
|
|
if success:
|
|
status = "enabled" if enabled else "disabled"
|
|
await interaction.response.send_message(
|
|
f"✅ Moderator applications are now {status} for this server.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def set_review_channel_callback(self, interaction: discord.Interaction, channel: discord.TextChannel):
|
|
"""Handle the /modapp config reviewchannel command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "review_channel_id", channel.id)
|
|
|
|
if success:
|
|
await interaction.response.send_message(
|
|
f"✅ New applications will now be posted in {channel.mention} for review.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def set_log_channel_callback(self, interaction: discord.Interaction, channel: discord.TextChannel):
|
|
"""Handle the /modapp config logchannel command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "log_channel_id", channel.id)
|
|
|
|
if success:
|
|
await interaction.response.send_message(
|
|
f"✅ Application activity will now be logged in {channel.mention}.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def set_reviewer_role_callback(self, interaction: discord.Interaction, role: discord.Role):
|
|
"""Handle the /modapp config reviewerrole command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "reviewer_role_id", role.id)
|
|
|
|
if success:
|
|
await interaction.response.send_message(
|
|
f"✅ Members with the {role.mention} role can now review applications.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def set_required_role_callback(self, interaction: discord.Interaction, role: Optional[discord.Role] = None):
|
|
"""Handle the /modapp settings requiredrole command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database (None means no role required)
|
|
role_id = role.id if role else None
|
|
success = await self.update_application_setting(interaction.guild_id, "required_role_id", role_id)
|
|
|
|
if success:
|
|
if role:
|
|
await interaction.response.send_message(
|
|
f"✅ Members now need the {role.mention} role to apply for moderator.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"✅ Any member can now apply for moderator (no role requirement).",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def set_cooldown_callback(self, interaction: discord.Interaction, days: int):
|
|
"""Handle the /modapp settings cooldown command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Validate days parameter
|
|
if days < 0 or days > 365:
|
|
await interaction.response.send_message(
|
|
"❌ Cooldown days must be between 0 and 365.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "cooldown_days", days)
|
|
|
|
if success:
|
|
if days == 0:
|
|
await interaction.response.send_message(
|
|
"✅ Application cooldown has been disabled. Users can reapply immediately after rejection.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
f"✅ Users must now wait {days} days after rejection before submitting a new application.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def toggle_log_new_applications_callback(self, interaction: discord.Interaction, enabled: bool):
|
|
"""Handle the /modapp settings lognewapps command"""
|
|
# Check if user has permission to manage applications
|
|
if not await self.check_admin_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to manage application settings.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Get current settings to check if log channel is set
|
|
settings = await self.get_application_settings(interaction.guild_id)
|
|
log_channel_id = settings.get("log_channel_id")
|
|
|
|
if enabled and not log_channel_id:
|
|
await interaction.response.send_message(
|
|
"❌ You need to set a log channel first using `/modapp settings_logchannel` before enabling this feature.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Update setting in database
|
|
success = await self.update_application_setting(interaction.guild_id, "log_new_applications", enabled)
|
|
|
|
if success:
|
|
status = "enabled" if enabled else "disabled"
|
|
if enabled:
|
|
log_channel = interaction.guild.get_channel(log_channel_id)
|
|
channel_mention = log_channel.mention if log_channel else "the configured log channel"
|
|
await interaction.response.send_message(
|
|
f"✅ New applications will now be automatically logged in {channel_mention}.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"✅ New applications will no longer be automatically logged.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.response.send_message(
|
|
"❌ Failed to update application settings.",
|
|
ephemeral=True
|
|
)
|
|
|
|
# --- Database Helper Methods ---
|
|
|
|
async def submit_application(self, guild_id: int, user_id: int, form_data: Dict[str, str]) -> bool:
|
|
"""Submit a new application to the database"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return False
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Convert form_data to JSON string
|
|
form_data_json = json.dumps(form_data)
|
|
|
|
# Insert application into database
|
|
await conn.execute("""
|
|
INSERT INTO mod_applications (guild_id, user_id, form_data)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (guild_id, user_id, status)
|
|
WHERE status IN ('PENDING', 'UNDER_REVIEW')
|
|
DO NOTHING
|
|
""", guild_id, user_id, form_data_json)
|
|
|
|
# Check if the insert was successful by querying for the application
|
|
result = await conn.fetchrow("""
|
|
SELECT application_id FROM mod_applications
|
|
WHERE guild_id = $1 AND user_id = $2 AND status IN ('PENDING', 'UNDER_REVIEW')
|
|
""", guild_id, user_id)
|
|
|
|
return result is not None
|
|
except Exception as e:
|
|
logger.error(f"Error submitting application: {e}")
|
|
return False
|
|
|
|
async def get_applications(self, guild_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""Get all applications for a guild, optionally filtered by status"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return []
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
if status:
|
|
# Filter by status
|
|
rows = await conn.fetch("""
|
|
SELECT * FROM mod_applications
|
|
WHERE guild_id = $1 AND status = $2
|
|
ORDER BY submission_date DESC
|
|
""", guild_id, status)
|
|
else:
|
|
# Get all applications
|
|
rows = await conn.fetch("""
|
|
SELECT * FROM mod_applications
|
|
WHERE guild_id = $1
|
|
ORDER BY submission_date DESC
|
|
""", guild_id)
|
|
|
|
# Convert rows to dictionaries and parse form_data JSON
|
|
applications = []
|
|
for row in rows:
|
|
app = dict(row)
|
|
app["form_data"] = json.loads(app["form_data"])
|
|
applications.append(app)
|
|
|
|
return applications
|
|
except Exception as e:
|
|
logger.error(f"Error getting applications: {e}")
|
|
return []
|
|
|
|
async def get_application_by_id(self, application_id: int) -> Optional[Dict[str, Any]]:
|
|
"""Get a specific application by ID"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return None
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
row = await conn.fetchrow("""
|
|
SELECT * FROM mod_applications
|
|
WHERE application_id = $1
|
|
""", application_id)
|
|
|
|
if not row:
|
|
return None
|
|
|
|
# Convert row to dictionary and parse form_data JSON
|
|
application = dict(row)
|
|
application["form_data"] = json.loads(application["form_data"])
|
|
|
|
return application
|
|
except Exception as e:
|
|
logger.error(f"Error getting application by ID: {e}")
|
|
return None
|
|
|
|
async def update_application_status(self, application_id: int, status: APPLICATION_STATUS, reviewer_id: int, notes: Optional[str] = None) -> bool:
|
|
"""Update the status of an application"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return False
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Update application status
|
|
await conn.execute("""
|
|
UPDATE mod_applications
|
|
SET status = $1, reviewer_id = $2, review_date = CURRENT_TIMESTAMP, notes = $3
|
|
WHERE application_id = $4
|
|
""", status, reviewer_id, notes, application_id)
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error updating application status: {e}")
|
|
return False
|
|
|
|
async def get_application_settings(self, guild_id: int) -> Dict[str, Any]:
|
|
"""Get application settings for a guild"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return {"enabled": False} # Default settings
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Check if settings exist for this guild
|
|
row = await conn.fetchrow("""
|
|
SELECT * FROM mod_application_settings
|
|
WHERE guild_id = $1
|
|
""", guild_id)
|
|
|
|
if not row:
|
|
# Create default settings
|
|
await conn.execute("""
|
|
INSERT INTO mod_application_settings (guild_id)
|
|
VALUES ($1)
|
|
""", guild_id)
|
|
|
|
# Return default settings
|
|
return {
|
|
"guild_id": guild_id,
|
|
"enabled": True,
|
|
"log_channel_id": None,
|
|
"review_channel_id": None,
|
|
"required_role_id": None,
|
|
"reviewer_role_id": None,
|
|
"custom_questions": None,
|
|
"cooldown_days": 30,
|
|
"log_new_applications": False
|
|
}
|
|
|
|
# Convert row to dictionary and parse custom_questions JSON if it exists
|
|
settings = dict(row)
|
|
if settings["custom_questions"]:
|
|
settings["custom_questions"] = json.loads(settings["custom_questions"])
|
|
|
|
return settings
|
|
except Exception as e:
|
|
logger.error(f"Error getting application settings: {e}")
|
|
return {"enabled": False} # Default settings on error
|
|
|
|
async def update_application_setting(self, guild_id: int, setting_key: str, setting_value: Any) -> bool:
|
|
"""Update a specific application setting for a guild"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return False
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Check if settings exist for this guild
|
|
exists = await conn.fetchval("""
|
|
SELECT COUNT(*) FROM mod_application_settings
|
|
WHERE guild_id = $1
|
|
""", guild_id)
|
|
|
|
if not exists:
|
|
# Create default settings first
|
|
await conn.execute("""
|
|
INSERT INTO mod_application_settings (guild_id)
|
|
VALUES ($1)
|
|
""", guild_id)
|
|
|
|
# Special handling for JSON fields
|
|
if setting_key == "custom_questions" and setting_value is not None:
|
|
setting_value = json.dumps(setting_value)
|
|
|
|
# Update the specific setting
|
|
query = f"""
|
|
UPDATE mod_application_settings
|
|
SET {setting_key} = $1
|
|
WHERE guild_id = $2
|
|
"""
|
|
await conn.execute(query, setting_value, guild_id)
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error updating application setting: {e}")
|
|
return False
|
|
|
|
async def check_active_application(self, guild_id: int, user_id: int) -> bool:
|
|
"""Check if a user has an active application (pending or under review)"""
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return False
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Check for active applications
|
|
result = await conn.fetchval("""
|
|
SELECT COUNT(*) FROM mod_applications
|
|
WHERE guild_id = $1 AND user_id = $2 AND status IN ('PENDING', 'UNDER_REVIEW')
|
|
""", guild_id, user_id)
|
|
|
|
return result > 0
|
|
except Exception as e:
|
|
logger.error(f"Error checking active application: {e}")
|
|
return False
|
|
|
|
async def check_application_cooldown(self, guild_id: int, user_id: int, cooldown_days: int) -> Tuple[bool, int]:
|
|
"""Check if a user is on cooldown from a rejected application
|
|
Returns (on_cooldown, days_left)
|
|
"""
|
|
if cooldown_days <= 0:
|
|
return False, 0
|
|
|
|
if not hasattr(self.bot, 'pg_pool') or not self.bot.pg_pool:
|
|
logger.error("Database pool not available")
|
|
return False, 0
|
|
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
# Get the most recent rejected application
|
|
result = await conn.fetchrow("""
|
|
SELECT review_date FROM mod_applications
|
|
WHERE guild_id = $1 AND user_id = $2 AND status = 'REJECTED'
|
|
ORDER BY review_date DESC
|
|
LIMIT 1
|
|
""", guild_id, user_id)
|
|
|
|
if not result:
|
|
return False, 0
|
|
|
|
# Calculate days since rejection
|
|
review_date = result["review_date"]
|
|
days_since = (datetime.datetime.now(datetime.timezone.utc) - review_date).days
|
|
|
|
# Check if still on cooldown
|
|
if days_since < cooldown_days:
|
|
days_left = cooldown_days - days_since
|
|
return True, days_left
|
|
|
|
return False, 0
|
|
except Exception as e:
|
|
logger.error(f"Error checking application cooldown: {e}")
|
|
return False, 0
|
|
|
|
async def check_reviewer_permission(self, guild_id: int, user_id: int) -> bool:
|
|
"""Check if a user has permission to review applications"""
|
|
# Get the guild object
|
|
guild = self.bot.get_guild(guild_id)
|
|
if not guild:
|
|
return False
|
|
|
|
# Get the member object
|
|
member = guild.get_member(user_id)
|
|
if not member:
|
|
return False
|
|
|
|
# Check if user is an administrator
|
|
if member.guild_permissions.administrator:
|
|
return True
|
|
|
|
# Check if user is the guild owner
|
|
if guild.owner_id == user_id:
|
|
return True
|
|
|
|
# Check if user has the reviewer role
|
|
settings = await self.get_application_settings(guild_id)
|
|
reviewer_role_id = settings.get("reviewer_role_id")
|
|
|
|
if reviewer_role_id:
|
|
return any(role.id == reviewer_role_id for role in member.roles)
|
|
|
|
# Default to requiring administrator if no reviewer role is set
|
|
return False
|
|
|
|
async def check_admin_permission(self, guild_id: int, user_id: int) -> bool:
|
|
"""Check if a user has permission to manage application settings"""
|
|
# Get the guild object
|
|
guild = self.bot.get_guild(guild_id)
|
|
if not guild:
|
|
return False
|
|
|
|
# Get the member object
|
|
member = guild.get_member(user_id)
|
|
if not member:
|
|
return False
|
|
|
|
# Check if user is an administrator
|
|
if member.guild_permissions.administrator:
|
|
return True
|
|
|
|
# Check if user is the guild owner
|
|
if guild.owner_id == user_id:
|
|
return True
|
|
|
|
# Only administrators and the guild owner can manage settings
|
|
return False
|
|
|
|
async def notify_new_application(self, guild: discord.Guild, user: discord.User, form_data: Dict[str, str]) -> None:
|
|
"""Notify staff about a new application"""
|
|
# Get application settings
|
|
settings = await self.get_application_settings(guild.id)
|
|
review_channel_id = settings.get("review_channel_id")
|
|
log_channel_id = settings.get("log_channel_id")
|
|
log_new_applications = settings.get("log_new_applications", False)
|
|
|
|
if not review_channel_id:
|
|
return
|
|
|
|
# Get the review channel
|
|
review_channel = guild.get_channel(review_channel_id)
|
|
if not review_channel:
|
|
return
|
|
|
|
# Get the application ID
|
|
application = None
|
|
try:
|
|
async with self.bot.pg_pool.acquire() as conn:
|
|
row = await conn.fetchrow("""
|
|
SELECT application_id FROM mod_applications
|
|
WHERE guild_id = $1 AND user_id = $2 AND status = 'PENDING'
|
|
ORDER BY submission_date DESC
|
|
LIMIT 1
|
|
""", guild.id, user.id)
|
|
|
|
if row:
|
|
application = dict(row)
|
|
except Exception as e:
|
|
logger.error(f"Error getting application ID for notification: {e}")
|
|
return
|
|
|
|
if not application:
|
|
return
|
|
|
|
# Create an embed for the notification
|
|
embed = discord.Embed(
|
|
title="New Moderator Application",
|
|
description=f"{user.mention} has submitted a moderator application.",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.datetime.now()
|
|
)
|
|
|
|
# Add user info
|
|
embed.set_author(name=f"{user.name}", icon_url=user.display_avatar.url)
|
|
embed.add_field(name="User ID", value=user.id, inline=True)
|
|
embed.add_field(name="Application ID", value=application["application_id"], inline=True)
|
|
|
|
# Add a preview of the application (first few questions)
|
|
preview_questions = 2 # Number of questions to show in preview
|
|
question_count = 0
|
|
|
|
for key, value in form_data.items():
|
|
if question_count >= preview_questions:
|
|
break
|
|
|
|
# Try to find the question label from DEFAULT_QUESTIONS
|
|
question_label = next((q["label"] for q in DEFAULT_QUESTIONS if q["id"] == key), key)
|
|
|
|
# Truncate long answers
|
|
if len(value) > 100:
|
|
value = value[:97] + "..."
|
|
|
|
embed.add_field(name=question_label, value=value, inline=False)
|
|
question_count += 1
|
|
|
|
# Add view details button
|
|
view = discord.ui.View()
|
|
view.add_item(discord.ui.Button(
|
|
label="View Application",
|
|
style=discord.ButtonStyle.primary,
|
|
custom_id=f"view_application_{application['application_id']}"
|
|
))
|
|
|
|
# Send the notification to the review channel
|
|
try:
|
|
await review_channel.send(
|
|
content=f"📝 New moderator application from {user.mention}",
|
|
embed=embed,
|
|
view=view
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error sending application notification to review channel: {e}")
|
|
|
|
# If log_new_applications is enabled and log_channel_id is set, also log to the log channel
|
|
if log_new_applications and log_channel_id:
|
|
log_channel = guild.get_channel(log_channel_id)
|
|
if log_channel:
|
|
try:
|
|
# Create a simpler embed for the log channel
|
|
log_embed = discord.Embed(
|
|
title="New Moderator Application Submitted",
|
|
description=f"A new moderator application has been submitted by {user.mention}.",
|
|
color=discord.Color.blue(),
|
|
timestamp=datetime.datetime.now()
|
|
)
|
|
log_embed.set_author(name=f"{user.name}", icon_url=user.display_avatar.url)
|
|
log_embed.add_field(name="Application ID", value=application["application_id"], inline=True)
|
|
log_embed.add_field(name="Status", value="PENDING", inline=True)
|
|
log_embed.add_field(name="Submission Time", value=discord.utils.format_dt(datetime.datetime.now()), inline=True)
|
|
|
|
await log_channel.send(embed=log_embed)
|
|
except Exception as e:
|
|
logger.error(f"Error sending application notification to log channel: {e}")
|
|
|
|
async def notify_application_status_change(self, guild: discord.Guild, user_id: int, status: APPLICATION_STATUS, reason: Optional[str] = None) -> None:
|
|
"""Notify the applicant about a status change"""
|
|
# Get the user
|
|
user = self.bot.get_user(user_id)
|
|
if not user:
|
|
try:
|
|
user = await self.bot.fetch_user(user_id)
|
|
except:
|
|
logger.error(f"Could not fetch user {user_id} for application notification")
|
|
return
|
|
|
|
# Create the notification message
|
|
if status == "APPROVED":
|
|
title = "🎉 Application Approved!"
|
|
description = "Congratulations! Your moderator application has been approved."
|
|
color = discord.Color.green()
|
|
elif status == "REJECTED":
|
|
title = "❌ Application Rejected"
|
|
description = "We're sorry, but your moderator application has been rejected."
|
|
if reason:
|
|
description += f"\n\nReason: {reason}"
|
|
color = discord.Color.red()
|
|
elif status == "UNDER_REVIEW":
|
|
title = "🔍 Application Under Review"
|
|
description = "Your moderator application is now being reviewed by our team."
|
|
color = discord.Color.gold()
|
|
else:
|
|
return # Don't notify for other statuses
|
|
|
|
# Create an embed for the notification
|
|
embed = discord.Embed(
|
|
title=title,
|
|
description=description,
|
|
color=color,
|
|
timestamp=datetime.datetime.now()
|
|
)
|
|
|
|
embed.set_author(name=guild.name, icon_url=guild.icon.url if guild.icon else None)
|
|
|
|
# Try to send a DM to the user
|
|
try:
|
|
await user.send(embed=embed)
|
|
except Exception as e:
|
|
logger.error(f"Error sending application status notification to user {user_id}: {e}")
|
|
|
|
# If DM fails, try to log it
|
|
settings = await self.get_application_settings(guild.id)
|
|
log_channel_id = settings.get("log_channel_id")
|
|
|
|
if log_channel_id:
|
|
log_channel = guild.get_channel(log_channel_id)
|
|
if log_channel:
|
|
await log_channel.send(f"⚠️ Failed to send application status notification to {user.mention}. They may have DMs disabled.")
|
|
|
|
@commands.Cog.listener()
|
|
async def on_interaction(self, interaction: discord.Interaction):
|
|
"""Handle custom interactions like view application buttons"""
|
|
if not interaction.data or not interaction.data.get("custom_id"):
|
|
return
|
|
|
|
custom_id = interaction.data["custom_id"]
|
|
|
|
# Handle view application button
|
|
if custom_id.startswith("view_application_"):
|
|
try:
|
|
application_id = int(custom_id.split("_")[2])
|
|
|
|
# Check if user has permission to view applications
|
|
if not await self.check_reviewer_permission(interaction.guild_id, interaction.user.id):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to view applications.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Fetch application from database
|
|
application = await self.get_application_by_id(application_id)
|
|
|
|
if not application or application["guild_id"] != interaction.guild_id:
|
|
await interaction.response.send_message(
|
|
"❌ Application not found.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Call the view application callback
|
|
await self.view_application_callback(interaction, application_id)
|
|
except ValueError:
|
|
pass # Invalid application ID format
|
|
except Exception as e:
|
|
logger.error(f"Error handling view application button: {e}")
|
|
await interaction.response.send_message(
|
|
"❌ An error occurred while processing your request.",
|
|
ephemeral=True
|
|
)
|
|
|
|
async def setup(bot: commands.Bot):
|
|
logger.info(f"ModApplicationCog setup function CALLED. Bot instance ID: {id(bot)}")
|
|
await bot.add_cog(ModApplicationCog(bot))
|
|
logger.info(f"ModApplicationCog setup function COMPLETED and cog added. Bot instance ID: {id(bot)}")
|