diff --git a/.env.example b/.env.example index 4f08ac030..2f1b86069 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ DISCORD_TOKEN=your_discord_bot_token_here CLIENT_ID=your_discord_client_id_here GUILD_ID=your_discord_guild_id_here +GUILD_IDS=id1,id2,id3 OWNER_IDS=your_discord_id_here (optional) # Bot Runtime Configuration diff --git a/README_TICKET_SYSTEM.md b/README_TICKET_SYSTEM.md new file mode 100644 index 000000000..a0644881d --- /dev/null +++ b/README_TICKET_SYSTEM.md @@ -0,0 +1,388 @@ +# ๐ŸŽซ Verity Ticket System + +A comprehensive, production-ready Discord ticket system for your server. Manage support requests efficiently with automatic channel creation, staff assignment, and detailed logging. + +## Features + +โœจ **Core Features:** +- ๐ŸŽซ One-click ticket creation via modal +- ๐Ÿ“‹ Automatic ticket channel creation +- ๐Ÿ‘ค User permission management +- ๐Ÿ” Secure channel isolation +- ๐Ÿ“Š Comprehensive dashboard +- ๐ŸŽฏ Staff assignment & claiming +- ๐Ÿ“ Internal notes system +- ๐Ÿ”ด Priority levels (Low, Normal, High, Urgent) +- ๐Ÿ“œ Transcript generation +- ๐Ÿ“ˆ Audit logging +- ๐Ÿ’พ Full PostgreSQL integration +- ๐Ÿท๏ธ Customizable categories +- โš™๏ธ Per-server configuration + +## Installation + +### 1. Run Database Migration + +```bash +npm run migrate +``` + +This creates all necessary tables: +- `tickets` - Main ticket data +- `ticket_messages` - Message logging +- `ticket_settings` - Server configuration +- `ticket_categories` - Custom categories +- `ticket_notes` - Staff notes +- `ticket_audit_log` - Action logging + +### 2. Load Commands + +Copy these files to your commands directory: +- `src/commands/tickets/ticket.js` - User ticket commands +- `src/commands/admin/ticketsetup.js` - Setup commands +- `src/commands/admin/ticketadmin.js` - Admin dashboard +- `src/models/Ticket.js` - Database model +- `src/events/ticketInteractions.js` - Event handlers + +### 3. Register Event Handlers + +Add the `ticketInteractions.js` event handler to your bot's event loader. + +## Setup Instructions + +### Step 1: Initialize Ticket System + +``` +/ticketsetup create-channels +``` + +This automatically creates: +- ๐ŸŽซ Support Tickets (category) +- ๐Ÿ“‹ ticket-logs (channel) +- ๐ŸŽŸ๏ธ create-ticket (panel channel) + +### Step 2: Configure Settings + +``` +/ticketsetup configure ticket-category: [Category] log-channel: [Channel] support-role: [Role] max-tickets: [Number] +``` + +**Options:** +- `ticket-category` - Where ticket channels are created +- `log-channel` - Where ticket actions are logged +- `support-role` - Role for support staff +- `max-tickets` - Max open tickets per user (default: 5) + +### Step 3: Create Ticket Panel (Optional) + +``` +/ticketsetup panel channel: [Channel] +``` + +Creates a dedicated ticket creation panel in any channel. + +## User Commands + +### Create a Ticket + +``` +/ticket open [category] +``` + +Opens a modal where users can: +- Enter ticket subject +- Describe their issue in detail +- Select category (optional) + +A private channel is automatically created! + +### View Your Tickets + +``` +/ticket list +``` + +Shows all your open tickets with status information. + +### Close Your Ticket + +``` +/ticket close ticket_id: [ID] [reason] +``` + +Close your own ticket (or admin closes others). + +### View Ticket Details + +``` +/ticket view ticket_id: [ID] +``` + +See full ticket information including assigned staff and status. + +### System Status + +``` +/ticket status +``` + +View overall ticket system statistics. + +## Staff Commands + +### Dashboard + +``` +/ticketadmin dashboard +``` + +View real-time statistics: +- Total tickets +- Open tickets +- Closed tickets +- Unassigned tickets +- Assignment rate +- Recent unassigned tickets + +### Claim a Ticket + +``` +/ticket claim ticket_id: [ID] +``` + +Staff can claim unassigned tickets to work on them. + +### Assign Ticket + +``` +/ticketadmin assign ticket_id: [ID] staff_member: [User] +``` + +Assign a ticket to a specific staff member. + +### Add Internal Notes + +``` +/ticketadmin notes ticket_id: [ID] note: [Text] +``` + +Add private notes (visible to staff only) for ticket context. + +### Set Priority + +``` +/ticketadmin priority ticket_id: [ID] priority: [low|normal|high|urgent] +``` + +Set ticket priority level. Affects channel appearance and urgency. + +### Search Tickets + +``` +/ticketadmin search query: [Text] +``` + +Search by: +- Ticket ID +- User ID +- Ticket subject +- Status + +### Generate Transcript + +``` +/ticketadmin transcript ticket_id: [ID] +``` + +Export full ticket conversation as `.txt` file. + +## Ticket Lifecycle + +``` +1. User opens ticket via /ticket open + โ†“ +2. Private channel created automatically + โ†“ +3. Staff see notification in logs + โ†“ +4. Staff claims ticket via button or /ticket claim + โ†“ +5. Staff and user communicate in channel + โ†“ +6. Staff adds notes and sets priority + โ†“ +7. Issue resolved โ†’ /ticket close + โ†“ +8. Channel deleted automatically + โ†“ +9. Transcript available if needed +``` + +## Database Schema + +### tickets +```sql +ticket_id (VARCHAR) - Unique ID like TKT-1234 +guild_id - Server ID +user_id - Ticket opener +channel_id - Discord channel +category - Ticket category +status - open/closed +assigned_to - Staff member ID +created_at - Creation timestamp +closed_at - Closure timestamp +subject - Ticket title +description - Ticket details +priority - low/normal/high/urgent +is_archived - Archive flag +``` + +### ticket_messages +```sql +ticket_id - Reference to ticket +author_id - Message sender +message_content - Full message text +created_at - Timestamp +``` + +### ticket_settings +```sql +guild_id - Server ID +category_channel_id - Ticket category +log_channel_id - Log channel +support_role_id - Staff role +ticket_prefix - ID prefix (default: TKT) +max_open_per_user - Limit per user +auto_close_days - Auto-close after X days +enable_priority - Show priority level +enable_categories - Enable categories +``` + +## Workflow Example + +### For Users: +1. Click "Create Ticket" button +2. Fill in problem details +3. Wait for staff response +4. Discuss in private channel +5. Staff resolves issue +6. Ticket auto-closes + +### For Admins: +1. Setup ticket system: `/ticketsetup create-channels` +2. Configure channels: `/ticketsetup configure` +3. View dashboard: `/ticketadmin dashboard` +4. Assign tickets: `/ticketadmin assign` +5. Track resolution: `/ticketadmin notes` +6. Archive transcripts: `/ticketadmin transcript` + +## Permissions + +**User Tickets:** +- Can open tickets +- Can see own ticket channel +- Can close own ticket + +**Staff:** +- Can claim tickets +- Can assign tickets +- Can add notes +- Can see all tickets +- Can close any ticket +- Can generate transcripts + +**Admin:** +- Full ticket system access +- Can configure settings +- Can setup channels +- Can manage all tickets + +## Customization + +### Add Custom Categories + +``` +/ticketsetup addcategory name: [Name] description: [Description] emoji: [Emoji] +``` + +Example: +``` +/ticketsetup addcategory name: "Bug Report" description: "Report bugs and issues" emoji: "๐Ÿ›" +/ticketsetup addcategory name: "Feature Request" description: "Suggest new features" emoji: "๐Ÿ’ก" +/ticketsetup addcategory name: "Account Support" description: "Account-related issues" emoji: "๐Ÿ‘ค" +``` + +### Ticket ID Format + +By default, tickets use format: `TKT-1234` + +To customize, update the `ticket_prefix` in database: + +```sql +UPDATE ticket_settings SET ticket_prefix = 'SUPPORT' WHERE guild_id = '...'; +``` + +### Auto-Close Settings + +Configure tickets to auto-close after N days: + +```sql +UPDATE ticket_settings SET auto_close_days = 7 WHERE guild_id = '...'; +``` + +## Troubleshooting + +### Ticket not appearing in logs? +- Check log channel is configured: `/ticketsetup configure log-channel: [Channel]` +- Verify bot has permissions to send messages + +### Can't see ticket channel? +- Check that bot has proper permissions in category +- Verify user ID matches ticket owner + +### Staff can't claim tickets? +- Ensure staff role is configured: `/ticketsetup configure support-role: [Role]` +- Check bot permissions in ticket channels + +### Messages not logging? +- Verify `ticket_messages` table exists +- Check database connection + +## Database Queries + +### Get all open tickets +```sql +SELECT * FROM tickets WHERE status = 'open' AND guild_id = '...' ORDER BY created_at DESC; +``` + +### Get unassigned tickets +```sql +SELECT * FROM tickets WHERE status = 'open' AND assigned_to IS NULL AND guild_id = '...'; +``` + +### Get staff performance +```sql +SELECT assigned_to, COUNT(*) as closed_count FROM tickets WHERE status = 'closed' AND guild_id = '...' GROUP BY assigned_to; +``` + +### Generate report +```sql +SELECT + DATE(created_at) as date, + COUNT(*) as total, + COUNT(CASE WHEN status = 'closed' THEN 1 END) as closed +FROM tickets WHERE guild_id = '...' GROUP BY DATE(created_at); +``` + +## Performance Tips + +1. **Index optimization** - Database already has proper indexes +2. **Archive old tickets** - Set `is_archived = true` for tickets > 30 days old +3. **Clean logs** - Periodically delete old `ticket_messages` entries +4. **Batch operations** - Use admin panel for bulk ticket actions + +## Support + +For issues or feature requests, check: +- Discord.js documentation: https://discord.js.org +- PostgreSQL documentation: https://www.postgresql.org/docs diff --git a/src/app.js b/src/app.js index c0625b6b5..4896b7d5f 100644 --- a/src/app.js +++ b/src/app.js @@ -11,6 +11,7 @@ import { getServerCounters, saveServerCounters, updateCounter } from './services import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; +import { checkRobloxJoinRequests } from './services/robloxJoinRequestService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; class TitanBot extends Client { @@ -228,6 +229,8 @@ class TitanBot extends Client { } setupCronJobs() { + // Check Roblox join requests 4 times a day (every 6 hours) + cron.schedule('0 0,6,12,18 * * *', () => checkRobloxJoinRequests(this)); cron.schedule('0 6 * * *', () => checkBirthdays(this)); cron.schedule('* * * * *', () => checkGiveaways(this)); cron.schedule('*/15 * * * *', () => this.updateAllCounters()); @@ -300,10 +303,17 @@ class TitanBot extends Client { } async registerCommands() { - try { - await registerSlashCommands(this, this.config.bot.guildId); - } catch (error) { - logger.error('Error registering commands:', error); + const guildIds = this.config.bot.guildIds; + if (!guildIds || guildIds.length === 0) { + logger.warn('No guild IDs configured. Set GUILD_IDS or GUILD_ID to register slash commands.'); + return; + } + for (const guildId of guildIds) { + try { + await registerSlashCommands(this, guildId); + } catch (error) { + logger.error(`Error registering commands for guild ${guildId}:`, error); + } } } diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js deleted file mode 100644 index 099398c34..000000000 --- a/src/commands/Community/app-admin.js +++ /dev/null @@ -1,733 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { getColor } from '../../config/bot.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplication, - getApplications, - updateApplication, - getApplicationRoles, - saveApplicationRoles, - getApplicationRoleSettings, - saveApplicationRoleSettings, - deleteApplication -} from '../../utils/database.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import appDashboard from './modules/app_dashboard.js'; - -function getApplicationStatusPresentation(statusValue) { - const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; - const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; - const statusEmoji = - normalized === 'pending' ? '๐ŸŸก' : - normalized === 'approved' ? '๐ŸŸข' : - normalized === 'denied' ? '๐Ÿ”ด' : - 'โšช'; - - return { normalized, statusLabel, statusEmoji }; -} - -export default { - data: new SlashCommandBuilder() - .setName("app-admin") - .setDescription("Manage staff applications") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new application") - ) - .addSubcommand((subcommand) => - subcommand - .setName("review") - .setDescription("Approve or deny an application") - .addStringOption((option) => - option - .setName("id") - .setDescription("The application ID") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List all applications") - .addStringOption((option) => - option - .setName("status") - .setDescription("Filter by status") - .addChoices( - { name: "Pending", value: "pending" }, - { name: "Approved", value: "approved" }, - { name: "Denied", value: "denied" }, - ), - ) - .addStringOption((option) => - option.setName("role").setDescription("Filter by role ID"), - ) - .addUserOption((option) => - option.setName("user").setDescription("Filter by user"), - ) - .addNumberOption((option) => - option - .setName("limit") - .setDescription( - "Maximum number of applications to show (default: 10)", - ) - .setMinValue(1) - .setMaxValue(25), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the applications configuration dashboard") - .addStringOption((option) => - option - .setName("application") - .setDescription("Select an application to configure") - .setRequired(false) - .setAutocomplete(true), - ), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This command can only be used in a server.")], - flags: ["Ephemeral"], - }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== 'dashboard' && subcommand !== 'setup') { - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - } - - logger.info(`App-admin command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - // โœ“ Permission check: User must have ManageGuild permission or a configured manager role - // This prevents unauthorized users from accessing admin functions - await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); - - if (subcommand === "setup") { - await handleSetup(interaction); - } else if (subcommand === "review") { - await handleReview(interaction); - } else if (subcommand === "list") { - await handleList(interaction); - } else if (subcommand === "dashboard") { - const selectedAppName = interaction.options.getString("application"); - await appDashboard.execute(interaction, null, interaction.client, selectedAppName); - } - }, { type: 'command', commandName: 'app-admin' }) -}; - -async function handleSetup(interaction) { - // Ensure interaction hasn't been deferred/replied yet (safety check) - if (interaction.deferred || interaction.replied) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This interaction has already been processed. Please try the command again.")], - flags: ["Ephemeral"], - }); - } - - // Build modal using LabelBuilder API with a native role select dropdown - const modal = new ModalBuilder() - .setCustomId('app_setup_modal') - .setTitle('Set Up New Application'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('role_id') - .setPlaceholder('Select the role users will apply for') - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('The role that users will be applying for') - .setRoleSelectMenuComponent(roleSelect); - - const appNameInput = new TextInputBuilder() - .setCustomId('app_name') - .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g., Moderator, Helper, Developer') - .setMaxLength(50) - .setMinLength(1) - .setRequired(true); - - const appNameLabel = new LabelBuilder() - .setLabel('Application Name') - .setTextInputComponent(appNameInput); - - const q1Input = new TextInputBuilder() - .setCustomId('app_question_1') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Why do you want this role?') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true); - - const q1Label = new LabelBuilder() - .setLabel('Question 1 (required)') - .setTextInputComponent(q1Input); - - const q2Input = new TextInputBuilder() - .setCustomId('app_question_2') - .setStyle(TextInputStyle.Short) - .setPlaceholder('What experience do you have?') - .setMaxLength(100) - .setRequired(false); - - const q2Label = new LabelBuilder() - .setLabel('Question 2 (optional)') - .setTextInputComponent(q2Input); - - const q3Input = new TextInputBuilder() - .setCustomId('app_question_3') - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setRequired(false); - - const q3Label = new LabelBuilder() - .setLabel('Question 3 (optional)') - .setTextInputComponent(q3Input); - - modal.addLabelComponents(roleLabel, appNameLabel, q1Label, q2Label, q3Label); - - await interaction.showModal(modal); - - const submitted = await interaction.awaitModalSubmit({ - time: 15 * 60 * 1000, // 15 minutes - filter: (i) => - i.customId === 'app_setup_modal' && - i.user.id === interaction.user.id, - }).catch(() => null); - - if (!submitted) { - logger.info('App setup modal dismissed or timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); - return; - } - - const appName = submitted.fields.getTextInputValue('app_name').trim(); - const selectedRoles = submitted.fields.getSelectedRoles('role_id'); - const roleId = selectedRoles.first()?.id; - - if (!roleId) { - await submitted.reply({ - embeds: [errorEmbed('No Role Selected', 'You must select a role for the application.')], - flags: ['Ephemeral'], - }); - return; - } - - const questions = [ - submitted.fields.getTextInputValue('app_question_1').trim(), - submitted.fields.getTextInputValue('app_question_2').trim(), - submitted.fields.getTextInputValue('app_question_3').trim(), - ].filter(q => q.length > 0); - - // Get the role to verify it exists - const role = await interaction.guild.roles.fetch(roleId).catch(() => null); - if (!role) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Role', 'The selected role could not be found.')], - flags: ['Ephemeral'], - }); - return; - } - - // Check if this role is already an application - const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - if (existingRoles.some(r => r.roleId === roleId)) { - await submitted.reply({ - embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], - flags: ['Ephemeral'], - }); - return; - } - - // Add the role to applications with enabled status - existingRoles.push({ - roleId: roleId, - name: appName, - enabled: true, // New applications start enabled - }); - - await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - - // Enable the system - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (!settings.enabled) { - await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); - } - - // Save the questions for this specific role - await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); - - await submitted.reply({ - embeds: [successEmbed( - 'โœ… Application Created', - `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, - )], - flags: ['Ephemeral'], - }); - - // Auto-open dashboard with this app selected - setTimeout(() => { - appDashboard.execute(submitted, null, interaction.client, appName); - }, 500); -} - - -async function handleReview(interaction) { - const appId = interaction.options.getString("id"); - - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - if (!application) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Application not found.")], - flags: ["Ephemeral"], - }); - } - - if (application.status !== "pending") { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("This application has already been processed."), - ], - flags: ["Ephemeral"], - }); - } - - // Show application details with approve/deny buttons - const appEmbed = createEmbed({ - title: `๐Ÿ“‹ Review Application`, - description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, - color: 'info', - }); - - // Add application answers to the embed - if (application.answers && application.answers.length > 0) { - application.answers.forEach((item, index) => { - appEmbed.addFields({ - name: `Q${index + 1}: ${item.question}`, - value: item.answer || '*No answer provided*', - inline: false - }); - }); - } - - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_review_approve_${appId}`) - .setLabel('Approve') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`app_review_deny_${appId}`) - .setLabel('Deny') - .setStyle(ButtonStyle.Danger), - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [appEmbed], - components: [buttonRow], - flags: ["Ephemeral"], - }); - - // Setup button collector - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - (i.customId.startsWith(`app_review_approve_${appId}`) || - i.customId.startsWith(`app_review_deny_${appId}`)), - time: 300_000, // 5 minutes - max: 1, - }); - - collector.on('collect', async buttonInteraction => { - const isApprove = buttonInteraction.customId.includes('approve'); - - // Show modal for reason - const reasonModal = new ModalBuilder() - .setCustomId(`app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}`) - .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); - - reasonModal.addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('review_reason') - .setLabel('Reason (optional)') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Provide a reason for this decision...') - .setMaxLength(500) - .setRequired(false), - ), - ); - - await buttonInteraction.showModal(reasonModal); - - try { - const reasonSubmit = await buttonInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, // 5 minutes - filter: i => - i.customId === `app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}` && - i.user.id === buttonInteraction.user.id, - }).catch(() => null); - - if (!reasonSubmit) return; - - const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "No reason provided."; - const action = isApprove ? 'approve' : 'deny'; - const status = isApprove ? 'approved' : 'denied'; - - const updatedApplication = await ApplicationService.reviewApplication( - reasonSubmit.client, - interaction.guild.id, - appId, - { - action, - reason, - reviewerId: reasonSubmit.user.id - } - ); - - // Send DM to user - try { - const user = await reasonSubmit.client.users.fetch(application.userId); - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.` - ).setColor(statusColor); - - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.warn('Failed to send DM to user for application review', { - error: error.message, - userId: application.userId, - applicationId: appId - }); - } - - // Update log message - if (application.logMessageId && application.logChannelId) { - try { - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const logChannel = interaction.guild.channels.cache.get( - application.logChannelId, - ); - if (logChannel) { - const logMessage = await logChannel.messages.fetch( - application.logMessageId, - ); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(statusColor) - .spliceFields(0, 1, { - name: "Status", - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, - }); - - await logMessage.edit({ - embeds: [newEmbed], - components: [], - }); - } - } - } - } catch (error) { - logger.warn('Failed to update log message for application', { - error: error.message, - applicationId: appId, - logMessageId: application.logMessageId - }); - } - } - - // Assign role if approved - if (isApprove) { - try { - const member = await interaction.guild.members.fetch( - application.userId, - ); - await member.roles.add(application.roleId); - } catch (error) { - logger.error('Failed to assign role to approved applicant', { - error: error.message, - userId: application.userId, - roleId: application.roleId, - applicationId: appId - }); - } - } - - // Respond to modal submission - await reasonSubmit.reply({ - embeds: [ - successEmbed( - `Application ${status}`, - `The application has been **${status}**.`, - ), - ], - flags: ["Ephemeral"], - }); - - } catch (error) { - logger.error('Error reviewing application:', error); - await buttonInteraction.reply({ - embeds: [errorEmbed('Error', 'An error occurred while reviewing the application.')], - flags: ["Ephemeral"], - }); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = createEmbed({ - title: 'โฑ๏ธ Review Timeout', - description: 'The review buttons have timed out.', - color: 'warning', - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); -} - -async function handleList(interaction) { - const status = interaction.options.getString("status"); - const user = interaction.options.getUser("user"); - const limit = interaction.options.getNumber("limit") || 10; - - const filters = {}; - // Default to showing only pending applications if no status specified - if (status) { - filters.status = status; - } else { - filters.status = 'pending'; - } - - let applications = await getApplications( - interaction.client, - interaction.guild.id, - filters, - ); - - // Filter out applications from users who are no longer in the guild (except if filtering by specific user) - if (!user) { - applications = await Promise.all( - applications.map(async (app) => { - try { - await interaction.guild.members.fetch(app.userId); - return app; // User still in guild - } catch { - // User no longer in guild, delete the application - await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); - return null; // Mark for removal - } - }) - ).then(results => results.filter(Boolean)); // Remove nulls - } - - if (user) { - applications = applications.filter((app) => app.userId === user.id); - } - - if (applications.length === 0) { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - if (applicationRoles.length > 0) { - const embed = createEmbed({ - title: "No Applications Found", - description: "No submitted applications found matching the specified criteria.\n\nHowever, the following application roles are configured:" - }); - - applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ? `<@&${appRole.roleId}>` : 'Role not found'}\n**Available for applications:** Yes`, - inline: false - }); - }); - - embed.setFooter({ - text: "Users can apply with /apply submit or see available roles with /apply list" - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } else { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "No applications found and no application roles configured.\n" + - "Use `/app-admin roles add` to configure application roles first." - ), - ], - flags: ["Ephemeral"], - }); - } - } - - applications = applications - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) - .slice(0, limit); - - const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); - - applications.forEach((app) => { - const statusView = getApplicationStatusPresentation(app?.status); - const roleName = app?.roleName || 'Unknown Role'; - const username = app?.username || 'Unknown User'; - const createdAt = app?.createdAt ? new Date(app.createdAt) : null; - const createdAtDisplay = createdAt && !Number.isNaN(createdAt.getTime()) - ? createdAt.toLocaleString() - : 'Unknown date'; - - embed.addFields({ - name: `${statusView.statusEmoji} ${roleName} - ${username}`, - value: - `**ID:** \`${app.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Date:** ${createdAtDisplay}`, - inline: true, - }); - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); -} - -export async function handleApplicationReviewModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_review_')) return; - - const [, appId, action] = customId.split('_'); - const reason = interaction.fields.getTextInputValue('reason') || 'No reason provided.'; - const isApprove = action === 'approve'; - - try { - const application = await getApplication(interaction.client, interaction.guild.id, appId); - if (!application) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Application not found.')], - flags: ["Ephemeral"] - }); - } - - const status = isApprove ? 'approved' : 'denied'; - await updateApplication(interaction.client, interaction.guild.id, appId, { - status, - reviewer: interaction.user.id, - reviewMessage: reason, - reviewedAt: new Date().toISOString() - }); - - try { - const user = await interaction.client.users.fetch(application.userId); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**.\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.`, - isApprove ? '#00FF00' : '#FF0000' - ); - - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.error('Error sending DM to user:', error); - } - - if (application.logMessageId && application.logChannelId) { - try { - const logChannel = interaction.guild.channels.cache.get(application.logChannelId); - if (logChannel) { - const logMessage = await logChannel.messages.fetch(application.logMessageId); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(isApprove ? '#00FF00' : '#FF0000') - .spliceFields(0, 1, { - name: 'Status', - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}` - }); - - await logMessage.edit({ - embeds: [newEmbed], - components: [] - }); - } - } - } - } catch (error) { - logger.error('Error updating log message:', error); - } - } - - if (isApprove) { - try { - const member = await interaction.guild.members.fetch(application.userId); - await member.roles.add(application.role); - } catch (error) { - logger.error('Error assigning role:', error); - } - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `${getApplicationStatusPresentation(status).statusEmoji} Application ${getApplicationStatusPresentation(status).statusLabel}`, - `The application has been marked as ${getApplicationStatusPresentation(status).statusLabel}.` - ) - ], - flags: ["Ephemeral"] - }); - - } catch (error) { - logger.error('Error processing application review:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('An error occurred while processing the application.')], - flags: ["Ephemeral"] - }); - } -} - - - diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js deleted file mode 100644 index f92bd1419..000000000 --- a/src/commands/Community/apply.js +++ /dev/null @@ -1,433 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - getApplicationSettings, - getUserApplications, - createApplication, - getApplication, - getApplicationRoles, - updateApplication, - getApplicationRoleSettings -} from '../../utils/database.js'; - -function getApplicationStatusPresentation(statusValue) { - const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; - const statusLabel = - normalized === 'pending' ? 'In Progress' : - normalized === 'approved' ? 'Accepted' : - normalized === 'denied' ? 'Denied' : - 'Unknown'; - const statusEmoji = - normalized === 'pending' ? '๐ŸŸก' : - normalized === 'approved' ? '๐ŸŸข' : - normalized === 'denied' ? '๐Ÿ”ด' : - 'โšช'; - - return { normalized, statusLabel, statusEmoji }; -} - -export default { - data: new SlashCommandBuilder() - .setName("apply") - .setDescription("Manage role applications") - .addSubcommand((subcommand) => - subcommand - .setName("submit") - .setDescription("Submit an application for a role") - .addStringOption((option) => - option - .setName("application") - .setDescription("The application you want to submit") - .setRequired(true) - .setAutocomplete(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("status") - .setDescription("Check the status of your application") - .addStringOption((option) => - option - .setName("id") - .setDescription("Application ID (leave empty to see all)") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List available applications to apply for"), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed("This command can only be used in a server.")], - flags: ["Ephemeral"], - }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== "submit") { - const isListCommand = subcommand === "list"; - await InteractionHelper.safeDefer(interaction, { flags: isListCommand ? [] : ["Ephemeral"] }); - } - - logger.info(`Apply command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - const settings = await getApplicationSettings( - interaction.client, - guild.id, - ); - - if (!settings.enabled) { - throw createError( - 'Applications are disabled', - ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', - { guildId: guild.id } - ); - } - - if (subcommand === "submit") { - await handleSubmit(interaction, settings); - } else if (subcommand === "status") { - await handleStatus(interaction); - } else if (subcommand === "list") { - await handleList(interaction); - } - }, { type: 'command', commandName: 'apply' }) -}; - -export async function handleApplicationModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_modal_')) return; - - const roleId = customId.split('_')[2]; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); - - if (!applicationRole) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Application configuration not found.')], - flags: ["Ephemeral"] - }); - } - - const role = interaction.guild.roles.cache.get(roleId); - - if (!role) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Role not found.')], - flags: ["Ephemeral"] - }); - } - - const answers = []; - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - - // Get questions - use per-application questions if they exist, otherwise use global - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); - if (roleSettings.questions && roleSettings.questions.length > 0) { - questions = roleSettings.questions; - } - - for (let i = 0; i < questions.length; i++) { - const answer = interaction.fields.getTextInputValue(`q${i}`); - answers.push({ - question: questions[i], - answer: answer - }); - } - - try { - const application = await ApplicationService.submitApplication(interaction.client, { - guildId: interaction.guild.id, - userId: interaction.user.id, - roleId: roleId, - roleName: applicationRole.name, - username: interaction.user.tag, - avatar: interaction.user.displayAvatarURL(), - answers: answers - }); - - const embed = successEmbed( - 'Application Submitted', - `Your application for **${applicationRole.name}** has been submitted successfully!\n\n` + - `Application ID: \`${application.id}\`\n` + - `You can check the status with \`/apply status id:${application.id}\`` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); - - // Use per-application log channel if exists, otherwise use global - const logChannelId = roleSettings.logChannelId || settings.logChannelId; - - if (logChannelId) { - const logChannel = interaction.guild.channels.cache.get(logChannelId); - if (logChannel) { - const logEmbed = createEmbed({ - title: '๐Ÿ“ New Application', - description: `**User:** <@${interaction.user.id}> (${interaction.user.tag})\n` + - `**Application:** ${applicationRole.name}\n` + - `**Role:** ${role.name}\n` + - `**Application ID:** \`${application.id}\`\n` + - `**Status:** ๐ŸŸก In Progress` - }).setColor(getColor('warning')); - - const logMessage = await logChannel.send({ embeds: [logEmbed] }); - - await updateApplication(interaction.client, interaction.guild.id, application.id, { - logMessageId: logMessage.id, - logChannelId: logChannelId - }); - } - } - - } catch (error) { - logger.error('Error creating application:', { - error: error.message, - userId: interaction.user.id, - guildId: interaction.guild.id, - roleId, - stack: error.stack - }); - - await handleInteractionError(interaction, error, { - type: 'modal', - handler: 'application_submission' - }); - } -} - -async function handleList(interaction) { - try { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - if (applicationRoles.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("No applications are currently available.")], - }); - } - - const embed = createEmbed({ - title: "Available Applications", - description: "Here are the roles you can apply for:" - }); - - applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ? `<@&${appRole.roleId}>` : 'Role not found'}\n` + - `**Apply with:** \`/apply submit application:"${appRole.name}"\``, - inline: false - }); - }); - - embed.setFooter({ - text: "Use /apply submit application: to apply for any of these roles." - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error('Error listing applications:', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack - }); - - throw createError( - 'Failed to load applications', - ErrorTypes.DATABASE, - 'Failed to load applications. Please try again later.', - { guildId: interaction.guild.id } - ); - } -} - -async function handleSubmit(interaction, settings) { - const applicationName = interaction.options.getString("application"); - const member = interaction.member; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - const applicationRole = applicationRoles.find(appRole => - appRole.name.toLowerCase() === applicationName.toLowerCase() - ); - - if (!applicationRole) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Application not found.", - "Use `/apply list` to see available applications." - ), - ], - flags: ["Ephemeral"], - }); - } - - const userApps = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - const pendingApp = userApps.find((app) => app.status === "pending"); - - if (pendingApp) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - `You already have a pending application. Please wait for it to be reviewed.`, - ), - ], - flags: ["Ephemeral"], - }); - } - - const role = interaction.guild.roles.cache.get(applicationRole.roleId); - if (!role) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('The role for this application no longer exists.')], - flags: ["Ephemeral"] - }); - } - - const modal = new ModalBuilder() - .setCustomId(`app_modal_${applicationRole.roleId}`) - .setTitle(`Application for ${applicationRole.name}`); - - // Get questions - use per-application questions if they exist, otherwise use global - let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); - if (roleSettings.questions && roleSettings.questions.length > 0) { - questions = roleSettings.questions; - } - - questions.forEach((question, index) => { - const input = new TextInputBuilder() - .setCustomId(`q${index}`) - .setLabel( - question.length > 45 - ? `${question.substring(0, 42)}...` - : question, - ) - .setStyle(TextInputStyle.Paragraph) - .setRequired(true) - .setMaxLength(1000); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - }); - - await interaction.showModal(modal); -} - -async function handleStatus(interaction) { - const appId = interaction.options.getString("id"); - - if (appId) { - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - - if (!application || application.userId !== interaction.user.id) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Application not found or you do not have permission to view it.", - ), - ], - flags: ["Ephemeral"], - }); - } - - const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; - const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) - ? submittedAt.toLocaleString() - : 'Unknown date'; - const statusView = getApplicationStatusPresentation(application.status); - const embed = createEmbed({ - title: `Application #${application.id} - ${application.roleName || 'Unknown Role'}`, - description: - `**Application ID:** \`${application.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}` - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } else { - const applications = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - - if (applications.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("You have not submitted any applications yet."), - ], - flags: ["Ephemeral"], - }); - } - - const recentApplications = applications - .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) - .slice(0, 10); - - const embed = createEmbed({ - title: "Your Applications", - description: `Showing ${recentApplications.length} recent application(s).` - }); - - recentApplications.forEach((application) => { - const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; - const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) - ? submittedAt.toLocaleDateString() - : 'Unknown date'; - const statusView = getApplicationStatusPresentation(application.status); - - embed.addFields({ - name: `${statusView.statusEmoji} ${application.roleName || 'Unknown Role'} (${statusView.statusLabel})`, - value: - `**ID:** \`${application.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Submitted:** ${submittedAtDisplay}`, - inline: true, - }); - }); - - if (applications.length > recentApplications.length) { - embed.setFooter({ text: `Showing latest ${recentApplications.length} of ${applications.length} applications.` }); - } - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } -} - - - diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js deleted file mode 100644 index bcb9cf2cb..000000000 --- a/src/commands/Community/modules/app_dashboard.js +++ /dev/null @@ -1,1220 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - RoleSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - LabelBuilder, - CheckboxBuilder, - TextDisplayBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { safeDeferInteraction } from '../../../utils/interactionValidator.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplicationRoles, - saveApplicationRoles, - getApplicationRoleSettings, - saveApplicationRoleSettings, - deleteApplicationRoleSettings, - getApplications, - deleteApplication, -} from '../../../utils/database.js'; - -// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function buildDashboardEmbed(settings, roles, guild) { - const logChannel = settings.logChannelId ? `<#${settings.logChannelId}>` : '`Not set`'; - const managerRoleList = - settings.managerRoles?.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') - : '`None configured`'; - const roleList = - roles.length > 0 - ? roles.map(r => `<@&${r.roleId}> โ€” ${r.name}`).join('\n') - : '`No application roles configured`'; - const questionCount = settings.questions?.length ?? 0; - const firstQ = - settings.questions?.[0] - ? `\`${settings.questions[0].length > 55 ? settings.questions[0].substring(0, 55) + 'โ€ฆ' : settings.questions[0]}\`` - : '`Not set`'; - - return new EmbedBuilder() - .setTitle('๐Ÿ“‹ Applications Dashboard') - .setDescription(`Manage application settings for **${guild.name}**.\nSelect an option below to modify a setting.`) - .setColor(getColor('info')) - .addFields( - { name: 'โš™๏ธ Application Status', value: settings.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, - { name: '๐Ÿ“ข Log Channel', value: logChannel, inline: true }, - { name: '\u200B', value: '\u200B', inline: true }, - { name: '๐Ÿ›ก๏ธ Manager Roles', value: managerRoleList, inline: false }, - { name: '๐Ÿ“ Questions', value: `${questionCount} configured โ€” first: ${firstQ}`, inline: false }, - { name: '๐ŸŽญ Application Roles', value: roleList, inline: false }, - { - name: '๐Ÿ—‘๏ธ Retention', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, - inline: false, - }, - ) - .setFooter({ text: 'Dashboard closes after 15 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`app_cfg_${guildId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where new applications are logged') - .setValue('log_channel') - .setEmoji('๐Ÿ“ข'), - new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') - .setValue('manager_role') - .setEmoji('๐Ÿ›ก๏ธ'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') - .setValue('questions') - .setEmoji('๐Ÿ“'), - new StringSelectMenuOptionBuilder() - .setLabel('Add Application Role') - .setDescription('Add a role that members can apply for') - .setValue('role_add') - .setEmoji('โž•'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Application Role') - .setDescription('Remove a role from the applications list') - .setValue('role_remove') - .setEmoji('โž–'), - new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') - .setValue('retention') - .setEmoji('๐Ÿ—‘๏ธ'), - ); -} - -function buildButtonRow(settings, guildId, disabled = false) { - const systemOn = settings.enabled === true; - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_cfg_toggle_${guildId}`) - .setLabel('Applications') - .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setDisabled(disabled), - ); -} - -// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function refreshDashboard(rootInteraction, settings, roles, guildId) { - const selectMenu = buildSelectMenu(guildId); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(settings, roles, rootInteraction.guild)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export default { - async execute(interaction, config, client, selectedAppName = null) { - try { - const guildId = interaction.guild.id; - - // Defer immediately to prevent Discord interaction timeout - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - - const [settings, roles] = await Promise.all([ - getApplicationSettings(client, guildId), - getApplicationRoles(client, guildId), - ]); - - // Check if application system is completely unconfigured - const isCompletelyUnconfigured = - !settings.logChannelId && - !settings.enabled && - (settings.managerRoles?.length ?? 0) === 0 && - roles.length === 0; - - if (isCompletelyUnconfigured) { - throw new TitanBotError( - 'Applications system not set up', - ErrorTypes.CONFIGURATION, - 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', - ); - } - - // If no application roles exist, show global settings to add one - if (roles.length === 0) { - await showGlobalDashboard(interaction, settings, roles, guildId, client); - return; - } - - // If a specific app was selected via autocomplete, show its dashboard directly - if (selectedAppName) { - const selectedRole = roles.find(r => r.name.toLowerCase() === selectedAppName.toLowerCase()); - if (selectedRole) { - await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); - return; - } - // If name doesn't match, fall through - } - - // Default: Show first application if no selection made - const defaultRole = roles[0]; - await showApplicationDashboard(interaction, defaultRole, settings, roles, guildId, client); - - } catch (error) { - if (error instanceof TitanBotError) throw error; - logger.error('Unexpected error in app_dashboard:', error); - throw new TitanBotError( - `Applications dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the applications dashboard.', - ); - } - }, -}; - -// โ”€โ”€โ”€ Application Selector (for multiple applications) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function showApplicationSelector(interaction, roles, settings, guildId, client) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(`app_select_${guildId}`) - .setPlaceholder('Select an application to configure...') - .addOptions( - roles.map(role => - new StringSelectMenuOptionBuilder() - .setLabel(role.name) - .setDescription(`Configure the ${role.name} application`) - .setValue(role.roleId) - .setEmoji('๐Ÿ“‹'), - ), - ); - - const embed = new EmbedBuilder() - .setTitle('๐ŸŽฏ Select Application') - .setDescription('Choose which application role you want to configure.') - .setColor(getColor('info')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [new ActionRowBuilder().addComponents(selectMenu)], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `app_select_${guildId}`, - time: 600_000, - max: 1, - }); - - collector.on('collect', async selectInteraction => { - const deferred = await safeDeferInteraction(selectInteraction); - if (!deferred) return; - - const selectedRoleId = selectInteraction.values[0]; - const selectedRole = roles.find(r => r.roleId === selectedRoleId); - - if (selectedRole) { - await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Timed Out', 'No selection was made. The dashboard has closed.')], - components: [], - }).catch(() => {}); - } - }); -} - -// โ”€โ”€โ”€ Global Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function showGlobalDashboard(interaction, settings, roles, guildId, client) { - const selectMenu = buildSelectMenu(guildId); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [buildDashboardEmbed(settings, roles, interaction.guild)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }); - - setupCollectors(interaction, settings, roles, guildId, client, null); -} - -// โ”€โ”€โ”€ Application-Specific Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { - const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); - - // Get application-specific settings - const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); - const questions = appSettings.questions || settings.questions || []; - const appLogChannelId = appSettings.logChannelId || settings.logChannelId; - const isEnabled = selectedRole.enabled !== false; // Default to true if not specified - - // Build comprehensive embed - const logChannelDisplay = appLogChannelId - ? `<#${appLogChannelId}>` - : '`Inherits global log channel`'; - - const questionsDisplay = questions.length > 0 - ? questions.map((q, i) => `${i + 1}. \`${q.length > 60 ? q.substring(0, 60) + 'โ€ฆ' : q}\``).join('\n') - : '`Inherits global questions`'; - - const managerRolesDisplay = settings.managerRoles && settings.managerRoles.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') - : '`None configured`'; - - const embed = new EmbedBuilder() - .setTitle('๐ŸŽญ Application Dashboard') - .setDescription(`Configuration for **${selectedRole.name}**`) - .setColor(isEnabled ? getColor('success') : getColor('error')) - .addFields( - { - name: '๐ŸŽญ Role', - value: roleObj ? roleObj.toString() : `<@&${selectedRole.roleId}>`, - inline: true - }, - { - name: 'โš™๏ธ Application Status', - value: isEnabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**', - inline: true - }, - { name: '\u200B', value: '\u200B', inline: true }, - { - name: '๐Ÿ“ Questions', - value: questionsDisplay, - inline: false - }, - { - name: '๐Ÿ“ข Log Channel', - value: logChannelDisplay, - inline: true - }, - { - name: '๐Ÿ›ก๏ธ Manager Roles', - value: managerRolesDisplay, - inline: true - }, - { - name: '๐Ÿ—‘๏ธ Retention Period', - value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, - inline: false - }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); - - // Create dropdown button with customization options - const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); - - // Create control buttons - const controlButtons = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_toggle_${selectedRole.roleId}`) - .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') - .setStyle(isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`app_delete_${selectedRole.roleId}`) - .setLabel('Delete Application') - .setStyle(ButtonStyle.Danger) - .setEmoji('๐Ÿ—‘๏ธ'), - ); - - const menuRow = new ActionRowBuilder().addComponents(configMenu); - - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [embed], - components: [menuRow, controlButtons], - }); - - setupCollectors(rootInteraction, settings, roles, guildId, client, selectedRole.roleId); -} - -// โ”€โ”€โ”€ Collector Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function setupCollectors(interaction, settings, roles, guildId, client, selectedRoleId) { - const customIdPrefix = selectedRoleId ? `app_cfg_${selectedRoleId}` : `app_cfg_${guildId}`; - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && - (selectedRoleId - ? i.customId === customIdPrefix - : (i.customId === `app_cfg_${guildId}` || i.customId === `app_select_${guildId}`)), - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - // Catch expired interactions - if (!selectInteraction.isStringSelectMenu()) { - return; - } - switch (selectedOption) { - case 'log_channel': - await handleLogChannel(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'manager_role': - await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'questions': - await handleQuestions(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - case 'role_add': - await handleRoleAdd(selectInteraction, interaction, settings, roles, guildId, client); - break; - case 'role_remove': - await handleRoleRemove(selectInteraction, interaction, settings, roles, guildId, client); - break; - case 'retention': - await handleRetention(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Applications config validation error: ${error.message}`); - } else { - logger.error('Unexpected applications dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while updating the configuration.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await safeDeferInteraction(selectInteraction); - } - - await selectInteraction - .followUp({ - embeds: [errorEmbed('Configuration Error', errorMessage)], - flags: MessageFlags.Ephemeral, - }) - .catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('\u23f0 Dashboard Timed Out') - .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') - .setColor(getColor('error')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - - // โ”€โ”€ Global Toggle Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (!selectedRoleId) { - const globalToggleCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_cfg_toggle_${guildId}`, - time: 600_000, - }); - - globalToggleCollector.on('collect', async toggleInteraction => { - const deferred = await safeDeferInteraction(toggleInteraction); - if (!deferred) return; - - try { - const wasEnabled = settings.enabled === true; - settings.enabled = !wasEnabled; - - // Save the updated settings - await saveApplicationSettings(interaction.client, guildId, settings); - - // Refresh dashboard to show new status - const updatedSettings = await getApplicationSettings(interaction.client, guildId); - const updatedRoles = await getApplicationRoles(interaction.client, guildId); - await showGlobalDashboard(interaction, updatedSettings, updatedRoles, guildId, interaction.client); - - await toggleInteraction.followUp({ - embeds: [successEmbed( - wasEnabled ? '๐Ÿ”ด Applications Disabled' : '๐ŸŸข Applications Enabled', - `The applications system is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ - wasEnabled - ? 'Members will no longer be able to apply for roles.' - : 'Members can now start applying for roles.' - }`, - )], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error toggling global application status:', error); - await toggleInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - globalToggleCollector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('โฑ๏ธ Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } - - // โ”€โ”€ Delete Button Collector (for application-specific dashboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (selectedRoleId) { - const btnCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_delete_${selectedRoleId}`, - time: 600_000, - }); - - btnCollector.on('collect', async btnInteraction => { - // Show confirmation modal - const appRoleForDelete = roles.find(r => r.roleId === selectedRoleId); - const appNameForDelete = appRoleForDelete?.name ?? 'this application'; - - const confirmModal = new ModalBuilder() - .setCustomId('app_delete_confirm') - .setTitle('Confirm Application Deletion'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`โš ๏ธ You are about to permanently delete **${appNameForDelete}**. All stored applications and settings for this role will be removed and cannot be recovered.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('confirm_delete') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm โ€” this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - confirmModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); - - try { - await btnInteraction.showModal(confirmModal); - } catch (error) { - logger.error('Error showing delete confirmation modal:', error); - await btnInteraction.followUp({ - embeds: [errorEmbed('Error', 'Failed to show confirmation modal. Please try again.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - return; - } - - try { - const confirmSubmit = await btnInteraction.awaitModalSubmit({ - time: 60_000, - filter: i => - i.customId === 'app_delete_confirm' && i.user.id === btnInteraction.user.id, - }).catch(() => null); - - if (!confirmSubmit) { - await btnInteraction.followUp({ - embeds: [errorEmbed('Cancelled', 'Application deletion was cancelled.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const confirmed = confirmSubmit.fields.getCheckbox('confirm_delete'); - if (!confirmed) { - await confirmSubmit.reply({ - embeds: [errorEmbed('Not Confirmed', 'You must tick the confirmation checkbox to delete the application.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - // Delete the application - await handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client); - collector.stop(); - btnCollector.stop(); - - } catch (error) { - logger.error('Error confirming application deletion:', error); - await btnInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the application.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - btnCollector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('โฑ๏ธ Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - - // โ”€โ”€ Toggle Enable/Disable Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - const toggleCollector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: i => - i.user.id === interaction.user.id && - i.customId === `app_toggle_${selectedRoleId}`, - time: 900_000, - }); - - toggleCollector.on('collect', async toggleInteraction => { - const deferred = await safeDeferInteraction(toggleInteraction); - if (!deferred) return; - - try { - // Find and toggle the role - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await toggleInteraction.followUp({ - embeds: [errorEmbed('Not Found', 'Application role not found.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const wasEnabled = roles[roleIndex].enabled !== false; - roles[roleIndex].enabled = !wasEnabled; - - // Save the updated roles - await saveApplicationRoles(interaction.client, guildId, roles); - - // Refresh dashboard to show new status - const updatedRole = roles[roleIndex]; - const updatedSettings = await getApplicationSettings(interaction.client, guildId); - await showApplicationDashboard(interaction, updatedRole, updatedSettings, roles, guildId, interaction.client); - - await toggleInteraction.followUp({ - embeds: [successEmbed( - wasEnabled ? '๐Ÿ”ด Application Disabled' : '๐ŸŸข Application Enabled', - `The **${updatedRole.name}** application is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ - wasEnabled - ? 'This application will no longer appear in `/apply submit` options.' - : 'This application will now appear in `/apply submit` options.' - }`, - )], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error toggling application status:', error); - await toggleInteraction.followUp({ - embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - toggleCollector.on('end', async (collected, reason) => { - if (reason === 'time') { - const timeoutEmbed = new EmbedBuilder() - .setTitle('โฑ๏ธ Configuration Timeout') - .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') - .setColor(getColor('warning')); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [timeoutEmbed], - components: [], - }).catch(() => {}); - } - }); - } -} - -// โ”€โ”€โ”€ Build Select Menus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function buildApplicationSelectMenu(guildId, roleId) { - return new StringSelectMenuBuilder() - .setCustomId(`app_cfg_${roleId}`) - .setPlaceholder('Select a setting to configure...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Set the channel where applications are logged') - .setValue('log_channel') - .setEmoji('๐Ÿ“ข'), - new StringSelectMenuOptionBuilder() - .setLabel('Manager Roles') - .setDescription('Add or remove a role that can manage applications') - .setValue('manager_role') - .setEmoji('๐Ÿ›ก๏ธ'), - new StringSelectMenuOptionBuilder() - .setLabel('Edit Questions') - .setDescription('Customise the questions shown on the application form') - .setValue('questions') - .setEmoji('๐Ÿ“'), - new StringSelectMenuOptionBuilder() - .setLabel('Retention Period') - .setDescription('Set how long pending and reviewed applications are kept') - .setValue('retention') - .setEmoji('๐Ÿ—‘๏ธ'), - ); -} - -// โ”€โ”€โ”€ Log Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { - let currentChannel = settings.logChannelId; - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - currentChannel = roleSettings.logChannelId || settings.logChannelId; - } - - const modal = new ModalBuilder() - .setCustomId(`app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`) - .setTitle('๐Ÿ“ข Configure Log Channel'); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('log_channel') - .setPlaceholder('Select a text channel...') - .setMinValues(1) - .setMaxValues(1) - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true); - - const channelLabel = new LabelBuilder() - .setLabel('Log Channel') - .setDescription('Channel where new applications will be logged') - .setChannelSelectMenuComponent(channelSelect); - - modal.addLabelComponents(channelLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`, - }); - - const channelId = modalSubmission.fields.getField('log_channel').values[0]; - const channel = selectInteraction.guild.channels.cache.get(channelId); - - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.logChannelId = channelId; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - settings.logChannelId = channelId; - await saveApplicationSettings(client, guildId, settings); - } - - await modalSubmission.reply({ - embeds: [successEmbed('โœ… Log Channel Updated', `Application logs will now be sent to ${channel ?? `<#${channelId}>`}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in log channel modal:', error); - await selectInteraction.followUp({ - embeds: [errorEmbed('An error occurred while updating the log channel.')], - flags: MessageFlags.Ephemeral, - }); - } -} - -// โ”€โ”€โ”€ Manager Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId(`app_cfg_manager_role_modal_${guildId}`) - .setTitle('๐Ÿ›ก๏ธ Configure Manager Roles'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('manager_roles') - .setPlaceholder('Select roles to grant manager access...') - .setMinValues(1) - .setMaxValues(5) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Manager Roles') - .setDescription('Selected roles will be toggled on/off as manager roles') - .setRoleSelectMenuComponent(roleSelect); - - modal.addLabelComponents(roleLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_manager_role_modal_${guildId}`, - }); - - const selectedRoleIds = modalSubmission.fields.getField('manager_roles').values; - const roleSet = new Set(settings.managerRoles ?? []); - - for (const roleId of selectedRoleIds) { - if (roleSet.has(roleId)) { - roleSet.delete(roleId); - } else { - roleSet.add(roleId); - } - } - - settings.managerRoles = Array.from(roleSet); - await saveApplicationSettings(client, guildId, settings); - - const finalList = settings.managerRoles.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') - : '`None`'; - - await modalSubmission.reply({ - embeds: [successEmbed('โœ… Manager Roles Updated', `Current manager roles: ${finalList}`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in manager role modal:', error); - await selectInteraction.followUp({ - embeds: [errorEmbed('An error occurred while updating manager roles.')], - flags: MessageFlags.Ephemeral, - }); - } -} - -// โ”€โ”€โ”€ Edit Questions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleQuestions(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { - let currentQuestions = settings.questions ?? []; - - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - currentQuestions = roleSettings.questions ?? currentQuestions; - } - - const modal = new ModalBuilder() - .setCustomId('app_cfg_questions') - .setTitle('Edit Application Questions') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q1') - .setLabel('Question 1 (required)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[0] ?? '') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q2') - .setLabel('Question 2 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[1] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q3') - .setLabel('Question 3 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[2] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q4') - .setLabel('Question 4 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[3] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('q5') - .setLabel('Question 5 (optional)') - .setStyle(TextInputStyle.Short) - .setValue(currentQuestions[4] ?? '') - .setMaxLength(100) - .setRequired(false), - ), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'app_cfg_questions' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'] - .map(key => submitted.fields.getTextInputValue(key).trim()) - .filter(Boolean); - - if (newQuestions.length === 0) { - await submitted.reply({ - embeds: [errorEmbed('No Questions', 'At least one question is required.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (selectedRoleId) { - // Save per-application questions - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.questions = newQuestions; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - // Save global questions - settings.questions = newQuestions; - await saveApplicationSettings(client, guildId, settings); - } - - await submitted.reply({ - embeds: [ - successEmbed( - 'โœ… Questions Updated', - `${newQuestions.length} question${newQuestions.length !== 1 ? 's' : ''} saved.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); -} - -// โ”€โ”€โ”€ Add Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId(`app_cfg_role_add_modal_${guildId}`) - .setTitle('โž• Add Application Role'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('application_role') - .setPlaceholder('Select the role members can apply for...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('Select the Discord role members will be applying for') - .setRoleSelectMenuComponent(roleSelect); - - const nameInput = new TextInputBuilder() - .setCustomId('role_name') - .setLabel('Display name (leave blank to use role name)') - .setStyle(TextInputStyle.Short) - .setMaxLength(50) - .setRequired(false); - - modal.addLabelComponents(roleLabel); - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_add_modal_${guildId}`, - }); - - const roleId = modalSubmission.fields.getField('application_role').values[0]; - const role = selectInteraction.guild.roles.cache.get(roleId); - const customName = modalSubmission.fields.getTextInputValue('role_name').trim() || role?.name || roleId; - - if (roles.some(r => r.roleId === roleId)) { - await modalSubmission.reply({ - embeds: [errorEmbed('Already Added', `${role ?? roleId} is already an application role.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - roles.push({ roleId, name: customName }); - await saveApplicationRoles(client, guildId, roles); - - await modalSubmission.reply({ - embeds: [successEmbed('โœ… Role Added', `${role ?? roleId} added as **${customName}**.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in role add modal:', error); - await selectInteraction.followUp({ - embeds: [errorEmbed('An error occurred while adding the application role.')], - flags: MessageFlags.Ephemeral, - }); - } -} - -// โ”€โ”€โ”€ Remove Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { - if (roles.length === 0) { - await selectInteraction.followUp({ - embeds: [errorEmbed('No Roles', 'There are no application roles configured to remove.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const modal = new ModalBuilder() - .setCustomId(`app_cfg_role_remove_modal_${guildId}`) - .setTitle('โž– Remove Application Role'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('remove_role') - .setPlaceholder('Select the role to remove...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Remove Application Role') - .setDescription('Select the role to remove from the applications list') - .setRoleSelectMenuComponent(roleSelect); - - modal.addLabelComponents(roleLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_remove_modal_${guildId}`, - }); - - const roleId = modalSubmission.fields.getField('remove_role').values[0]; - const index = roles.findIndex(r => r.roleId === roleId); - - if (index === -1) { - await modalSubmission.reply({ - embeds: [errorEmbed('Not Found', `<@&${roleId}> is not in the application roles list.`)], - flags: MessageFlags.Ephemeral, - }); - return; - } - - roles.splice(index, 1); - await saveApplicationRoles(client, guildId, roles); - - await modalSubmission.reply({ - embeds: [successEmbed('โœ… Role Removed', `<@&${roleId}> has been removed from the application roles.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in role remove modal:', error); - await selectInteraction.followUp({ - embeds: [errorEmbed('An error occurred while removing the application role.')], - flags: MessageFlags.Ephemeral, - }); - } -} - -// โ”€โ”€โ”€ Retention Period โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('app_cfg_retention') - .setTitle('Application Retention Periods'); - - const retentionInfo = new TextDisplayBuilder() - .setContent( - '**Pending** โ€” how long unanswered/in-progress applications are kept before being automatically removed.\n' + - '**Reviewed** โ€” how long approved or denied applications are kept.\n' + - '-# Enter a whole number between 1 and 3650 (max 10 years).', - ); - - const pendingLabel = new LabelBuilder() - .setLabel('Pending retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('pending_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - const reviewedLabel = new LabelBuilder() - .setLabel('Reviewed retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('reviewed_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - modal - .addTextDisplayComponents(retentionInfo) - .addLabelComponents(pendingLabel, reviewedLabel); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => - i.customId === 'app_cfg_retention' && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const pendingDays = parseInt(submitted.fields.getTextInputValue('pending_days').trim(), 10); - const reviewedDays = parseInt(submitted.fields.getTextInputValue('reviewed_days').trim(), 10); - - if (isNaN(pendingDays) || pendingDays < 1 || pendingDays > 3650) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Value', 'Pending retention must be a whole number between **1** and **3650** days.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { - await submitted.reply({ - embeds: [errorEmbed('Invalid Value', 'Reviewed retention must be a whole number between **1** and **3650** days.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - settings.pendingApplicationRetentionDays = pendingDays; - settings.reviewedApplicationRetentionDays = reviewedDays; - await saveApplicationSettings(client, guildId, settings); - - await submitted.reply({ - embeds: [ - successEmbed( - 'โœ… Retention Updated', - `Pending applications will be kept for **${pendingDays} days**.\nReviewed applications will be kept for **${reviewedDays} days**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId); -} - -// โ”€โ”€โ”€ Delete Application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { - try { - // Find the application in the roles array - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await confirmSubmit.reply({ - embeds: [errorEmbed('Not Found', 'Application role not found.')], - flags: MessageFlags.Ephemeral, - }); - return; - } - - const deletedRole = roles[roleIndex]; - - // Remove from roles array - roles.splice(roleIndex, 1); - - // Save updated roles list - await saveApplicationRoles(client, guildId, roles); - - // Delete per-application settings - await deleteApplicationRoleSettings(client, guildId, selectedRoleId); - - // Get all applications for this guild and find ones with this roleId - const allApplications = await getApplications(client, guildId); - const applicationsToDelete = allApplications.filter(app => app.roleId === selectedRoleId); - - // Delete each application - for (const app of applicationsToDelete) { - await deleteApplication(client, guildId, app.id, app.userId); - } - - // Send success message - await confirmSubmit.reply({ - embeds: [ - successEmbed( - '๐Ÿ—‘๏ธ Application Deleted', - `The application for <@&${selectedRoleId}> (**${deletedRole.name}**) has been permanently deleted.\n\n` + - `Deleted: **${applicationsToDelete.length}** application${applicationsToDelete.length !== 1 ? 's' : ''}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - } catch (error) { - logger.error('Error in handleDeleteApplication:', error); - await confirmSubmit.reply({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the application. Please try again.')], - flags: MessageFlags.Ephemeral, - }); - } -} diff --git a/src/commands/Core/bug.js b/src/commands/Core/bug.js index 9703be0ee..10214bca9 100644 --- a/src/commands/Core/bug.js +++ b/src/commands/Core/bug.js @@ -9,9 +9,9 @@ export default { async execute(interaction) { const githubButton = new ButtonBuilder() - .setLabel('?? Report Bug on GitHub') + .setLabel('?? Report Bug to Developers') .setStyle(ButtonStyle.Link) - .setURL('https://github.com/codebymitch/TitanBot/issues'); + .setURL('https://discord.gg/Z8CKzNdGCA'); const row = new ActionRowBuilder().addComponents(githubButton); @@ -19,10 +19,10 @@ export default { title: '?? Bug Report', description: 'Found a bug? Please report it on our GitHub Issues page!\n\n' + '**When reporting a bug, please include:**\n' + - '• ?? Detailed description of the issue\n' + - '• ?? Steps to reproduce the problem\n' + - '• ?? Screenshots if applicable\n' + - '• ?? Your bot version and environment\n\n' + + 'โ€ข ?? Detailed description of the issue\n' + + 'โ€ข ?? Steps to reproduce the problem\n' + + 'โ€ข ?? Screenshots if applicable\n' + + 'โ€ข ?? Your bot version and environment\n\n' + 'This helps us fix issues faster and more effectively!', color: 'error' }) diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 4ad58be10..bebcbcc9f 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -33,7 +33,6 @@ const CATEGORY_ICONS = { Giveaway: "๐ŸŽ‰", Counter: "๐Ÿ”ข", Tools: "๐Ÿ› ๏ธ", - Search: "๐Ÿ”", Reaction_Roles: "๐ŸŽญ", Community: "๐Ÿ‘ฅ", Birthday: "๐ŸŽ‚", @@ -122,7 +121,7 @@ export async function createInitialHelpMenu(client) { }, { name: "๐Ÿ‘ฅ **Community**", - value: "Community tools, applications, and member engagement", + value: "Community tools and member engagement", inline: true }, { @@ -158,7 +157,7 @@ export async function createInitialHelpMenu(client) { ); embed.setFooter({ - text: "Made with โค๏ธ" + text: "Made with Community Bot Dashboard!" }); embed.setTimestamp(); @@ -169,12 +168,12 @@ export async function createInitialHelpMenu(client) { const supportButton = new ButtonBuilder() .setLabel("Support Server") - .setURL("https://discord.gg/QnWNz2dKCE") + .setURL("https://discord.gg/Z8CKzNdGCA") .setStyle(ButtonStyle.Link); const touchpointButton = new ButtonBuilder() - .setLabel("Learn from Touchpoint") - .setURL("https://www.youtube.com/@TouchDisc") + .setLabel("Learn Hosting a bot") + .setURL("https://discord.gg/Z8CKzNdGCA") .setStyle(ButtonStyle.Link); const selectRow = createSelectMenu( diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js index aa392e3a6..0f4cd0114 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.js @@ -3,7 +3,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -const SUPPORT_SERVER_URL = "https://discord.gg/QnWNz2dKCE"; +const SUPPORT_SERVER_URL = "https://discord.gg/Z8CKzNdGCA"; export default { data: new SlashCommandBuilder() .setName("support") @@ -20,7 +20,7 @@ export default { await InteractionHelper.safeReply(interaction, { embeds: [ - createEmbed({ title: "๐Ÿš‘ Need Help?", description: "Join our official support server for assistance, report bugs, or suggest features. If you are customizing this bot, remember to change the link in the code!" }), + createEmbed({ title: "Need Support?", description: "Join our official support server for assistance, report bugs, or suggest features. If you are customizing this bot, remember to change to support us!" }), ], components: [actionRow], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Moderation/antiraid.js b/src/commands/Moderation/antiraid.js new file mode 100644 index 000000000..244a7e0f7 --- /dev/null +++ b/src/commands/Moderation/antiraid.js @@ -0,0 +1,400 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + EmbedBuilder, +} from 'discord.js'; +import { createEmbed, successEmbed, errorEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { logger } from '../../utils/logger.js'; +import { getColor } from '../../config/bot.js'; +import { + setAntiRaidEnabled, + updateAntiRaidConfig, + addToWhitelist, + removeFromWhitelist, + getAntiRaidConfig, + getLiveStatus, +} from '../../services/antiRaid.js'; + +export default { + data: new SlashCommandBuilder() + .setName('antiraid') + .setDescription('Manage the anti-raid protection system.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + + // โ”€โ”€ /antiraid enable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + .addSubcommand((sub) => + sub + .setName('enable') + .setDescription('Enable the anti-raid protection system.') + ) + + // โ”€โ”€ /antiraid disable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + .addSubcommand((sub) => + sub + .setName('disable') + .setDescription('Disable the anti-raid protection system.') + ) + + // โ”€โ”€ /antiraid status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + .addSubcommand((sub) => + sub + .setName('status') + .setDescription('Show the current anti-raid configuration and live status.') + ) + + // โ”€โ”€ /antiraid config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + .addSubcommand((sub) => + sub + .setName('config') + .setDescription('Configure anti-raid thresholds, actions, and log channel.') + .addIntegerOption((opt) => + opt + .setName('threshold') + .setDescription('Number of joins that trigger a raid (default: 5).') + .setMinValue(2) + .setMaxValue(50) + .setRequired(false) + ) + .addIntegerOption((opt) => + opt + .setName('time_window') + .setDescription('Time window in seconds to count joins (default: 10).') + .setMinValue(3) + .setMaxValue(120) + .setRequired(false) + ) + .addStringOption((opt) => + opt + .setName('action') + .setDescription('Action to take when a raid is detected (default: alert).') + .setRequired(false) + .addChoices( + { name: '๐Ÿ”” Alert only (no action on members)', value: 'alert' }, + { name: '๐Ÿ‘ข Kick raiders', value: 'kick' }, + { name: '๐Ÿ”จ Ban raiders', value: 'ban' }, + { name: '๐Ÿ”‡ Mute raiders (10 min timeout)', value: 'mute' }, + ) + ) + .addChannelOption((opt) => + opt + .setName('log_channel') + .setDescription('Channel to send raid alerts to.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false) + ) + ) + + // โ”€โ”€ /antiraid whitelist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + .addSubcommandGroup((group) => + group + .setName('whitelist') + .setDescription('Manage users exempt from anti-raid checks.') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a user to the anti-raid whitelist.') + .addUserOption((opt) => + opt + .setName('user') + .setDescription('The user to whitelist.') + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a user from the anti-raid whitelist.') + .addUserOption((opt) => + opt + .setName('user') + .setDescription('The user to remove from the whitelist.') + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List all whitelisted users.') + ) + ), + + category: 'moderation', + + async execute(interaction, config, client) { + try { + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(); + + // Defer for all subcommands + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + // โ”€โ”€ Whitelist group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (subcommandGroup === 'whitelist') { + return await handleWhitelist(interaction, subcommand, client); + } + + // โ”€โ”€ Top-level subcommands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + switch (subcommand) { + case 'enable': return await handleEnable(interaction, client); + case 'disable': return await handleDisable(interaction, client); + case 'config': return await handleConfig(interaction, client); + case 'status': return await handleStatus(interaction, client); + default: + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Unknown subcommand.')], + }); + } + } catch (error) { + logger.error('antiraid command error:', error); + await handleInteractionError(interaction, error, { command: 'antiraid' }); + } + }, +}; + +// โ”€โ”€โ”€ Subcommand handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleEnable(interaction, client) { + const { guildId } = interaction; + await setAntiRaidEnabled(client, guildId, true); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + 'The anti-raid system is now **enabled**. Members will be monitored for suspicious join patterns.', + '๐Ÿ›ก๏ธ Anti-Raid Enabled' + ), + ], + }); +} + +async function handleDisable(interaction, client) { + const { guildId } = interaction; + await setAntiRaidEnabled(client, guildId, false); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + warningEmbed( + 'The anti-raid system has been **disabled**. No automatic protection is active.', + '๐Ÿ›ก๏ธ Anti-Raid Disabled' + ), + ], + }); +} + +async function handleConfig(interaction, client) { + const { guildId } = interaction; + + const threshold = interaction.options.getInteger('threshold'); + const timeWindow = interaction.options.getInteger('time_window'); + const action = interaction.options.getString('action'); + const logChannel = interaction.options.getChannel('log_channel'); + + // Build only the keys the user actually provided + const updates = {}; + if (threshold !== null) updates.antiRaidThreshold = threshold; + if (timeWindow !== null) updates.antiRaidTimeWindow = timeWindow; + if (action !== null) updates.antiRaidAction = action; + if (logChannel !== null) updates.antiRaidLogChannel = logChannel.id; + + if (Object.keys(updates).length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + infoEmbed( + 'No changes were provided. Use the options to update the configuration.', + 'โš™๏ธ No Changes' + ), + ], + }); + } + + await updateAntiRaidConfig(client, guildId, updates); + + // Build a summary of what changed + const actionLabels = { + kick: '๐Ÿ‘ข Kick raiders', + ban: '๐Ÿ”จ Ban raiders', + mute: '๐Ÿ”‡ Mute raiders (10 min)', + alert: '๐Ÿ”” Alert only', + }; + + const lines = []; + if (threshold !== null) lines.push(`**Threshold:** ${threshold} joins`); + if (timeWindow !== null) lines.push(`**Time window:** ${timeWindow} seconds`); + if (action !== null) lines.push(`**Action:** ${actionLabels[action] ?? action}`); + if (logChannel !== null) lines.push(`**Log channel:** ${logChannel}`); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + lines.join('\n'), + 'โš™๏ธ Anti-Raid Configuration Updated' + ), + ], + }); +} + +async function handleStatus(interaction, client) { + const { guildId, guild } = interaction; + + const cfg = await getAntiRaidConfig(client, guildId); + const live = getLiveStatus(guildId); + + const actionLabels = { + kick: '๐Ÿ‘ข Kick raiders', + ban: '๐Ÿ”จ Ban raiders', + mute: '๐Ÿ”‡ Mute raiders (10 min)', + alert: '๐Ÿ”” Alert only', + }; + + const statusEmoji = cfg.enabled ? '๐ŸŸข' : '๐Ÿ”ด'; + const raidEmoji = live.raidActive ? '๐Ÿšจ' : 'โœ…'; + + const embed = new EmbedBuilder() + .setColor(cfg.enabled ? getColor('success') : getColor('error')) + .setTitle('๐Ÿ›ก๏ธ Anti-Raid Status') + .addFields( + { + name: 'System Status', + value: `${statusEmoji} ${cfg.enabled ? 'Enabled' : 'Disabled'}`, + inline: true, + }, + { + name: 'Raid Active?', + value: `${raidEmoji} ${live.raidActive ? 'YES โ€” cooldown active' : 'No'}`, + inline: true, + }, + { + name: 'Recent Joins (tracked)', + value: `${live.recentJoins}`, + inline: true, + }, + { + name: 'Threshold', + value: `${cfg.threshold} joins`, + inline: true, + }, + { + name: 'Time Window', + value: `${cfg.timeWindow} seconds`, + inline: true, + }, + { + name: 'Action', + value: actionLabels[cfg.action] ?? cfg.action, + inline: true, + }, + { + name: 'Log Channel', + value: cfg.logChannelId ? `<#${cfg.logChannelId}>` : 'Not set', + inline: true, + }, + { + name: 'Whitelisted Users', + value: cfg.whitelist.length > 0 + ? `${cfg.whitelist.length} user(s) โ€” use \`/antiraid whitelist list\` to view` + : 'None', + inline: true, + }, + ) + .setTimestamp() + .setFooter({ text: guild.name, iconURL: guild.iconURL() ?? undefined }); + + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); +} + +async function handleWhitelist(interaction, subcommand, client) { + const { guildId } = interaction; + + switch (subcommand) { + case 'add': { + const user = interaction.options.getUser('user'); + const added = await addToWhitelist(client, guildId, user.id); + + if (!added) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + warningEmbed( + `${user} is already on the anti-raid whitelist.`, + 'โš ๏ธ Already Whitelisted' + ), + ], + }); + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `${user} (\`${user.tag}\`) has been added to the anti-raid whitelist and will not be affected by automatic actions.`, + 'โœ… User Whitelisted' + ), + ], + }); + } + + case 'remove': { + const user = interaction.options.getUser('user'); + const removed = await removeFromWhitelist(client, guildId, user.id); + + if (!removed) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + warningEmbed( + `${user} is not on the anti-raid whitelist.`, + 'โš ๏ธ Not Whitelisted' + ), + ], + }); + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `${user} (\`${user.tag}\`) has been removed from the anti-raid whitelist.`, + 'โœ… User Removed from Whitelist' + ), + ], + }); + } + + case 'list': { + const cfg = await getAntiRaidConfig(client, guildId); + + if (cfg.whitelist.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + infoEmbed( + 'No users are currently whitelisted.', + '๐Ÿ“‹ Anti-Raid Whitelist' + ), + ], + }); + } + + const userLines = cfg.whitelist + .slice(0, 50) + .map((id, i) => `${i + 1}. <@${id}> (\`${id}\`)`) + .join('\n'); + + const embed = createEmbed({ + title: '๐Ÿ“‹ Anti-Raid Whitelist', + description: userLines, + color: 'info', + footer: cfg.whitelist.length > 50 + ? `Showing 50 of ${cfg.whitelist.length} whitelisted users` + : `${cfg.whitelist.length} whitelisted user(s)`, + }); + + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } + + default: + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Unknown whitelist subcommand.')], + }); + } +} diff --git a/src/commands/Moderation/test.js b/src/commands/Moderation/test.js new file mode 100644 index 000000000..91d6d5c3d --- /dev/null +++ b/src/commands/Moderation/test.js @@ -0,0 +1,11 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; + +export default { + data: new SlashCommandBuilder() + .setName('test') + .setDescription('A simple test command to verify the bot works'), + + async execute(interaction) { + await interaction.reply('Test command works!'); + }, +}; diff --git a/src/commands/Roblox/robloxConfig.js b/src/commands/Roblox/robloxConfig.js new file mode 100644 index 000000000..565ec9704 --- /dev/null +++ b/src/commands/Roblox/robloxConfig.js @@ -0,0 +1,95 @@ +import { SlashCommandBuilder, ChannelType, PermissionFlagsBits } from 'discord.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName('robloxconfig') + .setDescription('Configure Roblox join request notification channels') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('setchannel') + .setDescription('Set the Discord channel for Roblox join request notifications') + .addStringOption(option => + option + .setName('department') + .setDescription('Which department to configure') + .setRequired(true) + .addChoices( + { name: 'Test Group', value: 'TEST' }, + { name: 'LASD', value: 'LASD' }, + { name: 'CHP', value: 'CHP' }, + { name: 'LAFD', value: 'LAFD' } + ) + ) + .addChannelOption(option => + option + .setName('channel') + .setDescription('The Discord channel to send notifications to') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('View current Roblox configuration') + ), + + async execute(interaction) { + try { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'setchannel') { + const department = interaction.options.getString('department'); + const channel = interaction.options.getChannel('channel'); + + const envVarMap = { + TEST: 'ROBLOX_REQUESTS_CHANNEL_TEST', + LASD: 'ROBLOX_REQUESTS_CHANNEL_LASD', + CHP: 'ROBLOX_REQUESTS_CHANNEL_CHP', + LAFD: 'ROBLOX_REQUESTS_CHANNEL_LAFD' + }; + + const envVar = envVarMap[department]; + + // Note: In a real implementation, you'd save this to a database + // For now, we'll just show the user what they need to set + await interaction.reply({ + content: `โœ… To configure the ${department} join request channel, set the following environment variable in Railway:\n\n\`\`\`\n${envVar}=${channel.id}\n\`\`\`\n\nChannel: ${channel.toString()}`, + ephemeral: true + }); + + logger.info(`User ${interaction.user.tag} configured ${department} channel to ${channel.id}`); + } else if (subcommand === 'status') { + const configs = [ + { name: 'Test Group', envVar: 'ROBLOX_REQUESTS_CHANNEL_TEST' }, + { name: 'LASD', envVar: 'ROBLOX_REQUESTS_CHANNEL_LASD' }, + { name: 'CHP', envVar: 'ROBLOX_REQUESTS_CHANNEL_CHP' }, + { name: 'LAFD', envVar: 'ROBLOX_REQUESTS_CHANNEL_LAFD' } + ]; + + const statusLines = configs.map(config => { + const channelId = process.env[config.envVar]; + const status = channelId ? `โœ… <#${channelId}>` : 'โŒ Not configured'; + return `**${config.name}**: ${status}`; + }); + + await interaction.reply({ + embeds: [{ + title: '๐ŸŽฎ Roblox Configuration Status', + description: statusLines.join('\n'), + color: 0x1a1a1a, + footer: { text: 'Use /robloxconfig setchannel to configure channels' } + }], + ephemeral: true + }); + } + } catch (error) { + logger.error('Error in robloxconfig command:', error); + await handleInteractionError(interaction, error); + } + } +}; + diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js deleted file mode 100644 index 97dd195da..000000000 --- a/src/commands/Search/define.js +++ /dev/null @@ -1,114 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('define') - .setDescription('Look up a word definition') - .addStringOption(option => - option.setName('word') - .setDescription('The word to look up') - .setRequired(true)), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const word = interaction.options.getString('word'); - - if (word.length < 2) { - logger.warn('Define command - word too short', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Error', 'Please enter a word with at least 2 characters.')], - flags: MessageFlags.Ephemeral - }); - } - - const response = await axios.get( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, - { timeout: 5000 } - ); - - if (!response.data || response.data.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${word}".`)] - }); - } - - const data = response.data[0]; - const embed = createEmbed({ - title: data.word, - description: data.phonetic ? `*${data.phonetic}*` : '', - color: 'success' - }); - - data.meanings.slice(0, 5).forEach(meaning => { - const definitions = meaning.definitions - .slice(0, 3) - .map((def, idx) => { - let text = `${idx + 1}. ${def.definition}`; - if (def.example) { - text += `\n *Example: ${def.example}*`; - } - return text; - }) - .join('\n\n'); - - if (definitions) { - embed.addFields({ - name: `**${meaning.partOfSpeech || 'Definition'}**`, - value: definitions, - inline: false - }); - } - }); - - embed.setFooter({ text: 'Powered by Free Dictionary API' }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Dictionary definition retrieved', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId, - commandName: 'define' - }); - - } catch (error) { - logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - word: interaction.options.getString('word'), - guildId: interaction.guildId, - commandName: 'define' - }); - - - if (error.response?.status === 404) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('word')}".`)] - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'define', - source: 'dictionary_api' - }); - } - } - }, -}; - - diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js deleted file mode 100644 index 81844faca..000000000 --- a/src/commands/Search/google.js +++ /dev/null @@ -1,52 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('google') - .setDescription('Search Google') - .addStringOption(option => - option.setName('query') - .setDescription('What would you like to search for?') - .setRequired(true)), - async execute(interaction) { - try { - const query = interaction.options.getString('query'); - const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - - const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, - color: 'info' - }) - .setFooter({ text: 'Google Search Results' }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Google search link generated', { - userId: interaction.user.id, - query: query, - guildId: interaction.guildId, - commandName: 'google' - }); - } catch (error) { - logger.error('Error in google command', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'google' - }); - await handleInteractionError(interaction, error, { - commandName: 'google', - source: 'google_search' - }); - } - }, -}; - - diff --git a/src/commands/Search/movie.js b/src/commands/Search/movie.js deleted file mode 100644 index a4e72ebc0..000000000 --- a/src/commands/Search/movie.js +++ /dev/null @@ -1,261 +0,0 @@ -import axios from 'axios'; -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { getColor } from '../../config/bot.js'; - -const TMDB_API_KEY = process.env.TMDB_API_KEY || '4e44d9029b1270a757cddc766a1bcb63'; - "4e44d9029b1270a757cddc766a1bcb63"; -const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; -const MAX_RESULTS = 5; - -export default { - data: new SlashCommandBuilder() - .setName("movie") - .setDescription("Search for a movie or TV show") - .addStringOption((option) => - option - .setName("title") - .setDescription("The title of the movie or TV show") - .setRequired(true) - .setMaxLength(100), - ) - .addStringOption((option) => - option - .setName("type") - .setDescription("Type of content to search for") - .addChoices( - { name: "Movie", value: "movie" }, - { name: "TV Show", value: "tv" }, - ) - .setRequired(false), - ), - async execute(interaction) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const guildConfig = await getGuildConfig( - interaction.client, - interaction.guild?.id, - ); - if (guildConfig?.disabledCommands?.includes("movie")) { - logger.warn('Movie command disabled in guild', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'movie' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Command Disabled", - "The movie/TV show search command is disabled in this server.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - if (!TMDB_API_KEY) { - logger.error('TMDB API key not configured', { - guildId: interaction.guildId, - commandName: 'movie' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Movie/TV show search is not properly configured.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const title = interaction.options.getString("title"); - const type = interaction.options.getString("type") || "movie"; - - logger.debug('Movie search initiated', { - userId: interaction.user.id, - title: title, - type: type, - guildId: interaction.guildId - }); - - const searchResponse = await axios.get( - `https://api.themoviedb.org/3/search/${type}`, - { - params: { - api_key: TMDB_API_KEY, - query: title, - include_adult: guildConfig?.allowNsfwContent - ? undefined - : false, - language: guildConfig?.language || "en-US", - page: 1, - region: guildConfig?.region || "US", - }, -timeout: 8000, - }, - ); - - if (!searchResponse.data?.results?.length) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not Found", - `No ${type === "movie" ? "movies" : "TV shows"} found for "${title}".`, - ), - ], - }); - } - - const result = searchResponse.data.results[0]; - const mediaType = type === "movie" ? "Movie" : "TV Show"; - const mediaTitle = result.title || result.name || "Unknown Title"; - const releaseDate = result.release_date || result.first_air_date; - const year = releaseDate - ? new Date(releaseDate).getFullYear() - : "N/A"; - - const detailsResponse = await axios.get( - `https://api.themoviedb.org/3/${type}/${result.id}`, - { - params: { - api_key: TMDB_API_KEY, - language: guildConfig?.language || "en-US", - append_to_response: - "credits,release_dates,content_ratings", - }, - timeout: 8000, - }, - ); - - const details = detailsResponse.data; - const runtime = details.runtime - ? `${Math.floor(details.runtime / 60)}h ${details.runtime % 60}m` - : details.episode_run_time?.[0] - ? `${details.episode_run_time[0]}m per episode` - : "N/A"; - - let contentRating = "N/A"; - if (type === "movie") { - const usCert = details.release_dates?.results?.find( - (r) => r.iso_3166_1 === "US", - ); - if (usCert?.release_dates?.[0]?.certification) { - contentRating = usCert.release_dates[0].certification; - } - } else { - const usCert = details.content_ratings?.results?.find( - (r) => r.iso_3166_1 === "US", - ); - if (usCert?.rating) { - contentRating = usCert.rating; - } - } - - const genres = - details.genres?.map((g) => g.name).join(", ") || "N/A"; - - const cast = - details.credits?.cast - ?.slice(0, 3) - .map((p) => p.name) - .join(", ") || "N/A"; - - const embed = createEmbed({ - title: `${mediaTitle} (${year})`, - description: details.overview || "No overview available.", - color: 'info' - }) - .setURL(`https://www.themoviedb.org/${type}/${result.id}`) - .setThumbnail( - result.poster_path - ? `${IMAGE_BASE_URL}${result.poster_path}` - : null, - ) - .addFields( - { name: "Type", value: mediaType, inline: true }, - { - name: "Rating", - value: result.vote_average - ? `โญ ${result.vote_average.toFixed(1)}/10 (${result.vote_count.toLocaleString()} votes)` - : "N/A", - inline: true, - }, - { - name: "Content Rating", - value: contentRating, - inline: true, - }, - { name: "Runtime", value: runtime, inline: true }, - { - name: "Release Date", - value: releaseDate - ? new Date(releaseDate).toLocaleDateString() - : "N/A", - inline: true, - }, - { name: "Genres", value: genres, inline: true }, - { name: "Cast", value: cast, inline: false }, - ) - .setFooter({ - text: "Powered by The Movie Database", - iconURL: - "https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg", - }); - - if (result.backdrop_path) { - embed.setImage( - `https://image.tmdb.org/t/p/w1280${result.backdrop_path}`, - ); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Movie information retrieved', { - userId: interaction.user.id, - title: title, - type: type, - resultTitle: mediaTitle, - guildId: interaction.guildId, - commandName: 'movie' - }); - } catch (error) { - logger.error('Movie/TV show search error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'movie' - }); - - - if (error.response?.status === 404) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', 'The requested movie/TV show could not be found.')] - }); - } else if (error.response?.status === 401) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Configuration Error', 'Invalid TMDB API key. Please contact the bot administrator.')], - flags: MessageFlags.Ephemeral - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'movie', - source: 'tmdb_api' - }); - } - } - }, -}; - - diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js deleted file mode 100644 index e2020025b..000000000 --- a/src/commands/Search/urban.js +++ /dev/null @@ -1,159 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import axios from 'axios'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; -import { getColor } from '../../config/bot.js'; - -export default { - data: new SlashCommandBuilder() - .setName('urban') - .setDescription('Search Urban Dictionary for definitions') - .addStringOption(option => - option.setName('term') - .setDescription('The term to look up on Urban Dictionary') - .setRequired(true)), - - async execute(interaction) { - try { - const term = interaction.options.getString('term'); - - if (term.length < 2) { - logger.warn('Urban command - term too short', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId - }); - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Error', 'Please enter a term with at least 2 characters.')], - flags: MessageFlags.Ephemeral - }); - } - - const guildConfig = await getGuildConfig(interaction.client, interaction.guild?.id); - if (guildConfig?.disabledCommands?.includes('urban')) { - logger.warn('Urban command disabled in guild', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'urban' - }); - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Command Disabled', 'The Urban Dictionary command is disabled in this server.')], - flags: MessageFlags.Ephemeral - }); - } - - let deferTimer = null; - const clearDeferTimer = () => { - if (deferTimer) { - clearTimeout(deferTimer); - deferTimer = null; - } - }; - - deferTimer = setTimeout(() => { - InteractionHelper.safeDefer(interaction).catch((deferError) => { - logger.debug('Urban command defer fallback failed', { - error: deferError?.message, - interactionId: interaction.id, - commandName: 'urban' - }); - }); - }, 1500); - - const response = await axios.get( - `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, - { timeout: 5000 } - ); - clearDeferTimer(); - - if (!response.data?.list?.length) { - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${term}" on Urban Dictionary.`)] - }); - } - - const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); - - const formattedDefinition = cleanDefinition -.replace(/\n\s*\n/g, '\n\n') - .slice(0, 2000); - - const formattedExample = cleanExample - ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; - - const embed = createEmbed({ - title: definition.word, - description: formattedDefinition, - color: 'info' - }) - .setURL(definition.permalink) - .addFields( - { - name: 'Example', - value: formattedExample, - inline: false - }, - { - name: 'Stats', - value: `๐Ÿ‘ ${definition.thumbs_up.toLocaleString()} โ€ข ๐Ÿ‘Ž ${definition.thumbs_down.toLocaleString()}`, - inline: true - }, - { - name: 'Author', - value: definition.author || 'Anonymous', - inline: true - } - ) - .setFooter({ - text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' - }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Urban Dictionary definition retrieved', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId, - commandName: 'urban' - }); - - } catch (error) { - logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - term: interaction.options.getString('term'), - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'urban' - }); - - - if (error.response?.status === 404 || !error.response) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('term')}" on Urban Dictionary.`)] - }); - } else if (error.response?.status === 429) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Rate Limited', 'Too many requests to Urban Dictionary. Please try again in a few minutes.')] - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'urban', - source: 'urban_dictionary_api' - }); - } - } - }, -}; - - - - diff --git a/src/commands/Staff/infraction.js b/src/commands/Staff/infraction.js new file mode 100644 index 000000000..c4c246786 --- /dev/null +++ b/src/commands/Staff/infraction.js @@ -0,0 +1,123 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('infraction') + .setDescription('Issue a staff infraction notice') + .addUserOption(option => + option + .setName('supervisor') + .setDescription('The supervisor issuing the infraction') + .setRequired(true) + ) + .addUserOption(option => + option + .setName('staff_member') + .setDescription('The staff member receiving the infraction') + .setRequired(true) + ) + .addRoleOption(option => + option + .setName('infraction_role') + .setDescription('The type of infraction') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('Reason for the infraction') + .setRequired(true) + ), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Infraction interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'infraction' + }); + return; + } + + try { + const supervisor = interaction.options.getUser('supervisor'); + const staffMember = interaction.options.getUser('staff_member'); + const infractionRole = interaction.options.getRole('infraction_role'); + const reason = interaction.options.getString('reason'); + + const now = new Date(); + const issuedAt = now.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }).replace(',', '').replace(/(\d{4}),/, '$1 at'); + + const description = [ + `### ใ€” โš ๏ธ ใ€• Staff Infraction Notice`, + ``, + `The following staff member has received a formal infraction. This notice has been issued by the Califirnia State Roleplay Staff Division and is recorded on their personnel file.`, + ``, + `**โ€” Infraction Details โ€”**`, + ``, + `> <:arrow:1516172552592949350> **Staff Member** ใƒป ${staffMember}`, + `> <:arrow:1516172552592949350> **Infraction** ใƒป ${infractionRole}`, + `> <:arrow:1516172552592949350> **Reason** ใƒป ${reason}`, + `> <:arrow:1516172552592949350> **Issued By** ใƒป ${supervisor}`, + `> <:arrow:1516172552592949350> **Issued At** ใƒป ${issuedAt}`, + ``, + `-# Find this infraction invalid? Please contact us in Internal Affairs Support.`, + ].join('\n'); + + const embed = createEmbed({ + description, + color: 0x1a1a1a, + footer: 'โญ Califirnia State Roleplay โ€ข Staff Division', + image: 'https://cdn.discordapp.com/attachments/1493023004802679007/1516162356617547937/Copy_of_Copy_of_Free_Release_Banner_1.png?ex=6a31a3ba&is=6a30523a&hm=57a86c8192b237d4bd4a4492d752c96fc555d3b7f15e46dbfcb2e72e14a1593d', + timestamp: true + }); + + await interaction.channel.send({ embeds: [embed] }); + + // Assign the infraction role to the staff member + try { + const staffMemberGuildMember = await interaction.guild.members.fetch(staffMember.id); + await staffMemberGuildMember.roles.add(infractionRole); + logger.info('Infraction role assigned to staff member', { + staffMemberId: staffMember.id, + staffMemberTag: staffMember.tag, + roleId: infractionRole.id, + roleName: infractionRole.name, + issuedBy: interaction.user.id, + guildId: interaction.guildId + }); + } catch (roleError) { + logger.error('Failed to assign infraction role to staff member', { + staffMemberId: staffMember.id, + roleId: infractionRole.id, + guildId: interaction.guildId, + error: roleError + }); + await InteractionHelper.safeEditReply(interaction, { + content: 'โš ๏ธ Infraction notice posted, but the infraction role could not be assigned. Please check my permissions and role hierarchy.' + }); + return; + } + + await InteractionHelper.safeEditReply(interaction, { + content: 'โœ… Infraction notice posted and role assigned successfully!' + }); + } catch (error) { + logger.error('Infraction command error:', error); + await handleInteractionError(interaction, error, { subtype: 'infraction_failed' }); + } + } +}; diff --git a/src/commands/Staff/promotion.js b/src/commands/Staff/promotion.js new file mode 100644 index 000000000..028b2433b --- /dev/null +++ b/src/commands/Staff/promotion.js @@ -0,0 +1,139 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('promotion') + .setDescription('Issue a staff promotion') + .addUserOption(option => + option + .setName('supervisor') + .setDescription('The supervisor issuing the promotion') + .setRequired(true) + ) + .addUserOption(option => + option + .setName('staff_member') + .setDescription('The staff member being promoted') + .setRequired(true) + ) + .addRoleOption(option => + option + .setName('old_rank') + .setDescription('Their previous rank') + .setRequired(true) + ) + .addRoleOption(option => + option + .setName('new_rank') + .setDescription('Their new rank') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('Reason for the promotion') + .setRequired(true) + ), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Promotion interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'promotion' + }); + return; + } + + try { + const supervisor = interaction.options.getUser('supervisor'); + const staffMember = interaction.options.getUser('staff_member'); + const oldRank = interaction.options.getRole('old_rank'); + const newRank = interaction.options.getRole('new_rank'); + const reason = interaction.options.getString('reason'); + + const now = new Date(); + const issuedAt = now.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }).replace(',', '').replace(/(\d{4}),/, '$1 at'); + + const description = [ + `### ใ€” ๐ŸŽ–๏ธ ใ€• Congratulations on your Promotion!`, + ``, + `${staffMember} has demonstrated outstanding dedication and performance within the Califirnia State Roleplay Staff Division. It is with great pleasure that we announce the following promotion.`, + ``, + `**โ€” Promotion Details โ€”**`, + ``, + `> <:arrow:1516172552592949350> **Staff Member** ใƒป ${staffMember}`, + `> <:arrow:1516172552592949350> **Previous Rank** ใƒป ${oldRank}`, + `> <:arrow:1516172552592949350> **Promoted To** ใƒป ${newRank}`, + `> <:arrow:1516172552592949350> **Reason** ใƒป ${reason}`, + `> <:arrow:1516172552592949350> **Issued By** ใƒป ${supervisor}`, + `> <:arrow:1516172552592949350> **Issued At** ใƒป ${issuedAt}`, + ].join('\n'); + + const embed = createEmbed({ + description, + color: 0x1a1a1a, + footer: 'โญ Califirnia State Roleplay โ€ข Staff Division', + image: 'https://cdn.discordapp.com/attachments/1493023004802679007/1516161046790930554/Copy_of_Free_Release_Banner_1.png?ex=6a31a282&is=6a305102&hm=2c65693227f03abed5e168f188d8cc0ae0dde1716245cd6e37dc0892866430b7', + timestamp: true + }); + + await interaction.channel.send({ embeds: [embed] }); + + // Fetch the staff member as a GuildMember to perform role operations + const staffMemberGuildMember = await interaction.guild.members.fetch(staffMember.id); + + try { + await staffMemberGuildMember.roles.remove(oldRank, `Promotion: removed old rank by ${interaction.user.tag}`); + logger.info('Promotion: removed old rank role', { + userId: staffMember.id, + roleId: oldRank.id, + roleName: oldRank.name, + issuedBy: interaction.user.id, + guildId: interaction.guildId + }); + + await staffMemberGuildMember.roles.add(newRank, `Promotion: added new rank by ${interaction.user.tag}`); + logger.info('Promotion: added new rank role', { + userId: staffMember.id, + roleId: newRank.id, + roleName: newRank.name, + issuedBy: interaction.user.id, + guildId: interaction.guildId + }); + + await InteractionHelper.safeEditReply(interaction, { + content: `โœ… Promotion notice posted and roles updated โ€” removed **${oldRank.name}** and assigned **${newRank.name}** to ${staffMember}.` + }); + } catch (roleError) { + logger.error('Promotion: failed to update roles', { + userId: staffMember.id, + oldRoleId: oldRank.id, + newRoleId: newRank.id, + error: roleError.message, + guildId: interaction.guildId + }); + + await InteractionHelper.safeEditReply(interaction, { + content: `โš ๏ธ Promotion notice posted, but role update failed: ${roleError.message}. Please update the roles manually.` + }); + } + } catch (error) { + logger.error('Promotion command error:', error); + await handleInteractionError(interaction, error, { subtype: 'promotion_failed' }); + } + } +}; diff --git a/src/commands/Staff/retirement.js b/src/commands/Staff/retirement.js new file mode 100644 index 000000000..9b395f1c6 --- /dev/null +++ b/src/commands/Staff/retirement.js @@ -0,0 +1,96 @@ +import { SlashCommandBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('retirement') + .setDescription('Issue a staff retirement notice') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addUserOption(option => + option + .setName('staff_member') + .setDescription('The staff member retiring') + .setRequired(true) + ) + .addRoleOption(option => + option + .setName('final_rank') + .setDescription('Their final rank/position') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('Reason for retirement') + .setRequired(true) + ) + .addUserOption(option => + option + .setName('supervisor') + .setDescription('Who approved the retirement') + .setRequired(true) + ), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Retirement interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'retirement' + }); + return; + } + + try { + const staffMember = interaction.options.getUser('staff_member'); + const finalRank = interaction.options.getRole('final_rank'); + const reason = interaction.options.getString('reason'); + const supervisor = interaction.options.getUser('supervisor'); + + const issuedAt = new Date().toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }).replace(',', '').replace(/(\d{4}),/, '$1 at'); + + const description = [ + `### ใ€” ๐ŸŽ—๏ธ ใ€• Staff Retirement Announcement`, + ``, + `On behalf of the Califirnia State Roleplay Staff Division, we would like to thank ${staffMember} for their dedicated service and contributions. We wish them all the best in their future endeavours.`, + ``, + `**โ€” Retirement Details โ€”**`, + ``, + `> <:arrow:1516172552592949350> **Staff Member** ใƒป ${staffMember}`, + `> <:arrow:1516172552592949350> **Final Rank** ใƒป ${finalRank}`, + `> <:arrow:1516172552592949350> **Reason** ใƒป ${reason}`, + `> <:arrow:1516172552592949350> **Approved By** ใƒป ${supervisor}`, + `> <:arrow:1516172552592949350> **Issued At** ใƒป ${issuedAt}`, + ].join('\n'); + + const embed = createEmbed({ + description, + color: 0x1a1a1a, + footer: 'โญ Califirnia State Roleplay โ€ข Staff Division', + image: 'https://cdn.discordapp.com/attachments/1493023004802679007/1516162282663710730/Copy_of_Copy_of_Copy_of_Free_Release_Banner.png?ex=6a31a3a9&is=6a305229&hm=80d58b0354ff30b5d39c47d68b2cb2c8f965546929a9797b3de92cc7666a622b', + timestamp: true + }); + + await interaction.channel.send({ embeds: [embed] }); + + await InteractionHelper.safeEditReply(interaction, { + content: 'โœ… Retirement notice posted successfully!' + }); + } catch (error) { + logger.error('Retirement command error:', error); + await handleInteractionError(interaction, error, { subtype: 'retirement_failed' }); + } + } +}; diff --git a/src/commands/Staff/shift.js b/src/commands/Staff/shift.js new file mode 100644 index 000000000..fab07d2cb --- /dev/null +++ b/src/commands/Staff/shift.js @@ -0,0 +1,187 @@ +import { SlashCommandBuilder, MessageFlags, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { + getShiftStartRoleId, + getShiftBreakRoleId, + getShiftStopRoleId, + getActiveShift, + formatDuration, +} from '../../services/shiftService.js'; + +/** + * Build the shift status embed for a given user. + * @param {import('discord.js').User} user + * @param {Object|null} shift Active shift row, or null if none. + * @returns {import('discord.js').EmbedBuilder} + */ +export function buildShiftEmbed(user, shift) { + if (!shift) { + return createEmbed({ + title: '๐Ÿ• Shift Management', + description: `${user} currently has **no active shift**.\n\nClick **Start Shift** below to clock in.`, + color: 'gray', + fields: [ + { name: 'Status', value: 'โšซ Inactive', inline: true }, + ], + timestamp: true, + }); + } + + const startTimestamp = Math.floor(new Date(shift.start_time).getTime() / 1000); + const elapsedMs = Date.now() - new Date(shift.start_time).getTime(); + const breakMs = Number(shift.break_time); + + // If currently on break, add the in-progress break time to the total break time + let currentBreakMs = 0; + if (shift.on_break && shift.break_started_at) { + currentBreakMs = Date.now() - new Date(shift.break_started_at).getTime(); + } + const totalBreakMs = breakMs + currentBreakMs; + const activeMs = Math.max(0, elapsedMs - totalBreakMs); + + if (shift.on_break) { + return createEmbed({ + title: 'โธ๏ธ Shift Management', + description: `${user} is currently **on break**.\n\nClick **Break** to resume, or **Stop Shift** to clock out.`, + color: 'warning', + fields: [ + { name: 'Status', value: '๐ŸŸก On Break', inline: true }, + { name: 'Started At', value: ` ()`, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: 'Elapsed Time', value: formatDuration(elapsedMs), inline: true }, + { name: 'Break Time', value: formatDuration(totalBreakMs), inline: true }, + { name: 'Active Time', value: formatDuration(activeMs), inline: true }, + ], + timestamp: true, + }); + } + + return createEmbed({ + title: '๐ŸŸข Shift Management', + description: `${user} has an **active shift** in progress.\n\nClick **Break** to pause, or **Stop Shift** to clock out.`, + color: 'success', + fields: [ + { name: 'Status', value: '๐ŸŸข Active', inline: true }, + { name: 'Started At', value: ` ()`, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: 'Elapsed Time', value: formatDuration(elapsedMs), inline: true }, + { name: 'Break Time', value: formatDuration(totalBreakMs), inline: true }, + { name: 'Active Time', value: formatDuration(activeMs), inline: true }, + ], + timestamp: true, + }); +} + +/** + * Build the three shift action buttons. + * @param {Object|null} shift Active shift row, or null if none. + * @param {{ canStart: boolean, canBreak: boolean, canStop: boolean }} [perms] + * Whether the viewing member has each action's role. Defaults to all true + * so the panel still works when called from button handlers (which do their + * own role check before acting). + * @returns {ActionRowBuilder} + */ +export function buildShiftButtons(shift, perms = { canStart: true, canBreak: true, canStop: true }) { + const hasActiveShift = !!shift; + const onBreak = hasActiveShift && shift.on_break; + + const startButton = new ButtonBuilder() + .setCustomId('shift_start') + .setLabel('Start Shift') + .setEmoji('๐ŸŸข') + .setStyle(ButtonStyle.Success) + // Disabled when a shift is already running OR the user lacks the start role + .setDisabled(hasActiveShift || !perms.canStart); + + const breakButton = new ButtonBuilder() + .setCustomId('shift_break') + .setLabel(onBreak ? 'Resume Shift' : 'Break') + .setEmoji(onBreak ? 'โ–ถ๏ธ' : 'โธ๏ธ') + .setStyle(ButtonStyle.Secondary) + // Disabled when no shift is active OR the user lacks the break role + .setDisabled(!hasActiveShift || !perms.canBreak); + + const stopButton = new ButtonBuilder() + .setCustomId('shift_stop') + .setLabel('Stop Shift') + .setEmoji('๐Ÿ”ด') + .setStyle(ButtonStyle.Danger) + // Disabled when no shift is active OR the user lacks the stop role + .setDisabled(!hasActiveShift || !perms.canStop); + + return new ActionRowBuilder().addComponents(startButton, breakButton, stopButton); +} + +export default { + data: new SlashCommandBuilder() + .setName('shift') + .setDescription('Manage your staff shift'), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Shift interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'shift', + }); + return; + } + + try { + const userId = interaction.user.id; + const guildId = interaction.guildId; + + // --- Per-action role check --- + const [startRoleId, breakRoleId, stopRoleId] = await Promise.all([ + getShiftStartRoleId(guildId), + getShiftBreakRoleId(guildId), + getShiftStopRoleId(guildId), + ]); + + // At least one action role must be configured before the panel is usable + if (!startRoleId && !breakRoleId && !stopRoleId) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'The shift system has not been configured yet. An administrator must run `/shiftconfig setstartrole`, `/shiftconfig setbreakrole`, and `/shiftconfig setstoprole` first.' + ), + ], + }); + } + + const member = interaction.member; + const canStart = !!startRoleId && member.roles.cache.has(startRoleId); + const canBreak = !!breakRoleId && member.roles.cache.has(breakRoleId); + const canStop = !!stopRoleId && member.roles.cache.has(stopRoleId); + + // User must have at least one action role to open the panel + if (!canStart && !canBreak && !canStop) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'You do not have the required role to use shift commands.' + ), + ], + }); + } + + // --- Build and send the management panel --- + const shift = await getActiveShift(userId, guildId); + const embed = buildShiftEmbed(interaction.user, shift); + const row = buildShiftButtons(shift, { canStart, canBreak, canStop }); + + return InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [row], + }); + } catch (error) { + logger.error('Shift command error:', error); + await handleInteractionError(interaction, error, { subtype: 'shift_failed' }); + } + }, +}; diff --git a/src/commands/Staff/shiftconfig.js b/src/commands/Staff/shiftconfig.js new file mode 100644 index 000000000..8e996a1e3 --- /dev/null +++ b/src/commands/Staff/shiftconfig.js @@ -0,0 +1,114 @@ +import { SlashCommandBuilder, MessageFlags, PermissionsBitField } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { setShiftStartRole, setShiftBreakRole, setShiftStopRole } from '../../services/shiftService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('shiftconfig') + .setDescription('Configure the shift management system') + .addSubcommand(sub => + sub + .setName('setstartrole') + .setDescription('Set the role that is allowed to start shifts') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role that can use the Start Shift button') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('setbreakrole') + .setDescription('Set the role that is allowed to use break/resume') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role that can use the Break/Resume button') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('setstoprole') + .setDescription('Set the role that is allowed to stop shifts') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role that can use the Stop Shift button') + .setRequired(true) + ) + ), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Shiftconfig interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'shiftconfig', + }); + return; + } + + try { + // Admin-only + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'You need **Administrator** permissions to configure the shift system.' + ), + ], + }); + } + + const subcommand = interaction.options.getSubcommand(); + const role = interaction.options.getRole('role'); + const guildId = interaction.guildId; + + const subcommandMeta = { + setstartrole: { + fn: setShiftStartRole, + label: 'Shift Start Role', + description: `Members with this role can now use the **Start Shift** button in \`/shift\`.`, + }, + setbreakrole: { + fn: setShiftBreakRole, + label: 'Shift Break Role', + description: `Members with this role can now use the **Break/Resume** button in \`/shift\`.`, + }, + setstoprole: { + fn: setShiftStopRole, + label: 'Shift Stop Role', + description: `Members with this role can now use the **Stop Shift** button in \`/shift\`.`, + }, + }; + + const meta = subcommandMeta[subcommand]; + if (!meta) return; + + await meta.fn(guildId, role.id); + + const embed = createEmbed({ + title: 'โš™๏ธ Shift Role Configured', + description: `The **${meta.label}** has been set to ${role}.\n\n${meta.description}`, + color: 'success', + fields: [ + { name: 'Role', value: role.toString(), inline: true }, + { name: 'Role ID', value: role.id, inline: true }, + ], + timestamp: true, + }); + + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('Shiftconfig command error:', error); + await handleInteractionError(interaction, error, { subtype: 'shiftconfig_failed' }); + } + }, +}; diff --git a/src/commands/Staff/shiftwipe.js b/src/commands/Staff/shiftwipe.js new file mode 100644 index 000000000..09198a2eb --- /dev/null +++ b/src/commands/Staff/shiftwipe.js @@ -0,0 +1,88 @@ +import { SlashCommandBuilder, MessageFlags, PermissionsBitField } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { wipeShiftsByUserIds } from '../../services/shiftService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('shiftwipe') + .setDescription('Delete all shift records for members with a specific role') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role whose shift data should be wiped') + .setRequired(true) + ), + category: 'Staff', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn('Shiftwipe interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'shiftwipe', + }); + return; + } + + try { + // Moderator-only + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'You need **Moderator** permissions to wipe shift data.' + ), + ], + }); + } + + const role = interaction.options.getRole('role'); + const guildId = interaction.guildId; + const guild = interaction.guild; + + // Fetch all members with the target role + // Ensure the member cache is populated + await guild.members.fetch(); + const membersWithRole = guild.members.cache.filter(m => m.roles.cache.has(role.id)); + const userIds = membersWithRole.map(m => m.id); + + if (userIds.length === 0) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'โš ๏ธ No Members Found', + description: `No members with the ${role} role were found in this server. No shift records were deleted.`, + color: 'warning', + timestamp: true, + }), + ], + }); + } + + const deletedCount = await wipeShiftsByUserIds(guildId, userIds); + + const embed = createEmbed({ + title: '๐Ÿ—‘๏ธ Shift Data Wiped', + description: deletedCount > 0 + ? `Successfully deleted **${deletedCount}** shift record${deletedCount === 1 ? '' : 's'} for members with the ${role} role.` + : `No shift records were found for members with the ${role} role. Nothing was deleted.`, + color: deletedCount > 0 ? 'error' : 'warning', + fields: [ + { name: 'Role', value: role.toString(), inline: true }, + { name: 'Members Checked', value: String(userIds.length), inline: true }, + { name: 'Records Deleted', value: String(deletedCount), inline: true }, + ], + timestamp: true, + }); + + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('Shiftwipe command error:', error); + await handleInteractionError(interaction, error, { subtype: 'shiftwipe_failed' }); + } + }, +}; diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index e3a1a5015..999e3a604 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -129,16 +129,6 @@ export default { if (subcommand === "setup") { const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - 'Ticket System Already Active', - `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.`, - ), - ], - }); - } const panelChannel = interaction.options.getChannel("panel_channel"); diff --git a/src/commands/Utility/say.js b/src/commands/Utility/say.js new file mode 100644 index 000000000..8cc9a67b3 --- /dev/null +++ b/src/commands/Utility/say.js @@ -0,0 +1,169 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logEvent } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getColor } from '../../config/bot.js'; + +export default { + data: new SlashCommandBuilder() + .setName('say') + .setDescription('Send a message through the bot') + .addStringOption(option => + option + .setName('message') + .setDescription('The message to send (max 2000 characters)') + .setRequired(true) + .setMaxLength(2000) + ) + .addChannelOption(option => + option + .setName('channel') + .setDescription('Channel to send the message in (defaults to current channel)') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(false) + ) + .addBooleanOption(option => + option + .setName('embed') + .setDescription('Send the message as an embed (default: false)') + .setRequired(false) + ) + .addStringOption(option => + option + .setName('embed_title') + .setDescription('Title for the embed (only used when embed is true)') + .setMaxLength(256) + .setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .setDMPermission(false), + category: 'Utility', + + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + if (!deferSuccess) { + logger.warn('Say interaction defer failed', { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'say' + }); + return; + } + + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Permission Denied', + 'You need the `Manage Messages` permission to use this command.' + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + const rawMessage = interaction.options.getString('message'); + const targetChannel = interaction.options.getChannel('channel') || interaction.channel; + const asEmbed = interaction.options.getBoolean('embed') || false; + const embedTitle = interaction.options.getString('embed_title') || null; + + const message = sanitizeInput(rawMessage, 2000); + + if (!message || message.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Invalid Message', + 'The message cannot be empty after sanitization.' + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + // Verify the bot has permission to send messages in the target channel + const botMember = interaction.guild.members.me; + if (!targetChannel.permissionsFor(botMember).has(PermissionFlagsBits.SendMessages)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Missing Permissions', + `I don't have permission to send messages in ${targetChannel}.` + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + try { + if (asEmbed) { + const sayEmbed = createEmbed({ + title: embedTitle || null, + description: message, + color: 'primary', + timestamp: false, + }); + + await targetChannel.send({ embeds: [sayEmbed] }); + } else { + await targetChannel.send({ content: message }); + } + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Say Command Used', + target: `${targetChannel} (${targetChannel.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason: `Message sent${asEmbed ? ' as embed' : ''}`, + metadata: { + channelId: targetChannel.id, + moderatorId: interaction.user.id, + messageLength: message.length, + asEmbed, + embedTitle: embedTitle || null, + } + } + }); + + logger.info('Say command executed', { + userId: interaction.user.id, + guildId: interaction.guildId, + targetChannelId: targetChannel.id, + asEmbed, + messageLength: message.length, + }); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + successEmbed( + `Your message was sent in ${targetChannel}.`, + '๐Ÿ“จ Message Sent' + ), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.error('Say command error:', { + error: error.message, + stack: error.stack, + userId: interaction.user.id, + guildId: interaction.guildId, + targetChannelId: targetChannel.id, + }); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Failed to Send Message', + `An unexpected error occurred: ${error.message}` + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + } +}; diff --git a/src/commands/Voice/activity.js b/src/commands/Voice/activity.js deleted file mode 100644 index bcc3dc1a2..000000000 --- a/src/commands/Voice/activity.js +++ /dev/null @@ -1,215 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -const ACTIVITIES = { - 'youtube': '880218394199220334', - 'poker': '755827207812677713', - 'chess': '832012774040141894', - 'checkers': '832013003968348200', - 'letter-league': '879863686565621790', - 'spellcast': '852509694341283871', - 'sketch': '902271654783242291', - 'blazing8s': '832025144389533716', - 'puttparty': '945737671223947305', - 'landio': '903769130790969345', - 'bobble': '947957217959759964', - 'knowwhat': '976052223358406656' -}; - -const ACTIVITY_NAMES = { - 'youtube': 'YouTube Together', - 'poker': 'Poker Night', - 'chess': 'Chess in the Park', - 'checkers': 'Checkers in the Park', - 'letter-league': 'Letter League', - 'spellcast': 'SpellCast', - 'sketch': 'Sketch Heads', - 'blazing8s': 'Blazing 8s', - 'puttparty': 'Putt Party', - 'landio': 'Land-io', - 'bobble': 'Bobble League', - 'knowwhat': 'Know What I Mean' -}; - -export default { - data: new SlashCommandBuilder() - .setName('activity') - .setDescription('Start a Discord Activity in your voice channel') - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.Connect) - - .addSubcommand(subcommand => - subcommand - .setName('youtube') - .setDescription('Watch YouTube videos together in a voice channel') - ) - - .addSubcommand(subcommand => - subcommand - .setName('poker') - .setDescription('Play Poker Night with friends') - ) - - .addSubcommand(subcommand => - subcommand - .setName('chess') - .setDescription('Play Chess in the Park') - ) - - .addSubcommand(subcommand => - subcommand - .setName('checkers') - .setDescription('Play Checkers in the Park') - ) - - .addSubcommand(subcommand => - subcommand - .setName('letter-league') - .setDescription('Play the word-based game Letter League') - ) - - .addSubcommand(subcommand => - subcommand - .setName('spellcast') - .setDescription('Play the magical word game SpellCast') - ) - - .addSubcommand(subcommand => - subcommand - .setName('sketch') - .setDescription('Play Sketch Heads (Pictionary style)') - ) - - .addSubcommand(subcommand => - subcommand - .setName('blazing8s') - .setDescription('Play the card game Blazing 8s') - ) - - .addSubcommand(subcommand => - subcommand - .setName('puttparty') - .setDescription('Play Putt Party (Mini-golf)') - ) - - .addSubcommand(subcommand => - subcommand - .setName('landio') - .setDescription('Play the territory game Land-io') - ) - - .addSubcommand(subcommand => - subcommand - .setName('bobble') - .setDescription('Play Bobble League') - ) - - .addSubcommand(subcommand => - subcommand - .setName('knowwhat') - .setDescription('Play Know What I Mean') - ), - - category: "Voice", - - async execute(interaction, config, client) { - try { - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const { member, options } = interaction; - const activity = options.getSubcommand(); - const activityId = ACTIVITIES[activity]; - const activityName = ACTIVITY_NAMES[activity] || activity; - - if (!member.voice.channel) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Not in Voice Channel', 'You need to be in a voice channel to start an activity!')] - }); - } - - logger.debug('Activity command - validating permissions', { - userId: interaction.user.id, - voiceChannelId: member.voice.channel.id, - voiceChannelName: member.voice.channel.name, - activity: activity - }); - - const permissions = member.voice.channel.permissionsFor(interaction.guild.members.me); - if (!permissions.has('CreateInstantInvite')) { - logger.warn('Activity command - missing permissions', { - userId: interaction.user.id, - voiceChannelId: member.voice.channel.id, - guildId: interaction.guildId, - activity: activity, - missingPermission: 'CreateInstantInvite' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Missing Permissions', 'I need the `Create Invite` permission to start an activity!')] - }); - } - - const invite = await interaction.client.rest.post( - `/channels/${member.voice.channel.id}/invites`, - { - body: { - max_age: 86400, - target_type: 2, - target_application_id: activityId, - }, - } - ); - - logger.info('Activity invite created successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - voiceChannelId: member.voice.channel.id, - voiceChannelName: member.voice.channel.name, - guildId: interaction.guildId, - activity: activity, - activityName: activityName, - inviteCode: invite.code, - commandName: 'activity' - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: `๐ŸŽฎ ${activityName}`, - description: `Click the link below to start **${activityName}** in ${member.voice.channel.name}!\n\n[Join ${activityName} Activity](https://discord.gg/${invite.code})`, - color: 'success' - })] - }); - - } catch (error) { - logger.error('Error creating activity invite', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - voiceChannelId: interaction.member?.voice.channel?.id, - guildId: interaction.guildId, - activity: options.getSubcommand(), - commandName: 'activity' - }); - - if (!interaction.deferred && !interaction.replied) { - await handleInteractionError(interaction, error, { - commandName: 'activity', - source: 'discord_activity_api' - }); - } else { - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('Failed to Create Activity', 'An error occurred while trying to create the activity. Please try again later.')] - }); - } - } - }, -}; - - diff --git a/src/config/application.js b/src/config/application.js index d4d741bfc..22ada5e01 100644 --- a/src/config/application.js +++ b/src/config/application.js @@ -28,7 +28,14 @@ const appConfig = { ...botConfig, token: process.env.DISCORD_TOKEN || process.env.TOKEN, clientId: process.env.CLIENT_ID, - guildId: process.env.GUILD_ID, + guildId: process.env.GUILD_IDS + ? process.env.GUILD_IDS.split(',').map(id => id.trim())[0] + : process.env.GUILD_ID, + guildIds: process.env.GUILD_IDS + ? process.env.GUILD_IDS.split(',').map(id => id.trim()) + : process.env.GUILD_ID + ? [process.env.GUILD_ID] + : [], shop: { ...botConfig.shop, diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd4..dcea463aa 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -25,9 +25,9 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with โค๏ธ", + name: "Created By Atlas Design!", // Activity type number (0 = Playing). - type: 0, + type: 2, }, ], }, @@ -38,7 +38,7 @@ export const botConfig = { commands: { // Bot owner user IDs (comma-separated in OWNER_IDS env var). // Owners can access owner/admin-level bot commands. - owners: process.env.OWNER_IDS?.split(",") || [], + owners: process.env.OWNER_IDS?.split("1392814554383384626,") || [], // Default wait time between command uses (in seconds). defaultCooldown: 3, @@ -88,8 +88,8 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", - secondary: "#2F3136", + primary: "#D96AF5", + secondary: "#F7E86A", // Standard status colors for success/error/warning/info messages. success: "#57F287", @@ -136,7 +136,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "Management Bot", // Footer icon URL (null = no icon). icon: null, }, diff --git a/src/database/migrations/003_create_tickets_table.js b/src/database/migrations/003_create_tickets_table.js new file mode 100644 index 000000000..a4e1be7a3 --- /dev/null +++ b/src/database/migrations/003_create_tickets_table.js @@ -0,0 +1,96 @@ +export const up = async (client) => { + await client.query(` + CREATE TABLE IF NOT EXISTS tickets ( + id SERIAL PRIMARY KEY, + ticket_id VARCHAR(10) UNIQUE NOT NULL, + guild_id VARCHAR(20) NOT NULL, + user_id VARCHAR(20) NOT NULL, + channel_id VARCHAR(20) UNIQUE, + category VARCHAR(50) NOT NULL DEFAULT 'general', + status VARCHAR(20) NOT NULL DEFAULT 'open', + assigned_to VARCHAR(20), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP, + claimed_at TIMESTAMP, + claimed_by VARCHAR(20), + subject TEXT, + description TEXT, + priority VARCHAR(20) DEFAULT 'normal', + is_archived BOOLEAN DEFAULT FALSE, + reaction_message_id VARCHAR(20) + ); + + CREATE INDEX IF NOT EXISTS idx_tickets_guild ON tickets(guild_id); + CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id); + CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); + CREATE INDEX IF NOT EXISTS idx_tickets_channel ON tickets(channel_id); + + CREATE TABLE IF NOT EXISTS ticket_messages ( + id SERIAL PRIMARY KEY, + ticket_id VARCHAR(10) NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + author_id VARCHAR(20) NOT NULL, + author_name VARCHAR(100), + message_content TEXT NOT NULL, + message_id VARCHAR(20) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id); + + CREATE TABLE IF NOT EXISTS ticket_settings ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(20) UNIQUE NOT NULL, + category_channel_id VARCHAR(20), + log_channel_id VARCHAR(20), + support_role_id VARCHAR(20), + ticket_prefix VARCHAR(10) DEFAULT 'TKT', + max_open_per_user INT DEFAULT 5, + auto_close_days INT DEFAULT 7, + enable_priority BOOLEAN DEFAULT TRUE, + enable_categories BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS ticket_categories ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(20) NOT NULL, + category_name VARCHAR(100) NOT NULL, + description TEXT, + emoji VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(guild_id, category_name) + ); + + CREATE TABLE IF NOT EXISTS ticket_notes ( + id SERIAL PRIMARY KEY, + ticket_id VARCHAR(10) NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + author_id VARCHAR(20) NOT NULL, + note_content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS ticket_audit_log ( + id SERIAL PRIMARY KEY, + ticket_id VARCHAR(10) NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + actor_id VARCHAR(20), + details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE + ); + `); +}; + +export const down = async (client) => { + await client.query(` + DROP TABLE IF NOT EXISTS ticket_audit_log; + DROP TABLE IF NOT EXISTS ticket_notes; + DROP TABLE IF NOT EXISTS ticket_categories; + DROP TABLE IF NOT EXISTS ticket_settings; + DROP TABLE IF NOT EXISTS ticket_messages; + DROP TABLE IF NOT EXISTS tickets; + `); +}; diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 42c2d8450..aa0854b66 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -7,6 +7,7 @@ import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; +import { handleMemberJoin as antiRaidHandleMemberJoin } from '../services/antiRaid.js'; export default { name: Events.GuildMemberAdd, @@ -17,6 +18,14 @@ export default { const { guild, user } = member; const config = await getGuildConfig(member.client, guild.id); + + // โ”€โ”€ Anti-Raid check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Run before welcome/role logic so raiders can be actioned immediately. + try { + await antiRaidHandleMemberJoin(member); + } catch (error) { + logger.debug('Error in anti-raid member join handler:', error); + } const welcomeConfig = await getWelcomeConfig(member.client, guild.id); diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 450ace66b..66aa3b86e 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -1,8 +1,6 @@ import { Events, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/guildConfig.js'; -import { handleApplicationModal } from '../commands/Community/apply.js'; -import { handleApplicationReviewModal } from '../commands/Community/app-admin.js'; import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; import { MessageTemplates } from '../utils/messageTemplates.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -99,58 +97,7 @@ export default { // Handle autocomplete interactions const focusedOption = interaction.options.getFocused(true); - if (interaction.commandName === 'apply' && focusedOption.name === 'application') { - try { - const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); - const roleName = interaction.options.getString('application', false); - - // Filter: only show enabled applications - const filtered = roles.filter(role => - role.enabled !== false && - role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { - try { - const { getApplicationRoles } = await import('../utils/database.js'); - const roles = await getApplicationRoles(client, interaction.guildId); - const appName = interaction.options.getString('application', false); - - // Show all applications (enabled and disabled), but mark disabled ones - const filtered = roles.filter(role => - role.name.toLowerCase().startsWith(appName?.toLowerCase() || '') - ); - - await interaction.respond( - filtered.slice(0, 25).map(role => ({ - name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, - value: role.name - })) - ); - } catch (error) { - logger.error('Error handling app-admin autocomplete:', { - error: error.message, - guildId: interaction.guildId, - commandName: interaction.commandName - }); - await interaction.respond([]); - } - } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { + if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { try { const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); const guildId = interaction.guildId; @@ -254,16 +201,14 @@ export default { const button = client.buttons.get(customId); if (!button) { - if (!interaction.customId.includes(':')) { - return; - } - - throw createError( - `No button handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - withTraceContext({ customId }, interactionTraceContext) - ); + logger.warn(`No button handler found for customId: ${customId}`, { + event: 'interaction.button.unhandled', + customId: interaction.customId, + guildId: interaction.guildId, + userId: interaction.user?.id, + traceId: interactionTraceContext.traceId + }); + return; } try { @@ -304,32 +249,6 @@ export default { }, interactionTraceContext)); } } else if (interaction.isModalSubmit()) { - if (interaction.customId.startsWith('app_modal_')) { - try { - await handleApplicationModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application' - }, interactionTraceContext)); - } - return; - } - - if (interaction.customId.startsWith('app_review_')) { - try { - await handleApplicationReviewModal(interaction); - } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'modal', - customId: interaction.customId, - handler: 'application_review' - }, interactionTraceContext)); - } - return; - } - if (interaction.customId.startsWith('jtc_')) { logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`, { event: 'interaction.modal.inline_skipped', diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index 6753e76fa..cd83ebde6 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -61,7 +61,7 @@ export default { const now = Date.now(); if (channelCreationCooldown.has(cooldownKey)) { const lastCreation = channelCreationCooldown.get(cooldownKey); -if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { + if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { logger.warn(`User ${member.id} is on cooldown for channel creation`); return; } @@ -189,7 +189,6 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { const channelName = sanitizeVoiceChannelName(finalName); -const channelName = sanitizeVoiceChannelName(finalName); if (!member.voice?.channel || member.voice.channel.id !== triggerChannel.id) { logger.debug(`Member ${member.id} no longer in trigger channel ${triggerChannel.id}, aborting temporary channel creation`); channelCreationCooldown.delete(cooldownKey); @@ -198,9 +197,9 @@ const channelName = sanitizeVoiceChannelName(finalName); const tempChannel = await guild.channels.create({ name: channelName, -type: ChannelType.GuildVoice, + type: ChannelType.GuildVoice, parent: triggerChannel.parentId, -userLimit: userLimit === 0 ? undefined : userLimit, + userLimit: userLimit === 0 ? undefined : userLimit, bitrate: bitrate, permissionOverwrites: [ { diff --git a/src/handlers/commandLoader.js b/src/handlers/commandLoader.js index 5273f2934..cdec62947 100644 --- a/src/handlers/commandLoader.js +++ b/src/handlers/commandLoader.js @@ -89,6 +89,11 @@ export async function loadCommands(client) { continue; } + if (command.disabled === true) { + logger.info(`Skipping disabled command at ${filePath}`); + continue; + } + command.category = category; command.filePath = normalizedPath; diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js index b71a26b3d..746ec3a56 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/helpSelectMenus.js @@ -13,7 +13,7 @@ const BACK_BUTTON_ID = "help-back-to-main"; const ALL_COMMANDS_ID = "help-all-commands"; const PAGINATION_PREFIX = "help-page"; const CATEGORY_SELECT_ID = "help-category-select"; -const FOOTER_TEXT = "Made with โค๏ธ"; +const FOOTER_TEXT = "Made with Community Bot Dashboard"; const SUBCOMMAND_TYPE = 1; const SUBCOMMAND_GROUP_TYPE = 2; diff --git a/src/handlers/shiftButtons.js b/src/handlers/shiftButtons.js new file mode 100644 index 000000000..7aa56710c --- /dev/null +++ b/src/handlers/shiftButtons.js @@ -0,0 +1,254 @@ +import { MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed } from '../utils/embeds.js'; +import { logger } from '../utils/logger.js'; +import { handleInteractionError } from '../utils/errorHandler.js'; +import { InteractionHelper } from '../utils/interactionHelper.js'; +import { + getShiftStartRoleId, + getShiftBreakRoleId, + getShiftStopRoleId, + getActiveShift, + startShift, + stopShift, + toggleBreak, + formatDuration, +} from '../services/shiftService.js'; +import { buildShiftEmbed, buildShiftButtons } from '../commands/Staff/shift.js'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Verify the user has the role configured for the given shift action. + * @param {import('discord.js').Interaction} interaction + * @param {'start'|'break'|'stop'} action + * @returns {Promise} The role ID on success, or null after replying with an error. + */ +async function checkShiftRole(interaction, action) { + const guildId = interaction.guildId; + + const getRoleId = { + start: getShiftStartRoleId, + break: getShiftBreakRoleId, + stop: getShiftStopRoleId, + }[action]; + + const actionLabel = { + start: 'start shifts', + break: 'use break/resume', + stop: 'stop shifts', + }[action]; + + const configSubcommand = { + start: '`/shiftconfig setstartrole`', + break: '`/shiftconfig setbreakrole`', + stop: '`/shiftconfig setstoprole`', + }[action]; + + const roleId = await getRoleId(guildId); + + if (!roleId) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + `The shift system has not been fully configured yet. An administrator must run ${configSubcommand} first.` + ), + ], + }); + return null; + } + + const hasRole = interaction.member.roles.cache.has(roleId); + if (!hasRole) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed(`You do not have the required role to ${actionLabel}.`), + ], + }); + return null; + } + + return roleId; +} + +/** + * After any shift action, update the original management panel embed + buttons + * to reflect the new state, then send a confirmation follow-up. + */ +async function refreshPanelAndConfirm(interaction, confirmEmbed) { + const shift = await getActiveShift(interaction.user.id, interaction.guildId); + const updatedEmbed = buildShiftEmbed(interaction.user, shift); + const updatedRow = buildShiftButtons(shift); + + // Update the original deferred reply (the management panel) + await InteractionHelper.safeEditReply(interaction, { + embeds: [updatedEmbed], + components: [updatedRow], + }); + + // Send the confirmation as an ephemeral follow-up + await interaction.followUp({ + embeds: [confirmEmbed], + flags: MessageFlags.Ephemeral, + }); +} + +// --------------------------------------------------------------------------- +// shift_start +// --------------------------------------------------------------------------- + +export const shiftStartHandler = { + name: 'shift_start', + async execute(interaction, client) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + const roleId = await checkShiftRole(interaction, 'start'); + if (!roleId) return; + + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const existing = await getActiveShift(userId, guildId); + if (existing) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed('You already have an active shift. Use the **Stop Shift** button to end it first.'), + ], + }); + } + + const shift = await startShift(userId, guildId); + const startTimestamp = Math.floor(new Date(shift.start_time).getTime() / 1000); + + const confirmEmbed = createEmbed({ + title: '๐ŸŸข Shift Started', + description: `Your shift has begun. Good luck, ${interaction.user}!`, + color: 'success', + fields: [ + { name: 'Started At', value: ` ()`, inline: true }, + ], + timestamp: true, + }); + + await refreshPanelAndConfirm(interaction, confirmEmbed); + } catch (error) { + logger.error('shift_start button error:', error); + await handleInteractionError(interaction, error, { subtype: 'shift_start_failed' }); + } + }, +}; + +// --------------------------------------------------------------------------- +// shift_break +// --------------------------------------------------------------------------- + +export const shiftBreakHandler = { + name: 'shift_break', + async execute(interaction, client) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + const roleId = await checkShiftRole(interaction, 'break'); + if (!roleId) return; + + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const shift = await getActiveShift(userId, guildId); + if (!shift) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed('You do not have an active shift. Use the **Start Shift** button to begin one.'), + ], + }); + } + + const { shift: updated, nowOnBreak } = await toggleBreak(shift.id); + const breakMs = Number(updated.break_time); + + const confirmEmbed = nowOnBreak + ? createEmbed({ + title: 'โธ๏ธ Break Started', + description: 'You are now on break. Time tracking is paused.', + color: 'warning', + fields: [ + { name: 'Break Time So Far', value: formatDuration(breakMs), inline: true }, + ], + timestamp: true, + }) + : createEmbed({ + title: 'โ–ถ๏ธ Break Ended', + description: 'Welcome back! Time tracking has resumed.', + color: 'success', + fields: [ + { name: 'Total Break Time', value: formatDuration(breakMs), inline: true }, + ], + timestamp: true, + }); + + await refreshPanelAndConfirm(interaction, confirmEmbed); + } catch (error) { + logger.error('shift_break button error:', error); + await handleInteractionError(interaction, error, { subtype: 'shift_break_failed' }); + } + }, +}; + +// --------------------------------------------------------------------------- +// shift_stop +// --------------------------------------------------------------------------- + +export const shiftStopHandler = { + name: 'shift_stop', + async execute(interaction, client) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + const roleId = await checkShiftRole(interaction, 'stop'); + if (!roleId) return; + + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const shift = await getActiveShift(userId, guildId); + if (!shift) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed('You do not have an active shift. Use the **Start Shift** button to begin one.'), + ], + }); + } + + const ended = await stopShift(shift.id); + const startTimestamp = Math.floor(new Date(ended.start_time).getTime() / 1000); + const endTimestamp = Math.floor(new Date(ended.end_time).getTime() / 1000); + const totalMs = Number(ended.total_duration); + const breakMs = Number(ended.break_time); + + const confirmEmbed = createEmbed({ + title: '๐Ÿ”ด Shift Ended', + description: `Your shift has been recorded. Great work, ${interaction.user}!`, + color: 'error', + fields: [ + { name: 'Started At', value: ``, inline: true }, + { name: 'Ended At', value: ``, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: 'Total Duration', value: formatDuration(totalMs + breakMs), inline: true }, + { name: 'Break Time', value: formatDuration(breakMs), inline: true }, + { name: 'Active Time', value: formatDuration(Math.max(0, totalMs)), inline: true }, + ], + timestamp: true, + }); + + await refreshPanelAndConfirm(interaction, confirmEmbed); + } catch (error) { + logger.error('shift_stop button error:', error); + await handleInteractionError(interaction, error, { subtype: 'shift_stop_failed' }); + } + }, +}; diff --git a/src/interactions/buttons/robloxJoinRequest.js b/src/interactions/buttons/robloxJoinRequest.js new file mode 100644 index 000000000..626f00d2b --- /dev/null +++ b/src/interactions/buttons/robloxJoinRequest.js @@ -0,0 +1,63 @@ +import { robloxHandler } from '../../services/robloxJoinRequestService.js'; +import { logger } from '../../utils/logger.js'; + +export default { + customId: /^roblox_(accept|deny)_\d+_\d+$/, + async execute(interaction) { + try { + const [action, groupId, userId] = interaction.customId.split('_').slice(1); + const isAccept = action === 'accept'; + + await interaction.deferReply({ ephemeral: true }); + + let success; + if (isAccept) { + success = await robloxHandler.acceptJoinRequest(groupId, userId); + } else { + success = await robloxHandler.denyJoinRequest(groupId, userId); + } + + if (success) { + const actionText = isAccept ? 'accepted' : 'denied'; + await interaction.editReply({ + content: `โœ… Join request ${actionText} for user ${userId}`, + ephemeral: true + }); + + // Edit the original message to show it was processed + try { + const originalMessage = interaction.message; + const embed = originalMessage.embeds[0]; + const updatedEmbed = { + ...embed.toJSON(), + footer: { + text: `${isAccept ? 'โœ… Accepted' : 'โŒ Denied'} by ${interaction.user.tag}` + }, + color: isAccept ? 0x00ff00 : 0xff0000 + }; + + await originalMessage.edit({ + embeds: [updatedEmbed], + components: [] // Remove buttons + }); + } catch (error) { + logger.warn('Could not update original message:', error.message); + } + + logger.info(`User ${interaction.user.tag} ${isAccept ? 'accepted' : 'denied'} join request for user ${userId}`); + } else { + await interaction.editReply({ + content: `โŒ Failed to ${isAccept ? 'accept' : 'deny'} join request. Please try again.`, + ephemeral: true + }); + } + } catch (error) { + logger.error('Error in robloxJoinRequest button handler:', error); + await interaction.editReply({ + content: 'โŒ An error occurred while processing your request.', + ephemeral: true + }).catch(() => {}); + } + } +}; + diff --git a/src/interactions/buttons/shift.js b/src/interactions/buttons/shift.js new file mode 100644 index 000000000..97f969947 --- /dev/null +++ b/src/interactions/buttons/shift.js @@ -0,0 +1,11 @@ +import { + shiftStartHandler, + shiftBreakHandler, + shiftStopHandler, +} from '../../handlers/shiftButtons.js'; + +export default [ + shiftStartHandler, + shiftBreakHandler, + shiftStopHandler, +]; diff --git a/src/services/antiRaid.js b/src/services/antiRaid.js new file mode 100644 index 000000000..7f22cf413 --- /dev/null +++ b/src/services/antiRaid.js @@ -0,0 +1,343 @@ +/** + * Anti-Raid Service + * + * Tracks member join events in real-time and detects raid patterns. + * When a raid is detected the configured action (kick, ban, mute, alert) + * is executed against every member that joined within the active window. + * + * Configuration keys stored in guild config: + * antiRaidEnabled โ€“ boolean (default: false) + * antiRaidThreshold โ€“ number (default: 5) joins that trigger a raid + * antiRaidTimeWindow โ€“ number (default: 10) seconds to watch + * antiRaidAction โ€“ string (default: 'alert') kick | ban | mute | alert + * antiRaidWhitelist โ€“ string[] (default: []) user IDs exempt from checks + * antiRaidLogChannel โ€“ string (default: null) channel ID for raid alerts + */ + +import { PermissionFlagsBits, EmbedBuilder } from 'discord.js'; +import { getGuildConfig, updateGuildConfig } from './guildConfig.js'; +import { logger } from '../utils/logger.js'; +import { getColor } from '../config/bot.js'; + +// โ”€โ”€โ”€ In-memory join tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Structure: Map, raidActive, cooldownUntil }> +const guildJoinTrackers = new Map(); + +/** How long (ms) the raid-active cooldown lasts after a raid is detected. */ +const RAID_COOLDOWN_MS = 60_000; // 1 minute + +/** Maximum number of join timestamps to keep per guild (memory guard). */ +const MAX_JOIN_HISTORY = 200; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Return (or create) the join tracker for a guild. + * @param {string} guildId + */ +function getTracker(guildId) { + if (!guildJoinTrackers.has(guildId)) { + guildJoinTrackers.set(guildId, { + joins: [], + raidActive: false, + cooldownUntil: 0, + }); + } + return guildJoinTrackers.get(guildId); +} + +/** + * Prune join timestamps that are outside the active time window. + * @param {Array<{userId: string, timestamp: number}>} joins + * @param {number} windowMs Time window in milliseconds + */ +function pruneOldJoins(joins, windowMs) { + const cutoff = Date.now() - windowMs; + return joins.filter((j) => j.timestamp >= cutoff); +} + +// โ”€โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Process a new member join. Called from the guildMemberAdd event. + * + * @param {import('discord.js').GuildMember} member + * @returns {Promise} + */ +export async function handleMemberJoin(member) { + const { guild, user, client } = member; + + try { + const config = await getGuildConfig(client, guild.id); + + // Feature disabled? + if (!config.antiRaidEnabled) return; + + // Whitelisted user? + const whitelist = config.antiRaidWhitelist || []; + if (whitelist.includes(user.id) || user.bot) return; + + const threshold = config.antiRaidThreshold ?? 5; + const windowSecs = config.antiRaidTimeWindow ?? 10; + const windowMs = windowSecs * 1_000; + const action = config.antiRaidAction ?? 'alert'; + const logChannelId = config.antiRaidLogChannel ?? null; + + const tracker = getTracker(guild.id); + + // Prune stale entries then record this join + tracker.joins = pruneOldJoins(tracker.joins, windowMs); + tracker.joins.push({ userId: user.id, timestamp: Date.now() }); + + // Cap history size + if (tracker.joins.length > MAX_JOIN_HISTORY) { + tracker.joins = tracker.joins.slice(-MAX_JOIN_HISTORY); + } + + const recentCount = tracker.joins.length; + + // โ”€โ”€ Raid detected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (recentCount >= threshold) { + const now = Date.now(); + + // Still inside the cooldown window from a previous raid trigger? + if (tracker.raidActive && now < tracker.cooldownUntil) { + // Raid already being handled โ€“ just execute action on this member + await executeAction(member, action, guild, client); + return; + } + + // New raid trigger + tracker.raidActive = true; + tracker.cooldownUntil = now + RAID_COOLDOWN_MS; + + logger.warn(`[AntiRaid] Raid detected in guild ${guild.id} (${guild.name}) โ€” ${recentCount} joins in ${windowSecs}s`); + + // Collect all members that joined in the window + const raidUserIds = [...new Set(tracker.joins.map((j) => j.userId))]; + + // Send alert first so staff know what's happening + await sendRaidAlert({ + client, + guild, + logChannelId, + action, + raidUserIds, + threshold, + windowSecs, + }); + + // Execute the configured action on every raider + for (const raidUserId of raidUserIds) { + try { + const raidMember = await guild.members.fetch(raidUserId).catch(() => null); + if (raidMember) { + await executeAction(raidMember, action, guild, client); + } + } catch (err) { + logger.warn(`[AntiRaid] Failed to action user ${raidUserId} in guild ${guild.id}:`, err.message); + } + } + + // Reset join history after acting so we don't re-trigger immediately + tracker.joins = []; + + // Schedule cooldown reset + setTimeout(() => { + const t = guildJoinTrackers.get(guild.id); + if (t) { + t.raidActive = false; + t.cooldownUntil = 0; + } + }, RAID_COOLDOWN_MS); + } + } catch (error) { + logger.error(`[AntiRaid] Error processing member join for guild ${guild.id}:`, error); + } +} + +/** + * Execute the configured action against a single member. + * + * @param {import('discord.js').GuildMember} member + * @param {'kick'|'ban'|'mute'|'alert'} action + * @param {import('discord.js').Guild} guild + * @param {import('discord.js').Client} client + */ +async function executeAction(member, action, guild, client) { + const reason = 'Anti-Raid: Automatic action โ€” suspicious join pattern detected'; + + try { + switch (action) { + case 'ban': + if (member.bannable) { + await member.ban({ reason }); + logger.info(`[AntiRaid] Banned ${member.user.tag} (${member.id}) in guild ${guild.id}`); + } + break; + + case 'kick': + if (member.kickable) { + await member.kick(reason); + logger.info(`[AntiRaid] Kicked ${member.user.tag} (${member.id}) in guild ${guild.id}`); + } + break; + + case 'mute': { + // Discord timeout โ€“ 10 minutes + const MUTE_DURATION_MS = 10 * 60 * 1_000; + if (member.moderatable) { + await member.timeout(MUTE_DURATION_MS, reason); + logger.info(`[AntiRaid] Muted ${member.user.tag} (${member.id}) in guild ${guild.id}`); + } + break; + } + + case 'alert': + default: + // Alert-only: no action taken on the member + logger.info(`[AntiRaid] Alert-only mode โ€” no action taken on ${member.user.tag} (${member.id})`); + break; + } + } catch (err) { + logger.warn(`[AntiRaid] Could not execute action "${action}" on ${member.user.tag}:`, err.message); + } +} + +/** + * Send a raid alert embed to the configured log channel. + */ +async function sendRaidAlert({ client, guild, logChannelId, action, raidUserIds, threshold, windowSecs }) { + try { + const channelId = logChannelId; + if (!channelId) return; + + const channel = guild.channels.cache.get(channelId) + ?? await guild.channels.fetch(channelId).catch(() => null); + + if (!channel?.isTextBased()) return; + + const botMember = guild.members.me; + if (!botMember?.permissionsIn(channel).has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) return; + + const actionLabels = { + kick: '๐Ÿ‘ข Kicked', + ban: '๐Ÿ”จ Banned', + mute: '๐Ÿ”‡ Muted (10 min)', + alert: '๐Ÿ”” Alert only (no action)', + }; + + const userList = raidUserIds + .slice(0, 20) + .map((id) => `<@${id}> (${id})`) + .join('\n'); + + const embed = new EmbedBuilder() + .setColor(getColor('error')) + .setTitle('๐Ÿšจ Raid Detected!') + .setDescription( + `**${raidUserIds.length}** members joined within **${windowSecs}s** (threshold: ${threshold}).\n` + + `**Action taken:** ${actionLabels[action] ?? action}` + ) + .addFields( + { + name: `๐Ÿ‘ฅ Affected Users (${Math.min(raidUserIds.length, 20)} shown)`, + value: userList || 'None', + inline: false, + }, + ) + .setTimestamp() + .setFooter({ text: `Guild: ${guild.name}`, iconURL: guild.iconURL() ?? undefined }); + + await channel.send({ embeds: [embed] }); + } catch (err) { + logger.warn(`[AntiRaid] Failed to send raid alert in guild ${guild.id}:`, err.message); + } +} + +// โ”€โ”€โ”€ Config helpers (used by the /antiraid command) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Enable or disable the anti-raid system for a guild. + * @param {import('discord.js').Client} client + * @param {string} guildId + * @param {boolean} enabled + */ +export async function setAntiRaidEnabled(client, guildId, enabled) { + await updateGuildConfig(client, guildId, { antiRaidEnabled: enabled }); +} + +/** + * Update one or more anti-raid configuration values. + * @param {import('discord.js').Client} client + * @param {string} guildId + * @param {Object} updates Partial config object + */ +export async function updateAntiRaidConfig(client, guildId, updates) { + await updateGuildConfig(client, guildId, updates); +} + +/** + * Add a user ID to the whitelist. + * @param {import('discord.js').Client} client + * @param {string} guildId + * @param {string} userId + * @returns {Promise} false if already whitelisted + */ +export async function addToWhitelist(client, guildId, userId) { + const config = await getGuildConfig(client, guildId); + const whitelist = config.antiRaidWhitelist ?? []; + if (whitelist.includes(userId)) return false; + whitelist.push(userId); + await updateGuildConfig(client, guildId, { antiRaidWhitelist: whitelist }); + return true; +} + +/** + * Remove a user ID from the whitelist. + * @param {import('discord.js').Client} client + * @param {string} guildId + * @param {string} userId + * @returns {Promise} false if not in whitelist + */ +export async function removeFromWhitelist(client, guildId, userId) { + const config = await getGuildConfig(client, guildId); + const whitelist = config.antiRaidWhitelist ?? []; + const idx = whitelist.indexOf(userId); + if (idx === -1) return false; + whitelist.splice(idx, 1); + await updateGuildConfig(client, guildId, { antiRaidWhitelist: whitelist }); + return true; +} + +/** + * Return the current anti-raid configuration for a guild. + * @param {import('discord.js').Client} client + * @param {string} guildId + * @returns {Promise} + */ +export async function getAntiRaidConfig(client, guildId) { + const config = await getGuildConfig(client, guildId); + return { + enabled: config.antiRaidEnabled ?? false, + threshold: config.antiRaidThreshold ?? 5, + timeWindow: config.antiRaidTimeWindow ?? 10, + action: config.antiRaidAction ?? 'alert', + whitelist: config.antiRaidWhitelist ?? [], + logChannelId: config.antiRaidLogChannel ?? null, + }; +} + +/** + * Return the live tracker state for a guild (for status display). + * @param {string} guildId + */ +export function getLiveStatus(guildId) { + const tracker = guildJoinTrackers.get(guildId); + if (!tracker) return { raidActive: false, recentJoins: 0, cooldownUntil: 0 }; + return { + raidActive: tracker.raidActive, + recentJoins: tracker.joins.length, + cooldownUntil: tracker.cooldownUntil, + }; +} diff --git a/src/services/applicationService.js b/src/services/applicationService.js deleted file mode 100644 index 1abcf1899..000000000 --- a/src/services/applicationService.js +++ /dev/null @@ -1,549 +0,0 @@ - - - - - - - - - - - - - - - - - - - -import { logger } from '../utils/logger.js'; -import { createError, ErrorTypes } from '../utils/errorHandler.js'; -import { PermissionFlagsBits } from 'discord.js'; -import { sanitizeInput, sanitizeMarkdown } from '../utils/sanitization.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplication, - getApplications, - createApplication, - updateApplication, - getUserApplications, - getApplicationRoles, - saveApplicationRoles -} from '../utils/database.js'; - - -const applicationCooldowns = new Map(); -const APPLICATION_SUBMIT_COOLDOWN = 5 * 60 * 1000; - -class ApplicationService { - static sanitizeApplicationText(value, maxLength) { - return sanitizeMarkdown(sanitizeInput(String(value ?? ''), maxLength)); - } - - - - - - static validateApplicationSubmission(data) { - if (!data.guildId || !data.userId || !data.roleId) { - throw createError( - 'Missing required fields for application submission', - ErrorTypes.VALIDATION, - 'Invalid application data. Please try again.', - { data } - ); - } - - if (!data.answers || !Array.isArray(data.answers) || data.answers.length === 0) { - throw createError( - 'Application must have answers', - ErrorTypes.VALIDATION, - 'You must answer all application questions.', - { data } - ); - } - - - for (const answer of data.answers) { - const sanitizedQuestion = this.sanitizeApplicationText(answer.question, 200); - const sanitizedAnswer = this.sanitizeApplicationText(answer.answer, 1000); - - if (!sanitizedQuestion || !sanitizedAnswer) { - throw createError( - 'Invalid answer format', - ErrorTypes.VALIDATION, - 'All questions must have answers.', - { answer } - ); - } - - - if (sanitizedAnswer.length > 1000) { - throw createError( - 'Answer too long', - ErrorTypes.VALIDATION, - 'Each answer must be less than 1000 characters.', - { length: sanitizedAnswer.length } - ); - } - - if (sanitizedAnswer.trim().length < 10) { - throw createError( - 'Answer too short', - ErrorTypes.VALIDATION, - 'Please provide meaningful answers (at least 10 characters).', - { length: sanitizedAnswer.length } - ); - } - } - - return true; - } - - - - - - static checkApplicationCooldown(userId) { - const now = Date.now(); - const cooldownKey = `submit_${userId}`; - const lastSubmit = applicationCooldowns.get(cooldownKey); - - if (lastSubmit && now - lastSubmit < APPLICATION_SUBMIT_COOLDOWN) { - const remainingTime = Math.ceil((APPLICATION_SUBMIT_COOLDOWN - (now - lastSubmit)) / 1000); - throw createError( - 'Application submission on cooldown', - ErrorTypes.RATE_LIMIT, - `Please wait ${Math.ceil(remainingTime / 60)} minute(s) before submitting another application.`, - { remainingTime, userId } - ); - } - - applicationCooldowns.set(cooldownKey, now); - return true; - } - - - - - - static async checkManagerPermission(client, guildId, member) { - const settings = await getApplicationSettings(client, guildId); - - const isManager = - member.permissions.has(PermissionFlagsBits.ManageGuild) || - (settings.managerRoles && - settings.managerRoles.some(roleId => member.roles.cache.has(roleId))); - - if (!isManager) { - throw createError( - 'User lacks permission to manage applications', - ErrorTypes.PERMISSION, - 'You do not have permission to manage applications.', - { userId: member.id, guildId } - ); - } - - return true; - } - - - - - - - - static async submitApplication(client, data) { - try { - - this.validateApplicationSubmission(data); - - - this.checkApplicationCooldown(data.userId); - - - const settings = await getApplicationSettings(client, data.guildId); - if (!settings.enabled) { - throw createError( - 'Applications are disabled', - ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', - { guildId: data.guildId } - ); - } - - - const userApps = await getUserApplications(client, data.guildId, data.userId); - const pendingApp = userApps.find(app => app.status === 'pending'); - - if (pendingApp) { - throw createError( - 'User already has pending application', - ErrorTypes.VALIDATION, - 'You already have a pending application. Please wait for it to be reviewed.', - { userId: data.userId, pendingAppId: pendingApp.id } - ); - } - - - const sanitizedData = { - ...data, - answers: data.answers.map(answer => ({ - question: this.sanitizeApplicationText(answer.question, 200), - answer: this.sanitizeApplicationText(answer.answer, 1000) - })) - }; - - - const application = await createApplication(client, sanitizedData); - - logger.info('Application submitted', { - applicationId: application.id, - userId: data.userId, - guildId: data.guildId, - roleId: data.roleId, - roleName: data.roleName - }); - - return application; - } catch (error) { - logger.error('Error submitting application', { - error: error.message, - userId: data.userId, - guildId: data.guildId, - stack: error.stack - }); - throw error; - } - } - - - - - - - - - - static async reviewApplication(client, guildId, applicationId, reviewData) { - try { - const { action, reason, reviewerId } = reviewData; - - - if (!['approve', 'deny'].includes(action)) { - throw createError( - 'Invalid review action', - ErrorTypes.VALIDATION, - 'Review action must be either approve or deny.', - { action } - ); - } - - - const application = await getApplication(client, guildId, applicationId); - if (!application) { - throw createError( - 'Application not found', - ErrorTypes.CONFIGURATION, - 'The application you are trying to review does not exist.', - { applicationId, guildId } - ); - } - - - if (application.status !== 'pending') { - throw createError( - 'Application already processed', - ErrorTypes.VALIDATION, - 'This application has already been reviewed.', - { applicationId, status: application.status } - ); - } - - const status = action === 'approve' ? 'approved' : 'denied'; - const sanitizedReason = reason ? reason.trim().substring(0, 500) : 'No reason provided.'; - - - const updatedApplication = await updateApplication(client, guildId, applicationId, { - status, - reviewer: reviewerId, - reviewMessage: sanitizedReason, - reviewedAt: new Date().toISOString() - }); - - logger.info('Application reviewed', { - applicationId, - guildId, - status, - reviewerId, - userId: application.userId - }); - - return updatedApplication; - } catch (error) { - logger.error('Error reviewing application', { - error: error.message, - applicationId, - guildId, - stack: error.stack - }); - throw error; - } - } - - - - - - - - - static async getApplicationsList(client, guildId, filters = {}) { - try { - const applications = await getApplications(client, guildId, filters); - - logger.debug('Applications retrieved', { - guildId, - count: applications.length, - filters - }); - - return applications; - } catch (error) { - logger.error('Error getting applications list', { - error: error.message, - guildId, - filters, - stack: error.stack - }); - throw createError( - 'Failed to retrieve applications', - ErrorTypes.DATABASE, - 'An error occurred while retrieving applications.', - { guildId, filters } - ); - } - } - - - - - - - - - static async updateSettings(client, guildId, updates) { - try { - - if (updates.logChannelId && typeof updates.logChannelId !== 'string') { - throw createError( - 'Invalid log channel ID', - ErrorTypes.VALIDATION, - 'Invalid channel ID provided.', - { logChannelId: updates.logChannelId } - ); - } - - - if (updates.managerRoles && !Array.isArray(updates.managerRoles)) { - throw createError( - 'Invalid manager roles format', - ErrorTypes.VALIDATION, - 'Manager roles must be an array.', - { managerRoles: updates.managerRoles } - ); - } - - - if (updates.questions) { - if (!Array.isArray(updates.questions) || updates.questions.length === 0) { - throw createError( - 'Invalid questions format', - ErrorTypes.VALIDATION, - 'Questions must be a non-empty array.', - { questions: updates.questions } - ); - } - - - updates.questions = updates.questions.map(q => - typeof q === 'string' ? q.trim().substring(0, 100) : q - ); - } - - await saveApplicationSettings(client, guildId, updates); - const updatedSettings = await getApplicationSettings(client, guildId); - - logger.info('Application settings updated', { - guildId, - updates: Object.keys(updates) - }); - - return updatedSettings; - } catch (error) { - logger.error('Error updating application settings', { - error: error.message, - guildId, - updates, - stack: error.stack - }); - throw error; - } - } - - - - - - - - - static async manageApplicationRoles(client, guildId, data) { - try { - const { action, roleId, name } = data; - - const currentRoles = await getApplicationRoles(client, guildId); - - if (action === 'add') { - if (!roleId) { - throw createError( - 'Missing role ID', - ErrorTypes.VALIDATION, - 'You must specify a role to add.', - { action } - ); - } - - - if (currentRoles.some(appRole => appRole.roleId === roleId)) { - throw createError( - 'Role already configured', - ErrorTypes.VALIDATION, - 'This role is already configured for applications.', - { roleId } - ); - } - - currentRoles.push({ - roleId, - name: name ? name.trim().substring(0, 50) : 'Application Role' - }); - - await saveApplicationRoles(client, guildId, currentRoles); - - logger.info('Application role added', { - guildId, - roleId, - name - }); - } else if (action === 'remove') { - if (!roleId) { - throw createError( - 'Missing role ID', - ErrorTypes.VALIDATION, - 'You must specify a role to remove.', - { action } - ); - } - - const roleIndex = currentRoles.findIndex(appRole => appRole.roleId === roleId); - if (roleIndex === -1) { - throw createError( - 'Role not configured', - ErrorTypes.VALIDATION, - 'This role is not configured for applications.', - { roleId } - ); - } - - currentRoles.splice(roleIndex, 1); - await saveApplicationRoles(client, guildId, currentRoles); - - logger.info('Application role removed', { - guildId, - roleId - }); - } - - return currentRoles; - } catch (error) { - logger.error('Error managing application roles', { - error: error.message, - guildId, - data, - stack: error.stack - }); - throw error; - } - } - - - - - - - - - static async getUserApplications(client, guildId, userId) { - try { - const applications = await getUserApplications(client, guildId, userId); - - logger.debug('User applications retrieved', { - guildId, - userId, - count: applications.length - }); - - return applications; - } catch (error) { - logger.error('Error getting user applications', { - error: error.message, - guildId, - userId, - stack: error.stack - }); - throw createError( - 'Failed to retrieve your applications', - ErrorTypes.DATABASE, - 'An error occurred while retrieving your applications.', - { guildId, userId } - ); - } - } - - - - - - - - - static async getSingleApplication(client, guildId, applicationId) { - try { - const application = await getApplication(client, guildId, applicationId); - - if (!application) { - throw createError( - 'Application not found', - ErrorTypes.CONFIGURATION, - 'The application you are looking for does not exist.', - { applicationId, guildId } - ); - } - - return application; - } catch (error) { - logger.error('Error getting application', { - error: error.message, - applicationId, - guildId, - stack: error.stack - }); - throw error; - } - } -} - -export default ApplicationService; diff --git a/src/services/robloxJoinRequestService.js b/src/services/robloxJoinRequestService.js new file mode 100644 index 000000000..3e1f40b93 --- /dev/null +++ b/src/services/robloxJoinRequestService.js @@ -0,0 +1,298 @@ +import { logger } from '../utils/logger.js'; +import axios from 'axios'; + +const ROBLOX_API_BASE = 'https://apis.roblox.com'; +const ROBLOX_WEB_BASE = 'https://www.roblox.com'; + +class RobloxJoinRequestHandler { + constructor() { + this.username = process.env.ROBLOX_USERNAME; + this.password = process.env.ROBLOX_PASSWORD; + this.cookie = null; + this.lastAuthTime = 0; + this.authCooldown = 3600000; // 1 hour + } + + async authenticate() { + try { + // Check if we need to re-authenticate + if (this.cookie && Date.now() - this.lastAuthTime < this.authCooldown) { + return true; + } + + if (!this.username || !this.password) { + logger.error('Roblox credentials not configured'); + return false; + } + + const response = await axios.post( + `${ROBLOX_WEB_BASE}/login/v1/login`, + { + ctype: 'Username', + cvalue: this.username, + password: this.password + }, + { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0' + }, + withCredentials: true + } + ); + + if (response.data.user) { + // Extract cookies from response + const setCookieHeader = response.headers['set-cookie']; + if (setCookieHeader) { + this.cookie = setCookieHeader.join('; '); + this.lastAuthTime = Date.now(); + logger.info('Successfully authenticated with Roblox'); + return true; + } + } + + logger.error('Failed to authenticate with Roblox: No user data'); + return false; + } catch (error) { + logger.error('Roblox authentication error:', error.message); + return false; + } + } + + async getGroupJoinRequests(groupId) { + try { + if (!await this.authenticate()) { + return []; + } + + const response = await axios.get( + `${ROBLOX_API_BASE}/v1/groups/${groupId}/join-requests`, + { + headers: { + 'Cookie': this.cookie, + 'User-Agent': 'Mozilla/5.0' + } + } + ); + + return response.data.data || []; + } catch (error) { + logger.error(`Error fetching join requests for group ${groupId}:`, error.message); + return []; + } + } + + async getUserInfo(userId) { + try { + const response = await axios.get( + `${ROBLOX_API_BASE}/v1/users/${userId}`, + { + headers: { + 'User-Agent': 'Mozilla/5.0' + } + } + ); + + return response.data; + } catch (error) { + logger.error(`Error fetching user info for ${userId}:`, error.message); + return null; + } + } + + async getUserDetails(userId) { + try { + const response = await axios.get( + `${ROBLOX_WEB_BASE}/users/profile/profile-data?userId=${userId}`, + { + headers: { + 'User-Agent': 'Mozilla/5.0' + } + } + ); + + return response.data; + } catch (error) { + logger.error(`Error fetching user details for ${userId}:`, error.message); + return null; + } + } + + async acceptJoinRequest(groupId, userId) { + try { + if (!await this.authenticate()) { + return false; + } + + const response = await axios.post( + `${ROBLOX_API_BASE}/v1/groups/${groupId}/join-requests/users/${userId}/accept`, + {}, + { + headers: { + 'Cookie': this.cookie, + 'User-Agent': 'Mozilla/5.0' + } + } + ); + + logger.info(`Accepted join request for user ${userId} in group ${groupId}`); + return true; + } catch (error) { + logger.error(`Error accepting join request for user ${userId}:`, error.message); + return false; + } + } + + async denyJoinRequest(groupId, userId) { + try { + if (!await this.authenticate()) { + return false; + } + + const response = await axios.post( + `${ROBLOX_API_BASE}/v1/groups/${groupId}/join-requests/users/${userId}/decline`, + {}, + { + headers: { + 'Cookie': this.cookie, + 'User-Agent': 'Mozilla/5.0' + } + } + ); + + logger.info(`Denied join request for user ${userId} in group ${groupId}`); + return true; + } catch (error) { + logger.error(`Error denying join request for user ${userId}:`, error.message); + return false; + } + } +} + +export const robloxHandler = new RobloxJoinRequestHandler(); + +export async function checkRobloxJoinRequests(client) { + try { + const groupConfigs = [ + { + groupId: process.env.ROBLOX_TEST_GROUP_ID, + channelEnvVar: 'ROBLOX_REQUESTS_CHANNEL_TEST', + name: 'Test Group' + }, + { + groupId: process.env.ROBLOX_LASD_GROUP_ID, + channelEnvVar: 'ROBLOX_REQUESTS_CHANNEL_LASD', + name: 'LASD' + }, + { + groupId: process.env.ROBLOX_CHP_GROUP_ID, + channelEnvVar: 'ROBLOX_REQUESTS_CHANNEL_CHP', + name: 'CHP' + }, + { + groupId: process.env.ROBLOX_LAFD_GROUP_ID, + channelEnvVar: 'ROBLOX_REQUESTS_CHANNEL_LAFD', + name: 'LAFD' + } + ]; + + for (const config of groupConfigs) { + if (!config.groupId) continue; + + const channelId = process.env[config.channelEnvVar]; + if (!channelId) { + logger.warn(`No Discord channel configured for ${config.name} (${config.channelEnvVar})`); + continue; + } + + try { + const channel = await client.channels.fetch(channelId); + if (!channel) { + logger.warn(`Channel ${channelId} not found for ${config.name}`); + continue; + } + + const requests = await robloxHandler.getGroupJoinRequests(config.groupId); + + for (const request of requests) { + const userId = request.requester.userId; + const userInfo = await robloxHandler.getUserInfo(userId); + const userDetails = await robloxHandler.getUserDetails(userId); + + if (!userInfo) continue; + + // Create embed with user info + const embed = { + title: `๐ŸŽฎ Join Request - ${config.name}`, + color: 0x1a1a1a, + fields: [ + { + name: 'Username & Display', + value: `${userInfo.name} (${userInfo.displayName})`, + inline: false + }, + { + name: 'User ID', + value: `${userId}`, + inline: true + }, + { + name: 'About', + value: userDetails?.aboutMe || 'No bio', + inline: false + }, + { + name: 'Total Groups', + value: `${userDetails?.groupCount || 0}`, + inline: true + }, + { + name: 'Account Created', + value: userInfo.created ? new Date(userInfo.created).toLocaleDateString() : 'Unknown', + inline: true + } + ], + footer: { + text: `User ID: ${userId}` + }, + timestamp: new Date() + }; + + // Send message with buttons + const message = await channel.send({ + embeds: [embed], + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 3, // Green + label: 'Accept', + custom_id: `roblox_accept_${config.groupId}_${userId}`, + emoji: 'โœ…' + }, + { + type: 2, + style: 4, // Red + label: 'Deny', + custom_id: `roblox_deny_${config.groupId}_${userId}`, + emoji: 'โŒ' + } + ] + } + ] + }); + + logger.info(`Posted join request for user ${userId} in ${config.name}`); + } + } catch (error) { + logger.error(`Error checking join requests for ${config.name}:`, error.message); + } + } + } catch (error) { + logger.error('Error in checkRobloxJoinRequests:', error.message); + } +} + diff --git a/src/services/shiftService.js b/src/services/shiftService.js new file mode 100644 index 000000000..18dee17a8 --- /dev/null +++ b/src/services/shiftService.js @@ -0,0 +1,336 @@ +import { pgDb } from '../utils/postgresDatabase.js'; +import { logger } from '../utils/logger.js'; + +/** + * Ensures the shifts and shift_config tables exist in the database. + * Called lazily before any shift operation so the tables are always ready. + */ +async function ensureTables() { + if (!pgDb.isAvailable()) { + throw new Error('Database is not available'); + } + + await pgDb.pool.query(` + CREATE TABLE IF NOT EXISTS shifts ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(20) NOT NULL, + guild_id VARCHAR(20) NOT NULL, + start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP, + break_time BIGINT NOT NULL DEFAULT 0, + break_started_at TIMESTAMP, + on_break BOOLEAN NOT NULL DEFAULT FALSE, + total_duration BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + await pgDb.pool.query(` + CREATE INDEX IF NOT EXISTS idx_shifts_guild_user ON shifts(guild_id, user_id) + `); + + await pgDb.pool.query(` + CREATE INDEX IF NOT EXISTS idx_shifts_guild_id ON shifts(guild_id) + `); + + await pgDb.pool.query(` + CREATE TABLE IF NOT EXISTS shift_config ( + guild_id VARCHAR(20) PRIMARY KEY, + shift_role_id VARCHAR(20), + shift_start_role_id VARCHAR(20), + shift_break_role_id VARCHAR(20), + shift_stop_role_id VARCHAR(20), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Migrate existing tables: add per-action columns if they don't exist yet + await pgDb.pool.query(` + ALTER TABLE shift_config + ADD COLUMN IF NOT EXISTS shift_start_role_id VARCHAR(20), + ADD COLUMN IF NOT EXISTS shift_break_role_id VARCHAR(20), + ADD COLUMN IF NOT EXISTS shift_stop_role_id VARCHAR(20) + `); + + // Drop NOT NULL constraint on shift_role_id for tables created with the old schema + await pgDb.pool.query(` + ALTER TABLE shift_config ALTER COLUMN shift_role_id DROP NOT NULL + `); +} + +/** + * Get the configured shift role ID for a guild (legacy / backward-compat). + * Returns the first non-null role found across start/break/stop, or the old + * catch-all shift_role_id, so callers that only need "any configured role" + * still work without changes. + * @param {string} guildId + * @returns {Promise} + */ +export async function getShiftRoleId(guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + 'SELECT shift_role_id, shift_start_role_id, shift_break_role_id, shift_stop_role_id FROM shift_config WHERE guild_id = $1', + [guildId] + ); + if (result.rows.length === 0) return null; + const row = result.rows[0]; + return row.shift_start_role_id ?? row.shift_break_role_id ?? row.shift_stop_role_id ?? row.shift_role_id ?? null; +} + +/** + * Get the role ID allowed to start shifts for a guild. + * @param {string} guildId + * @returns {Promise} + */ +export async function getShiftStartRoleId(guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + 'SELECT shift_start_role_id FROM shift_config WHERE guild_id = $1', + [guildId] + ); + return result.rows.length > 0 ? result.rows[0].shift_start_role_id : null; +} + +/** + * Get the role ID allowed to use break/resume for a guild. + * @param {string} guildId + * @returns {Promise} + */ +export async function getShiftBreakRoleId(guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + 'SELECT shift_break_role_id FROM shift_config WHERE guild_id = $1', + [guildId] + ); + return result.rows.length > 0 ? result.rows[0].shift_break_role_id : null; +} + +/** + * Get the role ID allowed to stop shifts for a guild. + * @param {string} guildId + * @returns {Promise} + */ +export async function getShiftStopRoleId(guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + 'SELECT shift_stop_role_id FROM shift_config WHERE guild_id = $1', + [guildId] + ); + return result.rows.length > 0 ? result.rows[0].shift_stop_role_id : null; +} + +/** + * Set the role that can start shifts for a guild. + * @param {string} guildId + * @param {string} roleId + * @returns {Promise} + */ +export async function setShiftStartRole(guildId, roleId) { + await ensureTables(); + await pgDb.pool.query( + `INSERT INTO shift_config (guild_id, shift_start_role_id, shift_role_id, updated_at) + VALUES ($1, $2, NULL, CURRENT_TIMESTAMP) + ON CONFLICT (guild_id) + DO UPDATE SET shift_start_role_id = $2, updated_at = CURRENT_TIMESTAMP`, + [guildId, roleId] + ); +} + +/** + * Set the role that can use break/resume for a guild. + * @param {string} guildId + * @param {string} roleId + * @returns {Promise} + */ +export async function setShiftBreakRole(guildId, roleId) { + await ensureTables(); + await pgDb.pool.query( + `INSERT INTO shift_config (guild_id, shift_break_role_id, shift_role_id, updated_at) + VALUES ($1, $2, NULL, CURRENT_TIMESTAMP) + ON CONFLICT (guild_id) + DO UPDATE SET shift_break_role_id = $2, updated_at = CURRENT_TIMESTAMP`, + [guildId, roleId] + ); +} + +/** + * Set the role that can stop shifts for a guild. + * @param {string} guildId + * @param {string} roleId + * @returns {Promise} + */ +export async function setShiftStopRole(guildId, roleId) { + await ensureTables(); + await pgDb.pool.query( + `INSERT INTO shift_config (guild_id, shift_stop_role_id, shift_role_id, updated_at) + VALUES ($1, $2, NULL, CURRENT_TIMESTAMP) + ON CONFLICT (guild_id) + DO UPDATE SET shift_stop_role_id = $2, updated_at = CURRENT_TIMESTAMP`, + [guildId, roleId] + ); +} + +/** + * Get the active (open) shift for a user in a guild. + * @param {string} userId + * @param {string} guildId + * @returns {Promise} + */ +export async function getActiveShift(userId, guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + `SELECT * FROM shifts + WHERE user_id = $1 AND guild_id = $2 AND end_time IS NULL + ORDER BY start_time DESC + LIMIT 1`, + [userId, guildId] + ); + return result.rows.length > 0 ? result.rows[0] : null; +} + +/** + * Start a new shift for a user. + * @param {string} userId + * @param {string} guildId + * @returns {Promise} The created shift row + */ +export async function startShift(userId, guildId) { + await ensureTables(); + const result = await pgDb.pool.query( + `INSERT INTO shifts (user_id, guild_id, start_time) + VALUES ($1, $2, CURRENT_TIMESTAMP) + RETURNING *`, + [userId, guildId] + ); + return result.rows[0]; +} + +/** + * Stop an active shift, calculating total duration minus break time. + * @param {number} shiftId + * @returns {Promise} The updated shift row + */ +export async function stopShift(shiftId) { + await ensureTables(); + + // If currently on break, close the break first + const shiftResult = await pgDb.pool.query( + 'SELECT * FROM shifts WHERE id = $1', + [shiftId] + ); + const shift = shiftResult.rows[0]; + + let extraBreakMs = 0; + if (shift.on_break && shift.break_started_at) { + extraBreakMs = Math.floor(Date.now() - new Date(shift.break_started_at).getTime()); + } + + const totalBreakMs = Math.floor(Number(shift.break_time) + extraBreakMs); + + const result = await pgDb.pool.query( + `UPDATE shifts + SET end_time = CURRENT_TIMESTAMP, + on_break = FALSE, + break_started_at = NULL, + break_time = $2::BIGINT, + total_duration = (EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - start_time)) * 1000)::BIGINT - $2::BIGINT + WHERE id = $1 + RETURNING *`, + [shiftId, totalBreakMs] + ); + return result.rows[0]; +} + +/** + * Toggle break status on an active shift. + * @param {number} shiftId + * @returns {Promise<{shift: Object, nowOnBreak: boolean}>} + */ +export async function toggleBreak(shiftId) { + await ensureTables(); + + const shiftResult = await pgDb.pool.query( + 'SELECT * FROM shifts WHERE id = $1', + [shiftId] + ); + const shift = shiftResult.rows[0]; + + let updatedShift; + + if (shift.on_break) { + // Ending break โ€” accumulate break duration + const breakDurationMs = Date.now() - new Date(shift.break_started_at).getTime(); + const newBreakTime = Number(shift.break_time) + breakDurationMs; + + const result = await pgDb.pool.query( + `UPDATE shifts + SET on_break = FALSE, + break_started_at = NULL, + break_time = $2 + WHERE id = $1 + RETURNING *`, + [shiftId, newBreakTime] + ); + updatedShift = result.rows[0]; + return { shift: updatedShift, nowOnBreak: false }; + } else { + // Starting break + const result = await pgDb.pool.query( + `UPDATE shifts + SET on_break = TRUE, + break_started_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING *`, + [shiftId] + ); + updatedShift = result.rows[0]; + return { shift: updatedShift, nowOnBreak: true }; + } +} + +/** + * Delete all shift records for users who have a specific role in a guild. + * Since we can't query Discord roles from the DB, we accept a list of member IDs. + * @param {string} guildId + * @param {string[]} userIds - Array of user IDs to wipe shifts for + * @returns {Promise} Number of deleted records + */ +export async function wipeShiftsByUserIds(guildId, userIds) { + await ensureTables(); + + if (!userIds || userIds.length === 0) { + return 0; + } + + // Build parameterized query for the user ID list + const placeholders = userIds.map((_, i) => `$${i + 2}`).join(', '); + const result = await pgDb.pool.query( + `DELETE FROM shifts + WHERE guild_id = $1 AND user_id IN (${placeholders})`, + [guildId, ...userIds] + ); + return result.rowCount; +} + +/** + * Format a duration in milliseconds to a human-readable string. + * e.g. 9135000 โ†’ "2h 32m 15s" + * @param {number} ms + * @returns {string} + */ +export function formatDuration(ms) { + if (!ms || ms <= 0) return '0s'; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); +}