Start huge refactor
parent
b82eb08505
commit
6d33e7adb4
10
.eslintrc
10
.eslintrc
|
@ -11,6 +11,14 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"space-infix-ops": "error",
|
||||||
|
"space-unary-ops": ["error", {
|
||||||
|
"words": true,
|
||||||
|
"nonwords": false,
|
||||||
|
"overrides": {
|
||||||
|
"!": true,
|
||||||
|
"!!": true
|
||||||
|
}
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/.vscode
|
/.vscode
|
||||||
|
/.idea
|
||||||
/node_modules
|
/node_modules
|
||||||
/config.json
|
/config.json
|
||||||
/blocked.json
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!/.gitignore
|
601
index.js
601
index.js
|
@ -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);
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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();
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
Loading…
Reference in New Issue