Refactor large parts of the bot

master
Dragory 2018-01-01 02:16:05 +02:00
parent 8e388f93bb
commit bb6d8e5dbf
13 changed files with 444 additions and 364 deletions

View File

@ -3,7 +3,10 @@
"parserOptions": { "parserOptions": {
"ecmaVersion": 8, "ecmaVersion": 8,
"sourceType": "module" "sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
}, },
"env": { "env": {

View File

@ -13,10 +13,11 @@ exports.up = async function(knex, Promise) {
table.increments('id'); table.increments('id');
table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE'); table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE');
table.integer('message_type').unsigned().notNullable(); table.integer('message_type').unsigned().notNullable();
table.bigInteger('user_id').unsigned().notNullable(); table.bigInteger('user_id').unsigned().nullable();
table.string('user_name', 128).notNullable(); table.string('user_name', 128).notNullable();
table.text('body').notNullable(); table.text('body').notNullable();
table.bigInteger('original_message_id').unsigned().nullable(); table.integer('is_anonymous').unsigned().notNullable();
table.bigInteger('original_message_id').unsigned().nullable().unique();
table.dateTime('created_at').notNullable().index(); table.dateTime('created_at').notNullable().index();
}); });

View File

@ -41,6 +41,8 @@ const defaultConfig = {
"logDir": path.join(__dirname, '..', 'logs'), "logDir": path.join(__dirname, '..', 'logs'),
}; };
const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
const finalConfig = Object.assign({}, defaultConfig); const finalConfig = Object.assign({}, defaultConfig);
for (const [prop, value] of Object.entries(userConfig)) { for (const [prop, value] of Object.entries(userConfig)) {
@ -67,7 +69,6 @@ Object.assign(finalConfig['knex'], {
} }
}); });
const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
for (const opt of required) { for (const opt of required) {
if (! finalConfig[opt]) { if (! finalConfig[opt]) {
console.error(`Missing required config.json value: ${opt}`); console.error(`Missing required config.json value: ${opt}`);

14
src/data/Snippet.js Normal file
View File

@ -0,0 +1,14 @@
/**
* @property {String} trigger
* @property {String} body
* @property {Number} is_anonymous
* @property {String} created_by
* @property {String} created_at
*/
class Snippet {
constructor(props) {
Object.assign(this, props);
}
}
module.exports = Snippet;

190
src/data/Thread.js Normal file
View File

@ -0,0 +1,190 @@
const bot = require('../bot');
const knex = require('../knex');
const utils = require('../utils');
const attachments = require('./attachments');
const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
/**
* @property {String} id
* @property {Number} status
* @property {String} user_id
* @property {String} user_name
* @property {String} channel_id
* @property {String} created_at
* @property {Boolean} _wasCreated
*/
class Thread {
constructor(props) {
Object.assign(this, {_wasCreated: false}, props);
}
/**
* @param {Eris.Member} moderator
* @param {String} text
* @param {Eris.Attachment[]} replyAttachments
* @param {Boolean} isAnonymous
* @returns {Promise<void>}
*/
async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) {
// Try to open a DM channel with the user
const dmChannel = await bot.getDMChannel(this.user_id);
if (! dmChannel) {
const channel = bot.getChannel(this.channel_id);
if (channel) {
channel.createMessage('Could not send reply: couldn\'t open DM channel with user');
}
return;
}
// Username to reply with
let modUsername, logModUsername;
const mainRole = utils.getMainRole(moderator);
if (isAnonymous) {
modUsername = (mainRole ? mainRole.name : 'Moderator');
logModUsername = `(Anonymous) (${moderator.user.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
} else {
const name = (config.useNicknames ? moderator.nick || moderator.user.username : moderator.user.username);
modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
logModUsername = modUsername;
}
// Build the reply message
let dmContent = `**${modUsername}:** ${text}`;
let threadContent = `**${logModUsername}:** ${text}`;
let logContent = text;
let attachmentFile = null;
let attachmentUrl = null;
// Prepare attachments, if any
if (replyAttachments.length > 0) {
fs.readFile(attachments.getPath(replyAttachments[0].id), async (err, data) => {
attachmentFile = {file: data, name: replyAttachments[0].filename};
attachmentUrl = await attachments.getUrl(replyAttachments[0].id, replyAttachments[0].filename);
threadContent += `\n\n**Attachment:** ${attachmentUrl}`;
logContent += `\n\n**Attachment:** ${attachmentUrl}`;
});
}
// Send the reply DM
dmChannel.createMessage(dmContent, attachmentFile);
// Send the reply to the modmail thread
const originalMessage = await this.postToThreadChannel(threadContent);
// Add the message to the database
await this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.TO_USER,
user_id: moderator.id,
user_name: logModUsername,
body: logContent,
original_message_id: originalMessage.id
});
}
/**
* @param {Eris.Message} msg
* @returns {Promise<void>}
*/
async receiveUserReply(msg) {
const timestamp = utils.getTimestamp();
let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${msg.content}`;
let logContent = msg.content;
let finalThreadContent;
let attachmentSavePromise;
if (msg.attachments.length) {
attachmentSavePromise = attachments.saveAttachmentsInMessage(msg);
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
finalThreadContent = threadContent + attachmentMsg;
threadContent += '\n\n*Attachments pending...*';
logContent += attachmentMsg;
}
const createdMessage = await this.postToThreadChannel(threadContent);
await this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.FROM_USER,
user_id: this.user_id,
user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: logContent,
original_message_id: msg.id
});
if (msg.attachments.length) {
await attachmentSavePromise;
await createdMessage.edit(finalThreadContent);
}
}
/**
* @param {String} text
* @param {Eris.MessageFile} file
* @returns {Promise<Eris.Message>}
*/
async postToThreadChannel(text, file = null) {
const channel = bot.getChannel(this.channel_id);
return channel.createMessage(text, file);
}
/**
* @param {String} text
* @returns {Promise<void>}
*/
async postSystemMessage(text) {
const msg = await this.postToThreadChannel(text);
await this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.SYSTEM,
user_id: null,
user_name: '',
body: text,
original_message_id: msg.id
});
}
/**
* @param {Object} data
* @returns {Promise<void>}
*/
async addThreadMessageToDB(data) {
await knex('thread_messages').insert({
thread_id: this.id,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
...data
});
}
/**
* @returns {Promise<void>}
*/
async close() {
await this.postToThreadChannel('Closing thread...');
// Update DB status
await knex('threads')
.where('id', this.id)
.update({
status: THREAD_STATUS.CLOSED
});
// Delete channel
const channel = bot.getChannel(this.channel_id);
if (channel) {
channel.delete('Thread closed');
}
}
/**
* @returns {Promise<String>}
*/
getLogUrl() {
return utils.getSelfUrl(`logs/${this.id}`);
}
}
module.exports = Thread;

View File

@ -17,9 +17,11 @@ async function isBlocked(userId) {
/** /**
* Blocks the given userId * Blocks the given userId
* @param {String} userId * @param {String} userId
* @param {String} userName
* @param {String} blockedBy
* @returns {Promise} * @returns {Promise}
*/ */
async function block(userId, userName = '', blockedBy = 0) { async function block(userId, userName = '', blockedBy = null) {
if (await isBlocked(userId)) return; if (await isBlocked(userId)) return;
return knex('blocked_users') return knex('blocked_users')

52
src/data/constants.js Normal file
View File

@ -0,0 +1,52 @@
module.exports = {
THREAD_STATUS: {
OPEN: 1,
CLOSED: 2
},
THREAD_MESSAGE_TYPE: {
SYSTEM: 1,
CHAT: 2,
FROM_USER: 3,
TO_USER: 4,
LEGACY: 5
},
ACCIDENTAL_THREAD_MESSAGES: [
'ok',
'okay',
'thanks',
'ty',
'k',
'thank you',
'thanx',
'thnx',
'thx',
'tnx',
'ok thank you',
'ok thanks',
'ok ty',
'ok thanx',
'ok thnx',
'ok thx',
'ok no problem',
'ok np',
'okay thank you',
'okay thanks',
'okay ty',
'okay thanx',
'okay thnx',
'okay thx',
'okay no problem',
'okay np',
'okey thank you',
'okey thanks',
'okey ty',
'okey thanx',
'okey thnx',
'okey thx',
'okey no problem',
'okey np',
'cheers'
],
};

View File

@ -1,18 +1,6 @@
const moment = require('moment'); const moment = require('moment');
const knex = require('../knex'); const knex = require('../knex');
const Snippet = require('./Snippet');
/**
* @property {String} trigger
* @property {String} body
* @property {Number} is_anonymous
* @property {String} created_by
* @property {String} created_at
*/
class Snippet {
constructor(props) {
Object.assign(this, props);
}
}
/** /**
* @param {String} trigger * @param {String} trigger
@ -46,7 +34,7 @@ async function addSnippet(trigger, body, isAnonymous = false, createdBy = 0) {
/** /**
* @param {String} trigger * @param {String} trigger
* @returns {Promise} * @returns {Promise<void>}
*/ */
async function deleteSnippet(trigger) { async function deleteSnippet(trigger) {
return knex('snippets') return knex('snippets')
@ -54,6 +42,9 @@ async function deleteSnippet(trigger) {
.delete(); .delete();
} }
/**
* @returns {Promise<Snippet[]>}
*/
async function getAllSnippets() { async function getAllSnippets() {
const snippets = await knex('snippets') const snippets = await knex('snippets')
.select(); .select();

View File

@ -2,115 +2,39 @@ const Eris = require('eris');
const transliterate = require('transliteration'); const transliterate = require('transliteration');
const moment = require('moment'); const moment = require('moment');
const uuid = require('uuid'); const uuid = require('uuid');
const humanizeDuration = require('humanize-duration');
const bot = require('../bot'); const bot = require('../bot');
const knex = require('../knex'); const knex = require('../knex');
const config = require('../config'); const config = require('../config');
const utils = require('../utils');
const getUtils = () => require('../utils'); const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants');
// If the following messages would be used to start a thread, ignore it instead
// This is to prevent accidental threads from e.g. irrelevant replies after the thread was already closed
// or replies to the greeting message
const accidentalThreadMessages = [
'ok',
'okay',
'thanks',
'ty',
'k',
'thank you',
'thanx',
'thnx',
'thx',
'tnx',
'ok thank you',
'ok thanks',
'ok ty',
'ok thanx',
'ok thnx',
'ok thx',
'ok no problem',
'ok np',
'okay thank you',
'okay thanks',
'okay ty',
'okay thanx',
'okay thnx',
'okay thx',
'okay no problem',
'okay np',
'okey thank you',
'okey thanks',
'okey ty',
'okey thanx',
'okey thnx',
'okey thx',
'okey no problem',
'okey np',
'cheers'
];
const THREAD_STATUS = {
OPEN: 1,
CLOSED: 2
};
const THREAD_MESSAGE_TYPE = {
SYSTEM: 1,
CHAT: 2,
FROM_USER: 3,
TO_USER: 4,
LEGACY: 5
};
/** /**
* @property {Number} id * @param {String} userId
* @property {Number} status
* @property {String} user_id
* @property {String} user_name
* @property {String} channel_id
* @property {String} created_at
* @property {Boolean} _wasCreated
*/
class Thread {
constructor(props) {
Object.assign(this, {_wasCreated: false}, props);
}
}
/**
* 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.User} user
* @param {Boolean} allowCreate
* @returns {Promise<Thread>} * @returns {Promise<Thread>}
*/ */
async function getOpenThreadForUser(user, allowCreate = true, originalMessage = null) { async function findOpenThreadByUserId(userId) {
// Attempt to find an open thread for this user
const thread = await knex('threads') const thread = await knex('threads')
.where('user_id', user.id) .where('user_id', userId)
.where('status', THREAD_STATUS.OPEN) .where('status', THREAD_STATUS.OPEN)
.select(); .select();
if (thread) { return (thread ? new Thread(thread) : null);
return new Thread(thread);
} }
// If no open thread was found, and we're not allowed to create one, just return null /**
if (! allowCreate) { * Creates a new modmail thread for the specified user
return null; * @param {Eris.User} user
} * @returns {Promise<Thread>}
* @throws {Error}
// No open thread was found, and we *are* allowed to create a new one, so let's do that */
async function createNewThreadForUser(user) {
// If the message's content matches any of the values in accidentalThreadMessages, const existingThread = await findOpenThreadByUserId(user.id);
// and config.ignoreAccidentalThreads is enabled, ignore this thread if (existingThread) {
if (config.ignoreAccidentalThreads && originalMessage && originalMessage.cleanContent) { throw new Error('Attempted to create a new thread for a user with an existing open thread!');
const cleaned = originalMessage.cleanContent.replace(/[^a-z\s]/gi, '').toLowerCase().trim();
if (accidentalThreadMessages.includes(cleaned)) {
console.log('[NOTE] Skipping thread creation for message:', originalMessage.cleanContent);
return null;
}
} }
// Use the user's name+discrim for the thread channel's name // Use the user's name+discrim for the thread channel's name
@ -126,7 +50,7 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage =
// Attempt to create the inbox channel for this thread // Attempt to create the inbox channel for this thread
let createdChannel; let createdChannel;
try { try {
createdChannel = await getUtils().getInboxGuild().createChannel(channelName); createdChannel = await utils.getInboxGuild().createChannel(channelName);
if (config.newThreadCategoryId) { if (config.newThreadCategoryId) {
// If a category id for new threads is specified, move the newly created channel there // If a category id for new threads is specified, move the newly created channel there
bot.editChannel(createdChannel.id, {parentID: config.newThreadCategoryId}); bot.editChannel(createdChannel.id, {parentID: config.newThreadCategoryId});
@ -137,7 +61,7 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage =
} }
// Save the new thread in the database // Save the new thread in the database
const newThreadId = await create({ const newThreadId = await createThreadInDB({
status: THREAD_STATUS.OPEN, status: THREAD_STATUS.OPEN,
user_id: user.id, user_id: user.id,
user_name: `${user.username}#${user.discriminator}`, user_name: `${user.username}#${user.discriminator}`,
@ -145,10 +69,28 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage =
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
}); });
const newThreadObj = new Thread(newThread); const newThread = await findById(newThreadId);
newThreadObj._wasCreated = true;
return newThreadObj; // Post some info to the beginning of the new thread
const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(user.id) : null);
if (! member) console.log(`[INFO] Member ${user.id} not found in main guild ${config.mainGuildId}`);
let mainGuildNickname = null;
if (member && member.nick) mainGuildNickname = member.nick;
else if (member && member.user) mainGuildNickname = member.user.username;
else if (member == null) mainGuildNickname = 'NOT ON SERVER';
if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN';
const userLogCount = await getClosedThreadCountByUserId(user.id);
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2});
const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${user.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogCount}**\n-------------------------------`;
await newThread.postSystemMessage(infoHeader);
// Return the thread
return newThread;
} }
/** /**
@ -156,32 +98,35 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage =
* @param {Object} data * @param {Object} data
* @returns {Promise<String>} The ID of the created thread * @returns {Promise<String>} The ID of the created thread
*/ */
async function create(data) { async function createThreadInDB(data) {
const threadId = uuid.v4(); const threadId = uuid.v4();
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId}); const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId});
await knex('threads').insert(newThread); await knex('threads').insert(finalData);
return threadId; return threadId;
} }
async function addThreadMessage(threadId, messageType, user, body) { /**
return knex('thread_messages').insert({ * @param {String} id
thread_id: threadId, * @returns {Promise<Thread>}
message_type: messageType, */
user_id: (user ? user.id : 0), async function findById(id) {
user_name: (user ? `${user.username}#${user.discriminator}` : ''), const row = await knex('threads')
body, .where('id', id)
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') .first();
});
if (! row) return null;
return new Thread(row);
} }
/** /**
* @param {String} channelId * @param {String} channelId
* @returns {Promise<Thread>} * @returns {Promise<Thread>}
*/ */
async function getByChannelId(channelId) { async function findByChannelId(channelId) {
const thread = await knex('threads') const thread = await knex('threads')
.where('channel_id', channelId) .where('channel_id', channelId)
.first(); .first();
@ -190,24 +135,34 @@ async function getByChannelId(channelId) {
} }
/** /**
* Deletes the modmail thread for the given channel id * @param {String} userId
* @param {String} channelId * @returns {Promise<Thread[]>}
* @returns {Promise<void>}
*/ */
async function closeByChannelId(channelId) { async function getClosedThreadsByUserId(userId) {
await knex('threads') const threads = await knex('threads')
.where('channel_id', channelId) .where('status', THREAD_STATUS.CLOSED)
.update({ .where('user_id', userId)
status: THREAD_STATUS.CLOSED .select();
});
return threads.map(thread => new Thread(thread));
}
/**
* @param {String} userId
* @returns {Promise<number>}
*/
async function getClosedThreadCountByUserId(userId) {
const row = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
.first(knex.raw('COUNT(id) AS thread_count'));
return parseInt(row.thread_count, 10);
} }
module.exports = { module.exports = {
getOpenThreadForUser, findOpenThreadByUserId,
getByChannelId, findByChannelId,
closeByChannelId, createNewThreadForUser,
create, getClosedThreadsByUserId,
THREAD_STATUS,
THREAD_MESSAGE_TYPE,
}; };

View File

@ -36,7 +36,7 @@ process.on('unhandledRejection', err => {
console.log('=== LEGACY DATA MIGRATION FINISHED ==='); console.log('=== LEGACY DATA MIGRATION FINISHED ===');
console.log(''); console.log('');
console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.'); console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.');
console.log(`Once you've done that, feel free to delete the following legacy files/directories:`); console.log('Once you\'ve done that, feel free to delete the following legacy files/directories:');
console.log(''); console.log('');
console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json')); console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json')); console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
@ -47,5 +47,5 @@ process.on('unhandledRejection', err => {
} }
// Start the bot // Start the bot
// main.start(); main.start();
})(); })();

View File

@ -82,7 +82,7 @@ async function migrateOpenThreads() {
is_legacy: 1 is_legacy: 1
}; };
return threads.create(newThread); return threads.createThreadInDB(newThread);
}); });
return Promise.all(promises); return Promise.all(promises);

View File

@ -1,7 +1,5 @@
const fs = require('fs');
const Eris = require('eris'); const Eris = require('eris');
const moment = require('moment'); const moment = require('moment');
const humanizeDuration = require('humanize-duration');
const config = require('./config'); const config = require('./config');
const bot = require('./bot'); const bot = require('./bot');
@ -9,32 +7,72 @@ const Queue = require('./queue');
const utils = require('./utils'); const utils = require('./utils');
const blocked = require('./data/blocked'); const blocked = require('./data/blocked');
const threads = require('./data/threads'); const threads = require('./data/threads');
const attachments = require('./data/attachments');
const snippets = require('./data/snippets'); const snippets = require('./data/snippets');
const webserver = require('./webserver'); const webserver = require('./webserver');
const greeting = require('./greeting'); const greeting = require('./greeting');
const Thread = require('./data/Thread');
const messageQueue = new Queue(); const messageQueue = new Queue();
/**
* @callback CommandHandlerCB
* @interface
* @param {Eris~Message} msg
* @param {Array} args
* @param {Thread} thread
* @return void
*/
/**
* Adds a command that can only be triggered on the inbox server.
* Command handlers added with this function also get the thread the message was posted in as a third argument, if any.
* @param {String} cmd
* @param {CommandHandlerCB} commandHandler
* @param {Eris~CommandOptions} opts
*/
function addInboxServerCommand(cmd, commandHandler, opts) {
bot.registerCommand(cmd, async (msg, args) => {
if (! messageIsOnInboxServer(msg)) return;
if (! isStaff(msg.member)) return;
const thread = await threads.findByChannelId(msg.channel.id);
commandHandler(msg, args, thread);
}, opts);
}
// Once the bot has connected, set the status/"playing" message // Once the bot has connected, set the status/"playing" message
bot.on('ready', () => { bot.on('ready', () => {
bot.editStatus(null, {name: config.status}); bot.editStatus(null, {name: config.status});
console.log('Bot started, listening to DMs'); console.log('Bot started, listening to DMs');
}); });
// If the alwaysReply option is set to true, send all messages in modmail threads as replies, unless they start with a command prefix // Handle moderator messages in thread channels
if (config.alwaysReply) { bot.on('messageCreate', async msg => {
bot.on('messageCreate', msg => {
if (! utils.messageIsOnInboxServer(msg)) return; if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg)) return; if (! utils.isStaff(msg)) return;
if (msg.author.bot) return; if (msg.author.bot) return;
if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return; if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return;
reply(msg, msg.content.trim(), config.alwaysReplyAnon || false); const thread = await threads.findByChannelId(msg.channel.id);
if (! thread) return;
if (config.alwaysReply) {
// AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies
await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false);
msg.delete();
} else {
// Otherwise just save the messages as "chat" in the logs
thread.addThreadMessageToDB({
message_type: threads.THREAD_MESSAGE_TYPE.CHAT,
user_id: msg.author.id,
user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: msg.content,
original_message_id: msg.id
}); });
} }
});
// "Bot was mentioned in #general-discussion" // If the bot is mentioned on the main server, post a log message about it
bot.on('messageCreate', async msg => { bot.on('messageCreate', async msg => {
if (! utils.messageIsOnMainServer(msg)) return; if (! utils.messageIsOnMainServer(msg)) return;
if (! msg.mentions.some(user => user.id === bot.user.id)) return; if (! msg.mentions.some(user => user.id === bot.user.id)) return;
@ -58,79 +96,14 @@ bot.on('messageCreate', async msg => {
if (await blocked.isBlocked(msg.author.id)) return; if (await blocked.isBlocked(msg.author.id)) return;
// Download and save copies of attachments in the background
const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg);
let threadCreationFailed = false;
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
messageQueue.add(async () => { messageQueue.add(async () => {
let thread; let thread = await threads.findOpenThreadByUserId(msg.author.id);
// Find the corresponding modmail thread
try {
thread = await threads.getOpenThreadForUser(msg.author, true, msg);
} catch (e) {
console.error(e);
utils.postError(`
Modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) could not be created:
\`\`\`${e.message}\`\`\`
Here's what their message contained:
\`\`\`${msg.cleanContent}\`\`\``);
return;
}
if (! thread) { if (! thread) {
// If there's no thread returned, this message was probably ignored (e.g. due to a common word) thread = await threads.createNewThreadForUser(msg.author, msg);
// TODO: Move that logic here instead?
return;
} }
if (thread._wasCreated) { thread.receiveUserReply(msg);
const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(msg.author.id) : null);
if (! member) console.log(`[INFO] Member ${msg.author.id} not found in main guild ${config.mainGuildId}`);
let mainGuildNickname = null;
if (member && member.nick) mainGuildNickname = member.nick;
else if (member && member.user) mainGuildNickname = member.user.username;
else if (member == null) mainGuildNickname = 'NOT ON SERVER';
if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN';
const userLogs = await logs.getLogsByUserId(msg.author.id);
const accountAge = humanizeDuration(Date.now() - msg.author.createdAt, {largest: 2});
const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${msg.author.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogs.length}**\n-------------------------------`;
await bot.createMessage(thread.channelId, infoHeader);
// Ping mods of the new thread
await bot.createMessage(thread.channelId, {
content: `@here New modmail thread (${msg.author.username}#${msg.author.discriminator})`,
disableEveryone: false,
});
// Send an automatic reply to the user informing them of the successfully created modmail thread
msg.channel.createMessage(config.responseMessage).catch(err => {
utils.postError(`There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}); consider messaging manually`);
});
}
const timestamp = utils.getTimestamp();
const attachmentsPendingStr = '\n\n*Attachments pending...*';
let content = msg.content;
if (msg.attachments.length > 0) content += attachmentsPendingStr;
const createdMsg = await bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`);
if (msg.attachments.length > 0) {
await attachmentSavePromise;
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
createdMsg.edit(createdMsg.content.replace(attachmentsPendingStr, attachmentMsg));
}
}); });
}); });
@ -150,122 +123,39 @@ bot.on('messageUpdate', async (msg, oldMessage) => {
// Ignore bogus edit events with no changes // Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return; if (newContent.trim() === oldContent.trim()) return;
const thread = await threads.getOpenThreadForUser(msg.author); const thread = await threads.createNewThreadForUser(msg.author);
if (! thread) return; if (! thread) return;
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`); const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
bot.createMessage(thread.channelId, editMessage); bot.createMessage(thread.channelId, editMessage);
}); });
/**
* Sends a reply to the modmail thread where `msg` was posted.
* @param {Eris.Message} msg
* @param {string} text
* @param {bool} anonymous
* @returns {Promise<void>}
*/
async function reply(msg, text, anonymous = false) {
const thread = await threads.getByChannelId(msg.channel.id);
if (! thread) return;
await attachments.saveAttachmentsInMessage(msg);
const dmChannel = await bot.getDMChannel(thread.userId);
let modUsername, logModUsername;
const mainRole = utils.getMainRole(msg.member);
if (anonymous) {
modUsername = (mainRole ? mainRole.name : 'Moderator');
logModUsername = `(Anonymous) (${msg.author.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
} else {
const name = (config.useNicknames ? msg.member.nick || msg.author.username : msg.author.username);
modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
logModUsername = modUsername;
}
let content = `**${modUsername}:** ${text}`;
let logContent = `**${logModUsername}:** ${text}`;
async function sendMessage(file, attachmentUrl) {
try {
await dmChannel.createMessage(content, file);
} catch (e) {
if (e.resp && e.resp.statusCode === 403) {
msg.channel.createMessage(`Could not send reply; the user has likely left the server or blocked the bot`);
} else if (e.resp) {
msg.channel.createMessage(`Could not send reply; error code ${e.resp.statusCode}`);
} else {
msg.channel.createMessage(`Could not send reply: ${e.toString()}`);
}
}
if (attachmentUrl) {
content += `\n\n**Attachment:** ${attachmentUrl}`;
logContent += `\n\n**Attachment:** ${attachmentUrl}`;
}
// Show the message in the modmail thread as well
msg.channel.createMessage(`[${utils.getTimestamp()}] » ${logContent}`);
msg.delete();
};
if (msg.attachments.length > 0) {
// If the reply has an attachment, relay it as is
fs.readFile(attachments.getPath(msg.attachments[0].id), async (err, data) => {
const file = {file: data, name: msg.attachments[0].filename};
const attachmentUrl = await attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename);
sendMessage(file, attachmentUrl);
});
} else {
// Otherwise just send the message regularly
sendMessage();
}
}
// Mods can reply to modmail threads using !r or !reply // 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 // These messages get relayed back to the DM thread between the bot and the user
utils.addInboxCommand('reply', (msg, args) => { addInboxServerCommand('reply', (msg, args, thread) => {
if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
reply(msg, text, false); thread.replyToUser(msg.member, text, msg.attachments, false);
}); });
bot.registerCommandAlias('r', 'reply'); bot.registerCommandAlias('r', 'reply');
// Anonymous replies only show the role, not the username // Anonymous replies only show the role, not the username
utils.addInboxCommand('anonreply', (msg, args) => { addInboxServerCommand('anonreply', (msg, args, thread) => {
if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
reply(msg, text, true); thread.replyToUser(msg.member, text, msg.attachments, true);
}); });
bot.registerCommandAlias('ar', 'anonreply'); bot.registerCommandAlias('ar', 'anonreply');
// Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel. // Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
utils.addInboxCommand('close', async (msg, args, thread) => { addInboxServerCommand('close', async (msg, args, thread) => {
if (! thread) return; if (! thread) return;
thread.close();
await msg.channel.createMessage('Saving logs and closing channel...');
const logMessages = await msg.channel.getMessages(10000);
const log = logMessages.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';
const logFilename = await logs.getNewLogFile(thread.userId);
await logs.saveLogFile(logFilename, log);
const logUrl = await logs.getLogFileUrl(logFilename);
const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.username}
Logs: <${logUrl}>`;
bot.createMessage(utils.getLogChannel(bot).id, closeMessage);
await threads.closeByChannelId(thread.channelId);
msg.channel.delete();
}); });
utils.addInboxCommand('block', (msg, args, thread) => { addInboxServerCommand('block', (msg, args, thread) => {
async function block(userId) { async function block(userId) {
await blocked.block(userId); await blocked.block(userId);
msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`);
@ -282,7 +172,7 @@ utils.addInboxCommand('block', (msg, args, thread) => {
} }
}); });
utils.addInboxCommand('unblock', (msg, args, thread) => { addInboxServerCommand('unblock', (msg, args, thread) => {
async function unblock(userId) { async function unblock(userId) {
await blocked.unblock(userId); await blocked.unblock(userId);
msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`);
@ -299,15 +189,16 @@ utils.addInboxCommand('unblock', (msg, args, thread) => {
} }
}); });
utils.addInboxCommand('logs', (msg, args, thread) => { addInboxServerCommand('logs', (msg, args, thread) => {
async function getLogs(userId) { async function getLogs(userId) {
const infos = await logs.getLogsWithUrlByUserId(userId); const userThreads = await threads.getClosedThreadsByUserId(userId);
let message = `**Log files for <@${userId}>:**\n`; const threadLines = await Promise.all(userThreads.map(async thread => {
const logUrl = await thread.getLogUrl();
const formattedDate = moment.utc(thread.created_at).format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${logUrl}>`;
}));
message += infos.map(info => { const message = `**Log files for <@${userId}>:**\n${threadLines.join('\n')}`;
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');
// Send the list of logs in chunks of 15 lines per message // Send the list of logs in chunks of 15 lines per message
const lines = message.split('\n'); const lines = message.split('\n');
@ -347,7 +238,7 @@ bot.on('messageCreate', async msg => {
}); });
// Show or add a snippet // Show or add a snippet
utils.addInboxCommand('snippet', async (msg, args) => { addInboxServerCommand('snippet', async (msg, args) => {
const shortcut = args[0]; const shortcut = args[0];
if (! shortcut) return if (! shortcut) return
@ -376,7 +267,7 @@ utils.addInboxCommand('snippet', async (msg, args) => {
bot.registerCommandAlias('s', 'snippet'); bot.registerCommandAlias('s', 'snippet');
utils.addInboxCommand('delete_snippet', async (msg, args) => { addInboxServerCommand('delete_snippet', async (msg, args) => {
const shortcut = args[0]; const shortcut = args[0];
if (! shortcut) return; if (! shortcut) return;
@ -392,7 +283,7 @@ utils.addInboxCommand('delete_snippet', async (msg, args) => {
bot.registerCommandAlias('ds', 'delete_snippet'); bot.registerCommandAlias('ds', 'delete_snippet');
utils.addInboxCommand('edit_snippet', async (msg, args) => { addInboxServerCommand('edit_snippet', async (msg, args) => {
const shortcut = args[0]; const shortcut = args[0];
if (! shortcut) return; if (! shortcut) return;
@ -413,7 +304,7 @@ utils.addInboxCommand('edit_snippet', async (msg, args) => {
bot.registerCommandAlias('es', 'edit_snippet'); bot.registerCommandAlias('es', 'edit_snippet');
utils.addInboxCommand('snippets', async msg => { addInboxServerCommand('snippets', async msg => {
const allSnippets = await snippets.all(); const allSnippets = await snippets.all();
const shortcuts = Object.keys(allSnippets); const shortcuts = Object.keys(allSnippets);
shortcuts.sort(); shortcuts.sort();
@ -424,7 +315,7 @@ utils.addInboxCommand('snippets', async msg => {
module.exports = { module.exports = {
start() { start() {
bot.connect(); bot.connect();
webserver.run(); // webserver.run();
greeting.init(bot); greeting.init(bot);
} }
}; };

View File

@ -2,7 +2,6 @@ const Eris = require('eris');
const bot = require('./bot'); const bot = require('./bot');
const moment = require('moment'); const moment = require('moment');
const publicIp = require('public-ip'); const publicIp = require('public-ip');
const threads = require('./data/threads');
const attachments = require('./data/attachments'); const attachments = require('./data/attachments');
const config = require('./config'); const config = require('./config');
@ -86,23 +85,6 @@ function messageIsOnMainServer(msg) {
return true; return true;
} }
/**
* Adds a command that can only be triggered on the inbox server.
* Command handlers added with this function also get the thread the message was posted in as a third argument, if any.
* @param cmd
* @param fn
* @param opts
*/
function addInboxCommand(cmd, fn, opts) {
bot.registerCommand(cmd, async (msg, args) => {
if (! messageIsOnInboxServer(msg)) return;
if (! isStaff(msg.member)) return;
const thread = await threads.getByChannelId(msg.channel.id);
fn(msg, args, thread);
}, opts);
}
/** /**
* @param attachment * @param attachment
* @returns {Promise<string>} * @returns {Promise<string>}
@ -155,16 +137,15 @@ function disableLinkPreviews(str) {
/** /**
* Returns a URL to the bot's web server * Returns a URL to the bot's web server
* @param {String} path * @param {String} path
* @returns {String} * @returns {Promise<String>}
*/ */
function getSelfUrl(path = '') { async function getSelfUrl(path = '') {
if (config.url) { if (config.url) {
return Promise.resolve(`${config.url}/${path}`); return `${config.url}/${path}`;
} else { } else {
const port = config.port || 8890; const port = config.port || 8890;
return publicIp.v4().then(ip => { const ip = await publicIp.v4();
return `http://${ip}:${port}/${path}`; return `http://${ip}:${port}/${path}`;
});
} }
} }
@ -206,7 +187,6 @@ module.exports = {
isStaff, isStaff,
messageIsOnInboxServer, messageIsOnInboxServer,
messageIsOnMainServer, messageIsOnMainServer,
addInboxCommand,
formatAttachment, formatAttachment,