diff --git a/docs/COMMAND-WIKI.md b/docs/COMMAND-WIKI.md index c2cc7eca..e4c5e84d 100644 --- a/docs/COMMAND-WIKI.md +++ b/docs/COMMAND-WIKI.md @@ -304,6 +304,28 @@ - ``description``: The description of the customization to be set for the user. - **Subcommands:** None +# REMINDER +## announcement +- **Aliases:** None +- **Description:** Set up an announcement! +- **Examples:**
`/announcement` - Opens an setup form +- **Options:** None +- **Subcommands:** `create`, `name`, `description`, `executeCommand`, `isCommandResponseEphemeral`, `options`, `name`, `description`, `type`, `required` + +## reminder +- **Aliases:** None +- **Description:** Set up a reminder for anything you want! +- **Examples:**
`/reminder` - Opens an interactive reminder setup form +- **Options:** None +- **Subcommands:** `create`, `name`, `description`, `executeCommand`, `isCommandResponseEphemeral`, `options`, `name`, `description`, `type`, `required` + +## timer +- **Aliases:** None +- **Description:** Set up a timer for anything you want! +- **Examples:**
`/timer value minutes:5`
`/timer value hours:2 minutes:30`
`/timer value minutes:10 message:Take a break!` +- **Options:** None +- **Subcommands:** `create`, `name`, `description`, `executeCommand`, `isCommandResponseEphemeral`, `options`, `name`, `description`, `required`, `type` + # SUGGESTION ## suggestion - **Aliases:** ``suggest`` diff --git a/src/codeyCommand.ts b/src/codeyCommand.ts index 3058704a..36edc2c1 100644 --- a/src/codeyCommand.ts +++ b/src/codeyCommand.ts @@ -322,7 +322,6 @@ export class CodeyCommand extends SapphireCommand { interaction: SapphireCommand.ChatInputCommandInteraction, ): Promise | undefined> { const { client } = container; - // Get subcommand name let subcommandName = ''; try { @@ -373,7 +372,6 @@ export class CodeyCommand extends SapphireCommand { ) { successResponse.response = { content: successResponse.response }; } - // cannot double reply to a slash command (in case command replies on its own), runtime error if (!interaction.replied) { await interaction.reply( diff --git a/src/commandDetails/fun/rollDice.ts b/src/commandDetails/fun/rollDice.ts index 953b5312..73b03630 100644 --- a/src/commandDetails/fun/rollDice.ts +++ b/src/commandDetails/fun/rollDice.ts @@ -5,7 +5,7 @@ import { SapphireMessageExecuteType, SapphireMessageResponse, } from '../../codeyCommand'; -import { getRandomIntFrom1 } from '../../utils/num'; +// import { getRandomIntFrom1 } from '../../utils/num'; const rollDiceExecuteCommand: SapphireMessageExecuteType = ( _client, @@ -22,8 +22,16 @@ const rollDiceExecuteCommand: SapphireMessageExecuteType = ( if (sides > SIDES_UPPER_BOUND) { return new Promise((resolve, _reject) => resolve("that's too many sides!")); } - const diceFace = getRandomIntFrom1(sides); - return new Promise((resolve, _reject) => resolve(`you rolled a ${diceFace}!`)); + // const diceFace = getRandomIntFrom1(sides); + let userId: string; + if ('author' in _messageFromUser) { + userId = _messageFromUser.author.id; + } else if ('user' in _messageFromUser) { + userId = _messageFromUser.user.id; + } else { + userId = 'unknown'; + } + return new Promise((resolve, _reject) => resolve(`your id is ${userId}!`)); }; export const rollDiceCommandDetails: CodeyCommandDetails = { diff --git a/src/commandDetails/reminder/announcements.ts b/src/commandDetails/reminder/announcements.ts new file mode 100644 index 00000000..7c7103ba --- /dev/null +++ b/src/commandDetails/reminder/announcements.ts @@ -0,0 +1,494 @@ +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + Colors, + ComponentType, + EmbedBuilder, + Message, + ModalBuilder, + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +import * as announcementComponents from '../../components/reminder/announcement'; + +const getUser = (messageFromUser: Message | ChatInputCommandInteraction) => { + return 'user' in messageFromUser ? messageFromUser.user : messageFromUser.author; +}; + +// Create a new announcement +const announcementCreateCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const user = getUser(messageFromUser); + const interaction = messageFromUser as ChatInputCommandInteraction; + + const dateOption = args['date'] as string; + const timeOption = interaction.options.getString('time') || ''; + const messageOption = interaction.options.getString('message') || ''; + const photoOption = interaction.options.getString('photo') || ''; // Add this line + + const modal = new ModalBuilder() + .setCustomId('announcement-modal') + .setTitle('📅 Set Your Announcement'); + + const titleInput = new TextInputBuilder() + .setCustomId('announcement-title') + .setLabel('Title') + .setPlaceholder('e.g., Codey eats π') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(100) + .setMinLength(3); + + const dateInput = new TextInputBuilder() + .setCustomId('announcement-date') + .setLabel('Date (YYYY-MM-DD)') + .setPlaceholder('e.g., 2025-12-25') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(10) + .setMinLength(8); + + if (dateOption) { + dateInput.setValue(dateOption); + } + + const timeInput = new TextInputBuilder() + .setCustomId('announcement-time') + .setLabel('Time (HH:MM) - 24 hour format') + .setPlaceholder('e.g., 14:30 or 09:15') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(5) + .setMinLength(4); + + // Pre-populate time field if provided + if (timeOption) { + timeInput.setValue(timeOption); + } + + const messageInput = new TextInputBuilder() + .setCustomId('announcement-message') + .setLabel('Announcement Message') + .setPlaceholder('What would you like to announce?') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMinLength(1); + + // Pre-populate message field if provided + if (messageOption) { + messageInput.setValue(messageOption); + } + + const photoInput = new TextInputBuilder() + .setCustomId('announcement-photo') // Changed ID for consistency + .setLabel('Photo Embed') + .setPlaceholder('Provide an Embedded Photo Link if you have one.') + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setMaxLength(500); + + if (photoOption) { + photoInput.setValue(photoOption); + } + + const titleActionRow = new ActionRowBuilder().addComponents(titleInput); + const dateActionRow = new ActionRowBuilder().addComponents(dateInput); + const timeActionRow = new ActionRowBuilder().addComponents(timeInput); + const messageActionRow = new ActionRowBuilder().addComponents(messageInput); + const photoActionRow = new ActionRowBuilder().addComponents(photoInput); + + modal.addComponents( + titleActionRow, + dateActionRow, + timeActionRow, + messageActionRow, + photoActionRow, + ); + await interaction.showModal(modal); + + try { + const modalSubmit = await interaction.awaitModalSubmit({ + time: 300000, // 5 minutes timeout + filter: (i) => i.customId === 'announcement-modal' && i.user.id === interaction.user.id, + }); + + // Match these field IDs with the customIds set on the input fields + const title = modalSubmit.fields.getTextInputValue('announcement-title').trim(); + const date = modalSubmit.fields.getTextInputValue('announcement-date').trim(); + const time = modalSubmit.fields.getTextInputValue('announcement-time').trim(); + const message = modalSubmit.fields.getTextInputValue('announcement-message').trim(); + const image_url = modalSubmit.fields.getTextInputValue('announcement-photo').trim(); + + const dateRegex = /^\d{4}-\d{1,2}-\d{1,2}$/; + const timeRegex = /^\d{1,2}:\d{2}$/; + + const validationErrors = []; + + if (!dateRegex.test(date)) { + validationErrors.push('Date must be in YYYY-MM-DD format'); + } + + if (!timeRegex.test(time)) { + validationErrors.push('Time must be in HH:MM format (24-hour)'); + } + + const dateParts = date.split('-'); + const paddedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart( + 2, + '0', + )}`; + + const timeParts = time.split(':'); + const paddedTime = `${timeParts[0].padStart(2, '0')}:${timeParts[1].padStart(2, '0')}`; + + const inputDateTime = new Date(`${paddedDate}T${paddedTime}:00`); + const now = new Date(); + + if (inputDateTime <= now) { + validationErrors.push('Announcement time must be in the future'); + } + + if (validationErrors.length > 0) { + const errorEmbed = new EmbedBuilder() + .setTitle('❌ Invalid Input') + .setDescription( + `Please fix the following errors:\n${validationErrors + .map((error) => `• ${error}`) + .join('\n')}`, + ) + .setColor(Colors.Red); + + await modalSubmit.reply({ embeds: [errorEmbed], ephemeral: true }); + return ''; + } + + await announcementComponents.addAnnouncement( + user.id, + title, + now.toISOString(), + inputDateTime.toISOString(), + message, + image_url.length > 0 ? image_url : undefined, // Only pass image_url if it's not empty + ); + const formattedTimestamp = ``; + + const previewMessage = [ + `*Scheduled for: ${formattedTimestamp}*`, + "**Here's how the announcement will appear:**", + '', + message, + ].join('\n'); + + await modalSubmit.reply({ + content: `✅ **Announcement created and scheduled!**\n\n${previewMessage}`, + ephemeral: true, + files: image_url.length > 0 ? [image_url] : [], // Attach the image if provided + allowedMentions: { parse: ['users', 'roles'] }, + }); + } catch (error) { + return 'Error'; + } + return ''; +}; + +const announcementExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + _args, +): Promise => { + return `User id is ${messageFromUser.client.id}`; +}; + +const announcementViewCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + _args, +): Promise => { + const interaction = messageFromUser as ChatInputCommandInteraction; + const user = getUser(messageFromUser); + + // Immediately defer the reply to prevent timeout + await interaction.deferReply({ ephemeral: true }); + + // Fetch all announcements for the user + const announcements = await announcementComponents.getAnnouncements(); + + if (!announcements || announcements.length === 0) { + const noAnnouncementsEmbed = new EmbedBuilder() + .setTitle('No Announcements') + .setDescription("You don't have any scheduled announcements.") + .setColor(Colors.Red); + + await interaction.editReply({ embeds: [noAnnouncementsEmbed] }); + return ''; + } + + // Create dropdown options for each announcement + const options = announcements.map((announcement) => ({ + label: + announcement.title.length > 25 + ? announcement.title.substring(0, 22) + '...' + : announcement.title, + description: `Scheduled for ${new Date(announcement.reminder_at).toLocaleString()}`, + value: announcement.id.toString(), + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('announcement-select') + .setPlaceholder('Select an announcement to preview') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const listEmbed = new EmbedBuilder() + .setTitle('📢 Scheduled Announcements') + .setDescription('Select an announcement from the dropdown to preview how it will look') + .setColor(Colors.Blue) + .addFields({ + name: 'Total Announcements', + value: announcements.length.toString(), + inline: true, + }) + .setTimestamp(); + + const response = await interaction.editReply({ + embeds: [listEmbed], + components: [row], + }); + + const collector = response.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: (i) => i.user.id === user.id && i.customId === 'announcement-select', + time: 60000, // 1 minute timeout + }); + + collector.on('collect', async (selectInteraction) => { + const announcementId = parseInt(selectInteraction.values[0], 10); + + const selectedAnnouncement = announcements.find((a) => a.id === announcementId); + + if (!selectedAnnouncement) { + await selectInteraction.update({ + content: 'Error: Could not find the selected announcement', + embeds: [], + components: [], + }); + return; + } + + const formattedTimestamp = ``; + + // Create the preview content similar to how it will be displayed + const previewMessage = [ + selectedAnnouncement.message, + '', + `*(Scheduled to be announced on ${formattedTimestamp})*`, + ].join('\n'); + + const backButton = new ButtonBuilder() + .setCustomId('back-to-list') + .setLabel('Back to List') + .setStyle(ButtonStyle.Secondary); + + const buttonRow = new ActionRowBuilder().addComponents(backButton); + + // Show the announcement preview + await selectInteraction.update({ + content: previewMessage, + embeds: [], + components: [buttonRow], + files: selectedAnnouncement.image_url ? [selectedAnnouncement.image_url] : [], + }); + + const buttonCollector = response.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === user.id && i.customId === 'back-to-list', + time: 60000, // 1 minute timeout + }); + + buttonCollector.on('collect', async (buttonInteraction) => { + await buttonInteraction.update({ + content: null, + embeds: [listEmbed], + components: [row], + files: [], + }); + }); + }); + + collector.on('end', async (collected) => { + if (collected.size === 0) { + // Timeout - remove components + await interaction.editReply({ + content: 'Selection timed out.', + components: [], + }); + } + }); + + return ''; +}; + +const announcementDeleteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + _args, +): Promise => { + const interaction = messageFromUser as ChatInputCommandInteraction; + const user = getUser(messageFromUser); + + // Defering to avoid timeout + await interaction.deferReply({ ephemeral: true }); + + const announcements = await announcementComponents.getAnnouncements(); + + if (!announcements || announcements.length === 0) { + const errorEmbed = new EmbedBuilder() + .setTitle(`❌ No Announcements`) + .setDescription(`You do not have any announcements to delete.`) + .setColor(Colors.Red); + await interaction.editReply({ embeds: [errorEmbed] }); + return ''; + } + + const embed = new EmbedBuilder() + .setTitle(`🗑️ Delete an Announcement`) + .setDescription(`Select an announcement to delete from the dropdown menu below.`) + .setColor(Colors.Red); + + const options = announcements.map((announcement) => ({ + label: `${ + announcement.title.length > 50 + ? announcement.title.substring(0, 47) + '...' + : announcement.title + }`, + description: `Scheduled for: ${new Date(announcement.reminder_at).toLocaleString()}`, + value: announcement.id.toString(), + })); + + const customId = `delete-announcement-select`; + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(`Choose an announcement to delete...`) + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + const response = await interaction.editReply({ + embeds: [embed], + components: [row], + }); + + try { + const selection = await response.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + filter: (i) => i.user.id === user.id && i.customId === customId, + time: 60000, // 60 seconds timeout + }); + + const announcementIdToDelete = parseInt(selection.values[0], 10); + await announcementComponents.deleteAnnouncement(announcementIdToDelete); + + // Confirm deletion + const successEmbed = new EmbedBuilder() + .setTitle(`✅ Announcement Deleted`) + .setDescription(`Successfully deleted announcement.`) + .setColor(Colors.Green); + + await selection.update({ embeds: [successEmbed], components: [] }); + } catch (e) { + const timeoutEmbed = new EmbedBuilder() + .setTitle('⏰ Timed Out') + .setDescription('You did not make a selection in time.') + .setColor(Colors.Yellow); + await interaction.editReply({ embeds: [timeoutEmbed], components: [] }); + } + return ''; +}; + +export const announcementCommandDetails: CodeyCommandDetails = { + name: 'announcement', + aliases: [], + description: 'Set up an announcement!', + detailedDescription: `**Examples:** + \`/announcement\` - Opens an setup form`, + isCommandResponseEphemeral: true, + messageWhenExecutingCommand: 'Setting up the announcement...', + executeCommand: announcementExecuteCommand, + messageIfFailure: 'Failed to set up the announcement', + options: [], + subcommandDetails: { + create: { + name: 'create', + description: 'Set up an announcement with specified duration', + executeCommand: announcementCreateCommand, + isCommandResponseEphemeral: true, + options: [ + { + name: 'date', + description: 'A date in the format YYYY-MM-DD', + type: CodeyCommandOptionType.STRING, + required: false, + }, + { + name: 'time', + description: 'A time in the format HH:DD', + type: CodeyCommandOptionType.STRING, + required: false, + }, + { + name: 'message', + description: 'What you want to announce.', + type: CodeyCommandOptionType.STRING, + required: false, + }, + { + name: 'photo', + description: 'URL of an image to include with the announcement', + type: CodeyCommandOptionType.STRING, + required: false, + }, + ], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + + delete: { + name: 'delete', + description: 'Delete one of the planned announcements.', + executeCommand: announcementDeleteCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: 'Choose an announccement to permanently delete it.', + subcommandDetails: {}, + }, + + view: { + name: 'view', + description: 'View any announcements that are planned!', + executeCommand: announcementViewCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + }, +}; diff --git a/src/commandDetails/reminder/reminder.ts b/src/commandDetails/reminder/reminder.ts new file mode 100644 index 00000000..377d72b0 --- /dev/null +++ b/src/commandDetails/reminder/reminder.ts @@ -0,0 +1,261 @@ +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { + ActionRowBuilder, + ChatInputCommandInteraction, + Colors, + EmbedBuilder, + Message, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import * as reminderComponents from '../../components/reminder/reminder'; +import { genericDeleteResponse, genericViewResponse } from './sharedViews'; + +const getUser = (messageFromUser: Message | ChatInputCommandInteraction) => { + return 'user' in messageFromUser ? messageFromUser.user : messageFromUser.author; +}; + +// Create a new reminder +const reminderCreateCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const user = getUser(messageFromUser); + + const interaction = messageFromUser as ChatInputCommandInteraction; + + // Get option values from the command (if provided) + const dateOption = args['date'] as string; + const timeOption = interaction.options.getString('time') || ''; + const messageOption = interaction.options.getString('message') || ''; + + // Create the modal + const modal = new ModalBuilder().setCustomId('reminder-modal').setTitle('📅 Set Your Reminder'); + + // Create text inputs for date, time, and message with pre-populated values + const dateInput = new TextInputBuilder() + .setCustomId('reminder-date') + .setLabel('Date (YYYY-MM-DD)') + .setPlaceholder('e.g., 2025-12-25') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(10) + .setMinLength(8); + + // Pre-populate date field if provided + if (dateOption) { + dateInput.setValue(dateOption); + } + + const timeInput = new TextInputBuilder() + .setCustomId('reminder-time') + .setLabel('Time (HH:MM) - 24 hour format') + .setPlaceholder('e.g., 14:30 or 09:15') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(5) + .setMinLength(4); + + // Pre-populate time field if provided + if (timeOption) { + timeInput.setValue(timeOption); + } + + const messageInput = new TextInputBuilder() + .setCustomId('reminder-message') + .setLabel('Reminder Message') + .setPlaceholder('What would you like to be reminded about?') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(500) + .setMinLength(1); + + // Pre-populate message field if provided + if (messageOption) { + messageInput.setValue(messageOption); + } + + const firstActionRow = new ActionRowBuilder().addComponents(dateInput); + const secondActionRow = new ActionRowBuilder().addComponents(timeInput); + const thirdActionRow = new ActionRowBuilder().addComponents(messageInput); + + modal.addComponents(firstActionRow, secondActionRow, thirdActionRow); + + await interaction.showModal(modal); + + try { + const modalSubmit = await interaction.awaitModalSubmit({ + time: 300000, + filter: (i) => i.customId === 'reminder-modal' && i.user.id === interaction.user.id, + }); + + const date = modalSubmit.fields.getTextInputValue('reminder-date').trim(); + const time = modalSubmit.fields.getTextInputValue('reminder-time').trim(); + const message = modalSubmit.fields.getTextInputValue('reminder-message').trim(); + + // Basic validation + const dateRegex = /^\d{4}-\d{1,2}-\d{1,2}$/; + const timeRegex = /^\d{1,2}:\d{2}$/; + + const validationErrors = []; + + if (!dateRegex.test(date)) { + validationErrors.push('Date must be in YYYY-MM-DD format'); + } + + if (!timeRegex.test(time)) { + validationErrors.push('Time must be in HH:MM format (24-hour)'); + } + + const dateParts = date.split('-'); + const paddedDate = `${dateParts[0]}-${dateParts[1].padStart(2, '0')}-${dateParts[2].padStart( + 2, + '0', + )}`; + + const timeParts = time.split(':'); + const paddedTime = `${timeParts[0].padStart(2, '0')}:${timeParts[1].padStart(2, '0')}`; + + const inputDateTime = new Date(`${paddedDate}T${paddedTime}:00`); + const now = new Date(); + + if (inputDateTime <= now) { + validationErrors.push('Reminder time must be in the future'); + } + + if (validationErrors.length > 0) { + const errorEmbed = new EmbedBuilder() + .setTitle('❌ Invalid Input') + .setDescription( + `Please fix the following errors:\n${validationErrors + .map((error) => `• ${error}`) + .join('\n')}`, + ) + .setColor(Colors.Red); + await modalSubmit.reply({ embeds: [errorEmbed], ephemeral: true }); + return ''; + } + reminderComponents.addReminder( + user.id, + true, + now.toISOString(), + inputDateTime.toISOString(), + message, + ); + // Create success output embed + const outputEmbed = new EmbedBuilder() + .setTitle('📝 Reminder Created Successfully!') + .setDescription("Here's your reminder details:") + .addFields( + { name: '📅 Date', value: `\`${date}\``, inline: true }, + { name: '🕐 Time', value: `\`${time}\``, inline: true }, + { + name: '📍 Scheduled For', + value: ``, + inline: false, + }, + { name: '💬 Message', value: `\`${message}\``, inline: false }, + ) + .setColor(Colors.Green) + .setFooter({ text: 'Your reminder has been set!' }) + .setTimestamp(); + + await modalSubmit.reply({ embeds: [outputEmbed], ephemeral: true }); + } catch (error) { + // Modal submission timed out or was cancelled + } + + return ''; +}; + +// View reminders +const reminderViewCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const ret = await genericViewResponse(_client, messageFromUser, args); + return ret as SapphireMessageResponse; +}; + +// Delete Reminders +const reminderDeleteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const ret = await genericDeleteResponse(_client, messageFromUser, args); + return ret as SapphireMessageResponse; +}; + +export const reminderCommandDetails: CodeyCommandDetails = { + name: 'reminder', + aliases: [], + description: 'Set up a reminder for anything you want!', + detailedDescription: `**Examples:** + \`/reminder\` - Opens an interactive reminder setup form`, + isCommandResponseEphemeral: true, + messageWhenExecutingCommand: 'Setting up reminder form...', + messageIfFailure: 'Failed to set up reminder form.', + options: [], + subcommandDetails: { + create: { + name: 'create', + description: 'Set a timer with specified duration', + executeCommand: reminderCreateCommand, + isCommandResponseEphemeral: true, + options: [ + { + name: 'date', + description: 'A date in the format YYYY-MM-DD', + type: CodeyCommandOptionType.STRING, + required: false, + }, + { + name: 'time', + description: 'A time in the format HH:DD', + type: CodeyCommandOptionType.STRING, + required: false, + }, + { + name: 'message', + description: 'Message for your Reminder', + type: CodeyCommandOptionType.STRING, + required: false, + }, + ], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + + delete: { + name: 'delete', + description: 'Delete one of your active reminders.', + executeCommand: reminderDeleteCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: 'Choose a reminder to permanently delete it.', + subcommandDetails: {}, + }, + + view: { + name: 'view', + description: 'View any reminders you set!', + executeCommand: reminderViewCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + }, +}; diff --git a/src/commandDetails/reminder/sharedViews.ts b/src/commandDetails/reminder/sharedViews.ts new file mode 100644 index 00000000..b91f9642 --- /dev/null +++ b/src/commandDetails/reminder/sharedViews.ts @@ -0,0 +1,175 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommandArguments, SapphireMessageResponse } from '../../codeyCommand'; +import * as reminderComponents from '../../components/reminder/reminder'; +import { CacheType, ChatInputCommandInteraction, Client, Message } from 'discord.js'; +import { + ActionRowBuilder, + Colors, + ComponentType, + EmbedBuilder, + StringSelectMenuBuilder, +} from 'discord.js'; + +const getUser = (messageFromUser: Message | ChatInputCommandInteraction) => { + return 'user' in messageFromUser ? messageFromUser.user : messageFromUser.author; +}; + +export const genericViewResponse = async ( + _client: Client, + messageFromUser: Message | Command.ChatInputCommandInteraction, + _args: CodeyCommandArguments, + is_reminder = true, +): Promise => { + const user = getUser(messageFromUser); + + const list = is_reminder + ? await reminderComponents.getReminders(user.id) + : await reminderComponents.getTimers(user.id); + + const itemType = is_reminder ? 'reminders' : 'timers'; + const itemTypeCapitalized = is_reminder ? 'Reminders' : 'Timers'; + const emoji = is_reminder ? '📝' : '⏰'; + + if (!list || list.length === 0) { + return `📭 You have no ${itemType} set.`; + } + + let response = `${emoji} **Your ${itemTypeCapitalized}:**\n\n`; + + list.forEach((item, index) => { + // Parse the ISO date string + const reminderDate = new Date(item.reminder_at); + const createdDate = new Date(item.created_at); + + // Format dates nicely + const reminderFormatted = reminderDate.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + + const createdFormatted = createdDate.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + // Calculate time until reminder/timer + const now = new Date(); + const timeDiff = reminderDate.getTime() - now.getTime(); + let timeUntil = ''; + + if (timeDiff > 0) { + const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((timeDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + timeUntil = `⏳ In ${days}d ${hours}h`; + } else if (hours > 0) { + timeUntil = `⏳ In ${hours}h ${minutes}m`; + } else { + timeUntil = `⏳ In ${minutes}m`; + } + } else { + timeUntil = `🔴 **OVERDUE**`; + } + + response += `**${index + 1}.** ${item.message}\n`; + response += `📅 **When:** ${reminderFormatted}\n`; + response += `${timeUntil}\n`; + response += `📝 *Created: ${createdFormatted}*\n\n`; + }); + + return response; +}; + +// Delete a reminder +export const genericDeleteResponse = async ( + _client: Client, + messageFromUser: Message | Command.ChatInputCommandInteraction, + _args: CodeyCommandArguments, + is_reminder = true, +): Promise => { + const interaction = messageFromUser as ChatInputCommandInteraction; + const user = getUser(messageFromUser); + + // Immediately defer the reply to prevent timeout + await interaction.deferReply({ ephemeral: true }); + + // Fetch active items for the user (reminders or timers) + const items = is_reminder + ? await reminderComponents.getReminders(user.id) + : await reminderComponents.getTimers(user.id); + + const itemType = is_reminder ? 'reminder' : 'timer'; + const itemTypeCapitalized = is_reminder ? 'Reminder' : 'Timer'; + + if (!items || items.length === 0) { + const errorEmbed = new EmbedBuilder() + .setTitle(`❌ No ${itemTypeCapitalized}s`) + .setDescription(`You do not have any ${itemType}s to delete.`) + .setColor(Colors.Red); + await interaction.editReply({ embeds: [errorEmbed] }); + return ''; + } + + const embed = new EmbedBuilder() + .setTitle(`🗑️ Delete a ${itemTypeCapitalized}`) + .setDescription(`Select a ${itemType} to delete from the dropdown menu below.`) + .setColor(Colors.Red); + + // Create dropdown menu options + const options = items.map((item) => ({ + label: `ID: ${item.id} - "${ + item.message.length > 80 ? item.message.substring(0, 77) + '...' : item.message + }"`, + description: `Due: ${new Date(item.reminder_at).toLocaleString()}`, + value: item.id.toString(), + })); + + const customId = `delete-${itemType}-select`; + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(`Choose a ${itemType} to delete...`) + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + // Send the message with the select menu + const response = await interaction.editReply({ + embeds: [embed], + components: [row], + }); + + try { + const selection = await response.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + filter: (i) => i.user.id === user.id && i.customId === customId, + time: 60000, // 60 seconds timeout + }); + + const itemIdToDelete = parseInt(selection.values[0], 10); + await reminderComponents.deleteReminder(itemIdToDelete, user.id, is_reminder); + + // Confirm deletion + const successEmbed = new EmbedBuilder() + .setTitle(`✅ ${itemTypeCapitalized} Deleted`) + .setDescription(`Successfully deleted ${itemType} with ID \`${itemIdToDelete}\`.`) + .setColor(Colors.Green); + + await selection.update({ embeds: [successEmbed], components: [] }); + } catch (e) { + const timeoutEmbed = new EmbedBuilder() + .setTitle('⏰ Timed Out') + .setDescription('You did not make a selection in time.') + .setColor(Colors.Yellow); + await interaction.editReply({ embeds: [timeoutEmbed], components: [] }); + } + return ''; +}; diff --git a/src/commandDetails/reminder/timer.ts b/src/commandDetails/reminder/timer.ts new file mode 100644 index 00000000..e277d3ba --- /dev/null +++ b/src/commandDetails/reminder/timer.ts @@ -0,0 +1,172 @@ +import { + CodeyCommandDetails, + CodeyCommandOptionType, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import * as reminderComponents from '../../components/reminder/reminder'; +import { genericDeleteResponse, genericViewResponse } from './sharedViews'; + +const TIME_UNITS = { + minutes: { multiplier: 60, singular: 'minute', plural: 'minutes' }, + hours: { multiplier: 3600, singular: 'hour', plural: 'hours' }, + days: { multiplier: 86400, singular: 'day', plural: 'days' }, +} as const; + +type TimeUnit = keyof typeof TIME_UNITS; + +const timerExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + _args, +): Promise => { + return Promise.resolve('Please use a subcommand: `/timer create`'); +}; + +const timerSetExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const providedTimes: Array<{ unit: TimeUnit; value: number }> = []; + let totalSeconds = 0; + + for (const [unit, config] of Object.entries(TIME_UNITS)) { + const value = args[unit] as number; + if (value !== undefined && value > 0) { + providedTimes.push({ unit: unit as TimeUnit, value }); + totalSeconds += value * config.multiplier; + } + } + if (providedTimes.length === 0) { + return Promise.resolve('Please specify at least one time duration!'); + } + + if (totalSeconds > 604800) { + return Promise.resolve('Timer duration cannot exceed 1 week!'); + } + + const reminderMessage = (args['message'] as string) || "Time's up!"; + + const timeDescription = providedTimes + .map(({ unit, value }) => { + const config = TIME_UNITS[unit]; + const label = value === 1 ? config.singular : config.plural; + return `${value} ${label}`; + }) + .join(', '); + + const user = 'author' in messageFromUser ? messageFromUser.author : messageFromUser.user; + + // Calculate the future date/time when the timer should trigger + const now = new Date(); + const futureDateTime = new Date(now.getTime() + totalSeconds * 1000); + + try { + await reminderComponents.addReminder( + user.id, + false, + now.toISOString(), + futureDateTime.toISOString(), + `${reminderMessage}`, + ); + + const content = `⏰ Timer set for ${timeDescription}! I'll DM you with: "${reminderMessage}" + +📅 **Scheduled for:** `; + return Promise.resolve(content); + } catch (error) { + return Promise.resolve('Failed to set timer. Please try again.'); + } +}; + +const timerDeleteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const ret = await genericDeleteResponse(_client, messageFromUser, args, false); + return ret as SapphireMessageResponse; +}; + +const timerViewCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const ret = await genericViewResponse(_client, messageFromUser, args, false); + return ret as SapphireMessageResponse; +}; + +export const timerCommandDetails: CodeyCommandDetails = { + name: 'timer', + aliases: [], + description: 'Set up a timer for anything you want!', + detailedDescription: `**Examples:** + \`/timer value minutes:5\` + \`/timer value hours:2 minutes:30\` + \`/timer value minutes:10 message:Take a break!\``, + isCommandResponseEphemeral: true, + messageWhenExecutingCommand: 'Building a timer...', + executeCommand: timerExecuteCommand, + messageIfFailure: 'Failed to set up a timer.', + options: [], + subcommandDetails: { + create: { + name: 'create', + description: 'Set a timer with specified duration', + executeCommand: timerSetExecuteCommand, + isCommandResponseEphemeral: true, + options: [ + { + name: 'minutes', + description: 'Number of minutes', + required: false, + type: CodeyCommandOptionType.INTEGER, + }, + { + name: 'hours', + description: 'Number of hours', + required: false, + type: CodeyCommandOptionType.INTEGER, + }, + { + name: 'days', + description: 'Number of days', + required: false, + type: CodeyCommandOptionType.INTEGER, + }, + { + name: 'message', + description: 'Custom reminder message (optional)', + required: false, + type: CodeyCommandOptionType.STRING, + }, + ], + aliases: [], + detailedDescription: + 'Set a timer by specifying minutes, hours, and/or days with an optional message', + subcommandDetails: {}, + }, + view: { + name: 'view', + description: 'View any timers you set!', + executeCommand: timerViewCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + delete: { + name: 'delete', + description: 'Delete any timers you set!', + executeCommand: timerDeleteCommand, + isCommandResponseEphemeral: true, + options: [], + aliases: [], + detailedDescription: '', + subcommandDetails: {}, + }, + }, +}; diff --git a/src/commands/reminder/announcements.ts b/src/commands/reminder/announcements.ts new file mode 100644 index 00000000..c9963eae --- /dev/null +++ b/src/commands/reminder/announcements.ts @@ -0,0 +1,16 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommand } from '../../codeyCommand'; +import { announcementCommandDetails } from '../../commandDetails/reminder/announcements'; + +export class AnnouncementCommand extends CodeyCommand { + details = announcementCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: announcementCommandDetails.aliases, + description: announcementCommandDetails.description, + detailedDescription: announcementCommandDetails.detailedDescription, + }); + } +} diff --git a/src/commands/reminder/reminder.ts b/src/commands/reminder/reminder.ts new file mode 100644 index 00000000..589d6f10 --- /dev/null +++ b/src/commands/reminder/reminder.ts @@ -0,0 +1,16 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommand } from '../../codeyCommand'; +import { reminderCommandDetails } from '../../commandDetails/reminder/reminder'; + +export class ReminderCommand extends CodeyCommand { + details = reminderCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: reminderCommandDetails.aliases, + description: reminderCommandDetails.description, + detailedDescription: reminderCommandDetails.detailedDescription, + }); + } +} diff --git a/src/commands/reminder/timer.ts b/src/commands/reminder/timer.ts new file mode 100644 index 00000000..122b09aa --- /dev/null +++ b/src/commands/reminder/timer.ts @@ -0,0 +1,16 @@ +import { Command } from '@sapphire/framework'; +import { CodeyCommand } from '../../codeyCommand'; +import { timerCommandDetails } from '../../commandDetails/reminder/timer'; + +export class TimerCommand extends CodeyCommand { + details = timerCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: timerCommandDetails.aliases, + description: timerCommandDetails.description, + detailedDescription: timerCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/db.ts b/src/components/db.ts index 19dad417..0cd7fb5a 100644 --- a/src/components/db.ts +++ b/src/components/db.ts @@ -222,6 +222,35 @@ const initPeopleCompaniesTable = async (db: Database): Promise => { )`); }; +const initRemindersTable = async (db: Database): Promise => { + await db.run(` + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY NOT NULL, + is_reminder BOOLEAN NOT NULL, + user_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reminder_at TIMESTAMP NOT NULL, + message TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0 + ) + `); +}; + +const initAnnouncementsTable = async (db: Database): Promise => { + await db.run(` + CREATE TABLE IF NOT EXISTS announcements( + id INTEGER PRIMARY KEY NOT NULL, + user_id VARCHAR(255) NOT NULL, + title TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reminder_at TIMESTAMP NOT NULL, + message TEXT NOT NULL, + image_url TEXT, + status INTEGER NOT NULL DEFAULT 0 + ) + `); +}; + const initTables = async (db: Database): Promise => { //initialize all relevant tables await initCoffeeChatTables(db); @@ -236,6 +265,8 @@ const initTables = async (db: Database): Promise => { await initResumePreview(db); await initCompaniesTable(db); await initPeopleCompaniesTable(db); + await initRemindersTable(db); + await initAnnouncementsTable(db); }; export const openDB = async (): Promise => { diff --git a/src/components/reminder/announcement.ts b/src/components/reminder/announcement.ts new file mode 100644 index 00000000..03d359e1 --- /dev/null +++ b/src/components/reminder/announcement.ts @@ -0,0 +1,79 @@ +import { openDB } from '../db'; + +export interface Announcement { + id: number; + user_id: string; + title: string; + created_at: string; + reminder_at: string; + message: string; + image_url?: string; + status: number; +} + +// Returns a list of announcements that are due now +export const getDueAnnouncements = async (): Promise => { + const db = await openDB(); + const now = new Date().toISOString(); + return await db.all('SELECT * FROM announcements WHERE reminder_at <= ? AND status = 0', now); +}; + +// Returns a list of all announcements +export const getAnnouncements = async (): Promise => { + const db = await openDB(); + return await db.all('SELECT * FROM announcements WHERE status = 0'); +}; + +// Mark announcement as sent +export const markAnnouncementAsSent = async (announcementId: number): Promise => { + const db = await openDB(); + await db.run('UPDATE announcements SET status = 1 WHERE id = ?', announcementId); +}; + +// Delete an announcement +export const deleteAnnouncement = async (announcementId: number): Promise => { + const db = await openDB(); + await db.run('DELETE FROM announcements WHERE id = ?', announcementId); +}; + +// Adds an announcement to the DB (with optional image URL) +export const addAnnouncement = async ( + user_id: string, + title: string, + created_at: string, + reminder_at: string, + message: string, + image_url?: string, +): Promise => { + const db = await openDB(); + if (image_url) { + // With image URL + await db.run( + ` + INSERT INTO announcements (user_id, title, created_at, reminder_at, message, image_url, status) + VALUES(?,?,?,?,?,?,?); + `, + user_id, + title, + created_at, + reminder_at, + message, + image_url, + 0, + ); + } else { + // Without image URL + await db.run( + ` + INSERT INTO announcements (user_id, title, created_at, reminder_at, message, status) + VALUES(?,?,?,?,?,?); + `, + user_id, + title, + created_at, + reminder_at, + message, + 0, + ); + } +}; diff --git a/src/components/reminder/reminder.ts b/src/components/reminder/reminder.ts new file mode 100644 index 00000000..1f27f644 --- /dev/null +++ b/src/components/reminder/reminder.ts @@ -0,0 +1,77 @@ +import { openDB } from '../db'; +export interface Reminder { + id: number; + user_id: string; + created_at: string; + reminder_at: string; + message: string; +} + +// Returns a list of reminders that are due now + +export const getDueReminders = async (): Promise => { + const db = await openDB(); + const now = new Date().toISOString(); + return await db.all('SELECT * FROM reminders WHERE reminder_at <= ? AND status = 0', now); +}; + +// Returns a list of reminders that have yet to be shown +export const getReminders = async (userId: string): Promise => { + const db = await openDB(); + return await db.all( + 'SELECT * FROM reminders WHERE user_id = ? AND status = 0 AND is_reminder = 1', + userId, + ); +}; + +// Returns a list of timers that have yet to be shown +export const getTimers = async (userId: string): Promise => { + const db = await openDB(); + return await db.all( + 'SELECT * FROM reminders WHERE user_id = ? AND status = 0 AND is_reminder = 0', + userId, + ); +}; + +// Mark reminder as sent +export const markReminderAsSent = async (reminderId: number): Promise => { + const db = await openDB(); + await db.run('UPDATE reminders SET status = 1 WHERE id = ?', reminderId); +}; + +export const deleteReminder = async ( + reminderId: number, + userId: string, + is_reminder: boolean, +): Promise => { + const db = await openDB(); + await db.run( + 'DELETE FROM reminders WHERE id = ? AND user_id = ? and is_reminder = ?', + reminderId, + userId, + is_reminder, + ); +}; + +// Adds a reminder to the DB +export const addReminder = async ( + user_id: string, + is_reminder: boolean, + created_at: string, + reminder_at: string, + message: string, +): Promise => { + const db = await openDB(); + await db.run( + ` + INSERT INTO reminders (user_id, is_reminder, created_at, reminder_at, message, status) + VALUES(?,?,?,?,?,?); + `, + user_id, + is_reminder, + created_at, + reminder_at, + message, + 0, + ); +}; diff --git a/src/components/reminder/reminderObserver.ts b/src/components/reminder/reminderObserver.ts new file mode 100644 index 00000000..8ff35719 --- /dev/null +++ b/src/components/reminder/reminderObserver.ts @@ -0,0 +1,106 @@ +import { EventEmitter } from 'events'; +import { Client, TextChannel } from 'discord.js'; +import * as reminderComponents from './reminder'; +import * as announcementComponents from './announcement'; +import config from '../../../config/staging/vars.json'; + +export class ReminderObserver extends EventEmitter { + private client: Client; + private globalCheckInterval: NodeJS.Timeout | null = null; + + constructor(client: Client) { + super(); + this.client = client; + this.setupEventListeners(); + } + + private setupEventListeners() { + this.on('reminderDue', async (reminder) => { + await this.sendReminder(reminder); + await reminderComponents.markReminderAsSent(reminder.id); + }); + + this.on('announcementDue', async (announcement) => { + await this.sendAnnouncement(announcement); + await announcementComponents.markAnnouncementAsSent(announcement.id); + }); + } + + async start(): Promise { + this.globalCheckInterval = setInterval(async () => { + await this.checkForDueReminders(); + await this.checkForDueAnnouncements(); + }, 60 * 1000); + await this.checkForDueReminders(); + await this.checkForDueAnnouncements(); + } + + private async checkForDueReminders() { + try { + const dueReminders = await reminderComponents.getDueReminders(); + if (dueReminders.length > 0) { + dueReminders.forEach((reminder: reminderComponents.Reminder) => { + this.emit('reminderDue', reminder); + }); + } + } catch (error) { + // Error in checking for due reminders + } + } + private async checkForDueAnnouncements() { + try { + const dueReminders = await announcementComponents.getDueAnnouncements(); + if (dueReminders.length > 0) { + dueReminders.forEach((reminder: reminderComponents.Reminder) => { + this.emit('announcementDue', reminder); + }); + } + } catch (error) { + // Error finding announcement + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async sendReminder(reminder: any) { + try { + const user = await this.client.users.fetch(reminder.user_id); + if (user) { + await user.send({ + embeds: [ + { + title: reminder.is_reminder ? 'Reminder!' : 'Timer', + description: reminder.message, + color: 0x00ff00, + timestamp: new Date().toISOString(), + footer: { + text: `Set on ${new Date(reminder.created_at).toLocaleDateString()}`, + }, + }, + ], + }); + } + } catch (error) { + // Error in send reminder + } + } + + private async sendAnnouncement(announcement: announcementComponents.Announcement) { + try { + const guild = this.client.guilds.cache.get(config.TARGET_GUILD_ID); + if (!guild) { + return; + } + + // Get the announcements channel using its ID from config + const channel = guild.channels.cache.get(config.ANNOUNCEMENTS_CHANNEL_ID) as TextChannel; + if (!channel) { + return; + } + + await channel.send({ + content: announcement.message, + files: announcement.image_url ? [announcement.image_url] : [], + }); + } catch (error) {} + } +} diff --git a/src/events/ready.ts b/src/events/ready.ts index 8fa6e7ff..becd6f58 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -6,6 +6,7 @@ import { vars } from '../config'; import { logger } from '../logger/default'; import { getRepositoryReleases } from '../utils/github'; import { updateWiki } from '../utils/updateWiki'; +import { ReminderObserver } from '../components/reminder/reminderObserver'; const dev = process.env.NODE_ENV === 'dev'; @@ -46,5 +47,9 @@ export const initReady = (client: Client): void => { sendReady(client); initCrons(client); initEmojis(client); + + const reminderObserver = new ReminderObserver(client); + reminderObserver.start(); + if (dev) updateWiki(); // will not run in staging/prod };