From 85cc109adb00efcb7d6e022768d38f69bae34327 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Mon, 5 May 2025 15:19:18 -0400 Subject: [PATCH 1/4] fixed repeating reminders so they actually look for the next event and repeat --- src/commands/general/calreminder.ts | 2 + src/commands/reminders/remind.ts | 35 +++++--- src/lib/auth.ts | 122 ++++++++++++++++------------ src/lib/types/Reminder.d.ts | 2 + src/pieces/tasks.ts | 65 ++++++++++++--- 5 files changed, 153 insertions(+), 73 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 4c399505..ea942d58 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -208,6 +208,7 @@ export default class extends Command { } // Retrieve events + const events = await retrieveEvents( calendar.calendarId, interaction @@ -339,6 +340,7 @@ export default class extends Command { owner: btnInt.user.id, content: eventInfo, mode: 'private', + summary: chosenEvent.summary, expires: remindDate, // next fire time repeat: repeatInterval, // "every_event" or null calendarId: calendar.calendarId, // for fetching next events diff --git a/src/commands/reminders/remind.ts b/src/commands/reminders/remind.ts index 9dff9250..0e62fcd7 100644 --- a/src/commands/reminders/remind.ts +++ b/src/commands/reminders/remind.ts @@ -1,5 +1,10 @@ import { BOT, DB } from '@root/config'; -import { ApplicationCommandOptionData, ApplicationCommandOptionType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; +import { + ApplicationCommandOptionData, + ApplicationCommandOptionType, + ChatInputCommandInteraction, + InteractionResponse +} from 'discord.js'; import { Reminder } from '@lib/types/Reminder'; import parse from 'parse-duration'; import { reminderTime } from '@root/src/lib/utils/generalUtils'; @@ -7,36 +12,42 @@ import { Command } from '@lib/types/Command'; export default class extends Command { - description = `Have ${BOT.NAME} give you a reminder.`; extendedHelp = 'Reminders can be set to repeat daily or weekly.'; options: ApplicationCommandOptionData[] = [ { name: 'content', - description: 'What you\'d like to be reminded of', + description: "What you'd like to be reminded of", type: ApplicationCommandOptionType.String, required: true }, { name: 'duration', - description: 'When you\'d like to be reminded', + description: "When you'd like to be reminded", type: ApplicationCommandOptionType.String, required: true }, { name: 'repeat', description: 'How often you want the reminder to repeat', - choices: [{ name: 'Daily', value: 'daily' }, { name: 'Weekly', value: 'weekly' }], + choices: [ + { name: 'Daily', value: 'daily' }, + { name: 'Weekly', value: 'weekly' } + ], type: ApplicationCommandOptionType.String, required: false } - ] + ]; - run(interaction: ChatInputCommandInteraction): Promise | void> { + run( + interaction: ChatInputCommandInteraction + ): Promise | void> { const content = interaction.options.getString('content'); const rawDuration = interaction.options.getString('duration'); const duration = parse(rawDuration); - const repeat = interaction.options.getString('repeat') as 'daily' | 'weekly' || null; + const repeat + = interaction.options.getString('repeat') as 'daily' | 'weekly' + || null; if (!duration) { return interaction.reply({ @@ -49,12 +60,16 @@ export default class extends Command { content, mode: 'public', // temporary expires: new Date(duration + Date.now()), - repeat + repeat, + summary: content // safe default }; interaction.client.mongo.collection(DB.REMINDERS).insertOne(reminder); - return interaction.reply({ content: `I'll remind you about that at ${reminderTime(reminder)}.`, ephemeral: true }); + return interaction.reply({ + content: `I'll remind you about that at ${reminderTime(reminder)}.`, + ephemeral: true + }); } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 53886e06..771b252a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ /* eslint-disable camelcase */ +import dotenv from 'dotenv'; +dotenv.config(); import { calendar_v3, google } from 'googleapis'; import { JWT } from 'google-auth-library'; import { ChatInputCommandInteraction } from 'discord.js'; @@ -6,87 +8,103 @@ import { GaxiosResponse } from 'gaxios'; const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']; const KEY_PATH = process.env.MYPATH; +console.log('[DEBUG] MYPATH:', process.env.MYPATH); +console.log('[DEBUG] Resolved KEY_PATH:', KEY_PATH); +console.log('[DEBUG] Working directory:', process.cwd()); /** - * This function will retrive and return the events of the given calendar ID. It will send error messages if it cannot retrive the events + * This function will retrieve and return the events of the given calendar ID. + * If an interaction is provided, it handles user-facing error messages. + * If not, it throws errors for background use (e.g., in checkReminders). * * @param {string} calendarId The ID of the calendar you want to retrieve - * @param {ChatInputCommandInteraction} interaction Optional: Current Discord interacton - * @param {boolean} singleEvents Optional: Determines whether to list out each event instead of just the parent events - Default: true - * @returns {Promise>} Return the events of the given calendar ID + * @param {ChatInputCommandInteraction} interaction Optional: Current Discord interaction + * @param {boolean} singleEvents Optional: Whether to list each event separately (default: true) + * @returns {Promise} */ -export async function retrieveEvents(calendarId: string, interaction?: ChatInputCommandInteraction, singleEvents = true): Promise { - // Retrieve an authenticaiton token +export async function retrieveEvents( + calendarId: string, + interaction?: ChatInputCommandInteraction, + singleEvents = true +): Promise { + if (!KEY_PATH) { + const msg = '❌ Environment variable MYPATH is not set.'; + if (interaction) { + await safeReply(interaction, msg); + return []; + } else { + throw new Error(msg); + } + } + + // Initialize auth with keyFile const auth = new JWT({ keyFile: KEY_PATH, scopes: SCOPES }); - // Authorize access to google calendar and retrieve the calendar - let calendar: calendar_v3.Calendar = null; + let calendar: calendar_v3.Calendar; try { - calendar = google.calendar({ version: 'v3', auth: auth }); - } catch { - const errorMessage = '⚠️ Failed to authenticate with Google Calendar. Please try again later.'; + calendar = google.calendar({ version: 'v3', auth }); + } catch (err) { + const msg = '⚠️ Failed to authenticate with Google Calendar.'; if (interaction) { - if (interaction.replied) { - await interaction.followUp({ - content: errorMessage, - ephemeral: true - }); - } else { - await interaction.reply({ - content: errorMessage, - ephemeral: true - }); - } + await safeReply(interaction, msg); + return []; } else { - console.log(errorMessage); + throw err; } } - // Retrieve the events from the calendar - let events: calendar_v3.Schema$Event[] = null; try { + const tenDaysMs = 10 * 24 * 60 * 60 * 1000; + let response: GaxiosResponse; + const baseParams = { + calendarId: calendarId, + timeMin: new Date().toISOString(), + timeMax: new Date(Date.now() + tenDaysMs).toISOString(), + singleEvents + }; - // This makes sure events are only sorted if single events is true if (singleEvents) { response = await calendar.events.list({ - calendarId: calendarId, - timeMin: new Date().toISOString(), - timeMax: new Date(Date.now() + (10 * 24 * 60 * 60 * 1000)).toISOString(), - singleEvents: singleEvents, + ...baseParams, orderBy: 'startTime' }); } else { - response = await calendar.events.list({ - calendarId: calendarId, - timeMin: new Date().toISOString(), - timeMax: new Date(Date.now() + (10 * 24 * 60 * 60 * 1000)).toISOString(), - singleEvents: singleEvents - }); + response = await calendar.events.list(baseParams); } - events = response.data.items; - } catch { - const errorMessage = '⚠️ Failed to retrieve calendar events. Please try again later.'; + return response.data.items ?? []; + } catch (err) { + const msg = '⚠️ Failed to retrieve calendar events.'; if (interaction) { - if (interaction.replied) { - await interaction.followUp({ - content: errorMessage, - ephemeral: true - }); - } else { - await interaction.reply({ - content: errorMessage, - ephemeral: true - }); - } + await safeReply(interaction, msg); + return []; } else { - console.log(errorMessage); + throw err; } } +} + +/** + * Helper to safely reply or follow up without throwing if already replied + * @param {ChatInputCommandInteraction} interaction The Discord interaction + * @param {string} message The message to send + */ - return events; +async function safeReply( + interaction: ChatInputCommandInteraction, + message: string +): Promise { + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, ephemeral: true }); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } + } catch (err) { + console.warn('⚠️ Failed to send error message to interaction:', err); + } } diff --git a/src/lib/types/Reminder.d.ts b/src/lib/types/Reminder.d.ts index 93eb509f..999f5cfc 100644 --- a/src/lib/types/Reminder.d.ts +++ b/src/lib/types/Reminder.d.ts @@ -1,8 +1,10 @@ export interface Reminder { + _id?: ObjectId; owner: string; expires: Date; content: string; repeat: null | 'daily' | 'weekly' | 'every_event'; + summary: string; mode: 'public' | 'private'; calendarId?: string; offset?: number; diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index 2eda4732..bc18a831 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -3,6 +3,8 @@ import { ChannelType, Client, EmbedBuilder, TextChannel } from 'discord.js'; import { schedule } from 'node-cron'; import { Reminder } from '@lib/types/Reminder'; import { Poll, PollResult } from '@lib/types/Poll'; +import { retrieveEvents } from '../lib/auth'; +import { ObjectId } from 'mongodb'; async function register(bot: Client): Promise { schedule('0/30 * * * * *', () => { @@ -119,22 +121,22 @@ async function checkPolls(bot: Client): Promise { async function checkReminders(bot: Client): Promise { const now = new Date(); - // 1) fetch all reminders whose time has come + // 1) Fetch all reminders due const reminders: Reminder[] = await bot.mongo .collection(DB.REMINDERS) .find({ expires: { $lte: now } }) .toArray(); - // 2) send each one as a DM‐embed first, fallback to Sage channel + const handledIds: ObjectId[] = []; + + // 2) Send each one for (const rem of reminders) { - // build a pretty embed const embed = new EmbedBuilder() .setTitle('⏰ Reminder') - .setDescription(rem.content) // your full "content" string + .setDescription(rem.content) .setColor('Blue') .setTimestamp(now); - // only if it's repeating, tack on a “Repeats” field if (rem.repeat) { embed.addFields({ name: '🔁 Repeats', @@ -148,16 +150,57 @@ async function checkReminders(bot: Client): Promise { const user = await bot.users.fetch(rem.owner); await user.send({ embeds: [embed] }); } catch { - const sage = (await bot.channels.fetch( + const fallbackChannel = (await bot.channels.fetch( CHANNELS.SAGE )) as TextChannel; - await sage.send({ embeds: [embed] }); + await fallbackChannel.send({ embeds: [embed] }); + } + + // 3) Reschedule if it's a repeating reminder + if (rem.repeat === 'every_event') { + await tryRescheduleReminder(rem, now, bot); } + + // Collect ID to delete after loop + if (rem._id) handledIds.push(rem._id); } - // 3) clean up the ones we just dispatched - await bot.mongo - .collection(DB.REMINDERS) - .deleteMany({ expires: { $lte: now } }); + // 4) Remove all processed reminders + if (handledIds.length > 0) { + await bot.mongo.collection(DB.REMINDERS).deleteMany({ + _id: { $in: handledIds } + }); + } } +async function tryRescheduleReminder( + rem: Reminder, + now: Date, + bot: Client +): Promise { + try { + const futureEvents = await retrieveEvents(rem.calendarId); + const nextEvent = futureEvents.find( + (e) => + new Date(e.start?.dateTime || 0) > now + && e.summary === rem.summary + ); + + if (!nextEvent) return; + + const nextStart = new Date(nextEvent.start.dateTime); + const nextReminderTime = new Date(nextStart.getTime() - rem.offset); + + if (nextReminderTime > new Date(rem.repeatUntil)) return; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _id, ...reminderData } = rem; + await bot.mongo.collection(DB.REMINDERS).insertOne({ + ...reminderData, + expires: nextReminderTime + }); + } catch (err) { + console.error('Failed to reschedule repeating reminder:', err); + } +} + export default register; From 4ef0632b4ef67e0fe819e5eaf5e3de306cbe3052 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Mon, 5 May 2025 15:54:06 -0400 Subject: [PATCH 2/4] now updates content message for repeating reminders --- src/commands/general/calreminder.ts | 237 +++++++++++++++------------- src/pieces/tasks.ts | 94 ++++++----- 2 files changed, 178 insertions(+), 153 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index ea942d58..3a171ec6 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -1,6 +1,6 @@ -import { DB } from '@root/config'; -import { Command } from '@root/src/lib/types/Command'; -import { Reminder } from '@root/src/lib/types/Reminder'; +import { DB } from "@root/config"; +import { Command } from "@root/src/lib/types/Command"; +import { Reminder } from "@root/src/lib/types/Reminder"; import { ActionRowBuilder, ApplicationCommandOptionData, @@ -8,33 +8,32 @@ import { ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, - ComponentType -} from 'discord.js'; -import parse from 'parse-duration'; -import { retrieveEvents } from '@root/src/lib/auth'; -import { calendar_v3 as calendarV3 } from 'googleapis'; -import { MongoClient } from 'mongodb'; -import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; -const MONGO_URI = process.env.DB_CONN_STRING || ''; + ComponentType, +} from "discord.js"; +import parse from "parse-duration"; +import { retrieveEvents } from "@root/src/lib/auth"; +import { calendar_v3 as calendarV3 } from "googleapis"; +import { MongoClient } from "mongodb"; +import { PagifiedSelectMenu } from "@root/src/lib/types/PagifiedSelect"; +const MONGO_URI = process.env.DB_CONN_STRING || ""; export default class extends Command { - - name = 'calreminder'; - description = 'Setup reminders for calendar events'; + name = "calreminder"; + description = "Setup reminders for calendar events"; options: ApplicationCommandOptionData[] = [ { - name: 'classname', - description: 'Course ID', + name: "classname", + description: "Course ID", type: ApplicationCommandOptionType.String, - required: true + required: true, }, { - name: 'filter', + name: "filter", description: - 'Office-hours name or keyword to narrow results (optional)', + "Office-hours name or keyword to narrow results (optional)", type: ApplicationCommandOptionType.String, - required: false - } + required: false, + }, ]; async run(interaction: ChatInputCommandInteraction): Promise { @@ -46,7 +45,7 @@ export default class extends Command { let offsetMenu: PagifiedSelectMenu; const generateMessage = ( - repeatInterval: 'every_event' | null, + repeatInterval: "every_event" | null, chosenEvent?: calendarV3.Schema$Event, chosenOffset?: number, renderMenus = false, @@ -56,20 +55,20 @@ export default class extends Command { if (renderMenus) { eventMenu = new PagifiedSelectMenu(); eventMenu.createSelectMenu({ - customId: 'select_event', - placeHolder: 'Select an event', + customId: "select_event", + placeHolder: "Select an event", minimumValues: 1, - maximumValues: 1 + maximumValues: 1, }); let defaultSet = false; filteredEvents.forEach((event, index) => { if (!event.start?.dateTime) return; - const isDefault - = !defaultSet - && chosenEvent?.start?.dateTime - === event.start?.dateTime; + const isDefault = + !defaultSet && + chosenEvent?.start?.dateTime === + event.start?.dateTime; if (isDefault) defaultSet = true; @@ -79,7 +78,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault + default: isDefault, }); }); @@ -87,33 +86,33 @@ export default class extends Command { // Create offset select menu const offsetOptions = [ - { label: 'At event', value: '0' }, - { label: '10 minutes before', value: '10m' }, - { label: '30 minutes before', value: '30m' }, - { label: '1 hour before', value: '1h' }, - { label: '1 day before', value: '1d' } + { label: "At event", value: "0" }, + { label: "10 minutes before", value: "10m" }, + { label: "30 minutes before", value: "30m" }, + { label: "1 hour before", value: "1h" }, + { label: "1 day before", value: "1d" }, ]; offsetMenu = new PagifiedSelectMenu(); offsetMenu.createSelectMenu({ - customId: 'select_offset', - placeHolder: 'Select reminder offset', + customId: "select_offset", + placeHolder: "Select reminder offset", minimumValues: 1, - maximumValues: 1 + maximumValues: 1, }); let offsetDefaultSet = false; offsetOptions.forEach((option) => { - const isDefault - = !offsetDefaultSet - && chosenOffset === parse(option.value); + const isDefault = + !offsetDefaultSet && + chosenOffset === parse(option.value); if (isDefault) offsetDefaultSet = true; offsetMenu.addOption({ label: option.label, value: option.value, - default: isDefault + default: isDefault, }); }); @@ -128,22 +127,22 @@ export default class extends Command { // 3) Generate repeat button const toggleRepeatButton = new ButtonBuilder() - .setCustomId('toggle_repeat') + .setCustomId("toggle_repeat") .setLabel( - repeatInterval === 'every_event' - ? 'Repeat: On' - : 'Repeat: Off' + repeatInterval === "every_event" + ? "Repeat: On" + : "Repeat: Off" ) .setStyle(ButtonStyle.Secondary); // 4) Generate set reminder button const setReminder = new ButtonBuilder() - .setCustomId('set_reminder') - .setLabel('Set Reminder') + .setCustomId("set_reminder") + .setLabel("Set Reminder") .setStyle(ButtonStyle.Success); - const setReminderAndRepeatRow - = new ActionRowBuilder().addComponents( + const setReminderAndRepeatRow = + new ActionRowBuilder().addComponents( toggleRepeatButton, setReminder ); @@ -151,58 +150,58 @@ export default class extends Command { return [ ...eventMenuRows, ...offsetMenuRows, - setReminderAndRepeatRow + setReminderAndRepeatRow, ]; }; // 2) All your pre-flight checks (courseCode, DB lookup, retrieveEvents, filters, etc.) const courseCode = interaction.options - .getString('classname') + .getString("classname") ?.toUpperCase(); if (!courseCode) { await interaction.editReply({ - content: '❗ You must specify a class name.' + content: "❗ You must specify a class name.", }); return; } // OPTIONAL name filter (e.g. "Phil", "Sophia") - const nameFilter - = interaction.options.getString('filter')?.trim().toLowerCase() - ?? null; + const nameFilter = + interaction.options.getString("filter")?.trim().toLowerCase() ?? + null; // Lookup calendar from MongoDB let calendar: { calendarId: string; calendarName: string }; try { const client = new MongoClient(MONGO_URI, { - useUnifiedTopology: true + useUnifiedTopology: true, }); await client.connect(); - const db = client.db('CalendarDatabase'); - const collection = db.collection('calendarIds'); + const db = client.db("CalendarDatabase"); + const collection = db.collection("calendarIds"); const calendarInDB = await collection.findOne({ - calendarName: { $regex: `^${courseCode}$`, $options: 'i' } + calendarName: { $regex: `^${courseCode}$`, $options: "i" }, }); await client.close(); if (!calendarInDB) { await interaction.editReply({ - content: `⚠️ There are no matching calendars with course code **${courseCode}**.` + content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, }); return; } calendar = { calendarId: calendarInDB.calendarId, - calendarName: calendarInDB.calendarName + calendarName: calendarInDB.calendarName, }; } catch (error) { - console.error('Calendar lookup failed:', error); + console.error("Calendar lookup failed:", error); await interaction.editReply({ - content: `❌ Database error while fetching calendar for **${courseCode}**.` + content: `❌ Database error while fetching calendar for **${courseCode}**.`, }); return; } @@ -217,7 +216,7 @@ export default class extends Command { if (!events || events.length === 0) { await interaction.editReply({ content: - '⚠️ Failed to fetch calendar events or no events found.' + "⚠️ Failed to fetch calendar events or no events found.", }); return; } @@ -229,7 +228,7 @@ export default class extends Command { ); if (filteredEvents.length === 0) { await interaction.editReply({ - content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.` + content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, }); return; } @@ -237,7 +236,7 @@ export default class extends Command { let chosenEvent: calendarV3.Schema$Event = null; let chosenOffset: number = null; - let repeatInterval: 'every_event' = null; + let repeatInterval: "every_event" = null; let activeReminderId: string = null; const initialComponents = generateMessage( @@ -253,39 +252,39 @@ export default class extends Command { // 2) Send your menus by editing the deferred reply: const replyMessage = await interaction.editReply({ - components: initialComponents + components: initialComponents, }); // Main collector for event & offset const collector = replyMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, - time: 60_000 + time: 60_000, }); - collector.on('collect', async (i) => { - if (i.customId === 'select_event') { - const [, indexStr] = i.values[0].split('::'); + collector.on("collect", async (i) => { + if (i.customId === "select_event") { + const [, indexStr] = i.values[0].split("::"); const selectedIndex = parseInt(indexStr); chosenEvent = filteredEvents[selectedIndex]; await i.deferUpdate(); - } else if (i.customId === 'select_offset') { + } else if (i.customId === "select_offset") { const rawOffsetStr = i.values[0]; - chosenOffset - = rawOffsetStr === '0' ? 0 : parse(rawOffsetStr); + chosenOffset = + rawOffsetStr === "0" ? 0 : parse(rawOffsetStr); await i.deferUpdate(); } }); // Button collector for Cancel and Set Reminder - const buttonCollector - = replyMessage.createMessageComponentCollector({ + const buttonCollector = + replyMessage.createMessageComponentCollector({ componentType: ComponentType.Button, - time: 300_000 // 5 minutes + time: 300_000, // 5 minutes }); - buttonCollector.on('collect', async (btnInt) => { - if (btnInt.customId === 'toggle_repeat') { - repeatInterval = repeatInterval ? null : 'every_event'; + buttonCollector.on("collect", async (btnInt) => { + if (btnInt.customId === "toggle_repeat") { + repeatInterval = repeatInterval ? null : "every_event"; const updatedComponents = generateMessage( repeatInterval, @@ -297,9 +296,9 @@ export default class extends Command { ); await btnInt.update({ - components: updatedComponents + components: updatedComponents, }); - } else if (btnInt.customId === 'set_reminder') { + } else if (btnInt.customId === "set_reminder") { // If user hasn’t selected both fields, just silently acknowledge if (!chosenEvent || chosenOffset === null) { if (!btnInt.deferred && !btnInt.replied) { @@ -320,8 +319,8 @@ export default class extends Command { if (remindDate.getTime() <= Date.now()) { await btnInt.editReply({ content: - '⏰ That reminder time is in the past. No reminder was set.', - components: [] + "⏰ That reminder time is in the past. No reminder was set.", + components: [], }); collector.stop(); buttonCollector.stop(); @@ -329,9 +328,24 @@ export default class extends Command { } // Build more detailed reminder text + const formattedStart = dateObj.toLocaleString("en-US", { + timeZone: + chosenEvent.start?.timeZone || "America/New_York", + dateStyle: "short", + timeStyle: "short", + }); + const eventInfo = `${ - chosenEvent.summary - }\nStarts at: ${dateObj.toLocaleString()}`; + chosenEvent.summary || "Untitled Event" + }\nStarts at: ${formattedStart}${ + chosenEvent.location + ? `\nLocation: ${chosenEvent.location}` + : "" + }${ + chosenEvent.description + ? `\nDetails: ${chosenEvent.description}` + : "" + }`; // Create reminder in DB const EXPIRE_BUFFER_MS = 180 * 24 * 60 * 60 * 1000; // 180 days in ms @@ -339,7 +353,7 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: 'private', + mode: "private", summary: chosenEvent.summary, expires: remindDate, // next fire time repeat: repeatInterval, // "every_event" or null @@ -347,7 +361,7 @@ export default class extends Command { offset: chosenOffset, // ms before event repeatUntil: new Date( remindDate.getTime() + EXPIRE_BUFFER_MS - ) + ), }; let result; @@ -357,11 +371,11 @@ export default class extends Command { .insertOne(reminder); activeReminderId = result.insertedId; } catch (err) { - console.error('Failed to insert reminder:', err); + console.error("Failed to insert reminder:", err); await btnInt.editReply({ content: - '❌ Failed to save reminder. Please try again later.', - components: [] + "❌ Failed to save reminder. Please try again later.", + components: [], }); buttonCollector.stop(); return; @@ -369,12 +383,12 @@ export default class extends Command { // Build Cancel button row const cancelButton = new ButtonBuilder() - .setCustomId('cancel_reminder') - .setLabel('Cancel Reminder') + .setCustomId("cancel_reminder") + .setLabel("Cancel Reminder") .setStyle(ButtonStyle.Danger); - const buttonRow - = new ActionRowBuilder().addComponents( + const buttonRow = + new ActionRowBuilder().addComponents( cancelButton ); @@ -386,11 +400,11 @@ export default class extends Command { repeatInterval ? `\n🔁 Repeats every event (for up to 180 days) ` - : '' + : "" }`, - components: [buttonRow] + components: [buttonRow], }); - } else if (btnInt.customId === 'cancel_reminder') { + } else if (btnInt.customId === "cancel_reminder") { try { // 1) Defer *a new reply* (ephemeral) if (!btnInt.deferred && !btnInt.replied) { @@ -406,22 +420,22 @@ export default class extends Command { // 3) Send brand new ephemeral follow-up await btnInt.followUp({ - content: '❌ Your reminder has been canceled.', - ephemeral: true + content: "❌ Your reminder has been canceled.", + ephemeral: true, }); // 4) Stop the collector buttonCollector.stop(); } catch (err) { - console.error('Failed to cancel reminder:', err); + console.error("Failed to cancel reminder:", err); } } const actions: Record void> = { - 'next_button:select_event': () => eventMenu.currentPage++, - 'prev_button:select_event': () => eventMenu.currentPage--, - 'next_button:select_offset': () => offsetMenu.currentPage++, - 'prev_button:select_offset': () => offsetMenu.currentPage-- + "next_button:select_event": () => eventMenu.currentPage++, + "prev_button:select_event": () => eventMenu.currentPage--, + "next_button:select_offset": () => offsetMenu.currentPage++, + "prev_button:select_offset": () => offsetMenu.currentPage--, }; const action = actions[btnInt.customId]; @@ -441,22 +455,21 @@ export default class extends Command { } }); } catch (err) { - console.error('calreminder error:', err); + console.error("calreminder error:", err); // 5) Error fallback: if we’ve already deferred/replied, use followUp if (interaction.deferred || interaction.replied) { await interaction.followUp({ content: - '❌ An error occurred; the team has been notified.', - ephemeral: true + "❌ An error occurred; the team has been notified.", + ephemeral: true, }); } else { await interaction.reply({ content: - '❌ An error occurred; the team has been notified.', - ephemeral: true + "❌ An error occurred; the team has been notified.", + ephemeral: true, }); } } } - } diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index bc18a831..5a186adf 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -1,14 +1,14 @@ -import { CHANNELS, DB } from '@root/config'; -import { ChannelType, Client, EmbedBuilder, TextChannel } from 'discord.js'; -import { schedule } from 'node-cron'; -import { Reminder } from '@lib/types/Reminder'; -import { Poll, PollResult } from '@lib/types/Poll'; -import { retrieveEvents } from '../lib/auth'; -import { ObjectId } from 'mongodb'; +import { CHANNELS, DB } from "@root/config"; +import { ChannelType, Client, EmbedBuilder, TextChannel } from "discord.js"; +import { schedule } from "node-cron"; +import { Reminder } from "@lib/types/Reminder"; +import { Poll, PollResult } from "@lib/types/Poll"; +import { retrieveEvents } from "../lib/auth"; +import { ObjectId } from "mongodb"; async function register(bot: Client): Promise { - schedule('0/30 * * * * *', () => { - handleCron(bot).catch(async (error) => bot.emit('error', error)); + schedule("0/30 * * * * *", () => { + handleCron(bot).catch(async (error) => bot.emit("error", error)); }); } @@ -21,10 +21,10 @@ async function checkPolls(bot: Client): Promise { const polls: Poll[] = await bot.mongo .collection(DB.POLLS) .find({ - expires: { $lte: new Date() } + expires: { $lte: new Date() }, }) .toArray(); - const emotes = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; + const emotes = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]; polls.forEach(async (poll) => { const mdTimestamp = ``; @@ -55,25 +55,25 @@ async function checkPolls(bot: Client): Promise { winMessage = `**${ winners[0].option }** has won the poll with ${winCount} vote${ - winCount === 1 ? '' : 's' + winCount === 1 ? "" : "s" }!`; } else { winMessage = `**${winners .slice(0, -1) .map((win) => win.option) - .join(', ')} and ${ + .join(", ")} and ${ winners.slice(-1)[0].option }** have won the poll with ${winners[0].users.length} vote${ - winCount === 1 ? '' : 's' + winCount === 1 ? "" : "s" } each!`; } // build up the text that is on the final poll embed - let choiceText = ''; + let choiceText = ""; let count = 0; resultMap.forEach((value, key) => { choiceText += `${emotes[count++]} ${key}: ${value} vote${ - value === 1 ? '' : 's' + value === 1 ? "" : "s" }\n`; }); @@ -89,11 +89,11 @@ async function checkPolls(bot: Client): Promise { `This poll was created by ${owner.displayName} and ended **${mdTimestamp}**` ) .addFields({ - name: `Winner${winners.length === 1 ? '' : 's'}`, - value: winMessage + name: `Winner${winners.length === 1 ? "" : "s"}`, + value: winMessage, }) - .addFields({ name: 'Choices', value: choiceText }) - .setColor('Random'); + .addFields({ name: "Choices", value: choiceText }) + .setColor("Random"); pollMsg.edit({ embeds: [pollEmbed], components: [] }); @@ -103,15 +103,15 @@ async function checkPolls(bot: Client): Promise { .setTitle(poll.question) .setDescription(`${owner}'s poll has ended!`) .addFields({ - name: `Winner${winners.length === 1 ? '' : 's'}`, - value: winMessage + name: `Winner${winners.length === 1 ? "" : "s"}`, + value: winMessage, }) .addFields({ - name: 'Original poll', - value: `Click [here](${pollMsg.url}) to see the original poll.` + name: "Original poll", + value: `Click [here](${pollMsg.url}) to see the original poll.`, }) - .setColor('Random') - ] + .setColor("Random"), + ], }); await bot.mongo.collection(DB.POLLS).findOneAndDelete(poll); @@ -120,7 +120,6 @@ async function checkPolls(bot: Client): Promise { async function checkReminders(bot: Client): Promise { const now = new Date(); - // 1) Fetch all reminders due const reminders: Reminder[] = await bot.mongo .collection(DB.REMINDERS) @@ -132,17 +131,17 @@ async function checkReminders(bot: Client): Promise { // 2) Send each one for (const rem of reminders) { const embed = new EmbedBuilder() - .setTitle('⏰ Reminder') + .setTitle("⏰ Reminder") .setDescription(rem.content) - .setColor('Blue') + .setColor("Blue") .setTimestamp(now); if (rem.repeat) { embed.addFields({ - name: '🔁 Repeats', + name: "🔁 Repeats", value: - rem.repeat === 'every_event' ? 'Every event' : rem.repeat, - inline: true + rem.repeat === "every_event" ? "Every event" : rem.repeat, + inline: true, }); } @@ -157,7 +156,7 @@ async function checkReminders(bot: Client): Promise { } // 3) Reschedule if it's a repeating reminder - if (rem.repeat === 'every_event') { + if (rem.repeat === "every_event") { await tryRescheduleReminder(rem, now, bot); } @@ -168,7 +167,7 @@ async function checkReminders(bot: Client): Promise { // 4) Remove all processed reminders if (handledIds.length > 0) { await bot.mongo.collection(DB.REMINDERS).deleteMany({ - _id: { $in: handledIds } + _id: { $in: handledIds }, }); } } @@ -181,25 +180,38 @@ async function tryRescheduleReminder( const futureEvents = await retrieveEvents(rem.calendarId); const nextEvent = futureEvents.find( (e) => - new Date(e.start?.dateTime || 0) > now - && e.summary === rem.summary + new Date(e.start?.dateTime || 0) > now && + e.summary === rem.summary ); if (!nextEvent) return; const nextStart = new Date(nextEvent.start.dateTime); const nextReminderTime = new Date(nextStart.getTime() - rem.offset); - if (nextReminderTime > new Date(rem.repeatUntil)) return; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { _id, ...reminderData } = rem; + const tz = nextEvent.start.timeZone || "America/New_York"; + const formattedStart = nextStart.toLocaleString("en-US", { + timeZone: tz, + dateStyle: "short", + timeStyle: "short", + }); + + const newContent = `${ + nextEvent.summary || "Untitled Event" + }\nStarts at: ${formattedStart}${ + nextEvent.location ? `\nLocation: ${nextEvent.location}` : "" + }${nextEvent.description ? `\nDetails: ${nextEvent.description}` : ""}`; + + // Reschedule with updated content + const { _id: _, ...reminderData } = rem; await bot.mongo.collection(DB.REMINDERS).insertOne({ ...reminderData, - expires: nextReminderTime + expires: nextReminderTime, + content: newContent, }); } catch (err) { - console.error('Failed to reschedule repeating reminder:', err); + console.error("Failed to reschedule repeating reminder:", err); } } From 58d0809a06024e41471be17aea4a12fb61fcc887 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Mon, 5 May 2025 16:33:00 -0400 Subject: [PATCH 3/4] avoids duplicate repeating reminders in DB --- src/pieces/tasks.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index 5a186adf..d0b10b8f 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -178,18 +178,35 @@ async function tryRescheduleReminder( ): Promise { try { const futureEvents = await retrieveEvents(rem.calendarId); - const nextEvent = futureEvents.find( - (e) => - new Date(e.start?.dateTime || 0) > now && - e.summary === rem.summary - ); + const nextEvent = futureEvents + .filter( + (e) => + new Date(e.start?.dateTime || 0) > now && + e.summary === rem.summary + ) + .sort( + (a, b) => + new Date(a.start.dateTime).getTime() - + new Date(b.start.dateTime).getTime() + )[0]; if (!nextEvent) return; const nextStart = new Date(nextEvent.start.dateTime); const nextReminderTime = new Date(nextStart.getTime() - rem.offset); + if (nextReminderTime > new Date(rem.repeatUntil)) return; + // Prevent duplicate reminders + const existing = await bot.mongo.collection(DB.REMINDERS).findOne({ + summary: rem.summary, + calendarId: rem.calendarId, + expires: nextReminderTime, + owner: rem.owner, + }); + + if (existing) return; + const tz = nextEvent.start.timeZone || "America/New_York"; const formattedStart = nextStart.toLocaleString("en-US", { timeZone: tz, From 7d47db5a54de960d9c79d940d533eb0b6b6819b1 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Mon, 5 May 2025 16:41:40 -0400 Subject: [PATCH 4/4] fixed ESLINT --- src/commands/general/calreminder.ts | 256 ++++++++++++++-------------- src/pieces/tasks.ts | 91 +++++----- 2 files changed, 176 insertions(+), 171 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 3a171ec6..3dd2613a 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -1,6 +1,6 @@ -import { DB } from "@root/config"; -import { Command } from "@root/src/lib/types/Command"; -import { Reminder } from "@root/src/lib/types/Reminder"; +import { DB } from '@root/config'; +import { Command } from '@root/src/lib/types/Command'; +import { Reminder } from '@root/src/lib/types/Reminder'; import { ActionRowBuilder, ApplicationCommandOptionData, @@ -8,32 +8,33 @@ import { ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, - ComponentType, -} from "discord.js"; -import parse from "parse-duration"; -import { retrieveEvents } from "@root/src/lib/auth"; -import { calendar_v3 as calendarV3 } from "googleapis"; -import { MongoClient } from "mongodb"; -import { PagifiedSelectMenu } from "@root/src/lib/types/PagifiedSelect"; -const MONGO_URI = process.env.DB_CONN_STRING || ""; + ComponentType +} from 'discord.js'; +import parse from 'parse-duration'; +import { retrieveEvents } from '@root/src/lib/auth'; +import { calendar_v3 as calendarV3 } from 'googleapis'; +import { MongoClient } from 'mongodb'; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; +const MONGO_URI = process.env.DB_CONN_STRING || ''; export default class extends Command { - name = "calreminder"; - description = "Setup reminders for calendar events"; + + name = 'calreminder'; + description = 'Setup reminders for calendar events'; options: ApplicationCommandOptionData[] = [ { - name: "classname", - description: "Course ID", + name: 'classname', + description: 'Course ID', type: ApplicationCommandOptionType.String, - required: true, + required: true }, { - name: "filter", + name: 'filter', description: - "Office-hours name or keyword to narrow results (optional)", + 'Office-hours name or keyword to narrow results (optional)', type: ApplicationCommandOptionType.String, - required: false, - }, + required: false + } ]; async run(interaction: ChatInputCommandInteraction): Promise { @@ -45,7 +46,7 @@ export default class extends Command { let offsetMenu: PagifiedSelectMenu; const generateMessage = ( - repeatInterval: "every_event" | null, + repeatInterval: 'every_event' | null, chosenEvent?: calendarV3.Schema$Event, chosenOffset?: number, renderMenus = false, @@ -55,20 +56,20 @@ export default class extends Command { if (renderMenus) { eventMenu = new PagifiedSelectMenu(); eventMenu.createSelectMenu({ - customId: "select_event", - placeHolder: "Select an event", + customId: 'select_event', + placeHolder: 'Select an event', minimumValues: 1, - maximumValues: 1, + maximumValues: 1 }); let defaultSet = false; filteredEvents.forEach((event, index) => { if (!event.start?.dateTime) return; - const isDefault = - !defaultSet && - chosenEvent?.start?.dateTime === - event.start?.dateTime; + const isDefault + = !defaultSet + && chosenEvent?.start?.dateTime + === event.start?.dateTime; if (isDefault) defaultSet = true; @@ -78,7 +79,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault, + default: isDefault }); }); @@ -86,33 +87,33 @@ export default class extends Command { // Create offset select menu const offsetOptions = [ - { label: "At event", value: "0" }, - { label: "10 minutes before", value: "10m" }, - { label: "30 minutes before", value: "30m" }, - { label: "1 hour before", value: "1h" }, - { label: "1 day before", value: "1d" }, + { label: 'At event', value: '0' }, + { label: '10 minutes before', value: '10m' }, + { label: '30 minutes before', value: '30m' }, + { label: '1 hour before', value: '1h' }, + { label: '1 day before', value: '1d' } ]; offsetMenu = new PagifiedSelectMenu(); offsetMenu.createSelectMenu({ - customId: "select_offset", - placeHolder: "Select reminder offset", + customId: 'select_offset', + placeHolder: 'Select reminder offset', minimumValues: 1, - maximumValues: 1, + maximumValues: 1 }); let offsetDefaultSet = false; offsetOptions.forEach((option) => { - const isDefault = - !offsetDefaultSet && - chosenOffset === parse(option.value); + const isDefault + = !offsetDefaultSet + && chosenOffset === parse(option.value); if (isDefault) offsetDefaultSet = true; offsetMenu.addOption({ label: option.label, value: option.value, - default: isDefault, + default: isDefault }); }); @@ -127,22 +128,22 @@ export default class extends Command { // 3) Generate repeat button const toggleRepeatButton = new ButtonBuilder() - .setCustomId("toggle_repeat") + .setCustomId('toggle_repeat') .setLabel( - repeatInterval === "every_event" - ? "Repeat: On" - : "Repeat: Off" + repeatInterval === 'every_event' + ? 'Repeat: On' + : 'Repeat: Off' ) .setStyle(ButtonStyle.Secondary); // 4) Generate set reminder button const setReminder = new ButtonBuilder() - .setCustomId("set_reminder") - .setLabel("Set Reminder") + .setCustomId('set_reminder') + .setLabel('Set Reminder') .setStyle(ButtonStyle.Success); - const setReminderAndRepeatRow = - new ActionRowBuilder().addComponents( + const setReminderAndRepeatRow + = new ActionRowBuilder().addComponents( toggleRepeatButton, setReminder ); @@ -150,58 +151,58 @@ export default class extends Command { return [ ...eventMenuRows, ...offsetMenuRows, - setReminderAndRepeatRow, + setReminderAndRepeatRow ]; }; // 2) All your pre-flight checks (courseCode, DB lookup, retrieveEvents, filters, etc.) const courseCode = interaction.options - .getString("classname") + .getString('classname') ?.toUpperCase(); if (!courseCode) { await interaction.editReply({ - content: "❗ You must specify a class name.", + content: '❗ You must specify a class name.' }); return; } // OPTIONAL name filter (e.g. "Phil", "Sophia") - const nameFilter = - interaction.options.getString("filter")?.trim().toLowerCase() ?? - null; + const nameFilter + = interaction.options.getString('filter')?.trim().toLowerCase() + ?? null; // Lookup calendar from MongoDB let calendar: { calendarId: string; calendarName: string }; try { const client = new MongoClient(MONGO_URI, { - useUnifiedTopology: true, + useUnifiedTopology: true }); await client.connect(); - const db = client.db("CalendarDatabase"); - const collection = db.collection("calendarIds"); + const db = client.db('CalendarDatabase'); + const collection = db.collection('calendarIds'); const calendarInDB = await collection.findOne({ - calendarName: { $regex: `^${courseCode}$`, $options: "i" }, + calendarName: { $regex: `^${courseCode}$`, $options: 'i' } }); await client.close(); if (!calendarInDB) { await interaction.editReply({ - content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, + content: `⚠️ There are no matching calendars with course code **${courseCode}**.` }); return; } calendar = { calendarId: calendarInDB.calendarId, - calendarName: calendarInDB.calendarName, + calendarName: calendarInDB.calendarName }; } catch (error) { - console.error("Calendar lookup failed:", error); + console.error('Calendar lookup failed:', error); await interaction.editReply({ - content: `❌ Database error while fetching calendar for **${courseCode}**.`, + content: `❌ Database error while fetching calendar for **${courseCode}**.` }); return; } @@ -216,7 +217,7 @@ export default class extends Command { if (!events || events.length === 0) { await interaction.editReply({ content: - "⚠️ Failed to fetch calendar events or no events found.", + '⚠️ Failed to fetch calendar events or no events found.' }); return; } @@ -228,7 +229,7 @@ export default class extends Command { ); if (filteredEvents.length === 0) { await interaction.editReply({ - content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, + content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.` }); return; } @@ -236,7 +237,7 @@ export default class extends Command { let chosenEvent: calendarV3.Schema$Event = null; let chosenOffset: number = null; - let repeatInterval: "every_event" = null; + let repeatInterval: 'every_event' = null; let activeReminderId: string = null; const initialComponents = generateMessage( @@ -252,39 +253,39 @@ export default class extends Command { // 2) Send your menus by editing the deferred reply: const replyMessage = await interaction.editReply({ - components: initialComponents, + components: initialComponents }); // Main collector for event & offset const collector = replyMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, - time: 60_000, + time: 60_000 }); - collector.on("collect", async (i) => { - if (i.customId === "select_event") { - const [, indexStr] = i.values[0].split("::"); + collector.on('collect', async (i) => { + if (i.customId === 'select_event') { + const [, indexStr] = i.values[0].split('::'); const selectedIndex = parseInt(indexStr); chosenEvent = filteredEvents[selectedIndex]; await i.deferUpdate(); - } else if (i.customId === "select_offset") { + } else if (i.customId === 'select_offset') { const rawOffsetStr = i.values[0]; - chosenOffset = - rawOffsetStr === "0" ? 0 : parse(rawOffsetStr); + chosenOffset + = rawOffsetStr === '0' ? 0 : parse(rawOffsetStr); await i.deferUpdate(); } }); // Button collector for Cancel and Set Reminder - const buttonCollector = - replyMessage.createMessageComponentCollector({ + const buttonCollector + = replyMessage.createMessageComponentCollector({ componentType: ComponentType.Button, - time: 300_000, // 5 minutes + time: 300_000 // 5 minutes }); - buttonCollector.on("collect", async (btnInt) => { - if (btnInt.customId === "toggle_repeat") { - repeatInterval = repeatInterval ? null : "every_event"; + buttonCollector.on('collect', async (btnInt) => { + if (btnInt.customId === 'toggle_repeat') { + repeatInterval = repeatInterval ? null : 'every_event'; const updatedComponents = generateMessage( repeatInterval, @@ -296,9 +297,9 @@ export default class extends Command { ); await btnInt.update({ - components: updatedComponents, + components: updatedComponents }); - } else if (btnInt.customId === "set_reminder") { + } else if (btnInt.customId === 'set_reminder') { // If user hasn’t selected both fields, just silently acknowledge if (!chosenEvent || chosenOffset === null) { if (!btnInt.deferred && !btnInt.replied) { @@ -319,8 +320,8 @@ export default class extends Command { if (remindDate.getTime() <= Date.now()) { await btnInt.editReply({ content: - "⏰ That reminder time is in the past. No reminder was set.", - components: [], + '⏰ That reminder time is in the past. No reminder was set.', + components: [] }); collector.stop(); buttonCollector.stop(); @@ -328,24 +329,10 @@ export default class extends Command { } // Build more detailed reminder text - const formattedStart = dateObj.toLocaleString("en-US", { - timeZone: - chosenEvent.start?.timeZone || "America/New_York", - dateStyle: "short", - timeStyle: "short", - }); - - const eventInfo = `${ - chosenEvent.summary || "Untitled Event" - }\nStarts at: ${formattedStart}${ - chosenEvent.location - ? `\nLocation: ${chosenEvent.location}` - : "" - }${ - chosenEvent.description - ? `\nDetails: ${chosenEvent.description}` - : "" - }`; + const eventInfo = formatEventInfo( + chosenEvent, + chosenEvent.start?.timeZone + ); // Create reminder in DB const EXPIRE_BUFFER_MS = 180 * 24 * 60 * 60 * 1000; // 180 days in ms @@ -353,7 +340,7 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: "private", + mode: 'private', summary: chosenEvent.summary, expires: remindDate, // next fire time repeat: repeatInterval, // "every_event" or null @@ -361,7 +348,7 @@ export default class extends Command { offset: chosenOffset, // ms before event repeatUntil: new Date( remindDate.getTime() + EXPIRE_BUFFER_MS - ), + ) }; let result; @@ -371,11 +358,11 @@ export default class extends Command { .insertOne(reminder); activeReminderId = result.insertedId; } catch (err) { - console.error("Failed to insert reminder:", err); + console.error('Failed to insert reminder:', err); await btnInt.editReply({ content: - "❌ Failed to save reminder. Please try again later.", - components: [], + '❌ Failed to save reminder. Please try again later.', + components: [] }); buttonCollector.stop(); return; @@ -383,12 +370,12 @@ export default class extends Command { // Build Cancel button row const cancelButton = new ButtonBuilder() - .setCustomId("cancel_reminder") - .setLabel("Cancel Reminder") + .setCustomId('cancel_reminder') + .setLabel('Cancel Reminder') .setStyle(ButtonStyle.Danger); - const buttonRow = - new ActionRowBuilder().addComponents( + const buttonRow + = new ActionRowBuilder().addComponents( cancelButton ); @@ -400,11 +387,11 @@ export default class extends Command { repeatInterval ? `\n🔁 Repeats every event (for up to 180 days) ` - : "" + : '' }`, - components: [buttonRow], + components: [buttonRow] }); - } else if (btnInt.customId === "cancel_reminder") { + } else if (btnInt.customId === 'cancel_reminder') { try { // 1) Defer *a new reply* (ephemeral) if (!btnInt.deferred && !btnInt.replied) { @@ -420,22 +407,22 @@ export default class extends Command { // 3) Send brand new ephemeral follow-up await btnInt.followUp({ - content: "❌ Your reminder has been canceled.", - ephemeral: true, + content: '❌ Your reminder has been canceled.', + ephemeral: true }); // 4) Stop the collector buttonCollector.stop(); } catch (err) { - console.error("Failed to cancel reminder:", err); + console.error('Failed to cancel reminder:', err); } } const actions: Record void> = { - "next_button:select_event": () => eventMenu.currentPage++, - "prev_button:select_event": () => eventMenu.currentPage--, - "next_button:select_offset": () => offsetMenu.currentPage++, - "prev_button:select_offset": () => offsetMenu.currentPage--, + 'next_button:select_event': () => eventMenu.currentPage++, + 'prev_button:select_event': () => eventMenu.currentPage--, + 'next_button:select_offset': () => offsetMenu.currentPage++, + 'prev_button:select_offset': () => offsetMenu.currentPage-- }; const action = actions[btnInt.customId]; @@ -455,21 +442,38 @@ export default class extends Command { } }); } catch (err) { - console.error("calreminder error:", err); + console.error('calreminder error:', err); // 5) Error fallback: if we’ve already deferred/replied, use followUp if (interaction.deferred || interaction.replied) { await interaction.followUp({ content: - "❌ An error occurred; the team has been notified.", - ephemeral: true, + '❌ An error occurred; the team has been notified.', + ephemeral: true }); } else { await interaction.reply({ content: - "❌ An error occurred; the team has been notified.", - ephemeral: true, + '❌ An error occurred; the team has been notified.', + ephemeral: true }); } } } + +} + +function formatEventInfo( + event: calendarV3.Schema$Event, + timeZone = 'America/New_York' +): string { + const dateObj = new Date(event.start?.dateTime || ''); + const formattedStart = dateObj.toLocaleString('en-US', { + timeZone, + dateStyle: 'short', + timeStyle: 'short' + }); + + return `${event.summary || 'Untitled Event'}\nStarts at: ${formattedStart}${ + event.location ? `\nLocation: ${event.location}` : '' + }${event.description ? `\nDetails: ${event.description}` : ''}`; } diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index d0b10b8f..23c268ad 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -1,14 +1,14 @@ -import { CHANNELS, DB } from "@root/config"; -import { ChannelType, Client, EmbedBuilder, TextChannel } from "discord.js"; -import { schedule } from "node-cron"; -import { Reminder } from "@lib/types/Reminder"; -import { Poll, PollResult } from "@lib/types/Poll"; -import { retrieveEvents } from "../lib/auth"; -import { ObjectId } from "mongodb"; +import { CHANNELS, DB } from '@root/config'; +import { ChannelType, Client, EmbedBuilder, TextChannel } from 'discord.js'; +import { schedule } from 'node-cron'; +import { Reminder } from '@lib/types/Reminder'; +import { Poll, PollResult } from '@lib/types/Poll'; +import { retrieveEvents } from '../lib/auth'; +import { ObjectId } from 'mongodb'; async function register(bot: Client): Promise { - schedule("0/30 * * * * *", () => { - handleCron(bot).catch(async (error) => bot.emit("error", error)); + schedule('0/30 * * * * *', () => { + handleCron(bot).catch(async (error) => bot.emit('error', error)); }); } @@ -21,10 +21,10 @@ async function checkPolls(bot: Client): Promise { const polls: Poll[] = await bot.mongo .collection(DB.POLLS) .find({ - expires: { $lte: new Date() }, + expires: { $lte: new Date() } }) .toArray(); - const emotes = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]; + const emotes = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; polls.forEach(async (poll) => { const mdTimestamp = ``; @@ -55,25 +55,25 @@ async function checkPolls(bot: Client): Promise { winMessage = `**${ winners[0].option }** has won the poll with ${winCount} vote${ - winCount === 1 ? "" : "s" + winCount === 1 ? '' : 's' }!`; } else { winMessage = `**${winners .slice(0, -1) .map((win) => win.option) - .join(", ")} and ${ + .join(', ')} and ${ winners.slice(-1)[0].option }** have won the poll with ${winners[0].users.length} vote${ - winCount === 1 ? "" : "s" + winCount === 1 ? '' : 's' } each!`; } // build up the text that is on the final poll embed - let choiceText = ""; + let choiceText = ''; let count = 0; resultMap.forEach((value, key) => { choiceText += `${emotes[count++]} ${key}: ${value} vote${ - value === 1 ? "" : "s" + value === 1 ? '' : 's' }\n`; }); @@ -89,11 +89,11 @@ async function checkPolls(bot: Client): Promise { `This poll was created by ${owner.displayName} and ended **${mdTimestamp}**` ) .addFields({ - name: `Winner${winners.length === 1 ? "" : "s"}`, - value: winMessage, + name: `Winner${winners.length === 1 ? '' : 's'}`, + value: winMessage }) - .addFields({ name: "Choices", value: choiceText }) - .setColor("Random"); + .addFields({ name: 'Choices', value: choiceText }) + .setColor('Random'); pollMsg.edit({ embeds: [pollEmbed], components: [] }); @@ -103,15 +103,15 @@ async function checkPolls(bot: Client): Promise { .setTitle(poll.question) .setDescription(`${owner}'s poll has ended!`) .addFields({ - name: `Winner${winners.length === 1 ? "" : "s"}`, - value: winMessage, + name: `Winner${winners.length === 1 ? '' : 's'}`, + value: winMessage }) .addFields({ - name: "Original poll", - value: `Click [here](${pollMsg.url}) to see the original poll.`, + name: 'Original poll', + value: `Click [here](${pollMsg.url}) to see the original poll.` }) - .setColor("Random"), - ], + .setColor('Random') + ] }); await bot.mongo.collection(DB.POLLS).findOneAndDelete(poll); @@ -131,17 +131,17 @@ async function checkReminders(bot: Client): Promise { // 2) Send each one for (const rem of reminders) { const embed = new EmbedBuilder() - .setTitle("⏰ Reminder") + .setTitle('⏰ Reminder') .setDescription(rem.content) - .setColor("Blue") + .setColor('Blue') .setTimestamp(now); if (rem.repeat) { embed.addFields({ - name: "🔁 Repeats", + name: '🔁 Repeats', value: - rem.repeat === "every_event" ? "Every event" : rem.repeat, - inline: true, + rem.repeat === 'every_event' ? 'Every event' : rem.repeat, + inline: true }); } @@ -156,7 +156,7 @@ async function checkReminders(bot: Client): Promise { } // 3) Reschedule if it's a repeating reminder - if (rem.repeat === "every_event") { + if (rem.repeat === 'every_event') { await tryRescheduleReminder(rem, now, bot); } @@ -167,7 +167,7 @@ async function checkReminders(bot: Client): Promise { // 4) Remove all processed reminders if (handledIds.length > 0) { await bot.mongo.collection(DB.REMINDERS).deleteMany({ - _id: { $in: handledIds }, + _id: { $in: handledIds } }); } } @@ -181,8 +181,8 @@ async function tryRescheduleReminder( const nextEvent = futureEvents .filter( (e) => - new Date(e.start?.dateTime || 0) > now && - e.summary === rem.summary + new Date(e.start?.dateTime || 0) > now + && e.summary === rem.summary ) .sort( (a, b) => @@ -202,33 +202,34 @@ async function tryRescheduleReminder( summary: rem.summary, calendarId: rem.calendarId, expires: nextReminderTime, - owner: rem.owner, + owner: rem.owner }); if (existing) return; - const tz = nextEvent.start.timeZone || "America/New_York"; - const formattedStart = nextStart.toLocaleString("en-US", { + const tz = nextEvent.start.timeZone || 'America/New_York'; + const formattedStart = nextStart.toLocaleString('en-US', { timeZone: tz, - dateStyle: "short", - timeStyle: "short", + dateStyle: 'short', + timeStyle: 'short' }); const newContent = `${ - nextEvent.summary || "Untitled Event" + nextEvent.summary || 'Untitled Event' }\nStarts at: ${formattedStart}${ - nextEvent.location ? `\nLocation: ${nextEvent.location}` : "" - }${nextEvent.description ? `\nDetails: ${nextEvent.description}` : ""}`; + nextEvent.location ? `\nLocation: ${nextEvent.location}` : '' + }${nextEvent.description ? `\nDetails: ${nextEvent.description}` : ''}`; // Reschedule with updated content + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _id: _, ...reminderData } = rem; await bot.mongo.collection(DB.REMINDERS).insertOne({ ...reminderData, expires: nextReminderTime, - content: newContent, + content: newContent }); } catch (err) { - console.error("Failed to reschedule repeating reminder:", err); + console.error('Failed to reschedule repeating reminder:', err); } }