1
1
import { command , Module , listener } from 'cookiecord' ;
2
2
import { ThreadAutoArchiveDuration } from 'discord-api-types' ;
3
- import { Message , TextChannel , Channel , ThreadChannel } from 'discord.js' ;
4
- import { trustedRoleId , helpCategory , timeBeforeHelperPing } from '../env' ;
3
+ import {
4
+ Message ,
5
+ TextChannel ,
6
+ Channel ,
7
+ ThreadChannel ,
8
+ MessageEmbed ,
9
+ } from 'discord.js' ;
10
+ import { threadId } from 'worker_threads' ;
11
+ import { HelpThread } from '../entities/HelpThread' ;
12
+ import {
13
+ trustedRoleId ,
14
+ helpCategory ,
15
+ timeBeforeHelperPing ,
16
+ GREEN ,
17
+ BLOCKQUOTE_GREY ,
18
+ } from '../env' ;
5
19
import { isTrustedMember } from '../util/inhibitors' ;
20
+ import { LimitedSizeMap } from '../util/limitedSizeMap' ;
21
+ import { sendWithMessageOwnership } from '../util/send' ;
6
22
7
- const PINGED_HELPER_MESSAGE = '✅ Pinged helpers' ;
23
+ const THREAD_EXPIRE_MESSAGE = new MessageEmbed ( )
24
+ . setColor ( BLOCKQUOTE_GREY )
25
+ . setTitle ( 'This help thread expired.' ) . setDescription ( `
26
+ If your question was not resolved, you can make a new thread by simply asking your question again. \
27
+ Consider rephrasing the question to maximize your chance of getting a good answer. \
28
+ If you're not sure how, have a look through [StackOverflow's guide on asking a good question](https://stackoverflow.com/help/how-to-ask).
29
+ ` ) ;
30
+
31
+ // A zero-width space (necessary to prevent discord from trimming the leading whitespace), followed by a three non-breaking spaces.
32
+ const indent = '\u200b\u00a0\u00a0\u00a0' ;
33
+
34
+ const HELP_INFO = ( channel : TextChannel ) =>
35
+ new MessageEmbed ( ) . setColor ( GREEN ) . setTitle ( 'How To Get Help' )
36
+ . setDescription ( `
37
+ ${
38
+ channel . topic
39
+ ? `This channel is for ${
40
+ channel . topic [ 0 ] . toLowerCase ( ) +
41
+ channel . topic . slice ( 1 ) . split ( '\n' ) [ 0 ]
42
+ } `
43
+ : ''
44
+ }
45
+
46
+ **To get help:**
47
+ • Post your question to this channel.
48
+ ${ indent } • It's always ok to just ask your question; you don't need permission.
49
+ • Our bot will make a thread dedicated to answering your channel.
50
+ • Someone will (hopefully!) come along and help you.
51
+ • When your question is resolved, type \`!tclose\`.
52
+
53
+ **For better & faster answers:**
54
+ • Explain what you want to happen and why…
55
+ ${ indent } • …and what actually happens, and your best guess at why.
56
+ • Include a short code sample and error messages, if you got any.
57
+ ${ indent } • Text is better than screenshots. Start code blocks with ${ '\\`\\`\\`ts' } .
58
+ • If possible, create a minimal reproduction in the **[TypeScript Playground](https://www.typescriptlang.org/play)**.
59
+ ${ indent } • Send the full link in its own message. Do not use a link shortener.
60
+ • Run \`!title <brief description>\` to make your help thread easier to spot.
61
+
62
+ For more tips, check out StackOverflow's guide on **[asking good questions](https://stackoverflow.com/help/how-to-ask)**.
63
+
64
+ Usually someone will try to answer and help solve the issue within a few hours. \
65
+ If not, and **if you have followed the bullets above**, you may ping helpers by running \`!helper\`. \
66
+ Please allow extra time at night in America/Europe.
67
+ ` ) ;
68
+
69
+ const helpInfoLocks = new Map < string , Promise < void > > ( ) ;
70
+ const manuallyArchivedThreads = new LimitedSizeMap < string , void > ( 100 ) ;
8
71
9
72
export class HelpThreadModule extends Module {
10
- @listener ( { event : 'threadCreate' } )
11
- async onNewThread ( thread : ThreadChannel ) {
12
- if ( ! this . isHelpThread ( thread ) ) return ;
13
- this . fixThreadExpiry ( thread ) ;
73
+ @listener ( { event : 'messageCreate' } )
74
+ async onNewQuestion ( msg : Message ) {
75
+ if ( ! this . isHelpChannel ( msg . channel ) ) return ;
76
+ if ( msg . author . id === this . client . user ! . id ) return ;
77
+ this . updateHelpInfo ( msg . channel ) ;
78
+ let thread = await msg . startThread ( {
79
+ name : msg . member ?. nickname ?? msg . author . username ,
80
+ autoArchiveDuration : ThreadAutoArchiveDuration . OneDay ,
81
+ } ) ;
82
+ thread . setLocked ( true ) ;
83
+ await HelpThread . create ( {
84
+ threadId : thread . id ,
85
+ ownerId : msg . author . id ,
86
+ } ) . save ( ) ;
14
87
}
88
+
15
89
@listener ( { event : 'threadUpdate' } )
16
- async onThreadUpdated ( thread : ThreadChannel ) {
17
- if ( ! this . isHelpThread ( thread ) ) return ;
18
- this . fixThreadExpiry ( ( await thread . fetch ( ) ) as ThreadChannel ) ;
90
+ async onThreadExpire ( thread : ThreadChannel ) {
91
+ if (
92
+ ! this . isHelpThread ( thread ) ||
93
+ manuallyArchivedThreads . has ( thread . id ) ||
94
+ ! ( ( await thread . fetch ( ) ) as ThreadChannel ) . archived
95
+ )
96
+ return ;
97
+ await thread . send ( { embeds : [ THREAD_EXPIRE_MESSAGE ] } ) ;
98
+ await this . closeThread ( thread ) ;
19
99
}
20
100
21
- private async fixThreadExpiry ( thread : ThreadChannel ) {
22
- if ( thread . autoArchiveDuration !== ThreadAutoArchiveDuration . OneDay )
23
- await thread . setAutoArchiveDuration (
24
- ThreadAutoArchiveDuration . OneDay ,
101
+ @command ( )
102
+ async tclose ( msg : Message ) {
103
+ if ( ! this . isHelpThread ( msg . channel ) ) return ;
104
+
105
+ const threadData = ( await HelpThread . findOne ( msg . channel . id ) ) ! ;
106
+
107
+ if (
108
+ threadData . ownerId === msg . author . id ||
109
+ msg . member ?. permissions . has ( 'MANAGE_MESSAGES' )
110
+ ) {
111
+ await this . closeThread ( msg . channel ) ;
112
+ } else {
113
+ return await msg . channel . send (
114
+ ':warning: you have to be the asker to close the thread.' ,
25
115
) ;
116
+ }
117
+ }
118
+
119
+ private async closeThread ( thread : ThreadChannel ) {
120
+ manuallyArchivedThreads . set ( thread . id ) ;
121
+ await thread . setArchived ( true , 'grrr' ) ;
122
+ await HelpThread . delete ( thread . id ) ;
123
+ }
124
+
125
+ private updateHelpInfo ( channel : TextChannel ) {
126
+ helpInfoLocks . set (
127
+ channel . id ,
128
+ ( helpInfoLocks . get ( channel . id ) ?? Promise . resolve ( ) ) . then (
129
+ async ( ) => {
130
+ await Promise . all ( [
131
+ ...( await channel . messages . fetchPinned ( ) ) . map ( x =>
132
+ x . delete ( ) ,
133
+ ) ,
134
+ channel
135
+ . send ( { embeds : [ HELP_INFO ( channel ) ] } )
136
+ . then ( x => x . pin ( ) ) ,
137
+ ] ) ;
138
+ } ,
139
+ ) ,
140
+ ) ;
141
+ }
142
+
143
+ @listener ( { event : 'messageCreate' } )
144
+ deletePinMessage ( msg : Message ) {
145
+ if (
146
+ this . isHelpChannel ( msg . channel ) &&
147
+ msg . type === 'CHANNEL_PINNED_MESSAGE'
148
+ )
149
+ msg . delete ( ) ;
150
+ }
151
+
152
+ private isHelpChannel (
153
+ channel : Omit < Channel , 'partial' > ,
154
+ ) : channel is TextChannel {
155
+ return (
156
+ channel instanceof TextChannel && channel . parentId == helpCategory
157
+ ) ;
26
158
}
27
159
28
160
private isHelpThread (
29
161
channel : Omit < Channel , 'partial' > ,
30
162
) : channel is ThreadChannel & { parent : TextChannel } {
31
163
return (
32
164
channel instanceof ThreadChannel &&
33
- channel . parent instanceof TextChannel &&
34
- channel . parent . parentId == helpCategory
165
+ this . isHelpChannel ( channel . parent ! )
35
166
) ;
36
167
}
37
168
@@ -41,51 +172,60 @@ export class HelpThreadModule extends Module {
41
172
} )
42
173
async helper ( msg : Message ) {
43
174
if ( ! this . isHelpThread ( msg . channel ) ) {
44
- return msg . channel . send (
175
+ return sendWithMessageOwnership (
176
+ msg ,
45
177
':warning: You may only ping helpers from a help thread' ,
46
178
) ;
47
179
}
48
180
49
181
const thread = msg . channel ;
182
+ const threadData = ( await HelpThread . findOne ( thread . id ) ) ! ;
50
183
51
184
// Ensure the user has permission to ping helpers
52
- const isAsker = thread . ownerId === msg . author . id ;
185
+ const isAsker = msg . author . id === threadData . ownerId ;
53
186
const isTrusted =
54
187
( await isTrustedMember ( msg , this . client ) ) === undefined ; // No error if trusted
55
188
56
189
if ( ! isAsker && ! isTrusted ) {
57
- return msg . channel . send (
190
+ return sendWithMessageOwnership (
191
+ msg ,
58
192
':warning: Only the asker can ping helpers' ,
59
193
) ;
60
194
}
61
195
62
196
const askTime = thread . createdTimestamp ;
63
- // Find the last time helpers were called, to avoid multiple successive !helper pings.
64
- // It's possible that the last helper ping would not be within the most recent 20 messages,
65
- // while still being more recent than allowed, but in that case the person has likely already
66
- // recieved help, so them asking for further help is less likely to be spammy.
67
- const lastHelperPing = (
68
- await thread . messages . fetch ( { limit : 20 } )
69
- ) . find (
70
- msg =>
71
- msg . author . id === this . client . user ! . id &&
72
- msg . content === PINGED_HELPER_MESSAGE ,
73
- ) ?. createdTimestamp ;
74
197
const pingAllowedAfter =
75
- ( lastHelperPing ?? askTime ) + timeBeforeHelperPing ;
198
+ + ( threadData . helperTimestamp ?? askTime ) + timeBeforeHelperPing ;
76
199
77
200
// Ensure they've waited long enough
78
201
// Trusted members (who aren't the asker) are allowed to disregard the timeout
79
202
if ( isAsker && Date . now ( ) < pingAllowedAfter ) {
80
- return msg . channel . send (
203
+ return sendWithMessageOwnership (
204
+ msg ,
81
205
`:warning: Please wait a bit longer. You can ping helpers <t:${ Math . ceil (
82
206
pingAllowedAfter / 1000 ,
83
207
) } :R>.`,
84
208
) ;
85
209
}
86
210
87
211
// The beacons are lit, Gondor calls for aid
88
- thread . parent . send ( `<@&${ trustedRoleId } > ${ msg . channel } ` ) ;
89
- thread . send ( PINGED_HELPER_MESSAGE ) ;
212
+ await Promise . all ( [
213
+ thread . parent . send ( `<@&${ trustedRoleId } > ${ msg . channel } ` ) ,
214
+ this . updateHelpInfo ( thread . parent ) ,
215
+ msg . react ( '✅' ) ,
216
+ HelpThread . update ( thread . id , {
217
+ helperTimestamp : Date . now ( ) . toString ( ) ,
218
+ } ) ,
219
+ ] ) ;
220
+ }
221
+
222
+ @command ( { single : true } )
223
+ async title ( msg : Message , title : string ) {
224
+ if ( ! this . isHelpThread ( msg . channel ) ) return ;
225
+ if ( ! title ) return sendWithMessageOwnership ( msg , ':x: Missing title' ) ;
226
+ let username = msg . member ?. nickname ?? msg . author . username ;
227
+ if ( msg . channel . name !== username )
228
+ return sendWithMessageOwnership ( msg , ':x: Already set thread name' ) ;
229
+ msg . channel . setName ( `${ username } - ${ title } ` ) ;
90
230
}
91
231
}
0 commit comments