Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 133 additions & 90 deletions src/api/functions/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<null | GuildScheduledEventCreateOptions> => {
): Promise<string | null> => {
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<true>) => {
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<string, GuildScheduledEvent<GuildScheduledEventStatus>>,
event: GuildScheduledEvent<GuildScheduledEventStatus>,
) => {
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<string, GuildScheduledEvent<GuildScheduledEventStatus>>,
);
const { id } = event;

const existingMetadata = snowflakeMeetingLookup[id];
try {
const result = await new Promise<string | null>((resolve, reject) => {
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
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.");
}
}
};
68 changes: 53 additions & 15 deletions src/api/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/ui/pages/events/ManageEvent.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -530,6 +533,7 @@ export const ManageEventPage: React.FC = () => {

<Button
mt="md"
disabled={isSubmitting}
onClick={() => {
handleSubmit();
}}
Expand Down
Loading
Loading