Skip to content

Commit 86dd4b5

Browse files
committed
Add attachment rehosting; forward attachments in replies
1 parent f4c7a6a commit 86dd4b5

File tree

4 files changed

+206
-64
lines changed

4 files changed

+206
-64
lines changed

attachments/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!/.gitignore

index.js

+199-64
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
const fs = require('fs');
22
const http = require('http');
3+
const https = require('https');
34
const url = require('url');
45
const crypto = require('crypto');
56
const publicIp = require('public-ip');
67
const Eris = require('eris');
78
const moment = require('moment');
9+
const mime = require('mime');
810
const Queue = require('./queue');
911
const config = require('./config');
1012

@@ -29,6 +31,8 @@ const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
2931

3032
const userMentionRegex = /^<@\!?([0-9]+?)>$/;
3133

34+
const attachmentDir = `${__dirname}/attachments`;
35+
3236
try {
3337
const blockedJSON = fs.readFileSync(blockFile, {encoding: 'utf8'});
3438
blocked = JSON.parse(blockedJSON);
@@ -48,8 +52,11 @@ function getLogFileInfo(logfile) {
4852
const match = logfile.match(logFileFormatRegex);
4953
if (! match) return null;
5054

55+
const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss');
56+
5157
return {
52-
date: match[1],
58+
filename: logfile,
59+
date: date,
5360
userId: match[2],
5461
token: match[3],
5562
};
@@ -93,21 +100,85 @@ function findLogFile(token) {
93100
});
94101
}
95102

96-
function findLogFilesByUserId(userId) {
103+
function getLogsByUserId(userId) {
97104
return new Promise(resolve => {
98105
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+
}
102114

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;
104121
});
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);
105154

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));
107165
});
108166
});
109167
}
110168

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+
111182
/*
112183
* MAIN FUNCTIONALITY
113184
*/
@@ -121,6 +192,7 @@ bot.on('ready', () => {
121192
}
122193

123194
bot.editStatus(null, {name: config.status || 'Message me for help'});
195+
console.log('Bot started, listening to DMs');
124196
});
125197

126198
function getModmailChannelInfo(channel) {
@@ -178,62 +250,107 @@ function formatAttachment(attachment) {
178250
let filesize = attachment.size || 0;
179251
filesize /= 1024;
180252

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+
});
182256
}
183257

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.
184260
bot.on('messageCreate', (msg) => {
185261
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
186262
if (msg.author.id === bot.user.id) return;
187263

188264
if (blocked.indexOf(msg.author.id) !== -1) return;
189265

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
191269
messageQueue.add(() => {
192270
return getModmailChannel(msg.author).then(channel => {
193271
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}`;
203272

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}`;
207278
});
208279

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+
});
211306
});
212307
});
213308
});
214309

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
215313
bot.registerCommand('reply', (msg, args) => {
216314
if (msg.channel.guild.id !== modMailGuild.id) return;
217315
if (! msg.member.permission.has('manageRoles')) return;
218316

219317
const channelInfo = getModmailChannelInfo(msg.channel);
220318
if (! channelInfo) return;
221319

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+
});
225338

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+
};
230341

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};
233345

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+
});
237354
});
238355
});
239356

@@ -321,31 +438,15 @@ bot.registerCommand('logs', (msg, args) => {
321438

322439
if (! userId) return;
323440

324-
findLogFilesByUserId(userId).then(logfiles => {
441+
getLogsWithUrlByUserId(userId).then(infos => {
325442
let message = `**Log files for <@${userId}>:**\n`;
326443

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');
346448

347-
msg.channel.createMessage(message);
348-
});
449+
msg.channel.createMessage(message);
349450
});
350451
});
351452

@@ -355,24 +456,58 @@ bot.connect();
355456
* MODMAIL LOG SERVER
356457
*/
357458

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) {
364460
const token = pathParts[pathParts.length - 1];
365-
366461
if (token.match(/^[0-9a-f]+$/) === null) return res.end();
367462

368463
findLogFile(token).then(logfile => {
369464
if (logfile === null) return res.end();
370465

371466
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+
372473
res.setHeader('Content-Type', 'text/plain');
373474
res.end(data);
374475
});
375476
});
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-9a-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);
376511
});
377512

378513
server.listen(logServerPort);

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"license": "ISC",
1111
"dependencies": {
1212
"eris": "github:abalabahaha/eris#dev",
13+
"mime": "^1.3.4",
1314
"moment": "^2.17.1",
1415
"public-ip": "^2.0.1"
1516
},

yarn.lock

+4
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,10 @@ lodash@^4.0.0, lodash@^4.3.0:
543543
version "4.17.2"
544544
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.2.tgz#34a3055babe04ce42467b607d700072c7ff6bf42"
545545

546+
mime:
547+
version "1.3.4"
548+
resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
549+
546550
minimatch@^3.0.2:
547551
version "3.0.3"
548552
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774"

0 commit comments

Comments
 (0)