Skip to content

Commit df2445d

Browse files
authored
Store Discord Event ID in database (#273)
1 parent 8826610 commit df2445d

File tree

4 files changed

+192
-105
lines changed

4 files changed

+192
-105
lines changed

src/api/functions/discord.ts

Lines changed: 133 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,119 +2,162 @@ import {
22
Client,
33
GatewayIntentBits,
44
Events,
5-
type GuildScheduledEventCreateOptions,
5+
type GuildScheduledEventCreateOptions, // Use this for the payload type
66
GuildScheduledEventEntityType,
77
GuildScheduledEventPrivacyLevel,
8-
GuildScheduledEvent,
9-
GuildScheduledEventStatus,
8+
DiscordAPIError,
109
} from "discord.js";
1110
import { type EventPostRequest } from "../routes/events.js";
1211
import moment from "moment-timezone";
1312

14-
import { FastifyBaseLogger } from "fastify";
13+
import { type FastifyBaseLogger } from "fastify";
1514
import { DiscordEventError } from "../../common/errors/index.js";
16-
import { type SecretConfig } from "../../common/config.js";
1715

18-
// https://stackoverflow.com/a/3809435/5684541
19-
// https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30
20-
// https://www.acm.illinois.edu/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30
21-
22-
export type IUpdateDiscord = EventPostRequest & { id: string };
23-
24-
const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/;
16+
export type IUpdateDiscord = EventPostRequest & {
17+
id: string;
18+
discordEventId?: string;
19+
};
2520

21+
/**
22+
* Creates, updates, or deletes a Discord scheduled event directly using its ID.
23+
* @param config - Bot configuration containing the token and guild ID.
24+
* @param event - The event data, including an optional discordEventId for updates/deletions.
25+
* @param actor - The user performing the action, for logging purposes.
26+
* @param isDelete - A flag to indicate if the event should be deleted.
27+
* @param logger - The logger instance.
28+
* @returns The Discord event ID if created/updated, or null if deleted or the operation is skipped.
29+
*/
2630
export const updateDiscord = async (
2731
config: { botToken: string; guildId: string },
2832
event: IUpdateDiscord,
2933
actor: string,
3034
isDelete: boolean = false,
3135
logger: FastifyBaseLogger,
32-
): Promise<null | GuildScheduledEventCreateOptions> => {
36+
): Promise<string | null> => {
37+
if (!config.botToken) {
38+
logger.error("No Discord bot token found in secrets!");
39+
throw new DiscordEventError({
40+
message: "Discord bot token is not configured.",
41+
});
42+
}
43+
3344
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
34-
let payload: GuildScheduledEventCreateOptions | null = null;
35-
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
36-
logger.debug(`Logged in as ${readyClient.user.tag}`);
37-
const guildID = config.guildId;
38-
const guild = await client.guilds.fetch(guildID?.toString() || "");
39-
const discordEvents = await guild.scheduledEvents.fetch();
40-
const snowflakeMeetingLookup = discordEvents.reduce(
41-
(
42-
o: Record<string, GuildScheduledEvent<GuildScheduledEventStatus>>,
43-
event: GuildScheduledEvent<GuildScheduledEventStatus>,
44-
) => {
45-
const { description } = event;
46-
// Find url in description using regex and extract the slug
47-
const url = (description || "").match(urlRegex);
48-
if (url) {
49-
const id = url[1];
50-
o[id] = event;
51-
}
52-
return o;
53-
},
54-
{} as Record<string, GuildScheduledEvent<GuildScheduledEventStatus>>,
55-
);
56-
const { id } = event;
5745

58-
const existingMetadata = snowflakeMeetingLookup[id];
46+
try {
47+
const result = await new Promise<string | null>((resolve, reject) => {
48+
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
49+
logger.debug(`Logged in to Discord as ${readyClient.user.tag}`);
50+
try {
51+
const guild = await client.guilds.fetch(config.guildId);
5952

60-
if (isDelete) {
61-
if (existingMetadata) {
62-
await guild.scheduledEvents.delete(existingMetadata.id);
63-
} else {
64-
logger.warn(`Event with id ${id} not found in Discord`);
65-
}
66-
await client.destroy();
67-
logger.debug("Logged out of Discord.");
68-
return null;
69-
}
53+
if (isDelete) {
54+
if (event.discordEventId) {
55+
await guild.scheduledEvents.delete(event.discordEventId);
56+
logger.info(
57+
`Successfully deleted Discord event ${event.discordEventId}`,
58+
);
59+
return resolve(null);
60+
}
61+
logger.warn(
62+
`Cannot delete event with internal ID ${event.id}: no discordEventId was provided.`,
63+
);
64+
return resolve(null);
65+
}
7066

71-
// Handle creation or update
72-
const { title, description, start, end, location, host } = event;
73-
const dateStart = moment.tz(start, "America/Chicago").format("YYYY-MM-DD");
74-
const calendarURL = `https://www.acm.illinois.edu/calendar?id=${id}&date=${dateStart}`;
75-
const fullDescription = `${description}\n${calendarURL}`;
76-
const fullTitle = title.toLowerCase().includes(host.toLowerCase())
77-
? title
78-
: `${host} - ${title}`;
67+
const { id, title, description, start, end, location, host } = event;
68+
const dateStart = moment
69+
.tz(start, "America/Chicago")
70+
.format("YYYY-MM-DD");
71+
const calendarURL = `https://www.acm.illinois.edu/calendar?id=${id}&date=${dateStart}`;
72+
const fullDescription = `${description}\n\nView on ACM Calendar: ${calendarURL}`;
73+
const fullTitle =
74+
title.toLowerCase().includes(host.toLowerCase()) || host === "ACM"
75+
? title
76+
: `${host} - ${title}`;
7977

80-
payload = {
81-
entityType: GuildScheduledEventEntityType.External,
82-
privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly,
83-
name: fullTitle,
84-
description: fullDescription,
85-
scheduledStartTime: moment.tz(start, "America/Chicago").utc().toDate(),
86-
scheduledEndTime: end && moment.tz(end, "America/Chicago").utc().toDate(),
87-
image: existingMetadata?.coverImageURL({}) || undefined,
88-
entityMetadata: {
89-
location,
90-
},
91-
reason: `${existingMetadata ? "Modified" : "Created"} by ${actor}.`,
92-
};
78+
const payload: GuildScheduledEventCreateOptions = {
79+
name: fullTitle,
80+
description: fullDescription,
81+
scheduledStartTime: moment
82+
.tz(start, "America/Chicago")
83+
.utc()
84+
.toDate(),
85+
scheduledEndTime: end
86+
? moment.tz(end, "America/Chicago").utc().toDate()
87+
: undefined,
88+
entityType: GuildScheduledEventEntityType.External,
89+
privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly,
90+
entityMetadata: { location },
91+
};
9392

94-
if (existingMetadata) {
95-
if (existingMetadata.creator?.bot !== true) {
96-
logger.warn(`Refusing to edit non-bot event "${title}"`);
97-
} else {
98-
await guild.scheduledEvents.edit(existingMetadata.id, payload);
99-
}
100-
} else if (payload.scheduledStartTime < new Date()) {
101-
logger.warn(`Refusing to create past event "${title}"`);
102-
} else {
103-
await guild.scheduledEvents.create(payload);
104-
}
93+
if (event.discordEventId) {
94+
const existingEvent = await guild.scheduledEvents
95+
.fetch(event.discordEventId)
96+
.catch(() => null);
10597

106-
await client.destroy();
107-
logger.debug("Logged out of Discord.");
108-
return payload;
109-
});
98+
if (!existingEvent) {
99+
logger.warn(
100+
`Discord event ${event.discordEventId} not found for update. Attempting to create a new one instead.`,
101+
);
102+
} else {
103+
logger.info(
104+
`Updating Discord event ${existingEvent.id} for "${title}"`,
105+
);
106+
const updatedEvent = await guild.scheduledEvents.edit(
107+
existingEvent.id,
108+
{
109+
...payload,
110+
reason: `Modified by ${actor}.`,
111+
},
112+
);
113+
return resolve(updatedEvent.id);
114+
}
115+
}
110116

111-
const token = config.botToken;
117+
if (payload.scheduledStartTime < new Date()) {
118+
logger.warn(`Refusing to create past event "${title}"`);
119+
return resolve(null);
120+
}
112121

113-
if (!token) {
114-
logger.error("No Discord bot token found in secrets!");
115-
throw new DiscordEventError({});
116-
}
122+
logger.info(`Creating new Discord event for "${title}"`);
123+
const newEvent = await guild.scheduledEvents.create({
124+
...payload,
125+
reason: `Created by ${actor}.`,
126+
});
127+
return resolve(newEvent.id);
128+
} catch (error) {
129+
if (
130+
error instanceof DiscordAPIError &&
131+
error.status === 404 &&
132+
error.method === "DELETE" &&
133+
isDelete
134+
) {
135+
logger.warn(`Event ${event.id} was already deleted from Discord!`);
136+
return resolve(null);
137+
}
138+
logger.error(
139+
error,
140+
"An error occurred while managing a Discord scheduled event.",
141+
);
142+
reject(
143+
new DiscordEventError({
144+
message: "An error occurred while interacting with Discord.",
145+
}),
146+
);
147+
}
148+
});
149+
150+
client.login(config.botToken).catch((loginError) => {
151+
logger.error(loginError, "Failed to log in to Discord.");
152+
reject(new DiscordEventError({ message: "Discord login failed." }));
153+
});
154+
});
117155

118-
client.login(token.toString());
119-
return payload;
156+
return result;
157+
} finally {
158+
if (client.readyAt) {
159+
await client.destroy();
160+
logger.debug("Logged out of Discord.");
161+
}
162+
}
120163
};

src/api/routes/events.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -407,15 +407,18 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
407407
message: "Failed to update event in Dynamo table.",
408408
});
409409
}
410-
411-
const updatedEntryForDiscord = updatedItem as unknown as IUpdateDiscord;
410+
oldAttributes = unmarshall(oldAttributes);
411+
const updatedEntryForDiscord = {
412+
...updatedItem,
413+
discordEventId: oldAttributes.discordEventId,
414+
} as unknown as IUpdateDiscord;
412415

413416
if (
414417
updatedEntryForDiscord.featured &&
415418
!updatedEntryForDiscord.repeats
416419
) {
417420
try {
418-
await updateDiscord(
421+
const discordEventId = await updateDiscord(
419422
{
420423
botToken: fastify.secretConfig.discord_bot_token,
421424
guildId: fastify.environmentConfig.DiscordGuildId,
@@ -425,6 +428,22 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
425428
false,
426429
request.log,
427430
);
431+
432+
if (discordEventId) {
433+
await fastify.dynamoClient.send(
434+
new UpdateItemCommand({
435+
TableName: genericConfig.EventsDynamoTableName,
436+
Key: { id: { S: entryUUID } },
437+
UpdateExpression: "SET #discordEventId = :discordEventId",
438+
ExpressionAttributeNames: {
439+
"#discordEventId": "discordEventId",
440+
},
441+
ExpressionAttributeValues: {
442+
":discordEventId": { S: discordEventId },
443+
},
444+
}),
445+
);
446+
}
428447
} catch (e) {
429448
await fastify.dynamoClient.send(
430449
new PutItemCommand({
@@ -527,7 +546,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
527546
);
528547
try {
529548
if (request.body.featured && !request.body.repeats) {
530-
await updateDiscord(
549+
const discordEventId = await updateDiscord(
531550
{
532551
botToken: fastify.secretConfig.discord_bot_token,
533552
guildId: fastify.environmentConfig.DiscordGuildId,
@@ -537,6 +556,21 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
537556
false,
538557
request.log,
539558
);
559+
if (discordEventId) {
560+
await fastify.dynamoClient.send(
561+
new UpdateItemCommand({
562+
TableName: genericConfig.EventsDynamoTableName,
563+
Key: { id: { S: entryUUID } },
564+
UpdateExpression: "SET #discordEventId = :discordEventId",
565+
ExpressionAttributeNames: {
566+
"#discordEventId": "discordEventId",
567+
},
568+
ExpressionAttributeValues: {
569+
":discordEventId": { S: discordEventId },
570+
},
571+
}),
572+
);
573+
}
540574
}
541575
} catch (e: unknown) {
542576
// restore original DB status if Discord fails.
@@ -670,23 +704,27 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
670704
throw new UnauthenticatedError({ message: "Username not found." });
671705
}
672706
try {
673-
await fastify.dynamoClient.send(
707+
const result = await fastify.dynamoClient.send(
674708
new DeleteItemCommand({
675709
TableName: genericConfig.EventsDynamoTableName,
676710
Key: marshall({ id }),
711+
ReturnValues: "ALL_OLD",
677712
}),
678713
);
714+
if (result.Attributes) {
715+
const unmarshalledResult = unmarshall(result.Attributes);
716+
await updateDiscord(
717+
{
718+
botToken: fastify.secretConfig.discord_bot_token,
719+
guildId: fastify.environmentConfig.DiscordGuildId,
720+
},
721+
unmarshalledResult as IUpdateDiscord,
722+
request.username,
723+
true,
724+
request.log,
725+
);
726+
}
679727
await deleteCacheCounter(fastify.dynamoClient, `events-etag-${id}`);
680-
await updateDiscord(
681-
{
682-
botToken: fastify.secretConfig.discord_bot_token,
683-
guildId: fastify.environmentConfig.DiscordGuildId,
684-
},
685-
{ id } as IUpdateDiscord,
686-
request.username,
687-
true,
688-
request.log,
689-
);
690728
reply.status(204).send();
691729
await createAuditLogEntry({
692730
dynamoClient: fastify.dynamoClient,

src/ui/pages/events/ManageEvent.page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ export const ManageEventPage: React.FC = () => {
194194
}, [form.values.locationLink]);
195195

196196
const handleSubmit = async () => {
197+
if (isSubmitting) {
198+
return;
199+
}
197200
const result = form.validate();
198201
if (result.hasErrors) {
199202
console.warn(result.errors);
@@ -530,6 +533,7 @@ export const ManageEventPage: React.FC = () => {
530533

531534
<Button
532535
mt="md"
536+
disabled={isSubmitting}
533537
onClick={() => {
534538
handleSubmit();
535539
}}

0 commit comments

Comments
 (0)