Major refactor about done

master
Miikka Virtanen 2017-02-10 06:36:47 +02:00
parent 6d33e7adb4
commit 6a13724c6a
10 changed files with 224 additions and 87 deletions

2
.nvmrc
View File

@ -1 +1 @@
6.9.1
7.5.0

View File

@ -1,14 +1,26 @@
const Eris = require('eris');
const fs = require('fs');
const https = require('https');
const config = require('../config');
const utils = require('./utils');
const attachmentDir = config.attachmentDir || `${__dirname}/attachments`;
const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`;
function getAttachmentPath(id) {
return `${attachmentDir}/${id}`;
/**
* Returns the filesystem path for the given attachment id
* @param {String} attachmentId
* @returns {String}
*/
function getPath(attachmentId) {
return `${attachmentDir}/${attachmentId}`;
}
/**
* Attempts to download and save the given attachement
* @param {Object} attachment
* @param {Number=0} tries
* @returns {Promise}
*/
function saveAttachment(attachment, tries = 0) {
return new Promise((resolve, reject) => {
if (tries > 3) {
@ -17,7 +29,7 @@ function saveAttachment(attachment, tries = 0) {
return;
}
const filepath = getAttachmentPath(attachment.id);
const filepath = getPath(attachment.id);
const writeStream = fs.createWriteStream(filepath);
https.get(attachment.url, (res) => {
@ -34,19 +46,30 @@ function saveAttachment(attachment, tries = 0) {
});
}
function saveAttachments(msg) {
/**
* Attempts to download and save all attachments in the given message
* @param {Eris.Message} msg
* @returns {Promise}
*/
function saveAttachmentsInMessage(msg) {
if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve();
return Promise.all(msg.attachments.map(saveAttachment));
}
function getAttachmentUrl(id, desiredName) {
/**
* Returns the self-hosted URL to the given attachment ID
* @param {String} attachmentId
* @param {String=null} desiredName Custom name for the attachment as a hint for the browser
* @returns {String}
*/
function getUrl(attachmentId, desiredName = null) {
if (desiredName == null) desiredName = 'file.bin';
return utils.getSelfUrl(`attachments/${id}/${desiredName}`);
return utils.getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
}
module.exports = {
getAttachmentPath,
getPath,
saveAttachment,
saveAttachments,
getAttachmentUrl,
saveAttachmentsInMessage,
getUrl,
};

View File

@ -1,20 +1,35 @@
const jsonDb = require('./jsonDb');
/**
* Checks whether userId is blocked
* @param {String} userId
* @returns {Promise<Boolean>}
*/
function isBlocked(userId) {
return jsonDb.get('blocked').then(blocked => {
return jsonDb.get('blocked', []).then(blocked => {
return blocked.indexOf(userId) !== -1;
});
}
/**
* Blocks the given userId
* @param {String} userId
* @returns {Promise}
*/
function block(userId) {
return jsonDb.get('blocked').then(blocked => {
return jsonDb.get('blocked', []).then(blocked => {
blocked.push(userId);
return jsonDb.save('blocked', blocked);
});
}
/**
* Unblocks the given userId
* @param {String} userId
* @returns {Promise}
*/
function unblock(userId) {
return jsonDb.get('blocked').then(blocked => {
return jsonDb.get('blocked', []).then(blocked => {
blocked.splice(blocked.indexOf(userId), 1);
return jsonDb.save('blocked', blocked);
});

View File

@ -1,7 +1,8 @@
const Eris = require('eris');
const fs = require('fs');
const moment = require('moment');
const Queue = require('./queue');
const config = require('../config');
const Queue = require('./queue');
const utils = require('./utils');
const blocked = require('./blocked');
const threads = require('./threads');
@ -23,6 +24,29 @@ bot.on('ready', () => {
console.log('Bot started, listening to DMs');
});
function formatAttachment(attachment) {
let filesize = attachment.size || 0;
filesize /= 1024;
return attachments.getUrl(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;
});
}
// "Bot was mentioned in #general-discussion"
bot.on('messageCreate', msg => {
if (msg.author.id === bot.user.id) return;
@ -48,19 +72,20 @@ bot.on('messageCreate', (msg) => {
if (isBlocked) return;
// Download and save copies of attachments in the background
attachments.saveAttachments(msg);
attachments.saveAttachmentsInMessage(msg);
let thread, logs;
let thread, userLogs;
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels aren't created
messageQueue.add(() => {
threads.getForUser(bot, msg.author)
return threads.getForUser(bot, msg.author)
.then(userThread => {
thread = userThread;
return logs.getLogsByUserId(msg.author.id);
})
.then(userLogs => {
logs = userLogs;
return utils.formatUserDM(msg);
.then(foundUserLogs => {
userLogs = foundUserLogs;
return 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
@ -88,7 +113,7 @@ bot.on('messageCreate', (msg) => {
}
// Ping mods of the new thread
let creationNotificationMessage = `New modmail thread: <#${channel.id}>`;
let creationNotificationMessage = `New modmail thread: <#${thread.channelId}>`;
if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`;
bot.createMessage(utils.getModmailGuild(bot).id, {
@ -98,15 +123,15 @@ bot.on('messageCreate', (msg) => {
// 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, {
bot.createMessage(utils.getModmailGuild(bot).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}`);
})
bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
});
});
});
});
@ -144,11 +169,10 @@ bot.registerCommand('reply', (msg, args) => {
threads.getByChannelId(msg.channel.id).then(thread => {
if (! thread) return;
attachments.saveAttachments(msg).then(() => {
attachments.saveAttachmentsInMessage(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}) ` : '');
const mainRole = utils.getMainRole(msg.member);
const roleStr = (mainRole ? `(${mainRole.name}) ` : '');
let argMsg = args.join(' ').trim();
let content = `**${roleStr}${msg.author.username}:** ${argMsg}`;
@ -157,7 +181,7 @@ bot.registerCommand('reply', (msg, args) => {
dmChannel.createMessage(content, file).then(() => {
if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`;
const timestamp = getTimestamp();
const timestamp = utils.getTimestamp();
msg.channel.createMessage(`[${timestamp}] » ${content}`);
}, (err) => {
if (err.resp && err.resp.statusCode === 403) {
@ -174,10 +198,10 @@ bot.registerCommand('reply', (msg, args) => {
// 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) => {
fs.readFile(attachments.getPath(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 => {
attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => {
sendMessage(file, attachmentUrl);
});
});
@ -193,7 +217,7 @@ bot.registerCommandAlias('r', 'reply');
bot.registerCommand('close', (msg, args) => {
if (! msg.channel.guild) return;
if (msg.channel.guild.id !== modMailGuild.id) 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 => {
@ -208,7 +232,7 @@ bot.registerCommand('close', (msg, args) => {
logs.getNewLogFile(thread.userId).then(logFilename => {
logs.saveLogFile(logFilename, log)
.then(() => getLogFileUrl(logFilename))
.then(() => logs.getLogFileUrl(logFilename))
.then(url => {
const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.mention}
Logs: <${url}>`;
@ -240,7 +264,7 @@ bot.registerCommand('block', (msg, args) => {
// 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);
block(thread.userId);
});
}
});
@ -264,7 +288,7 @@ bot.registerCommand('unblock', (msg, args) => {
// 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);
unblock(thread.userId);
});
}
});
@ -275,7 +299,7 @@ bot.registerCommand('logs', (msg, args) => {
if (! msg.member.permission.has('manageRoles')) return;
function getLogs(userId) {
getLogsWithUrlByUserId(userId).then(infos => {
logs.getLogsWithUrlByUserId(userId).then(infos => {
let message = `**Log files for <@${userId}>:**\n`;
message += infos.map(info => {
@ -295,7 +319,7 @@ bot.registerCommand('logs', (msg, args) => {
// 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);
getLogs(thread.userId);
});
}
});

View File

@ -1,12 +1,13 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
const dbDir = config.dbDir || `${__dirname}/db`;
const dbDir = config.dbDir || `${__dirname}/../db`;
const databases = {};
class JSONDB {
constructor(path, def = {}, useCloneByDefault = true) {
constructor(path, def = {}, useCloneByDefault = false) {
this.path = path;
this.useCloneByDefault = useCloneByDefault;

View File

@ -2,10 +2,25 @@ const fs = require('fs');
const crypto = require('crypto');
const moment = require('moment');
const config = require('../config');
const utils = require('./utils');
const logDir = config.logDir || `${__dirname}/logs`;
const logDir = config.logDir || `${__dirname}/../logs`;
const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/;
/**
* @typedef {Object} LogFileInfo
* @property {String} filename
* @property {String} date
* @property {String} userId
* @property {String} token
* @property {String=} url
*/
/**
* Returns information about the given logfile
* @param {String} logFilename
* @returns {LogFileInfo}
*/
function getLogFileInfo(logFilename) {
const match = logFilename.match(logFileFormatRegex);
if (! match) return null;
@ -20,15 +35,30 @@ function getLogFileInfo(logFilename) {
};
}
/**
* Returns the filesystem path to the given logfile
* @param {String} logFilename
* @returns {String}
*/
function getLogFilePath(logFilename) {
return `${logDir}/${logFilename}`;
}
/**
* Returns the self-hosted URL to the given logfile
* @param {String} logFilename
* @returns {String}
*/
function getLogFileUrl(logFilename) {
const info = getLogFileInfo(logFilename);
return utils.getSelfUrl(`logs/${info.token}`);
}
/**
* Returns a new, unique log file name for the given userId
* @param {String} userId
* @returns {Promise<String>}
*/
function getNewLogFile(userId) {
return new Promise(resolve => {
crypto.randomBytes(16, (err, buf) => {
@ -40,6 +70,11 @@ function getNewLogFile(userId) {
});
}
/**
* Finds a log file name by its token
* @param {String} token
* @returns {Promise<String>}
*/
function findLogFile(token) {
return new Promise(resolve => {
fs.readdir(logDir, (err, files) => {
@ -55,9 +90,16 @@ function findLogFile(token) {
});
}
/**
* Returns all log file infos for the given userId
* @param {String} userId
* @returns {Promise<LogFileInfo[]>}
*/
function getLogsByUserId(userId) {
return new Promise(resolve => {
return new Promise((resolve, reject) => {
fs.readdir(logDir, (err, files) => {
if (err) return reject(err);
const logfileInfos = files
.map(file => getLogFileInfo(file))
.filter(info => info && info.userId === userId);
@ -67,6 +109,11 @@ function getLogsByUserId(userId) {
});
}
/**
* Returns all log file infos with URLs for the given userId
* @param {String} userId
* @returns {Promise<LogFileInfo[]>}
*/
function getLogsWithUrlByUserId(userId) {
return getLogsByUserId(userId).then(infos => {
const urlPromises = infos.map(info => {
@ -88,6 +135,11 @@ function getLogsWithUrlByUserId(userId) {
});
}
/**
* @param {String} logFilename
* @param {String} content
* @returns {Promise}
*/
function saveLogFile(logFilename, content) {
return new Promise((resolve, reject) => {
fs.writeFile(getLogFilePath(logFilename), content, {encoding: 'utf8'}, err => {
@ -105,4 +157,5 @@ module.exports = {
getLogsByUserId,
getLogsWithUrlByUserId,
saveLogFile,
getLogFileUrl,
};

View File

@ -16,10 +16,10 @@ const jsonDb = require('./jsonDb');
* @param {Eris.Client} bot
* @param {Eris.User} user
* @param {Boolean} allowCreate
* @returns {Promise<ThreadInfo>}
* @returns {Promise<ModMailThread>}
*/
function getForUser(bot, user, allowCreate = true) {
return jsonDb.get('threads').then(threads => {
return jsonDb.get('threads', []).then(threads => {
const thread = threads.find(t => t.userId === user.id);
if (thread) return thread;
@ -41,12 +41,12 @@ function getForUser(bot, user, allowCreate = true) {
username: `${user.username}#${user.discriminator}`,
};
const threads = jsonDb.get('threads');
threads.push(thread);
jsonDb.save('threads', threads);
return jsonDb.get('threads', []).then(threads => {
threads.push(thread);
jsonDb.save('threads', threads);
thread._wasCreated = true;
return thread;
return Object.assign({}, thread, {_wasCreated: true});
});
}, err => {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
});
@ -55,17 +55,22 @@ function getForUser(bot, user, allowCreate = true) {
/**
* @param {String} channelId
* @returns {Promise<ThreadInfo>}
* @returns {Promise<ModMailThread>}
*/
function getByChannelId(channelId) {
return jsonDb.get('threads').then(threads => {
return threads.find(t => t.userId === user.id);
return jsonDb.get('threads', []).then(threads => {
return threads.find(t => t.channelId === channelId);
});
}
/**
* Deletes the modmail thread for the given channel id
* @param {String} channelId
* @returns {Promise}
*/
function close(channelId) {
return jsonDb.get('threads').then(threads => {
const thread = threads.find(t => t.userId === user.id);
return jsonDb.get('threads', []).then(threads => {
const thread = threads.find(t => t.channelId === channelId);
if (! thread) return;
threads.splice(threads.indexOf(thread), 1);

View File

@ -1,37 +1,22 @@
const Eris = require('eris');
const moment = require('moment');
const publicIp = require('public-ip');
const config = require('../config');
const utils = require('./utils');
let modMailGuild = null;
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;
});
if (! modMailGuild) modMailGuild = bot.guilds.find(g => g.id === config.mailGuildId);
return modMailGuild;
}
const userMentionRegex = /^<@\!?([0-9]+?)>$/;
/**
* Returns the user ID of the user mentioned in str, if any
* @param {String} str
* @returns {String|null}
*/
function getUserMention(str) {
str = str.trim();
@ -46,30 +31,56 @@ function getUserMention(str) {
return null;
}
/**
* Returns the current timestamp in an easily readable form
* @param {String|Date|undefined} date
* @returns {String}
*/
function getTimestamp(date) {
return moment.utc(date).format('HH:mm');
}
/**
* Disables link previews in the given string by wrapping links in < >
* @param {String} str
* @returns {String}
*/
function disableLinkPreviews(str) {
return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>');
}
function getSelfUrl(path) {
/**
* Returns a URL to the bot's web server
* @param {String} path
* @returns {String}
*/
function getSelfUrl(path = '') {
if (config.url) {
return Promise.resolve(`${config.url}/${path}`);
} else {
const port = config.port || 8890;
return publicIp.v4().then(ip => {
return `http://${ip}:${logServerPort}/${path}`;
return `http://${ip}:${port}/${path}`;
});
}
}
/**
* Returns the highest hoisted role of the given member
* @param {Eris.Member} member
* @returns {Eris.Role}
*/
function getMainRole(member) {
const roles = member.roles.map(id => member.guild.roles.get(id));
roles.sort((a, b) => a.position > b.position ? -1 : 1);
return roles.find(r => r.hoist);
}
module.exports = {
getModmailGuild,
formatAttachment,
formatUserDM,
getUserMention,
getTimestamp,
disableLinkPreviews,
getSelfUrl,
getMainRole,
};

View File

@ -1,6 +1,10 @@
const http = require('http');
const mime = require('mime');
const url = require('url');
const fs = require('fs');
const config = require('../config');
const logs = require('./logs');
const attachments = require('./attachments');
const port = config.port || 8890;
@ -8,10 +12,10 @@ 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();
logs.findLogFile(token).then(logFilename => {
if (logFilename === null) return res.end();
fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => {
fs.readFile(logs.getLogFilePath(logFilename), {encoding: 'utf8'}, (err, data) => {
if (err) {
res.statusCode = 404;
res.end('Log not found');
@ -31,7 +35,7 @@ function serveAttachments(res, pathParts) {
if (id.match(/^[0-9]+$/) === null) return res.end();
if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end();
const attachmentPath = getAttachmentPath(id);
const attachmentPath = attachments.getPath(id);
fs.access(attachmentPath, (err) => {
if (err) {
res.statusCode = 404;
@ -59,7 +63,7 @@ function run() {
if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts);
});
server.listen(logServerPort);
server.listen(port);
}
module.exports = {

View File

@ -1,4 +1,5 @@
#!/bin/bash
git pull
yarn
pm2 restart ModmailBot