@@ -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" ;
1110import { type EventPostRequest } from "../routes/events.js" ;
1211import moment from "moment-timezone" ;
1312
14- import { FastifyBaseLogger } from "fastify" ;
13+ import { type FastifyBaseLogger } from "fastify" ;
1514import { 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 = / h t t p s : \/ \/ [ a - z 0 - 9 . - ] + \/ c a l e n d a r \? i d = ( [ a - f 0 - 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+ */
2630export 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} ;
0 commit comments