1
1
const fs = require ( 'fs' ) ;
2
2
const http = require ( 'http' ) ;
3
+ const https = require ( 'https' ) ;
3
4
const url = require ( 'url' ) ;
4
5
const crypto = require ( 'crypto' ) ;
5
6
const publicIp = require ( 'public-ip' ) ;
6
7
const Eris = require ( 'eris' ) ;
7
8
const moment = require ( 'moment' ) ;
9
+ const mime = require ( 'mime' ) ;
8
10
const Queue = require ( './queue' ) ;
9
11
const config = require ( './config' ) ;
10
12
@@ -29,6 +31,8 @@ const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
29
31
30
32
const userMentionRegex = / ^ < @ \! ? ( [ 0 - 9 ] + ?) > $ / ;
31
33
34
+ const attachmentDir = `${ __dirname } /attachments` ;
35
+
32
36
try {
33
37
const blockedJSON = fs . readFileSync ( blockFile , { encoding : 'utf8' } ) ;
34
38
blocked = JSON . parse ( blockedJSON ) ;
@@ -48,8 +52,11 @@ function getLogFileInfo(logfile) {
48
52
const match = logfile . match ( logFileFormatRegex ) ;
49
53
if ( ! match ) return null ;
50
54
55
+ const date = moment . utc ( match [ 1 ] , 'YYYY-MM-DD-HH-mm-ss' ) . format ( 'YYYY-MM-DD HH:mm:ss' ) ;
56
+
51
57
return {
52
- date : match [ 1 ] ,
58
+ filename : logfile ,
59
+ date : date ,
53
60
userId : match [ 2 ] ,
54
61
token : match [ 3 ] ,
55
62
} ;
@@ -93,21 +100,85 @@ function findLogFile(token) {
93
100
} ) ;
94
101
}
95
102
96
- function findLogFilesByUserId ( userId ) {
103
+ function getLogsByUserId ( userId ) {
97
104
return new Promise ( resolve => {
98
105
fs . readdir ( logDir , ( err , files ) => {
99
- const logfiles = files . filter ( file => {
100
- const info = getLogFileInfo ( file ) ;
101
- if ( ! info ) return false ;
106
+ const logfileInfos = files
107
+ . map ( file => getLogFileInfo ( file ) )
108
+ . filter ( info => info && info . userId === userId ) ;
109
+
110
+ resolve ( logfileInfos ) ;
111
+ } ) ;
112
+ } ) ;
113
+ }
102
114
103
- return info . userId === userId ;
115
+ function getLogsWithUrlByUserId ( userId ) {
116
+ return getLogsByUserId ( userId ) . then ( infos => {
117
+ const urlPromises = infos . map ( info => {
118
+ return getLogFileUrl ( info . filename ) . then ( url => {
119
+ info . url = url ;
120
+ return info ;
104
121
} ) ;
122
+ } ) ;
123
+
124
+ return Promise . all ( urlPromises ) . then ( infos => {
125
+ infos . sort ( ( a , b ) => {
126
+ if ( a . date > b . date ) return 1 ;
127
+ if ( a . date < b . date ) return - 1 ;
128
+ return 0 ;
129
+ } ) ;
130
+
131
+ return infos ;
132
+ } ) ;
133
+ } ) ;
134
+ }
135
+
136
+ /*
137
+ * Attachments
138
+ */
139
+
140
+ function getAttachmentPath ( id ) {
141
+ return `${ attachmentDir } /${ id } ` ;
142
+ }
143
+
144
+ function saveAttachment ( attachment , tries = 0 ) {
145
+ return new Promise ( ( resolve , reject ) => {
146
+ if ( tries > 3 ) {
147
+ console . error ( 'Attachment download failed after 3 tries:' , attachment ) ;
148
+ reject ( 'Attachment download failed after 3 tries' ) ;
149
+ return ;
150
+ }
151
+
152
+ const filepath = getAttachmentPath ( attachment . id ) ;
153
+ const writeStream = fs . createWriteStream ( filepath ) ;
105
154
106
- resolve ( logfiles ) ;
155
+ https . get ( attachment . url , ( res ) => {
156
+ res . pipe ( writeStream ) ;
157
+ writeStream . on ( 'finish' , ( ) => {
158
+ writeStream . close ( )
159
+ resolve ( ) ;
160
+ } ) ;
161
+ } ) . on ( 'error' , ( err ) => {
162
+ fs . unlink ( filepath ) ;
163
+ console . error ( 'Error downloading attachment, retrying' ) ;
164
+ resolve ( saveAttachment ( attachment ) ) ;
107
165
} ) ;
108
166
} ) ;
109
167
}
110
168
169
+ function saveAttachments ( msg ) {
170
+ if ( ! msg . attachments || msg . attachments . length === 0 ) return Promise . resolve ( ) ;
171
+ return Promise . all ( msg . attachments . map ( saveAttachment ) ) ;
172
+ }
173
+
174
+ function getAttachmentUrl ( id , desiredName ) {
175
+ if ( desiredName == null ) desiredName = 'file.bin' ;
176
+
177
+ return publicIp . v4 ( ) . then ( ip => {
178
+ return `http://${ ip } :${ logServerPort } /attachments/${ id } /${ desiredName } ` ;
179
+ } ) ;
180
+ }
181
+
111
182
/*
112
183
* MAIN FUNCTIONALITY
113
184
*/
@@ -121,6 +192,7 @@ bot.on('ready', () => {
121
192
}
122
193
123
194
bot . editStatus ( null , { name : config . status || 'Message me for help' } ) ;
195
+ console . log ( 'Bot started, listening to DMs' ) ;
124
196
} ) ;
125
197
126
198
function getModmailChannelInfo ( channel ) {
@@ -178,62 +250,107 @@ function formatAttachment(attachment) {
178
250
let filesize = attachment . size || 0 ;
179
251
filesize /= 1024 ;
180
252
181
- return `**Attachment:** ${ attachment . filename } (${ filesize . toFixed ( 1 ) } KB)\n${ attachment . url } `
253
+ return getAttachmentUrl ( attachment . id , attachment . filename ) . then ( attachmentUrl => {
254
+ return `**Attachment:** ${ attachment . filename } (${ filesize . toFixed ( 1 ) } KB)\n${ attachmentUrl } ` ;
255
+ } ) ;
182
256
}
183
257
258
+ // When we get a private message, create a modmail channel or reuse an existing one.
259
+ // If the channel was not reused, assume it's a new modmail thread and send the user an introduction message.
184
260
bot . on ( 'messageCreate' , ( msg ) => {
185
261
if ( ! ( msg . channel instanceof Eris . PrivateChannel ) ) return ;
186
262
if ( msg . author . id === bot . user . id ) return ;
187
263
188
264
if ( blocked . indexOf ( msg . author . id ) !== - 1 ) return ;
189
265
190
- // This needs to be queued as otherwise, if a user sent a bunch of messages initially and the createChannel endpoint is delayed, we might get duplicate channels
266
+ saveAttachments ( msg ) ;
267
+
268
+ // This needs to be queued, as otherwise if a user sent a bunch of messages initially and the createChannel endpoint is delayed, we might get duplicate channels
191
269
messageQueue . add ( ( ) => {
192
270
return getModmailChannel ( msg . author ) . then ( channel => {
193
271
let content = msg . content ;
194
- msg . attachments . forEach ( attachment => {
195
- content += `\n\n${ formatAttachment ( attachment ) } ` ;
196
- } ) ;
197
-
198
- channel . createMessage ( `« **${ msg . author . username } #${ msg . author . discriminator } :** ${ content } ` ) ;
199
-
200
- if ( channel . _wasCreated ) {
201
- let creationNotificationMessage = `New modmail thread: ${ channel . mention } ` ;
202
- if ( config . pingCreationNotification ) creationNotificationMessage = `@here ${ config . pingCreationNotification } ` ;
203
272
204
- bot . createMessage ( modMailGuild . id , {
205
- content : creationNotificationMessage ,
206
- disableEveryone : false ,
273
+ // Get a local URL for all attachments so we don't rely on discord's servers (which delete attachments when the channel/DM thread is deleted)
274
+ const attachmentFormatPromise = msg . attachments . map ( formatAttachment ) ;
275
+ Promise . all ( attachmentFormatPromise ) . then ( formattedAttachments => {
276
+ formattedAttachments . forEach ( str => {
277
+ content += `\n\n${ str } ` ;
207
278
} ) ;
208
279
209
- msg . channel . createMessage ( "Thank you for your message! Our mod team will reply to you here as soon as possible." ) ;
210
- }
280
+ // Get previous modmail logs for this user
281
+ // Show a note of them at the beginning of the thread for reference
282
+ getLogsByUserId ( msg . author . id ) . then ( logs => {
283
+ if ( channel . _wasCreated ) {
284
+ if ( logs . length > 0 ) {
285
+ channel . createMessage ( `${ logs . length } previous modmail logs with this user. Use !logs ${ msg . author . id } for details.` ) ;
286
+ }
287
+
288
+ let creationNotificationMessage = `New modmail thread: ${ channel . mention } ` ;
289
+ if ( config . pingCreationNotification ) creationNotificationMessage = `@here ${ config . pingCreationNotification } ` ;
290
+
291
+ bot . createMessage ( modMailGuild . id , {
292
+ content : creationNotificationMessage ,
293
+ disableEveryone : false ,
294
+ } ) ;
295
+
296
+ msg . channel . createMessage ( "Thank you for your message! Our mod team will reply to you here as soon as possible." ) . then ( null , ( err ) => {
297
+ bot . createMessage ( modMailGuild . id , {
298
+ content : `There is an issue sending messages to ${ msg . author . username } #${ msg . author . discriminator } (id ${ msg . author . id } ); consider messaging manually`
299
+ } ) ;
300
+ } ) ;
301
+ }
302
+
303
+ channel . createMessage ( `« **${ msg . author . username } #${ msg . author . discriminator } :** ${ content } ` ) ;
304
+ } ) ;
305
+ } ) ;
211
306
} ) ;
212
307
} ) ;
213
308
} ) ;
214
309
310
+ // Mods can reply to modmail threads using !r or !reply
311
+ // These messages get relayed back to the DM thread between the bot and the user
312
+ // Attachments are shown as URLs
215
313
bot . registerCommand ( 'reply' , ( msg , args ) => {
216
314
if ( msg . channel . guild . id !== modMailGuild . id ) return ;
217
315
if ( ! msg . member . permission . has ( 'manageRoles' ) ) return ;
218
316
219
317
const channelInfo = getModmailChannelInfo ( msg . channel ) ;
220
318
if ( ! channelInfo ) return ;
221
319
222
- bot . getDMChannel ( channelInfo . userId ) . then ( channel => {
223
- let argMsg = args . join ( ' ' ) . trim ( ) ;
224
- let content = `**${ msg . author . username } :** ${ argMsg } ` ;
320
+ saveAttachments ( msg ) . then ( ( ) => {
321
+ bot . getDMChannel ( channelInfo . userId ) . then ( dmChannel => {
322
+ let argMsg = args . join ( ' ' ) . trim ( ) ;
323
+ let content = `**${ msg . author . username } :** ${ argMsg } ` ;
324
+
325
+ const sendMessage = ( file , attachmentUrl ) => {
326
+ dmChannel . createMessage ( content , file ) . then ( ( ) => {
327
+ if ( attachmentUrl ) content += `\n\n**Attachment:** ${ attachmentUrl } ` ;
328
+ msg . channel . createMessage ( `» ${ content } ` ) ;
329
+ } , ( err ) => {
330
+ if ( err . resp && err . resp . statusCode === 403 ) {
331
+ msg . channel . createMessage ( `Could not send reply; the user has likely blocked the bot` ) ;
332
+ } else if ( err . resp ) {
333
+ msg . channel . createMessage ( `Could not send reply; error code ${ err . resp . statusCode } ` ) ;
334
+ } else {
335
+ msg . channel . createMessage ( `Could not send reply: ${ err . toString ( ) } ` ) ;
336
+ }
337
+ } ) ;
225
338
226
- if ( msg . attachments . length > 0 && argMsg !== '' ) content += '\n\n' ;
227
- content += msg . attachments . map ( attachment => {
228
- return `${ attachment . url } ` ;
229
- } ) . join ( '\n' ) ;
339
+ msg . delete ( ) ;
340
+ } ;
230
341
231
- channel . createMessage ( content ) ;
232
- msg . channel . createMessage ( `» ${ content } ` ) ;
342
+ if ( msg . attachments . length > 0 ) {
343
+ fs . readFile ( getAttachmentPath ( msg . attachments [ 0 ] . id ) , ( err , data ) => {
344
+ const file = { file : data , name : msg . attachments [ 0 ] . filename } ;
233
345
234
- // Delete the !r message if there are no attachments
235
- // When there are attachments, we need to keep the original message or the attachments get deleted as well
236
- if ( msg . attachments . length === 0 ) msg . delete ( ) ;
346
+ getAttachmentUrl ( msg . attachments [ 0 ] . id , msg . attachments [ 0 ] . filename ) . then ( attachmentUrl => {
347
+ sendMessage ( file , attachmentUrl ) ;
348
+ } ) ;
349
+ } ) ;
350
+ } else {
351
+ sendMessage ( ) ;
352
+ }
353
+ } ) ;
237
354
} ) ;
238
355
} ) ;
239
356
@@ -321,31 +438,15 @@ bot.registerCommand('logs', (msg, args) => {
321
438
322
439
if ( ! userId ) return ;
323
440
324
- findLogFilesByUserId ( userId ) . then ( logfiles => {
441
+ getLogsWithUrlByUserId ( userId ) . then ( infos => {
325
442
let message = `**Log files for <@${ userId } >:**\n` ;
326
443
327
- const urlPromises = logfiles . map ( logfile => {
328
- const info = getLogFileInfo ( logfile ) ;
329
- return getLogFileUrl ( logfile ) . then ( url => {
330
- info . url = url ;
331
- return info ;
332
- } ) ;
333
- } ) ;
334
-
335
- Promise . all ( urlPromises ) . then ( infos => {
336
- infos . sort ( ( a , b ) => {
337
- if ( a . date > b . date ) return 1 ;
338
- if ( a . date < b . date ) return - 1 ;
339
- return 0 ;
340
- } ) ;
341
-
342
- message += infos . map ( info => {
343
- const formattedDate = moment . utc ( info . date , 'YYYY-MM-DD-HH-mm-ss' ) . format ( 'MMM Mo [at] HH:mm [UTC]' ) ;
344
- return `${ formattedDate } : <${ info . url } >` ;
345
- } ) . join ( '\n' ) ;
444
+ message += infos . map ( info => {
445
+ const formattedDate = moment . utc ( info . date , 'YYYY-MM-DD HH:mm:ss' ) . format ( 'MMM Mo [at] HH:mm [UTC]' ) ;
446
+ return `${ formattedDate } : <${ info . url } >` ;
447
+ } ) . join ( '\n' ) ;
346
448
347
- msg . channel . createMessage ( message ) ;
348
- } ) ;
449
+ msg . channel . createMessage ( message ) ;
349
450
} ) ;
350
451
} ) ;
351
452
@@ -355,24 +456,58 @@ bot.connect();
355
456
* MODMAIL LOG SERVER
356
457
*/
357
458
358
- const server = http . createServer ( ( req , res ) => {
359
- const parsedUrl = url . parse ( `http://${ req . url } ` ) ;
360
-
361
- if ( ! parsedUrl . path . startsWith ( '/logs/' ) ) return ;
362
-
363
- const pathParts = parsedUrl . path . split ( '/' ) . filter ( v => v !== '' ) ;
459
+ function serveLogs ( res , pathParts ) {
364
460
const token = pathParts [ pathParts . length - 1 ] ;
365
-
366
461
if ( token . match ( / ^ [ 0 - 9 a - f ] + $ / ) === null ) return res . end ( ) ;
367
462
368
463
findLogFile ( token ) . then ( logfile => {
369
464
if ( logfile === null ) return res . end ( ) ;
370
465
371
466
fs . readFile ( getLogFilePath ( logfile ) , { encoding : 'utf8' } , ( err , data ) => {
467
+ if ( err ) {
468
+ res . statusCode = 404 ;
469
+ res . end ( 'Log not found' ) ;
470
+ return ;
471
+ }
472
+
372
473
res . setHeader ( 'Content-Type' , 'text/plain' ) ;
373
474
res . end ( data ) ;
374
475
} ) ;
375
476
} ) ;
477
+ }
478
+
479
+ function serveAttachments ( res , pathParts ) {
480
+ const desiredFilename = pathParts [ pathParts . length - 1 ] ;
481
+ const id = pathParts [ pathParts . length - 2 ] ;
482
+
483
+ if ( id . match ( / ^ [ 0 - 9 ] + $ / ) === null ) return res . end ( ) ;
484
+ if ( desiredFilename . match ( / ^ [ 0 - 9 a - z \. _ - ] + $ / i) === null ) return res . end ( ) ;
485
+
486
+ const attachmentPath = getAttachmentPath ( id ) ;
487
+ fs . access ( attachmentPath , ( err ) => {
488
+ if ( err ) {
489
+ res . statusCode = 404 ;
490
+ res . end ( 'Attachment not found' ) ;
491
+ return ;
492
+ }
493
+
494
+ const filenameParts = desiredFilename . split ( '.' ) ;
495
+ const ext = ( filenameParts . length > 1 ? filenameParts [ filenameParts . length - 1 ] : 'bin' ) ;
496
+ const fileMime = mime . lookup ( ext ) ;
497
+
498
+ res . setHeader ( 'Content-Type' , fileMime ) ;
499
+
500
+ const read = fs . createReadStream ( attachmentPath ) ;
501
+ read . pipe ( res ) ;
502
+ } )
503
+ }
504
+
505
+ const server = http . createServer ( ( req , res ) => {
506
+ const parsedUrl = url . parse ( `http://${ req . url } ` ) ;
507
+ const pathParts = parsedUrl . path . split ( '/' ) . filter ( v => v !== '' ) ;
508
+
509
+ if ( parsedUrl . path . startsWith ( '/logs/' ) ) serveLogs ( res , pathParts ) ;
510
+ if ( parsedUrl . path . startsWith ( '/attachments/' ) ) serveAttachments ( res , pathParts ) ;
376
511
} ) ;
377
512
378
513
server . listen ( logServerPort ) ;
0 commit comments