@@ -2,119 +2,162 @@ import {
2
2
Client ,
3
3
GatewayIntentBits ,
4
4
Events ,
5
- type GuildScheduledEventCreateOptions ,
5
+ type GuildScheduledEventCreateOptions , // Use this for the payload type
6
6
GuildScheduledEventEntityType ,
7
7
GuildScheduledEventPrivacyLevel ,
8
- GuildScheduledEvent ,
9
- GuildScheduledEventStatus ,
8
+ DiscordAPIError ,
10
9
} from "discord.js" ;
11
10
import { type EventPostRequest } from "../routes/events.js" ;
12
11
import moment from "moment-timezone" ;
13
12
14
- import { FastifyBaseLogger } from "fastify" ;
13
+ import { type FastifyBaseLogger } from "fastify" ;
15
14
import { DiscordEventError } from "../../common/errors/index.js" ;
16
- import { type SecretConfig } from "../../common/config.js" ;
17
15
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
+ } ;
25
20
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
+ */
26
30
export const updateDiscord = async (
27
31
config : { botToken : string ; guildId : string } ,
28
32
event : IUpdateDiscord ,
29
33
actor : string ,
30
34
isDelete : boolean = false ,
31
35
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
+
33
44
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 ;
57
45
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 ) ;
59
52
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
+ }
70
66
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 } ` ;
79
77
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
+ } ;
93
92
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 ) ;
105
97
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
+ }
110
116
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
+ }
112
121
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
+ } ) ;
117
155
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
+ }
120
163
} ;
0 commit comments