diff --git a/src/api/functions/discord.ts b/src/api/functions/discord.ts index f184308d..a8a1ce9f 100644 --- a/src/api/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -2,119 +2,162 @@ import { Client, GatewayIntentBits, Events, - type GuildScheduledEventCreateOptions, + type GuildScheduledEventCreateOptions, // Use this for the payload type GuildScheduledEventEntityType, GuildScheduledEventPrivacyLevel, - GuildScheduledEvent, - GuildScheduledEventStatus, + DiscordAPIError, } from "discord.js"; import { type EventPostRequest } from "../routes/events.js"; import moment from "moment-timezone"; -import { FastifyBaseLogger } from "fastify"; +import { type FastifyBaseLogger } from "fastify"; import { DiscordEventError } from "../../common/errors/index.js"; -import { type SecretConfig } from "../../common/config.js"; -// https://stackoverflow.com/a/3809435/5684541 -// https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30 -// https://www.acm.illinois.edu/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30 - -export type IUpdateDiscord = EventPostRequest & { id: string }; - -const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/; +export type IUpdateDiscord = EventPostRequest & { + id: string; + discordEventId?: string; +}; +/** + * Creates, updates, or deletes a Discord scheduled event directly using its ID. + * @param config - Bot configuration containing the token and guild ID. + * @param event - The event data, including an optional discordEventId for updates/deletions. + * @param actor - The user performing the action, for logging purposes. + * @param isDelete - A flag to indicate if the event should be deleted. + * @param logger - The logger instance. + * @returns The Discord event ID if created/updated, or null if deleted or the operation is skipped. + */ export const updateDiscord = async ( config: { botToken: string; guildId: string }, event: IUpdateDiscord, actor: string, isDelete: boolean = false, logger: FastifyBaseLogger, -): Promise => { +): Promise => { + if (!config.botToken) { + logger.error("No Discord bot token found in secrets!"); + throw new DiscordEventError({ + message: "Discord bot token is not configured.", + }); + } + const client = new Client({ intents: [GatewayIntentBits.Guilds] }); - let payload: GuildScheduledEventCreateOptions | null = null; - client.once(Events.ClientReady, async (readyClient: Client) => { - logger.debug(`Logged in as ${readyClient.user.tag}`); - const guildID = config.guildId; - const guild = await client.guilds.fetch(guildID?.toString() || ""); - const discordEvents = await guild.scheduledEvents.fetch(); - const snowflakeMeetingLookup = discordEvents.reduce( - ( - o: Record>, - event: GuildScheduledEvent, - ) => { - const { description } = event; - // Find url in description using regex and extract the slug - const url = (description || "").match(urlRegex); - if (url) { - const id = url[1]; - o[id] = event; - } - return o; - }, - {} as Record>, - ); - const { id } = event; - const existingMetadata = snowflakeMeetingLookup[id]; + try { + const result = await new Promise((resolve, reject) => { + client.once(Events.ClientReady, async (readyClient: Client) => { + logger.debug(`Logged in to Discord as ${readyClient.user.tag}`); + try { + const guild = await client.guilds.fetch(config.guildId); - if (isDelete) { - if (existingMetadata) { - await guild.scheduledEvents.delete(existingMetadata.id); - } else { - logger.warn(`Event with id ${id} not found in Discord`); - } - await client.destroy(); - logger.debug("Logged out of Discord."); - return null; - } + if (isDelete) { + if (event.discordEventId) { + await guild.scheduledEvents.delete(event.discordEventId); + logger.info( + `Successfully deleted Discord event ${event.discordEventId}`, + ); + return resolve(null); + } + logger.warn( + `Cannot delete event with internal ID ${event.id}: no discordEventId was provided.`, + ); + return resolve(null); + } - // Handle creation or update - const { title, description, start, end, location, host } = event; - const dateStart = moment.tz(start, "America/Chicago").format("YYYY-MM-DD"); - const calendarURL = `https://www.acm.illinois.edu/calendar?id=${id}&date=${dateStart}`; - const fullDescription = `${description}\n${calendarURL}`; - const fullTitle = title.toLowerCase().includes(host.toLowerCase()) - ? title - : `${host} - ${title}`; + const { id, title, description, start, end, location, host } = event; + const dateStart = moment + .tz(start, "America/Chicago") + .format("YYYY-MM-DD"); + const calendarURL = `https://www.acm.illinois.edu/calendar?id=${id}&date=${dateStart}`; + const fullDescription = `${description}\n\nView on ACM Calendar: ${calendarURL}`; + const fullTitle = + title.toLowerCase().includes(host.toLowerCase()) || host === "ACM" + ? title + : `${host} - ${title}`; - payload = { - entityType: GuildScheduledEventEntityType.External, - privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, - name: fullTitle, - description: fullDescription, - scheduledStartTime: moment.tz(start, "America/Chicago").utc().toDate(), - scheduledEndTime: end && moment.tz(end, "America/Chicago").utc().toDate(), - image: existingMetadata?.coverImageURL({}) || undefined, - entityMetadata: { - location, - }, - reason: `${existingMetadata ? "Modified" : "Created"} by ${actor}.`, - }; + const payload: GuildScheduledEventCreateOptions = { + name: fullTitle, + description: fullDescription, + scheduledStartTime: moment + .tz(start, "America/Chicago") + .utc() + .toDate(), + scheduledEndTime: end + ? moment.tz(end, "America/Chicago").utc().toDate() + : undefined, + entityType: GuildScheduledEventEntityType.External, + privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, + entityMetadata: { location }, + }; - if (existingMetadata) { - if (existingMetadata.creator?.bot !== true) { - logger.warn(`Refusing to edit non-bot event "${title}"`); - } else { - await guild.scheduledEvents.edit(existingMetadata.id, payload); - } - } else if (payload.scheduledStartTime < new Date()) { - logger.warn(`Refusing to create past event "${title}"`); - } else { - await guild.scheduledEvents.create(payload); - } + if (event.discordEventId) { + const existingEvent = await guild.scheduledEvents + .fetch(event.discordEventId) + .catch(() => null); - await client.destroy(); - logger.debug("Logged out of Discord."); - return payload; - }); + if (!existingEvent) { + logger.warn( + `Discord event ${event.discordEventId} not found for update. Attempting to create a new one instead.`, + ); + } else { + logger.info( + `Updating Discord event ${existingEvent.id} for "${title}"`, + ); + const updatedEvent = await guild.scheduledEvents.edit( + existingEvent.id, + { + ...payload, + reason: `Modified by ${actor}.`, + }, + ); + return resolve(updatedEvent.id); + } + } - const token = config.botToken; + if (payload.scheduledStartTime < new Date()) { + logger.warn(`Refusing to create past event "${title}"`); + return resolve(null); + } - if (!token) { - logger.error("No Discord bot token found in secrets!"); - throw new DiscordEventError({}); - } + logger.info(`Creating new Discord event for "${title}"`); + const newEvent = await guild.scheduledEvents.create({ + ...payload, + reason: `Created by ${actor}.`, + }); + return resolve(newEvent.id); + } catch (error) { + if ( + error instanceof DiscordAPIError && + error.status === 404 && + error.method === "DELETE" && + isDelete + ) { + logger.warn(`Event ${event.id} was already deleted from Discord!`); + return resolve(null); + } + logger.error( + error, + "An error occurred while managing a Discord scheduled event.", + ); + reject( + new DiscordEventError({ + message: "An error occurred while interacting with Discord.", + }), + ); + } + }); + + client.login(config.botToken).catch((loginError) => { + logger.error(loginError, "Failed to log in to Discord."); + reject(new DiscordEventError({ message: "Discord login failed." })); + }); + }); - client.login(token.toString()); - return payload; + return result; + } finally { + if (client.readyAt) { + await client.destroy(); + logger.debug("Logged out of Discord."); + } + } }; diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index b6d71398..69bce11b 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -407,15 +407,18 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( message: "Failed to update event in Dynamo table.", }); } - - const updatedEntryForDiscord = updatedItem as unknown as IUpdateDiscord; + oldAttributes = unmarshall(oldAttributes); + const updatedEntryForDiscord = { + ...updatedItem, + discordEventId: oldAttributes.discordEventId, + } as unknown as IUpdateDiscord; if ( updatedEntryForDiscord.featured && !updatedEntryForDiscord.repeats ) { try { - await updateDiscord( + const discordEventId = await updateDiscord( { botToken: fastify.secretConfig.discord_bot_token, guildId: fastify.environmentConfig.DiscordGuildId, @@ -425,6 +428,22 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( false, request.log, ); + + if (discordEventId) { + await fastify.dynamoClient.send( + new UpdateItemCommand({ + TableName: genericConfig.EventsDynamoTableName, + Key: { id: { S: entryUUID } }, + UpdateExpression: "SET #discordEventId = :discordEventId", + ExpressionAttributeNames: { + "#discordEventId": "discordEventId", + }, + ExpressionAttributeValues: { + ":discordEventId": { S: discordEventId }, + }, + }), + ); + } } catch (e) { await fastify.dynamoClient.send( new PutItemCommand({ @@ -527,7 +546,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( ); try { if (request.body.featured && !request.body.repeats) { - await updateDiscord( + const discordEventId = await updateDiscord( { botToken: fastify.secretConfig.discord_bot_token, guildId: fastify.environmentConfig.DiscordGuildId, @@ -537,6 +556,21 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( false, request.log, ); + if (discordEventId) { + await fastify.dynamoClient.send( + new UpdateItemCommand({ + TableName: genericConfig.EventsDynamoTableName, + Key: { id: { S: entryUUID } }, + UpdateExpression: "SET #discordEventId = :discordEventId", + ExpressionAttributeNames: { + "#discordEventId": "discordEventId", + }, + ExpressionAttributeValues: { + ":discordEventId": { S: discordEventId }, + }, + }), + ); + } } } catch (e: unknown) { // restore original DB status if Discord fails. @@ -670,23 +704,27 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( throw new UnauthenticatedError({ message: "Username not found." }); } try { - await fastify.dynamoClient.send( + const result = await fastify.dynamoClient.send( new DeleteItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: marshall({ id }), + ReturnValues: "ALL_OLD", }), ); + if (result.Attributes) { + const unmarshalledResult = unmarshall(result.Attributes); + await updateDiscord( + { + botToken: fastify.secretConfig.discord_bot_token, + guildId: fastify.environmentConfig.DiscordGuildId, + }, + unmarshalledResult as IUpdateDiscord, + request.username, + true, + request.log, + ); + } await deleteCacheCounter(fastify.dynamoClient, `events-etag-${id}`); - await updateDiscord( - { - botToken: fastify.secretConfig.discord_bot_token, - guildId: fastify.environmentConfig.DiscordGuildId, - }, - { id } as IUpdateDiscord, - request.username, - true, - request.log, - ); reply.status(204).send(); await createAuditLogEntry({ dynamoClient: fastify.dynamoClient, diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx index 969d1bdf..ebe84fe9 100644 --- a/src/ui/pages/events/ManageEvent.page.tsx +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -194,6 +194,9 @@ export const ManageEventPage: React.FC = () => { }, [form.values.locationLink]); const handleSubmit = async () => { + if (isSubmitting) { + return; + } const result = form.validate(); if (result.hasErrors) { console.warn(result.errors); @@ -530,6 +533,7 @@ export const ManageEventPage: React.FC = () => {