Start huge refactor

master
Miikka Virtanen 2017-02-10 04:56:36 +02:00
parent b82eb08505
commit 6d33e7adb4
13 changed files with 798 additions and 605 deletions

View File

@ -11,6 +11,14 @@
}, },
"rules": { "rules": {
"space-infix-ops": "error",
"space-unary-ops": ["error", {
"words": true,
"nonwords": false,
"overrides": {
"!": true,
"!!": true
}
}]
} }
} }

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
/.vscode /.vscode
/.idea
/node_modules /node_modules
/config.json /config.json
/blocked.json

2
db/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!/.gitignore

601
index.js
View File

@ -1,601 +0,0 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const url = require('url');
const crypto = require('crypto');
const publicIp = require('public-ip');
const Eris = require('eris');
const moment = require('moment');
const mime = require('mime');
const Queue = require('./queue');
const config = require('./config');
const logServerPort = config.port || 8890;
const bot = new Eris.CommandClient(config.token, {}, {
prefix: config.prefix || '!',
ignoreSelf: true,
ignoreBots: true,
defaultHelpCommand: false,
});
let modMailGuild;
const modMailChannels = {};
const messageQueue = new Queue();
const blockFile = `${__dirname}/blocked.json`;
let blocked = [];
const logDir = `${__dirname}/logs`;
const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
const userMentionRegex = /^<@\!?([0-9]+?)>$/;
const attachmentDir = `${__dirname}/attachments`;
try {
const blockedJSON = fs.readFileSync(blockFile, {encoding: 'utf8'});
blocked = JSON.parse(blockedJSON);
} catch(e) {
fs.writeFileSync(blockFile, '[]');
}
function saveBlocked() {
fs.writeFileSync(blockFile, JSON.stringify(blocked, null, 4));
}
/*
* MODMAIL LOG UTILITY FUNCTIONS
*/
function getLogFileInfo(logfile) {
const match = logfile.match(logFileFormatRegex);
if (! match) return null;
const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss');
return {
filename: logfile,
date: date,
userId: match[2],
token: match[3],
};
}
function getLogFilePath(logfile) {
return `${logDir}/${logfile}`;
}
function getLogFileUrl(logfile) {
const info = getLogFileInfo(logfile);
return publicIp.v4().then(ip => {
return `http://${ip}:${logServerPort}/logs/${info.token}`;
});
}
function getRandomLogFile(userId) {
return new Promise(resolve => {
crypto.randomBytes(16, (err, buf) => {
const token = buf.toString('hex');
const date = moment.utc().format('YYYY-MM-DD-HH-mm-ss');
resolve(`${date}__${userId}__${token}.txt`);
});
});
}
function findLogFile(token) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
for (const file of files) {
if (file.endsWith(`__${token}.txt`)) {
resolve(file);
return;
}
}
resolve(null);
});
});
}
function getLogsByUserId(userId) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
const logfileInfos = files
.map(file => getLogFileInfo(file))
.filter(info => info && info.userId === userId);
resolve(logfileInfos);
});
});
}
function getLogsWithUrlByUserId(userId) {
return getLogsByUserId(userId).then(infos => {
const urlPromises = infos.map(info => {
return getLogFileUrl(info.filename).then(url => {
info.url = url;
return info;
});
});
return Promise.all(urlPromises).then(infos => {
infos.sort((a, b) => {
if (a.date > b.date) return 1;
if (a.date < b.date) return -1;
return 0;
});
return infos;
});
});
}
/*
* Attachments
*/
function getAttachmentPath(id) {
return `${attachmentDir}/${id}`;
}
function saveAttachment(attachment, tries = 0) {
return new Promise((resolve, reject) => {
if (tries > 3) {
console.error('Attachment download failed after 3 tries:', attachment);
reject('Attachment download failed after 3 tries');
return;
}
const filepath = getAttachmentPath(attachment.id);
const writeStream = fs.createWriteStream(filepath);
https.get(attachment.url, (res) => {
res.pipe(writeStream);
writeStream.on('finish', () => {
writeStream.close()
resolve();
});
}).on('error', (err) => {
fs.unlink(filepath);
console.error('Error downloading attachment, retrying');
resolve(saveAttachment(attachment));
});
});
}
function saveAttachments(msg) {
if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve();
return Promise.all(msg.attachments.map(saveAttachment));
}
function getAttachmentUrl(id, desiredName) {
if (desiredName == null) desiredName = 'file.bin';
return publicIp.v4().then(ip => {
return `http://${ip}:${logServerPort}/attachments/${id}/${desiredName}`;
});
}
/*
* MAIN FUNCTIONALITY
*/
bot.on('ready', () => {
modMailGuild = bot.guilds.find(g => g.id === config.mailGuildId);
if (! modMailGuild) {
console.error('You need to set and invite me to the mod mail guild first!');
process.exit(0);
}
bot.editStatus(null, {name: config.status || 'Message me for help'});
console.log('Bot started, listening to DMs');
});
function getModmailChannelInfo(channel) {
if (! channel.topic) return null;
const match = channel.topic.match(/^MODMAIL\|([0-9]+)\|(.*)$/);
if (! match) return null;
return {
userId: match[1],
name: match[2],
};
}
// This function doesn't actually return the channel object, but an object like {id, _wasCreated}
// This is because we can't trust the guild.channels list to actually contain our channel, even if it exists
// (has to do with the api events' consistency), and we only cache IDs, so we return an object that can be
// constructed from just the ID; we do this even if we find a matching channel so the returned object's signature is consistent
function getModmailChannel(user, allowCreate = true) {
// If the channel id's in the cache, use that
if (modMailChannels[user.id]) {
return Promise.resolve({
id: modMailChannels[user.id],
_wasCreated: false,
});
}
// Try to find a matching channel
let candidate = modMailGuild.channels.find(c => {
const info = getModmailChannelInfo(c);
return info && info.userId === user.id;
});
if (candidate) {
// If we found a matching channel, return that
return Promise.resolve({
id: candidate.id,
_wasCreated: false,
});
} else {
// If one is not found, create and cache it
if (! allowCreate) return Promise.resolve(null);
let cleanName = user.username.replace(/[^a-zA-Z0-9]/ig, '').toLowerCase().trim();
if (cleanName === '') cleanName = 'unknown';
console.log(`[NOTE] Since no candidate was found, creating channel ${cleanName}-${user.discriminator}`);
return modMailGuild.createChannel(`${cleanName}-${user.discriminator}`)
.then(channel => {
// This is behind a timeout because Discord was telling me the channel didn't exist after creation even though it clearly did
// ¯\_(ツ)_/¯
return new Promise(resolve => {
const topic = `MODMAIL|${user.id}|${user.username}#${user.discriminator}`;
setTimeout(() => resolve(channel.edit({topic: topic})), 200);
});
}, err => {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
})
.then(channel => {
modMailChannels[user.id] = channel.id;
return {
id: channel.id,
_wasCreated: true,
};
});
}
}
function formatAttachment(attachment) {
let filesize = attachment.size || 0;
filesize /= 1024;
return getAttachmentUrl(attachment.id, attachment.filename).then(attachmentUrl => {
return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`;
});
}
function formatRelayedPM(msg) {
let content = msg.content;
// 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)
const attachmentFormatPromise = msg.attachments.map(formatAttachment);
return Promise.all(attachmentFormatPromise).then(formattedAttachments => {
formattedAttachments.forEach(str => {
content += `\n\n${str}`;
});
return content;
});
}
function getTimestamp(date) {
return moment.utc(date).format('HH:mm');
}
// When we get a private message, create a modmail channel or reuse an existing one.
// If the channel was not reused, assume it's a new modmail thread and send the user an introduction message.
bot.on('messageCreate', (msg) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (blocked.indexOf(msg.author.id) !== -1) return;
saveAttachments(msg);
// 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
messageQueue.add(() => {
return getModmailChannel(msg.author).then(channel => {
return formatRelayedPM(msg).then(content => {
// Get previous modmail logs for this user
// Show a note of them at the beginning of the thread for reference
return getLogsByUserId(msg.author.id).then(logs => {
if (channel._wasCreated) {
if (logs.length > 0) {
bot.createMessage(channel.id, `${logs.length} previous modmail logs with this user. Use !logs ${msg.author.id} for details.`);
}
let creationNotificationMessage = `New modmail thread: <#${channel.id}>`;
if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`;
bot.createMessage(modMailGuild.id, {
content: creationNotificationMessage,
disableEveryone: false,
});
msg.channel.createMessage("Thank you for your message! Our mod team will reply to you here as soon as possible.").then(null, (err) => {
bot.createMessage(modMailGuild.id, {
content: `There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (id ${msg.author.id}); consider messaging manually`
});
});
}
const timestamp = getTimestamp();
bot.createMessage(channel.id, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
}); // getLogsByUserId
}); // formatRelayedPM
}); // getModmailChannel
}); // messageQueue.add
});
// Edits in PMs
bot.on('messageUpdate', (msg, oldMessage) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (blocked.indexOf(msg.author.id) !== -1) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
getModmailChannel(msg.author, false).then(channel => {
if (! channel) return;
bot.createMessage(channel.id, `**The user edited their message:**\n**Before:** ${oldContent}\n**After:** ${newContent}`);
});
});
// "Bot was mentioned in #general-discussion"
bot.on('messageCreate', msg => {
if (msg.author.id === bot.user.id) return;
if (blocked.indexOf(msg.author.id) !== -1) return;
if (msg.mentions.some(user => user.id === bot.user.id)) {
bot.createMessage(modMailGuild.id, {
content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`,
disableEveryone: false,
});
}
});
// Mods can reply to modmail threads using !r or !reply
// These messages get relayed back to the DM thread between the bot and the user
// Attachments are shown as URLs
bot.registerCommand('reply', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
const channelInfo = getModmailChannelInfo(msg.channel);
if (! channelInfo) return;
saveAttachments(msg).then(() => {
bot.getDMChannel(channelInfo.userId).then(dmChannel => {
const roleId = msg.member.roles[0];
const role = (roleId ? (modMailGuild.roles.get(roleId) || {}).name : '');
const roleStr = (role ? `(${role}) ` : '');
let argMsg = args.join(' ').trim();
let content = `**${roleStr}${msg.author.username}:** ${argMsg}`;
const sendMessage = (file, attachmentUrl) => {
dmChannel.createMessage(content, file).then(() => {
if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`;
const timestamp = getTimestamp();
msg.channel.createMessage(`[${timestamp}] » ${content}`);
}, (err) => {
if (err.resp && err.resp.statusCode === 403) {
msg.channel.createMessage(`Could not send reply; the user has likely blocked the bot`);
} else if (err.resp) {
msg.channel.createMessage(`Could not send reply; error code ${err.resp.statusCode}`);
} else {
msg.channel.createMessage(`Could not send reply: ${err.toString()}`);
}
});
msg.delete();
};
if (msg.attachments.length > 0) {
fs.readFile(getAttachmentPath(msg.attachments[0].id), (err, data) => {
const file = {file: data, name: msg.attachments[0].filename};
getAttachmentUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => {
sendMessage(file, attachmentUrl);
});
});
} else {
sendMessage();
}
});
});
});
bot.registerCommandAlias('r', 'reply');
bot.registerCommand('close', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
const channelInfo = getModmailChannelInfo(msg.channel);
if (! channelInfo) return;
msg.channel.createMessage('Saving logs and closing channel...');
msg.channel.getMessages(10000).then(messages => {
const log = messages.reverse().map(message => {
const date = moment.utc(message.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
return `[${date}] ${message.author.username}#${message.author.discriminator}: ${message.content}`;
}).join('\n') + '\n';
getRandomLogFile(channelInfo.userId).then(logfile => {
fs.writeFile(getLogFilePath(logfile), log, {encoding: 'utf8'}, err => {
getLogFileUrl(logfile).then(logurl => {
const closeMessage = `Modmail thread with ${channelInfo.name} (${channelInfo.userId}) was closed by ${msg.author.mention}
Logs: <${logurl}>`;
bot.createMessage(modMailGuild.id, closeMessage);
delete modMailChannels[channelInfo.userId];
msg.channel.delete();
});
});
})
});
});
bot.registerCommand('block', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
let userId;
if (args[0]) {
if (args[0].match(/^[0-9]+$/)) {
userId = args[0];
} else {
let mentionMatch = args[0].match(userMentionRegex);
if (mentionMatch) userId = mentionMatch[1];
}
} else {
const modmailChannelInfo = getModmailChannelInfo(msg.channel);
if (modmailChannelInfo) userId = modmailChannelInfo.userId;
}
if (! userId) return;
blocked.push(userId);
saveBlocked();
msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`);
});
bot.registerCommand('unblock', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
let userId;
if (args[0]) {
if (args[0].match(/^[0-9]+$/)) {
userId = args[0];
} else {
let mentionMatch = args[0].match(userMentionRegex);
if (mentionMatch) userId = mentionMatch[1];
}
} else {
const modmailChannelInfo = getModmailChannelInfo(msg.channel);
if (modmailChannelInfo) userId = modmailChannelInfo.userId;
}
if (! userId) return;
blocked.splice(blocked.indexOf(userId), 1);
saveBlocked();
msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`);
});
bot.registerCommand('logs', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
let userId;
if (args[0]) {
if (args[0].match(/^[0-9]+$/)) {
userId = args[0];
} else {
let mentionMatch = args[0].match(userMentionRegex);
if (mentionMatch) userId = mentionMatch[1];
}
} else {
const modmailChannelInfo = getModmailChannelInfo(msg.channel);
if (modmailChannelInfo) userId = modmailChannelInfo.userId;
}
if (! userId) return;
getLogsWithUrlByUserId(userId).then(infos => {
let message = `**Log files for <@${userId}>:**\n`;
message += infos.map(info => {
const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${info.url}>`;
}).join('\n');
msg.channel.createMessage(message);
});
});
bot.on('channelCreate', channel => {
console.log(`[NOTE] Got channel creation event for #${channel.name} (ID ${channel.id})`);
});
bot.connect();
/*
* MODMAIL LOG SERVER
*/
function serveLogs(res, pathParts) {
const token = pathParts[pathParts.length - 1];
if (token.match(/^[0-9a-f]+$/) === null) return res.end();
findLogFile(token).then(logfile => {
if (logfile === null) return res.end();
fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => {
if (err) {
res.statusCode = 404;
res.end('Log not found');
return;
}
res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
res.end(data);
});
});
}
function serveAttachments(res, pathParts) {
const desiredFilename = pathParts[pathParts.length - 1];
const id = pathParts[pathParts.length - 2];
if (id.match(/^[0-9]+$/) === null) return res.end();
if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end();
const attachmentPath = getAttachmentPath(id);
fs.access(attachmentPath, (err) => {
if (err) {
res.statusCode = 404;
res.end('Attachment not found');
return;
}
const filenameParts = desiredFilename.split('.');
const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : 'bin');
const fileMime = mime.lookup(ext);
res.setHeader('Content-Type', fileMime);
const read = fs.createReadStream(attachmentPath);
read.pipe(res);
})
}
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(`http://${req.url}`);
const pathParts = parsedUrl.path.split('/').filter(v => v !== '');
if (parsedUrl.path.startsWith('/logs/')) serveLogs(res, pathParts);
if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts);
});
server.listen(logServerPort);

52
src/attachments.js Normal file
View File

@ -0,0 +1,52 @@
const fs = require('fs');
const https = require('https');
const config = require('../config');
const utils = require('./utils');
const attachmentDir = config.attachmentDir || `${__dirname}/attachments`;
function getAttachmentPath(id) {
return `${attachmentDir}/${id}`;
}
function saveAttachment(attachment, tries = 0) {
return new Promise((resolve, reject) => {
if (tries > 3) {
console.error('Attachment download failed after 3 tries:', attachment);
reject('Attachment download failed after 3 tries');
return;
}
const filepath = getAttachmentPath(attachment.id);
const writeStream = fs.createWriteStream(filepath);
https.get(attachment.url, (res) => {
res.pipe(writeStream);
writeStream.on('finish', () => {
writeStream.close()
resolve();
});
}).on('error', (err) => {
fs.unlink(filepath);
console.error('Error downloading attachment, retrying');
resolve(saveAttachment(attachment));
});
});
}
function saveAttachments(msg) {
if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve();
return Promise.all(msg.attachments.map(saveAttachment));
}
function getAttachmentUrl(id, desiredName) {
if (desiredName == null) desiredName = 'file.bin';
return utils.getSelfUrl(`attachments/${id}/${desiredName}`);
}
module.exports = {
getAttachmentPath,
saveAttachment,
saveAttachments,
getAttachmentUrl,
};

27
src/blocked.js Normal file
View File

@ -0,0 +1,27 @@
const jsonDb = require('./jsonDb');
function isBlocked(userId) {
return jsonDb.get('blocked').then(blocked => {
return blocked.indexOf(userId) !== -1;
});
}
function block(userId) {
return jsonDb.get('blocked').then(blocked => {
blocked.push(userId);
return jsonDb.save('blocked', blocked);
});
}
function unblock(userId) {
return jsonDb.get('blocked').then(blocked => {
blocked.splice(blocked.indexOf(userId), 1);
return jsonDb.save('blocked', blocked);
});
}
module.exports = {
isBlocked,
block,
unblock,
};

304
src/index.js Normal file
View File

@ -0,0 +1,304 @@
const Eris = require('eris');
const moment = require('moment');
const Queue = require('./queue');
const config = require('../config');
const utils = require('./utils');
const blocked = require('./blocked');
const threads = require('./threads');
const logs = require('./logs');
const attachments = require('./attachments');
const webserver = require('./webserver');
const bot = new Eris.CommandClient(config.token, {}, {
prefix: config.prefix || '!',
ignoreSelf: true,
ignoreBots: true,
defaultHelpCommand: false,
});
const messageQueue = new Queue();
bot.on('ready', () => {
bot.editStatus(null, {name: config.status || 'Message me for help'});
console.log('Bot started, listening to DMs');
});
// "Bot was mentioned in #general-discussion"
bot.on('messageCreate', msg => {
if (msg.author.id === bot.user.id) return;
if (msg.mentions.some(user => user.id === bot.user.id)) {
blocked.isBlocked(msg.author.id).then(isBlocked => {
if (isBlocked) return;
bot.createMessage(utils.getModmailGuild(bot).id, {
content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`,
disableEveryone: false,
});
});
}
});
// When we get a private message, forward the contents to the corresponding modmail thread
bot.on('messageCreate', (msg) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
blocked.isBlocked(msg.author.id).then(isBlocked => {
if (isBlocked) return;
// Download and save copies of attachments in the background
attachments.saveAttachments(msg);
let thread, logs;
messageQueue.add(() => {
threads.getForUser(bot, msg.author)
.then(userThread => {
thread = userThread;
return logs.getLogsByUserId(msg.author.id);
})
.then(userLogs => {
logs = userLogs;
return utils.formatUserDM(msg);
})
.then(content => {
// If the thread does not exist and could not be created, send a warning about this to all mods so they can DM the user directly instead
if (! thread) {
let warningMessage = `
@here Error creating modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id})!
Here's what their message contained:
\`\`\`${content}\`\`\`
`;
bot.createMessage(utils.getModmailGuild(bot).id, {
content: `@here Error creating modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id})!`,
disableEveryone: false,
});
return;
}
// If the thread was just created, do some extra stuff
if (thread._wasCreated) {
// Mention previous logs at the start of the thread
if (logs.length > 0) {
bot.createMessage(thread.channelId, `${logs.length} previous modmail logs with this user. Use !logs ${msg.author.id} for details.`);
}
// Ping mods of the new thread
let creationNotificationMessage = `New modmail thread: <#${channel.id}>`;
if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`;
bot.createMessage(utils.getModmailGuild(bot).id, {
content: creationNotificationMessage,
disableEveryone: false,
});
// Send an automatic reply to the user informing them of the successfully created modmail thread
msg.channel.createMessage("Thank you for your message! Our mod team will reply to you here as soon as possible.").then(null, (err) => {
bot.createMessage(modMailGuild.id, {
content: `There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (id ${msg.author.id}); consider messaging manually`
});
});
}
const timestamp = utils.getTimestamp();
bot.createMessage(channel.id, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
})
});
});
});
// Edits in DMs
bot.on('messageUpdate', (msg, oldMessage) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
blocked.isBlocked(msg.author.id).then(isBlocked => {
if (isBlocked) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
threads.getForUser(bot, msg.author).then(thread => {
if (! thread) return;
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n**Before:** ${oldContent}\n**After:** ${newContent}`);
bot.createMessage(thread.channelId, editMessage);
});
});
});
// Mods can reply to modmail threads using !r or !reply
// These messages get relayed back to the DM thread between the bot and the user
bot.registerCommand('reply', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return;
if (! msg.member.permission.has('manageRoles')) return;
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
attachments.saveAttachments(msg).then(() => {
bot.getDMChannel(thread.userId).then(dmChannel => {
const roleId = msg.member.roles[0];
const role = (roleId ? (modMailGuild.roles.get(roleId) || {}).name : '');
const roleStr = (role ? `(${role}) ` : '');
let argMsg = args.join(' ').trim();
let content = `**${roleStr}${msg.author.username}:** ${argMsg}`;
function sendMessage(file, attachmentUrl) {
dmChannel.createMessage(content, file).then(() => {
if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`;
const timestamp = getTimestamp();
msg.channel.createMessage(`[${timestamp}] » ${content}`);
}, (err) => {
if (err.resp && err.resp.statusCode === 403) {
msg.channel.createMessage(`Could not send reply; the user has likely blocked the bot`);
} else if (err.resp) {
msg.channel.createMessage(`Could not send reply; error code ${err.resp.statusCode}`);
} else {
msg.channel.createMessage(`Could not send reply: ${err.toString()}`);
}
});
msg.delete();
};
// If the reply has an attachment, relay it as is
if (msg.attachments.length > 0) {
fs.readFile(attachments.getAttachmentPath(msg.attachments[0].id), (err, data) => {
const file = {file: data, name: msg.attachments[0].filename};
getAttachmentUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => {
sendMessage(file, attachmentUrl);
});
});
} else {
sendMessage();
}
});
});
});
});
bot.registerCommandAlias('r', 'reply');
bot.registerCommand('close', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) return;
if (! msg.member.permission.has('manageRoles')) return;
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
msg.channel.createMessage('Saving logs and closing channel...');
msg.channel.getMessages(10000).then(messages => {
const log = messages.reverse().map(msg => {
const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`;
}).join('\n') + '\n';
logs.getNewLogFile(thread.userId).then(logFilename => {
logs.saveLogFile(logFilename, log)
.then(() => getLogFileUrl(logFilename))
.then(url => {
const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.mention}
Logs: <${url}>`;
bot.createMessage(utils.getModmailGuild(bot).id, closeMessage);
threads.close(thread.channelId).then(() => msg.channel.delete());
});
});
});
});
});
bot.registerCommand('block', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return;
if (! msg.member.permission.has('manageRoles')) return;
function block(userId) {
blocked.block(userId).then(() => {
msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`);
});
}
if (args.length > 0) {
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
block(userId);
} else {
// Calling !block without args in a modmail thread blocks the user of that thread
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
block(userId);
});
}
});
bot.registerCommand('unblock', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return;
if (! msg.member.permission.has('manageRoles')) return;
function unblock(userId) {
blocked.unblock(userId).then(() => {
msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`);
});
}
if (args.length > 0) {
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
unblock(userId);
} else {
// Calling !unblock without args in a modmail thread unblocks the user of that thread
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
unblock(userId);
});
}
});
bot.registerCommand('logs', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return;
if (! msg.member.permission.has('manageRoles')) return;
function getLogs(userId) {
getLogsWithUrlByUserId(userId).then(infos => {
let message = `**Log files for <@${userId}>:**\n`;
message += infos.map(info => {
const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${info.url}>`;
}).join('\n');
msg.channel.createMessage(message);
});
}
if (args.length > 0) {
const userId = utils.getUserMention(args.join(' '));
if (! userId) return;
getLogs(userId);
} else {
// Calling !logs without args in a modmail thread returns the logs of the user of that thread
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
getLogs(userId);
});
}
});
bot.connect();
webserver.run();

67
src/jsonDb.js Normal file
View File

@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
const dbDir = config.dbDir || `${__dirname}/db`;
const databases = {};
class JSONDB {
constructor(path, def = {}, useCloneByDefault = true) {
this.path = path;
this.useCloneByDefault = useCloneByDefault;
this.load = new Promise(resolve => {
fs.readFile(path, {encoding: 'utf8'}, (err, data) => {
if (err) return resolve(def);
let unserialized;
try { unserialized = JSON.parse(data); }
catch (e) { unserialized = def; }
resolve(unserialized);
});
});
}
get(clone) {
if (clone == null) clone = this.useCloneByDefault;
return this.load.then(data => {
if (clone) return JSON.parse(JSON.stringify(data));
else return data;
});
}
save(newData) {
const serialized = JSON.stringify(newData);
this.load = new Promise((resolve, reject) => {
fs.writeFile(this.path, serialized, {encoding: 'utf8'}, () => {
resolve(newData);
});
});
return this.get();
}
}
function getDb(dbName, def) {
if (! databases[dbName]) {
const dbPath = path.resolve(dbDir, `${dbName}.json`);
databases[dbName] = new JSONDB(dbPath, def);
}
return databases[dbName];
}
function get(dbName, def) {
return getDb(dbName, def).get();
}
function save(dbName, data) {
return getDb(dbName, data).save(data);
}
module.exports = {
get,
save,
};

108
src/logs.js Normal file
View File

@ -0,0 +1,108 @@
const fs = require('fs');
const crypto = require('crypto');
const moment = require('moment');
const config = require('../config');
const logDir = config.logDir || `${__dirname}/logs`;
const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
function getLogFileInfo(logFilename) {
const match = logFilename.match(logFileFormatRegex);
if (! match) return null;
const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss');
return {
filename: logFilename,
date: date,
userId: match[2],
token: match[3],
};
}
function getLogFilePath(logFilename) {
return `${logDir}/${logFilename}`;
}
function getLogFileUrl(logFilename) {
const info = getLogFileInfo(logFilename);
return utils.getSelfUrl(`logs/${info.token}`);
}
function getNewLogFile(userId) {
return new Promise(resolve => {
crypto.randomBytes(16, (err, buf) => {
const token = buf.toString('hex');
const date = moment.utc().format('YYYY-MM-DD-HH-mm-ss');
resolve(`${date}__${userId}__${token}.txt`);
});
});
}
function findLogFile(token) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
for (const file of files) {
if (file.endsWith(`__${token}.txt`)) {
resolve(file);
return;
}
}
resolve(null);
});
});
}
function getLogsByUserId(userId) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
const logfileInfos = files
.map(file => getLogFileInfo(file))
.filter(info => info && info.userId === userId);
resolve(logfileInfos);
});
});
}
function getLogsWithUrlByUserId(userId) {
return getLogsByUserId(userId).then(infos => {
const urlPromises = infos.map(info => {
return getLogFileUrl(info.filename).then(url => {
info.url = url;
return info;
});
});
return Promise.all(urlPromises).then(infos => {
infos.sort((a, b) => {
if (a.date > b.date) return 1;
if (a.date < b.date) return -1;
return 0;
});
return infos;
});
});
}
function saveLogFile(logFilename, content) {
return new Promise((resolve, reject) => {
fs.writeFile(getLogFilePath(logFilename), content, {encoding: 'utf8'}, err => {
if (err) return reject(err);
resolve();
});
});
}
module.exports = {
getLogFileInfo,
getLogFilePath,
getNewLogFile,
findLogFile,
getLogsByUserId,
getLogsWithUrlByUserId,
saveLogFile,
};

View File

@ -6,7 +6,7 @@ class Queue {
add(fn) { add(fn) {
this.queue.push(fn); this.queue.push(fn);
if (!this.running) this.next(); if (! this.running) this.next();
} }
next() { next() {
@ -18,7 +18,11 @@ class Queue {
} }
const fn = this.queue.shift(); const fn = this.queue.shift();
Promise.resolve(fn()).then(() => this.next()); new Promise(resolve => {
// Either fn() completes or the timeout of 10sec is reached
Promise.resolve(fn()).then(resolve);
setTimeout(resolve, 10000);
}).then(() => this.next());
} }
} }

80
src/threads.js Normal file
View File

@ -0,0 +1,80 @@
const Eris = require('eris');
const utils = require('./utils');
const jsonDb = require('./jsonDb');
/**
* @typedef {Object} ModMailThread
* @property {String} channelId
* @property {String} userId
* @property {String} username
* @property {Boolean} _wasCreated
*/
/**
* Returns information about the modmail thread channel for the given user. We can't return channel objects
* directly since they're not always available immediately after creation.
* @param {Eris.Client} bot
* @param {Eris.User} user
* @param {Boolean} allowCreate
* @returns {Promise<ThreadInfo>}
*/
function getForUser(bot, user, allowCreate = true) {
return jsonDb.get('threads').then(threads => {
const thread = threads.find(t => t.userId === user.id);
if (thread) return thread;
// If we didn't find an existing modmail thread, attempt creating one
if (! allowCreate) return null;
// Channel names are particularly picky about what characters they allow...
let cleanName = user.username.replace(/[^a-zA-Z0-9]/ig, '').toLowerCase().trim();
if (cleanName === '') cleanName = 'unknown';
const channelName = `${cleanName}-${user.discriminator}`;
console.log(`[NOTE] Creating new thread channel ${channelName}`);
return utils.getModmailGuild(bot).createChannel(`${channelName}`)
.then(channel => {
const thread = {
channelId: channel.id,
userId: user.id,
username: `${user.username}#${user.discriminator}`,
};
const threads = jsonDb.get('threads');
threads.push(thread);
jsonDb.save('threads', threads);
thread._wasCreated = true;
return thread;
}, err => {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
});
});
}
/**
* @param {String} channelId
* @returns {Promise<ThreadInfo>}
*/
function getByChannelId(channelId) {
return jsonDb.get('threads').then(threads => {
return threads.find(t => t.userId === user.id);
});
}
function close(channelId) {
return jsonDb.get('threads').then(threads => {
const thread = threads.find(t => t.userId === user.id);
if (! thread) return;
threads.splice(threads.indexOf(thread), 1);
return jsonDb.save('threads', threads);
});
}
module.exports = {
getForUser,
getByChannelId,
close,
};

75
src/utils.js Normal file
View File

@ -0,0 +1,75 @@
const moment = require('moment');
const publicIp = require('public-ip');
const config = require('../config');
const utils = require('./utils');
function getModmailGuild(bot) {
return bot.guilds.find(g => g.id === config.mailGuildId);
}
function formatAttachment(attachment) {
let filesize = attachment.size || 0;
filesize /= 1024;
return utils.getAttachmentUrl(attachment.id, attachment.filename).then(attachmentUrl => {
return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`;
});
}
function formatUserDM(msg) {
let content = msg.content;
// 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)
const attachmentFormatPromise = msg.attachments.map(formatAttachment);
return Promise.all(attachmentFormatPromise).then(formattedAttachments => {
formattedAttachments.forEach(str => {
content += `\n\n${str}`;
});
return content;
});
}
const userMentionRegex = /^<@\!?([0-9]+?)>$/;
function getUserMention(str) {
str = str.trim();
if (str.match(/^[0-9]+$/)) {
// User ID
return str;
} else {
let mentionMatch = str.match(userMentionRegex);
if (mentionMatch) return mentionMatch[1];
}
return null;
}
function getTimestamp(date) {
return moment.utc(date).format('HH:mm');
}
function disableLinkPreviews(str) {
return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>');
}
function getSelfUrl(path) {
if (config.url) {
return Promise.resolve(`${config.url}/${path}`);
} else {
return publicIp.v4().then(ip => {
return `http://${ip}:${logServerPort}/${path}`;
});
}
}
module.exports = {
getModmailGuild,
formatAttachment,
formatUserDM,
getUserMention,
getTimestamp,
disableLinkPreviews,
getSelfUrl,
};

67
src/webserver.js Normal file
View File

@ -0,0 +1,67 @@
const http = require('http');
const mime = require('mime');
const config = require('../config');
const port = config.port || 8890;
function serveLogs(res, pathParts) {
const token = pathParts[pathParts.length - 1];
if (token.match(/^[0-9a-f]+$/) === null) return res.end();
findLogFile(token).then(logfile => {
if (logfile === null) return res.end();
fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => {
if (err) {
res.statusCode = 404;
res.end('Log not found');
return;
}
res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
res.end(data);
});
});
}
function serveAttachments(res, pathParts) {
const desiredFilename = pathParts[pathParts.length - 1];
const id = pathParts[pathParts.length - 2];
if (id.match(/^[0-9]+$/) === null) return res.end();
if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end();
const attachmentPath = getAttachmentPath(id);
fs.access(attachmentPath, (err) => {
if (err) {
res.statusCode = 404;
res.end('Attachment not found');
return;
}
const filenameParts = desiredFilename.split('.');
const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : 'bin');
const fileMime = mime.lookup(ext);
res.setHeader('Content-Type', fileMime);
const read = fs.createReadStream(attachmentPath);
read.pipe(res);
})
}
function run() {
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(`http://${req.url}`);
const pathParts = parsedUrl.path.split('/').filter(v => v !== '');
if (parsedUrl.path.startsWith('/logs/')) serveLogs(res, pathParts);
if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts);
});
server.listen(logServerPort);
}
module.exports = {
run,
};