From e422d64be1aaf95347984fce4ff2ecca830c05fd Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 13:09:31 -0400 Subject: [PATCH 1/6] added extra search filtering for calreminder --- src/commands/general/calreminder.ts | 216 +++++++++++++++------------- 1 file changed, 119 insertions(+), 97 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index b7870637..8837d9d5 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,26 +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", + description: + "Office-hours name or keyword to narrow results (optional)", + type: ApplicationCommandOptionType.String, + required: false, + }, ]; async run(interaction: ChatInputCommandInteraction): Promise { @@ -35,7 +41,7 @@ export default class extends Command { let offsetMenu: PagifiedSelectMenu; function generateMessage( - repeatInterval: 'every_event' | null, + repeatInterval: "every_event" | null, chosenEvent?: calendarV3.Schema$Event, chosenOffset?: number, renderMenus = false, @@ -45,19 +51,19 @@ 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; @@ -67,7 +73,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault + default: isDefault, }); }); @@ -75,33 +81,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, }); }); @@ -116,22 +122,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 ); @@ -139,35 +145,40 @@ export default class extends Command { return [ ...eventMenuRows, ...offsetMenuRows, - setReminderAndRepeatRow + setReminderAndRepeatRow, ]; } const courseCode = interaction.options - .getString('classname') + .getString("classname") ?.toUpperCase(); if (!courseCode) { await interaction.reply({ - content: '❗ You must specify a class name.', - ephemeral: true + content: "❗ You must specify a class name.", + ephemeral: true, }); return; } + // OPTIONAL name filter (e.g. "Phil", "Sophia") + 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(); @@ -175,20 +186,20 @@ export default class extends Command { if (!calendarInDB) { await interaction.reply({ content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true + ephemeral: true, }); 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.reply({ content: `❌ Database error while fetching calendar for **${courseCode}**.`, - ephemeral: true + ephemeral: true, }); return; } @@ -199,17 +210,29 @@ export default class extends Command { if (!events || events.length === 0) { await interaction.reply({ content: - '⚠️ Failed to fetch calendar events or no events found.', - ephemeral: true + "⚠️ Failed to fetch calendar events or no events found.", + ephemeral: true, }); return; } - const filteredEvents = events; // no filtering needed since each calendar is specific to a course + let filteredEvents = events; // no filtering needed since each calendar is specific to a course + if (nameFilter) { + filteredEvents = events.filter((e) => + e.summary?.toLowerCase().includes(nameFilter) + ); + if (filteredEvents.length === 0) { + await interaction.reply({ + content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, + ephemeral: true, + }); + return; + } + } 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( @@ -224,24 +247,24 @@ export default class extends Command { const replyMessage = await interaction.reply({ components: initialComponents, - ephemeral: true + ephemeral: true, }); // 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(); } }); @@ -249,12 +272,12 @@ export default class extends Command { // Button collector for Cancel and Set Reminder 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, @@ -266,9 +289,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) { @@ -287,8 +310,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(); @@ -306,11 +329,11 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: 'public', + mode: "public", expires: repeatInterval ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time : remindDate, // one-time reminders - repeat: repeatInterval + repeat: repeatInterval, }; let result; @@ -320,11 +343,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; @@ -332,12 +355,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 ); @@ -349,11 +372,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) { @@ -369,22 +392,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]; @@ -404,5 +427,4 @@ export default class extends Command { } }); } - } From 39c62dea1d002bf73c1a574e793c88ee684ca2b0 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 13:13:53 -0400 Subject: [PATCH 2/6] fixed eslint errors --- src/commands/general/calreminder.ts | 204 ++++++++++++++-------------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 8837d9d5..824813cf 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 { @@ -41,7 +42,7 @@ export default class extends Command { let offsetMenu: PagifiedSelectMenu; function generateMessage( - repeatInterval: "every_event" | null, + repeatInterval: 'every_event' | null, chosenEvent?: calendarV3.Schema$Event, chosenOffset?: number, renderMenus = false, @@ -51,19 +52,19 @@ 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; @@ -73,7 +74,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault, + default: isDefault }); }); @@ -81,33 +82,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 }); }); @@ -122,22 +123,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 ); @@ -145,40 +146,40 @@ export default class extends Command { return [ ...eventMenuRows, ...offsetMenuRows, - setReminderAndRepeatRow, + setReminderAndRepeatRow ]; } const courseCode = interaction.options - .getString("classname") + .getString('classname') ?.toUpperCase(); if (!courseCode) { await interaction.reply({ - content: "❗ You must specify a class name.", - ephemeral: true, + content: '❗ You must specify a class name.', + ephemeral: true }); 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(); @@ -186,20 +187,20 @@ export default class extends Command { if (!calendarInDB) { await interaction.reply({ content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true, + ephemeral: true }); 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.reply({ content: `❌ Database error while fetching calendar for **${courseCode}**.`, - ephemeral: true, + ephemeral: true }); return; } @@ -210,8 +211,8 @@ export default class extends Command { if (!events || events.length === 0) { await interaction.reply({ content: - "⚠️ Failed to fetch calendar events or no events found.", - ephemeral: true, + '⚠️ Failed to fetch calendar events or no events found.', + ephemeral: true }); return; } @@ -224,7 +225,7 @@ export default class extends Command { if (filteredEvents.length === 0) { await interaction.reply({ content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, - ephemeral: true, + ephemeral: true }); return; } @@ -232,7 +233,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( @@ -247,24 +248,24 @@ export default class extends Command { const replyMessage = await interaction.reply({ components: initialComponents, - ephemeral: true, + ephemeral: true }); // 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(); } }); @@ -272,12 +273,12 @@ export default class extends Command { // Button collector for Cancel and Set Reminder 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, @@ -289,9 +290,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) { @@ -310,8 +311,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,11 +330,11 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: "public", + mode: 'public', expires: repeatInterval ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time : remindDate, // one-time reminders - repeat: repeatInterval, + repeat: repeatInterval }; let result; @@ -343,11 +344,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; @@ -355,12 +356,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 ); @@ -372,11 +373,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) { @@ -392,22 +393,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]; @@ -427,4 +428,5 @@ export default class extends Command { } }); } + } From cf2fcd672c44160ee27cd619b55b9d44517c79b3 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 17:02:31 -0400 Subject: [PATCH 3/6] sends reminder to DM --- Documentation/Spring 2025 Implementations | 47 +- src/commands/general/calreminder.ts | 741 +++++++++++----------- src/pieces/tasks.ts | 163 ++--- 3 files changed, 520 insertions(+), 431 deletions(-) diff --git a/Documentation/Spring 2025 Implementations b/Documentation/Spring 2025 Implementations index 28b2d0b5..9312a015 100644 --- a/Documentation/Spring 2025 Implementations +++ b/Documentation/Spring 2025 Implementations @@ -1,2 +1,45 @@ -Add here! -Please commit please! \ No newline at end of file +Here is what we got done: + +1. /calendar: + +2. /importcalendar: + +3. /calreminder: - Google OAuth flow wired up so users can link their calendar + + - Events pulled into bot memory via retrieveEvents() + + - Parsing system extracts summary, start date/time, location, etc. + + - Filter menu lets you pick by course ID, day, date, location (virtual/in-person) + + - UI shows 3 events per page with Next/Prev buttons + + - Offset selector (At event, 10m / 30m / 1h / 1 d before) + + - “Repeat” toggle for per-event reminders (bug documented in code) + + - Builds reminder date, checks against past times + + - Inserts reminder into MongoDB (calreminders collection) + + - Sends ephemeral DM confirmation with the scheduled time + +4. /cancelreminder: + + - “Cancel Reminder” button available after setting a reminder + + - Deletes the reminder document from MongoDB + + - Sends an ephemeral DM follow-up: “Your reminder has been canceled.” + + - Basic try/catch around the delete, logs errors to console + +5. /viewreminders: + + - Fetches all upcoming reminders for the invoking user from MongoDB + + - Displays them in pages of 3 (same paginated UI as /calreminder) + + - Shows event summary, reminder time, and repeat status + + - All output sent as an ephemeral DM for privacy diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 824813cf..64b99565 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,425 +8,450 @@ 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 { - let eventMenu: PagifiedSelectMenu; - let offsetMenu: PagifiedSelectMenu; - - function generateMessage( - repeatInterval: 'every_event' | null, - chosenEvent?: calendarV3.Schema$Event, - chosenOffset?: number, - renderMenus = false, - eventCurrentPage = 0, - offsetCurrentPage = 0 - ) { - if (renderMenus) { - eventMenu = new PagifiedSelectMenu(); - eventMenu.createSelectMenu({ - customId: 'select_event', - placeHolder: 'Select an event', - minimumValues: 1, - maximumValues: 1 - }); - let defaultSet = false; - - filteredEvents.forEach((event, index) => { - if (!event.start?.dateTime) return; - - const isDefault - = !defaultSet - && chosenEvent?.start?.dateTime === event.start?.dateTime; + await interaction.deferReply({ ephemeral: true }); - if (isDefault) defaultSet = true; + try { + let eventMenu: PagifiedSelectMenu; + let offsetMenu: PagifiedSelectMenu; + + function generateMessage( + repeatInterval: "every_event" | null, + chosenEvent?: calendarV3.Schema$Event, + chosenOffset?: number, + renderMenus = false, + eventCurrentPage = 0, + offsetCurrentPage = 0 + ) { + if (renderMenus) { + eventMenu = new PagifiedSelectMenu(); + eventMenu.createSelectMenu({ + customId: "select_event", + placeHolder: "Select an event", + minimumValues: 1, + maximumValues: 1, + }); + let defaultSet = false; + + filteredEvents.forEach((event, index) => { + if (!event.start?.dateTime) return; + + const isDefault = + !defaultSet && + chosenEvent?.start?.dateTime === + event.start?.dateTime; + + if (isDefault) defaultSet = true; + + eventMenu.addOption({ + label: event.summary, + value: `${event.start.dateTime}::${index}`, + description: `Starts at: ${new Date( + event.start.dateTime + ).toLocaleString()}`, + default: isDefault, + }); + }); - eventMenu.addOption({ - label: event.summary, - value: `${event.start.dateTime}::${index}`, - description: `Starts at: ${new Date( - event.start.dateTime - ).toLocaleString()}`, - default: isDefault + eventMenu.currentPage = eventCurrentPage; + + // 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" }, + ]; + + offsetMenu = new PagifiedSelectMenu(); + offsetMenu.createSelectMenu({ + customId: "select_offset", + placeHolder: "Select reminder offset", + minimumValues: 1, + maximumValues: 1, }); - }); - eventMenu.currentPage = eventCurrentPage; + let offsetDefaultSet = false; - // 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' } - ]; + offsetOptions.forEach((option) => { + const isDefault = + !offsetDefaultSet && + chosenOffset === parse(option.value); + if (isDefault) offsetDefaultSet = true; - offsetMenu = new PagifiedSelectMenu(); - offsetMenu.createSelectMenu({ - customId: 'select_offset', - placeHolder: 'Select reminder offset', - minimumValues: 1, - maximumValues: 1 - }); - - let offsetDefaultSet = false; + offsetMenu.addOption({ + label: option.label, + value: option.value, + default: isDefault, + }); + }); - offsetOptions.forEach((option) => { - const isDefault - = !offsetDefaultSet - && chosenOffset === parse(option.value); - if (isDefault) offsetDefaultSet = true; + offsetMenu.currentPage = offsetCurrentPage; + } - offsetMenu.addOption({ - label: option.label, - value: option.value, - default: isDefault - }); - }); + // 1) Generate event menu row(s) + const eventMenuRows = eventMenu.generateActionRows(); + + // 2) Generate offset menu row(s) + const offsetMenuRows = offsetMenu.generateActionRows(); + + // 3) Generate repeat button + const toggleRepeatButton = new ButtonBuilder() + .setCustomId("toggle_repeat") + .setLabel( + 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") + .setStyle(ButtonStyle.Success); + + const setReminderAndRepeatRow = + new ActionRowBuilder().addComponents( + toggleRepeatButton, + setReminder + ); - offsetMenu.currentPage = offsetCurrentPage; + return [ + ...eventMenuRows, + ...offsetMenuRows, + setReminderAndRepeatRow, + ]; } - // 1) Generate event menu row(s) - const eventMenuRows = eventMenu.generateActionRows(); - - // 2) Generate offset menu row(s) - const offsetMenuRows = offsetMenu.generateActionRows(); - - // 3) Generate repeat button - const toggleRepeatButton = new ButtonBuilder() - .setCustomId('toggle_repeat') - .setLabel( - 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') - .setStyle(ButtonStyle.Success); - - const setReminderAndRepeatRow - = new ActionRowBuilder().addComponents( - toggleRepeatButton, - setReminder - ); + const courseCode = interaction.options + .getString("classname") + ?.toUpperCase(); - return [ - ...eventMenuRows, - ...offsetMenuRows, - setReminderAndRepeatRow - ]; - } - - const courseCode = interaction.options - .getString('classname') - ?.toUpperCase(); + if (!courseCode) { + await interaction.reply({ + content: "❗ You must specify a class name.", + ephemeral: true, + }); + return; + } - if (!courseCode) { - await interaction.reply({ - content: '❗ You must specify a class name.', - ephemeral: true - }); - return; - } + // OPTIONAL name filter (e.g. "Phil", "Sophia") + const nameFilter = + interaction.options.getString("filter")?.trim().toLowerCase() || + null; - // OPTIONAL name filter (e.g. "Phil", "Sophia") - 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, + }); + await client.connect(); - // Lookup calendar from MongoDB - let calendar: { calendarId: string; calendarName: string }; - try { - const client = new MongoClient(MONGO_URI, { - 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" }, + }); - const calendarInDB = await collection.findOne({ - calendarName: { $regex: `^${courseCode}$`, $options: 'i' } - }); + await client.close(); - await client.close(); + if (!calendarInDB) { + await interaction.reply({ + content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, + ephemeral: true, + }); + return; + } - if (!calendarInDB) { + calendar = { + calendarId: calendarInDB.calendarId, + calendarName: calendarInDB.calendarName, + }; + } catch (error) { + console.error("Calendar lookup failed:", error); await interaction.reply({ - content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true + content: `❌ Database error while fetching calendar for **${courseCode}**.`, + ephemeral: true, }); return; } - calendar = { - calendarId: calendarInDB.calendarId, - calendarName: calendarInDB.calendarName - }; - } catch (error) { - console.error('Calendar lookup failed:', error); - await interaction.reply({ - content: `❌ Database error while fetching calendar for **${courseCode}**.`, - ephemeral: true - }); - return; - } - - // Retrieve events - const events = await retrieveEvents(calendar.calendarId, interaction); - - if (!events || events.length === 0) { - await interaction.reply({ - content: - '⚠️ Failed to fetch calendar events or no events found.', - ephemeral: true - }); - return; - } - - let filteredEvents = events; // no filtering needed since each calendar is specific to a course - if (nameFilter) { - filteredEvents = events.filter((e) => - e.summary?.toLowerCase().includes(nameFilter) + // Retrieve events + const events = await retrieveEvents( + calendar.calendarId, + interaction ); - if (filteredEvents.length === 0) { + + if (!events || events.length === 0) { await interaction.reply({ - content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, - ephemeral: true + content: + "⚠️ Failed to fetch calendar events or no events found.", + ephemeral: true, }); return; } - } - let chosenEvent: calendarV3.Schema$Event = null; - let chosenOffset: number = null; - let repeatInterval: 'every_event' = null; - let activeReminderId: string = null; - - const initialComponents = generateMessage( - repeatInterval, - chosenEvent, - chosenOffset, - true - ); - if (chosenOffset === null) { - chosenOffset = 0; - } - - const replyMessage = await interaction.reply({ - components: initialComponents, - ephemeral: true - }); - - // Main collector for event & offset - const collector = replyMessage.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - time: 60_000 - }); - - 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') { - const rawOffsetStr = i.values[0]; - chosenOffset = rawOffsetStr === '0' ? 0 : parse(rawOffsetStr); - await i.deferUpdate(); - } - }); - - // Button collector for Cancel and Set Reminder - const buttonCollector = replyMessage.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300_000 // 5 minutes - }); - - buttonCollector.on('collect', async (btnInt) => { - if (btnInt.customId === 'toggle_repeat') { - repeatInterval = repeatInterval ? null : 'every_event'; - - const updatedComponents = generateMessage( - repeatInterval, - chosenEvent, - chosenOffset, - true, - eventMenu.currentPage, - offsetMenu.currentPage + let filteredEvents = events; // no filtering needed since each calendar is specific to a course + if (nameFilter) { + filteredEvents = events.filter((e) => + e.summary?.toLowerCase().includes(nameFilter) ); - - await btnInt.update({ - components: updatedComponents - }); - } 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) { - await btnInt.deferUpdate(); // Prevent "interaction failed" - } + if (filteredEvents.length === 0) { + await interaction.reply({ + content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, + ephemeral: true, + }); return; } + } - // Everything is valid, continue with reminder setup - await btnInt.deferUpdate(); + let chosenEvent: calendarV3.Schema$Event = null; + let chosenOffset: number = null; + let repeatInterval: "every_event" = null; + let activeReminderId: string = null; - const dateObj = new Date(chosenEvent.start.dateTime); - const remindDate = new Date(dateObj.getTime() - chosenOffset); + const initialComponents = generateMessage( + repeatInterval, + chosenEvent, + chosenOffset, + true + ); + if (chosenOffset === null) { + chosenOffset = 0; + } - // Check if it's already in the past - if (remindDate.getTime() <= Date.now()) { - await btnInt.editReply({ - content: - '⏰ That reminder time is in the past. No reminder was set.', - components: [] - }); - collector.stop(); - buttonCollector.stop(); - return; + // 2) Send your menus by editing the deferred reply: + const replyMessage = await interaction.editReply({ + components: initialComponents, + }); + + // Main collector for event & offset + const collector = replyMessage.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60_000, + }); + + 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") { + const rawOffsetStr = i.values[0]; + chosenOffset = + rawOffsetStr === "0" ? 0 : parse(rawOffsetStr); + await i.deferUpdate(); } + }); - // Build more detailed reminder text - const eventInfo = `${ - chosenEvent.summary - }\nStarts at: ${dateObj.toLocaleString()}`; - - // Create reminder in DB - const EXPIRE_BUFFER_MS = 180 * 24 * 60 * 60 * 1000; // 180 days in ms - - const reminder: Reminder = { - owner: btnInt.user.id, - content: eventInfo, - mode: 'public', - expires: repeatInterval - ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time - : remindDate, // one-time reminders - repeat: repeatInterval - }; + // Button collector for Cancel and Set Reminder + const buttonCollector = + replyMessage.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300_000, // 5 minutes + }); - let result; - try { - result = await btnInt.client.mongo - .collection(DB.REMINDERS) - .insertOne(reminder); - activeReminderId = result.insertedId; - } catch (err) { - console.error('Failed to insert reminder:', err); - await btnInt.editReply({ - content: - '❌ Failed to save reminder. Please try again later.', - components: [] + buttonCollector.on("collect", async (btnInt) => { + if (btnInt.customId === "toggle_repeat") { + repeatInterval = repeatInterval ? null : "every_event"; + + const updatedComponents = generateMessage( + repeatInterval, + chosenEvent, + chosenOffset, + true, + eventMenu.currentPage, + offsetMenu.currentPage + ); + + await btnInt.update({ + components: updatedComponents, }); - buttonCollector.stop(); - return; - } + } 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) { + await btnInt.deferUpdate(); // Prevent "interaction failed" + } + return; + } - // Build Cancel button row - const cancelButton = new ButtonBuilder() - .setCustomId('cancel_reminder') - .setLabel('Cancel Reminder') - .setStyle(ButtonStyle.Danger); + // Everything is valid, continue with reminder setup + await btnInt.deferUpdate(); - const buttonRow - = new ActionRowBuilder().addComponents( - cancelButton + const dateObj = new Date(chosenEvent.start.dateTime); + const remindDate = new Date( + dateObj.getTime() - chosenOffset ); - // Update ephemeral message with final reminder text + Cancel button - await btnInt.editReply({ - content: `✅ Your reminder is set!\nI'll remind you at **${remindDate.toLocaleString()}** about:\n\`\`\`\n${ - reminder.content - }\n\`\`\`${ - repeatInterval - ? `\n🔁 Repeats every event (for up to 180 days) -` - : '' - }`, - components: [buttonRow] - }); - } else if (btnInt.customId === 'cancel_reminder') { - try { - // 1) Defer *a new reply* (ephemeral) - if (!btnInt.deferred && !btnInt.replied) { - await btnInt.deferReply({ ephemeral: true }); + // Check if it's already in the past + if (remindDate.getTime() <= Date.now()) { + await btnInt.editReply({ + content: + "⏰ That reminder time is in the past. No reminder was set.", + components: [], + }); + collector.stop(); + buttonCollector.stop(); + return; } - // 2) Delete the reminder from DB if it exists - if (activeReminderId) { - await btnInt.client.mongo + // Build more detailed reminder text + const eventInfo = `${ + chosenEvent.summary + }\nStarts at: ${dateObj.toLocaleString()}`; + + // Create reminder in DB + const EXPIRE_BUFFER_MS = 180 * 24 * 60 * 60 * 1000; // 180 days in ms + + const reminder: Reminder = { + owner: btnInt.user.id, + content: eventInfo, + mode: "public", + expires: repeatInterval + ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time + : remindDate, // one-time reminders + repeat: repeatInterval, + }; + + let result; + try { + result = await btnInt.client.mongo .collection(DB.REMINDERS) - .deleteOne({ _id: activeReminderId }); + .insertOne(reminder); + activeReminderId = result.insertedId; + } catch (err) { + console.error("Failed to insert reminder:", err); + await btnInt.editReply({ + content: + "❌ Failed to save reminder. Please try again later.", + components: [], + }); + buttonCollector.stop(); + return; } - // 3) Send brand new ephemeral follow-up - await btnInt.followUp({ - content: '❌ Your reminder has been canceled.', - ephemeral: true - }); + // Build Cancel button row + const cancelButton = new ButtonBuilder() + .setCustomId("cancel_reminder") + .setLabel("Cancel Reminder") + .setStyle(ButtonStyle.Danger); - // 4) Stop the collector - buttonCollector.stop(); - } catch (err) { - console.error('Failed to cancel reminder:', err); + const buttonRow = + new ActionRowBuilder().addComponents( + cancelButton + ); + + // Update ephemeral message with final reminder text + Cancel button + await btnInt.editReply({ + content: `✅ Your reminder is set!\nI'll remind you at **${remindDate.toLocaleString()}** about:\n\`\`\`\n${ + reminder.content + }\n\`\`\`${ + repeatInterval + ? `\n🔁 Repeats every event (for up to 180 days) +` + : "" + }`, + components: [buttonRow], + }); + } else if (btnInt.customId === "cancel_reminder") { + try { + // 1) Defer *a new reply* (ephemeral) + if (!btnInt.deferred && !btnInt.replied) { + await btnInt.deferReply({ ephemeral: true }); + } + + // 2) Delete the reminder from DB if it exists + if (activeReminderId) { + await btnInt.client.mongo + .collection(DB.REMINDERS) + .deleteOne({ _id: activeReminderId }); + } + + // 3) Send brand new ephemeral follow-up + await btnInt.followUp({ + content: "❌ Your reminder has been canceled.", + ephemeral: true, + }); + + // 4) Stop the collector + buttonCollector.stop(); + } catch (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-- - }; - const action = actions[btnInt.customId]; - - if (action) { - await btnInt.deferUpdate(); - action(); - - const newRows = generateMessage( - repeatInterval, - chosenEvent, - chosenOffset, - true, // ← force menus to regenerate - eventMenu.currentPage, // ← keep the event page - offsetMenu.currentPage // ← keep the offset page - ); - await btnInt.editReply({ components: newRows }); + 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--, + }; + const action = actions[btnInt.customId]; + + if (action) { + await btnInt.deferUpdate(); + action(); + + const newRows = generateMessage( + repeatInterval, + chosenEvent, + chosenOffset, + true, // ← force menus to regenerate + eventMenu.currentPage, // ← keep the event page + offsetMenu.currentPage // ← keep the offset page + ); + await btnInt.editReply({ components: newRows }); + } + }); + } catch (err) { + console.error("calreminder error:", err); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: + "❌ 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, + }); } - }); + } } - } diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index a3850612..a95cfa97 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -1,13 +1,12 @@ -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 { 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"; 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)); }); } @@ -17,25 +16,30 @@ async function handleCron(bot: Client): Promise { } async function checkPolls(bot: Client): Promise { - const polls: Poll[] = await bot.mongo.collection(DB.POLLS).find({ - expires: { $lte: new Date() } - }).toArray(); - const emotes = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟']; - - polls.forEach(async poll => { + const polls: Poll[] = await bot.mongo + .collection(DB.POLLS) + .find({ + expires: { $lte: new Date() }, + }) + .toArray(); + const emotes = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]; + + polls.forEach(async (poll) => { const mdTimestamp = ``; // figure out the winner and also put the results in a map for ease of use const resultMap = new Map(); let winners: PollResult[] = []; - poll.results.forEach(res => { + poll.results.forEach((res) => { resultMap.set(res.option, res.users.length); if (!winners[0]) { winners = [res]; return; } - if (winners[0] && res.users.length > winners[0].users.length) winners = [res]; - else if (res.users.length === winners[0].users.length) winners.push(res); + if (winners[0] && res.users.length > winners[0].users.length) + winners = [res]; + else if (res.users.length === winners[0].users.length) + winners.push(res); }); // build up the win string @@ -44,85 +48,102 @@ async function checkPolls(bot: Client): Promise { if (winCount === 0) { winMessage = `It looks like no one has voted!`; } else if (winners.length === 1) { - winMessage = `**${winners[0].option}** has won the poll with ${winCount} vote${winCount === 1 ? '' : 's'}!`; - } else { winMessage = `**${ - winners.slice(0, -1).map(win => win.option).join(', ') - } and ${ + winners[0].option + }** has won the poll with ${winCount} vote${ + winCount === 1 ? "" : "s" + }!`; + } else { + winMessage = `**${winners + .slice(0, -1) + .map((win) => win.option) + .join(", ")} and ${ winners.slice(-1)[0].option - }** have won the poll with ${winners[0].users.length} vote${winCount === 1 ? '' : 's'} each!`; + }** have won the poll with ${winners[0].users.length} vote${ + 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'}\n`; + choiceText += `${emotes[count++]} ${key}: ${value} vote${ + value === 1 ? "" : "s" + }\n`; }); const pollChannel = await bot.channels.fetch(poll.channel); - if (pollChannel.type !== ChannelType.GuildText) throw 'something went wrong fetching the poll\'s channel'; + if (pollChannel.type !== ChannelType.GuildText) + throw "something went wrong fetching the poll's channel"; const pollMsg = await pollChannel.messages.fetch(poll.message); const owner = await pollMsg.guild.members.fetch(poll.owner); const pollEmbed = new EmbedBuilder() .setTitle(poll.question) - .setDescription(`This poll was created by ${owner.displayName} and ended **${mdTimestamp}**`) - .addFields({ name: `Winner${winners.length === 1 ? '' : 's'}`, value: winMessage }) - .addFields({ name: 'Choices', value: choiceText }) - .setColor('Random'); + .setDescription( + `This poll was created by ${owner.displayName} and ended **${mdTimestamp}**` + ) + .addFields({ + name: `Winner${winners.length === 1 ? "" : "s"}`, + value: winMessage, + }) + .addFields({ name: "Choices", value: choiceText }) + .setColor("Random"); pollMsg.edit({ embeds: [pollEmbed], components: [] }); - - pollMsg.channel.send({ embeds: [new EmbedBuilder() - .setTitle(poll.question) - .setDescription(`${owner}'s poll has ended!`) - .addFields({ name: `Winner${winners.length === 1 ? '' : 's'}`, value: winMessage }) - .addFields({ name: 'Original poll', value: `Click [here](${pollMsg.url}) to see the original poll.` }) - .setColor('Random') - ] }); + pollMsg.channel.send({ + embeds: [ + new EmbedBuilder() + .setTitle(poll.question) + .setDescription(`${owner}'s poll has ended!`) + .addFields({ + name: `Winner${winners.length === 1 ? "" : "s"}`, + value: winMessage, + }) + .addFields({ + name: "Original poll", + value: `Click [here](${pollMsg.url}) to see the original poll.`, + }) + .setColor("Random"), + ], + }); await bot.mongo.collection(DB.POLLS).findOneAndDelete(poll); }); } async function checkReminders(bot: Client): Promise { - const reminders: Array = await bot.mongo.collection(DB.REMINDERS).find({ - expires: { $lte: new Date() } - }).toArray(); - const pubChan = await bot.channels.fetch(CHANNELS.SAGE) as TextChannel; - - reminders.forEach(reminder => { - const message = `<@${reminder.owner}>, here's the reminder you asked for: **${reminder.content}**`; - - if (reminder.mode === 'public') { - pubChan.send(message); - } else { - bot.users.fetch(reminder.owner).then(user => user.send(message).catch(() => { - pubChan.send(`<@${reminder.owner}>, I tried to send you a DM about your private reminder but it looks like you have -DMs closed. Please enable DMs in the future if you'd like to get private reminders.`); - })); + const now = new Date(); + + // 1) fetch all reminders whose time has come + const reminders: Reminder[] = await bot.mongo + .collection(DB.REMINDERS) + .find({ expires: { $lte: now } }) + .toArray(); + + // 2) DM‐first send + for (const reminder of reminders) { + const dmMessage = `⏰ **Reminder:** ${reminder.content}`; + + try { + const user = await bot.users.fetch(reminder.owner); + await user.send(dmMessage); + } catch { + const sage = (await bot.channels.fetch( + CHANNELS.SAGE + )) as TextChannel; + await sage.send( + `<@${reminder.owner}>, I couldn’t DM you. Here’s your reminder: **${reminder.content}**` + ); } + } - // copied value by value for several reasons, change it and I take no responsibility for it breaking. - const newReminder: Reminder = { - content: reminder.content, - expires: new Date(reminder.expires), - mode: reminder.mode, - repeat: reminder.repeat, - owner: reminder.owner - }; - - if (reminder.repeat === 'daily') { - newReminder.expires.setDate(reminder.expires.getDate() + 1); - bot.mongo.collection(DB.REMINDERS).findOneAndReplace(reminder, newReminder); - } else if (reminder.repeat === 'weekly') { - newReminder.expires.setDate(reminder.expires.getDate() + 7); - bot.mongo.collection(DB.REMINDERS).findOneAndReplace(reminder, newReminder); - } else { - bot.mongo.collection(DB.REMINDERS).findOneAndDelete(reminder); - } - }); + // 3) clean up the ones we just dispatched + // (rescheduling for repeats is left out here — adapt if needed) + await bot.mongo + .collection(DB.REMINDERS) + .deleteMany({ expires: { $lte: now } }); } export default register; From 7e7a8e7c678ce89bac077ef337be1f8d336b0f39 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 17:37:43 -0400 Subject: [PATCH 4/6] working reminders in DM (need to fix repeating) --- src/commands/general/calreminder.ts | 35 +++++++-------- src/lib/types/Reminder.d.ts | 6 ++- src/pieces/tasks.ts | 69 ++++++++++++++++++++++------- 3 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 64b99565..4588f0d2 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -153,21 +153,20 @@ export default class extends Command { ]; } + // 2) All your pre-flight checks (courseCode, DB lookup, retrieveEvents, filters, etc.) const courseCode = interaction.options .getString("classname") ?.toUpperCase(); - if (!courseCode) { - await interaction.reply({ + await interaction.editReply({ content: "❗ You must specify a class name.", - ephemeral: true, }); return; } // OPTIONAL name filter (e.g. "Phil", "Sophia") const nameFilter = - interaction.options.getString("filter")?.trim().toLowerCase() || + interaction.options.getString("filter")?.trim().toLowerCase() ?? null; // Lookup calendar from MongoDB @@ -188,9 +187,8 @@ export default class extends Command { await client.close(); if (!calendarInDB) { - await interaction.reply({ + await interaction.editReply({ content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true, }); return; } @@ -201,9 +199,8 @@ export default class extends Command { }; } catch (error) { console.error("Calendar lookup failed:", error); - await interaction.reply({ + await interaction.editReply({ content: `❌ Database error while fetching calendar for **${courseCode}**.`, - ephemeral: true, }); return; } @@ -215,10 +212,9 @@ export default class extends Command { ); if (!events || events.length === 0) { - await interaction.reply({ + await interaction.editReply({ content: "⚠️ Failed to fetch calendar events or no events found.", - ephemeral: true, }); return; } @@ -229,9 +225,8 @@ export default class extends Command { e.summary?.toLowerCase().includes(nameFilter) ); if (filteredEvents.length === 0) { - await interaction.reply({ + await interaction.editReply({ content: `⚠️ No events found for **${courseCode}** matching **${nameFilter}**.`, - ephemeral: true, }); return; } @@ -248,6 +243,7 @@ export default class extends Command { chosenOffset, true ); + if (chosenOffset === null) { chosenOffset = 0; } @@ -340,11 +336,13 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: "public", - expires: repeatInterval - ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time - : remindDate, // one-time reminders - repeat: repeatInterval, + expires: remindDate, // next fire time + repeat: repeatInterval, // "every_event" or null + calendarId: calendar.calendarId, // for fetching next events + offset: chosenOffset, // ms before event + repeatUntil: new Date( + remindDate.getTime() + EXPIRE_BUFFER_MS + ), }; let result; @@ -439,7 +437,8 @@ export default class extends Command { }); } catch (err) { console.error("calreminder error:", err); - if (interaction.replied || interaction.deferred) { + // 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.", diff --git a/src/lib/types/Reminder.d.ts b/src/lib/types/Reminder.d.ts index 23535a1c..e238f99a 100644 --- a/src/lib/types/Reminder.d.ts +++ b/src/lib/types/Reminder.d.ts @@ -2,6 +2,8 @@ export interface Reminder { owner: string; expires: Date; content: string; - repeat: null | 'daily' | 'weekly' | 'every_event'; - mode: 'public' | 'private'; + repeat: null | "daily" | "weekly" | "every_event"; + calendarId?: string; + offset?: number; + repeatUntil?: Date; } diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index a95cfa97..a28f661d 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -3,6 +3,7 @@ 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"; async function register(bot: Client): Promise { schedule("0/30 * * * * *", () => { @@ -113,37 +114,71 @@ async function checkPolls(bot: Client): Promise { }); } -async function checkReminders(bot: Client): Promise { +export async function checkReminders(bot: Client): Promise { const now = new Date(); - // 1) fetch all reminders whose time has come - const reminders: Reminder[] = await bot.mongo - .collection(DB.REMINDERS) + // 1) fetch all due reminders + const due = await bot.mongo + .collection< + Reminder & { + _id: any; + repeat: "every_event" | null; + calendarId?: string; + offset?: number; + repeatUntil?: Date; + } + >(DB.REMINDERS) .find({ expires: { $lte: now } }) .toArray(); - // 2) DM‐first send - for (const reminder of reminders) { - const dmMessage = `⏰ **Reminder:** ${reminder.content}`; - + for (const rem of due) { + // fire it try { - const user = await bot.users.fetch(reminder.owner); - await user.send(dmMessage); + const user = await bot.users.fetch(rem.owner); + await user.send(`⏰ **Reminder:** ${rem.content}`); } catch { const sage = (await bot.channels.fetch( CHANNELS.SAGE )) as TextChannel; await sage.send( - `<@${reminder.owner}>, I couldn’t DM you. Here’s your reminder: **${reminder.content}**` + `<@${rem.owner}>, I couldn’t DM you. Here’s your reminder: **${rem.content}**` ); } - } - // 3) clean up the ones we just dispatched - // (rescheduling for repeats is left out here — adapt if needed) - await bot.mongo - .collection(DB.REMINDERS) - .deleteMany({ expires: { $lte: now } }); + // if repeating, schedule the next if still within 180 days + if ( + rem.repeat === "every_event" && + rem.calendarId && + typeof rem.offset === "number" && + rem.repeatUntil && + now.getTime() < rem.repeatUntil.getTime() + ) { + const events = await retrieveEvents(rem.calendarId, null as any); + // compute next event reminder time + const next = events + .map((e) => ({ + e, + remindAt: new Date( + new Date(e.start!.dateTime!).getTime() - rem.offset! + ), + })) + .find(({ remindAt }) => remindAt.getTime() > now.getTime()); + + if (next && next.remindAt.getTime() <= rem.repeatUntil.getTime()) { + // update for the next fire + await bot.mongo + .collection(DB.REMINDERS) + .updateOne( + { _id: rem._id }, + { $set: { expires: next.remindAt } } + ); + continue; + } + } + + // otherwise delete it + await bot.mongo.collection(DB.REMINDERS).deleteOne({ _id: rem._id }); + } } export default register; From a5a15c9adf4f2c0ca504c4b8ceafd6bd42ef44cd Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 18:10:06 -0400 Subject: [PATCH 5/6] working repeating reminder in DM --- src/commands/general/calreminder.ts | 1 + src/lib/types/Reminder.d.ts | 1 + src/pieces/tasks.ts | 84 +++++++++++------------------ 3 files changed, 32 insertions(+), 54 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 4588f0d2..0a93667a 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -336,6 +336,7 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, + mode: "private", expires: remindDate, // next fire time repeat: repeatInterval, // "every_event" or null calendarId: calendar.calendarId, // for fetching next events diff --git a/src/lib/types/Reminder.d.ts b/src/lib/types/Reminder.d.ts index e238f99a..14c6b81f 100644 --- a/src/lib/types/Reminder.d.ts +++ b/src/lib/types/Reminder.d.ts @@ -3,6 +3,7 @@ export interface Reminder { expires: Date; content: string; repeat: null | "daily" | "weekly" | "every_event"; + mode: "public" | "private"; calendarId?: string; offset?: number; repeatUntil?: Date; diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index a28f661d..947f64da 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -3,7 +3,6 @@ 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"; async function register(bot: Client): Promise { schedule("0/30 * * * * *", () => { @@ -114,71 +113,48 @@ async function checkPolls(bot: Client): Promise { }); } -export async function checkReminders(bot: Client): Promise { +async function checkReminders(bot: Client): Promise { const now = new Date(); - // 1) fetch all due reminders - const due = await bot.mongo - .collection< - Reminder & { - _id: any; - repeat: "every_event" | null; - calendarId?: string; - offset?: number; - repeatUntil?: Date; - } - >(DB.REMINDERS) + // 1) fetch all reminders whose time has come + const reminders: Reminder[] = await bot.mongo + .collection(DB.REMINDERS) .find({ expires: { $lte: now } }) .toArray(); - for (const rem of due) { - // fire it + // 2) send each one as a DM‐embed first, fallback to Sage channel + for (const rem of reminders) { + // build a pretty embed + const embed = new EmbedBuilder() + .setTitle("⏰ Reminder") + .setDescription(rem.content) // your full "content" string + .setColor("Blue") + .setTimestamp(now); + + // only if it's repeating, tack on a “Repeats” field + if (rem.repeat) { + embed.addFields({ + name: "🔁 Repeats", + value: + rem.repeat === "every_event" ? "Every event" : rem.repeat, + inline: true, + }); + } + try { const user = await bot.users.fetch(rem.owner); - await user.send(`⏰ **Reminder:** ${rem.content}`); + await user.send({ embeds: [embed] }); } catch { const sage = (await bot.channels.fetch( CHANNELS.SAGE )) as TextChannel; - await sage.send( - `<@${rem.owner}>, I couldn’t DM you. Here’s your reminder: **${rem.content}**` - ); + await sage.send({ embeds: [embed] }); } - - // if repeating, schedule the next if still within 180 days - if ( - rem.repeat === "every_event" && - rem.calendarId && - typeof rem.offset === "number" && - rem.repeatUntil && - now.getTime() < rem.repeatUntil.getTime() - ) { - const events = await retrieveEvents(rem.calendarId, null as any); - // compute next event reminder time - const next = events - .map((e) => ({ - e, - remindAt: new Date( - new Date(e.start!.dateTime!).getTime() - rem.offset! - ), - })) - .find(({ remindAt }) => remindAt.getTime() > now.getTime()); - - if (next && next.remindAt.getTime() <= rem.repeatUntil.getTime()) { - // update for the next fire - await bot.mongo - .collection(DB.REMINDERS) - .updateOne( - { _id: rem._id }, - { $set: { expires: next.remindAt } } - ); - continue; - } - } - - // otherwise delete it - await bot.mongo.collection(DB.REMINDERS).deleteOne({ _id: rem._id }); } -} + // 3) clean up the ones we just dispatched + await bot.mongo + .collection(DB.REMINDERS) + .deleteMany({ expires: { $lte: now } }); +} export default register; From df545bf8f12561f49b4aaf584cf4f4f278ceef00 Mon Sep 17 00:00:00 2001 From: BENJAMIN ZLATIN Date: Wed, 30 Apr 2025 18:18:49 -0400 Subject: [PATCH 6/6] fully working reminders to DM --- src/commands/general/calreminder.ts | 229 ++++++++++++++-------------- src/lib/types/Reminder.d.ts | 4 +- src/pieces/tasks.ts | 67 ++++---- 3 files changed, 153 insertions(+), 147 deletions(-) diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 0a93667a..4c399505 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,66 +8,68 @@ 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 { - await interaction.deferReply({ ephemeral: true }); - + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + } try { let eventMenu: PagifiedSelectMenu; let offsetMenu: PagifiedSelectMenu; - function generateMessage( - repeatInterval: "every_event" | null, + const generateMessage = ( + repeatInterval: 'every_event' | null, chosenEvent?: calendarV3.Schema$Event, chosenOffset?: number, renderMenus = false, eventCurrentPage = 0, offsetCurrentPage = 0 - ) { + ) => { 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; @@ -77,7 +79,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault, + default: isDefault }); }); @@ -85,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 }); }); @@ -126,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 ); @@ -149,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; } @@ -214,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; } @@ -226,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; } @@ -234,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( @@ -250,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, @@ -294,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) { @@ -317,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(); @@ -336,14 +338,14 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: "private", + mode: 'private', expires: remindDate, // next fire time repeat: repeatInterval, // "every_event" or null calendarId: calendar.calendarId, // for fetching next events offset: chosenOffset, // ms before event repeatUntil: new Date( remindDate.getTime() + EXPIRE_BUFFER_MS - ), + ) }; let result; @@ -353,11 +355,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; @@ -365,12 +367,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 ); @@ -382,11 +384,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) { @@ -402,22 +404,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]; @@ -437,21 +439,22 @@ 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/lib/types/Reminder.d.ts b/src/lib/types/Reminder.d.ts index 14c6b81f..93eb509f 100644 --- a/src/lib/types/Reminder.d.ts +++ b/src/lib/types/Reminder.d.ts @@ -2,8 +2,8 @@ export interface Reminder { owner: string; expires: Date; content: string; - repeat: null | "daily" | "weekly" | "every_event"; - mode: "public" | "private"; + repeat: null | 'daily' | 'weekly' | 'every_event'; + mode: 'public' | 'private'; calendarId?: string; offset?: number; repeatUntil?: Date; diff --git a/src/pieces/tasks.ts b/src/pieces/tasks.ts index 947f64da..2eda4732 100644 --- a/src/pieces/tasks.ts +++ b/src/pieces/tasks.ts @@ -1,12 +1,12 @@ -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 { 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'; 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)); }); } @@ -19,10 +19,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 = ``; @@ -36,10 +36,12 @@ async function checkPolls(bot: Client): Promise { winners = [res]; return; } - if (winners[0] && res.users.length > winners[0].users.length) + + if (winners[0] && res.users.length > winners[0].users.length) { winners = [res]; - else if (res.users.length === winners[0].users.length) + } else if (res.users.length === winners[0].users.length) { winners.push(res); + } }); // build up the win string @@ -51,31 +53,32 @@ 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`; }); const pollChannel = await bot.channels.fetch(poll.channel); - if (pollChannel.type !== ChannelType.GuildText) + if (pollChannel.type !== ChannelType.GuildText) { throw "something went wrong fetching the poll's channel"; + } const pollMsg = await pollChannel.messages.fetch(poll.message); const owner = await pollMsg.guild.members.fetch(poll.owner); const pollEmbed = new EmbedBuilder() @@ -84,11 +87,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: [] }); @@ -98,15 +101,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); @@ -126,18 +129,18 @@ async function checkReminders(bot: Client): Promise { for (const rem of reminders) { // build a pretty embed const embed = new EmbedBuilder() - .setTitle("⏰ Reminder") + .setTitle('⏰ Reminder') .setDescription(rem.content) // your full "content" string - .setColor("Blue") + .setColor('Blue') .setTimestamp(now); // only if it's repeating, tack on a “Repeats” field 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 }); }