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) # Add the missing column if it doesn't exist await conn.execute( """ ALTER TABLE mod_application_settings ADD COLUMN IF NOT EXISTS log_new_applications BOOLEAN NOT NULL DEFAULT FALSE; """ ) logger.info( "Moderator application tables created and/or updated 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)}" )