diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index 4c399505..3dd2613a 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 @@ -328,9 +329,10 @@ export default class extends Command { } // Build more detailed reminder text - const eventInfo = `${ - chosenEvent.summary - }\nStarts at: ${dateObj.toLocaleString()}`; + const eventInfo = formatEventInfo( + chosenEvent, + chosenEvent.start?.timeZone + ); // Create reminder in DB const EXPIRE_BUFFER_MS = 180 * 24 * 60 * 60 * 1000; // 180 days in ms @@ -339,6 +341,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 @@ -458,3 +461,19 @@ export default class extends Command { } } + +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/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..23c268ad 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 * * * * *', () => { @@ -118,23 +120,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 +149,88 @@ 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 + .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, + 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 + // 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 + }); + } catch (err) { + console.error('Failed to reschedule repeating reminder:', err); + } +} + export default register;