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": {
"ecmaVersion": 8,
"sourceType": "module"
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
},
"env": {

View File

@ -13,10 +13,11 @@ exports.up = async function(knex, Promise) {
table.increments('id');
table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE');
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.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();
});

View File

@ -41,6 +41,8 @@ const defaultConfig = {
"logDir": path.join(__dirname, '..', 'logs'),
};
const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
const finalConfig = Object.assign({}, defaultConfig);
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) {
if (! finalConfig[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

@ -11,15 +11,17 @@ async function isBlocked(userId) {
.where('user_id', userId)
.first();
return !!row;
return !! row;
}
/**
* Blocks the given userId
* @param {String} userId
* @param {String} userName
* @param {String} blockedBy
* @returns {Promise}
*/
async function block(userId, userName = '', blockedBy = 0) {
async function block(userId, userName = '', blockedBy = null) {
if (await isBlocked(userId)) return;
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 knex = require('../knex');
/**
* @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);
}
}
const Snippet = require('./Snippet');
/**
* @param {String} trigger
@ -46,7 +34,7 @@ async function addSnippet(trigger, body, isAnonymous = false, createdBy = 0) {
/**
* @param {String} trigger
* @returns {Promise}
* @returns {Promise<void>}
*/
async function deleteSnippet(trigger) {
return knex('snippets')
@ -54,6 +42,9 @@ async function deleteSnippet(trigger) {
.delete();
}
/**
* @returns {Promise<Snippet[]>}
*/
async function getAllSnippets() {
const snippets = await knex('snippets')
.select();

View File

@ -2,115 +2,39 @@ const Eris = require('eris');
const transliterate = require('transliteration');
const moment = require('moment');
const uuid = require('uuid');
const humanizeDuration = require('humanize-duration');
const bot = require('../bot');
const knex = require('../knex');
const config = require('../config');
const utils = require('../utils');
const getUtils = () => require('../utils');
// 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
};
const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants');
/**
* @property {Number} 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);
}
}
/**
* 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
* @param {String} userId
* @returns {Promise<Thread>}
*/
async function getOpenThreadForUser(user, allowCreate = true, originalMessage = null) {
// Attempt to find an open thread for this user
async function findOpenThreadByUserId(userId) {
const thread = await knex('threads')
.where('user_id', user.id)
.where('user_id', userId)
.where('status', THREAD_STATUS.OPEN)
.select();
if (thread) {
return new Thread(thread);
}
return (thread ? new Thread(thread) : null);
}
// If no open thread was found, and we're not allowed to create one, just return null
if (! allowCreate) {
return null;
}
// No open thread was found, and we *are* allowed to create a new one, so let's do that
// If the message's content matches any of the values in accidentalThreadMessages,
// and config.ignoreAccidentalThreads is enabled, ignore this thread
if (config.ignoreAccidentalThreads && originalMessage && originalMessage.cleanContent) {
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;
}
/**
* Creates a new modmail thread for the specified user
* @param {Eris.User} user
* @returns {Promise<Thread>}
* @throws {Error}
*/
async function createNewThreadForUser(user) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) {
throw new Error('Attempted to create a new thread for a user with an existing open thread!');
}
// 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
let createdChannel;
try {
createdChannel = await getUtils().getInboxGuild().createChannel(channelName);
createdChannel = await utils.getInboxGuild().createChannel(channelName);
if (config.newThreadCategoryId) {
// If a category id for new threads is specified, move the newly created channel there
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
const newThreadId = await create({
const newThreadId = await createThreadInDB({
status: THREAD_STATUS.OPEN,
user_id: user.id,
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')
});
const newThreadObj = new Thread(newThread);
newThreadObj._wasCreated = true;
const newThread = await findById(newThreadId);
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
* @returns {Promise<String>} The ID of the created thread
*/
async function create(data) {
async function createThreadInDB(data) {
const threadId = uuid.v4();
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
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;
}
async function addThreadMessage(threadId, messageType, user, body) {
return knex('thread_messages').insert({
thread_id: threadId,
message_type: messageType,
user_id: (user ? user.id : 0),
user_name: (user ? `${user.username}#${user.discriminator}` : ''),
body,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
/**
* @param {String} id
* @returns {Promise<Thread>}
*/
async function findById(id) {
const row = await knex('threads')
.where('id', id)
.first();
if (! row) return null;
return new Thread(row);
}
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function getByChannelId(channelId) {
async function findByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.first();
@ -190,24 +135,34 @@ async function getByChannelId(channelId) {
}
/**
* Deletes the modmail thread for the given channel id
* @param {String} channelId
* @returns {Promise<void>}
* @param {String} userId
* @returns {Promise<Thread[]>}
*/
async function closeByChannelId(channelId) {
await knex('threads')
.where('channel_id', channelId)
.update({
status: THREAD_STATUS.CLOSED
});
async function getClosedThreadsByUserId(userId) {
const threads = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
.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 = {
getOpenThreadForUser,
getByChannelId,
closeByChannelId,
create,
THREAD_STATUS,
THREAD_MESSAGE_TYPE,
findOpenThreadByUserId,
findByChannelId,
createNewThreadForUser,
getClosedThreadsByUserId,
};

View File

@ -36,7 +36,7 @@ process.on('unhandledRejection', err => {
console.log('=== LEGACY DATA MIGRATION FINISHED ===');
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(`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('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
@ -47,5 +47,5 @@ process.on('unhandledRejection', err => {
}
// Start the bot
// main.start();
main.start();
})();

View File

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

View File

@ -1,7 +1,5 @@
const fs = require('fs');
const Eris = require('eris');
const moment = require('moment');
const humanizeDuration = require('humanize-duration');
const config = require('./config');
const bot = require('./bot');
@ -9,32 +7,72 @@ const Queue = require('./queue');
const utils = require('./utils');
const blocked = require('./data/blocked');
const threads = require('./data/threads');
const attachments = require('./data/attachments');
const snippets = require('./data/snippets');
const webserver = require('./webserver');
const greeting = require('./greeting');
const Thread = require('./data/Thread');
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
bot.on('ready', () => {
bot.editStatus(null, {name: config.status});
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
if (config.alwaysReply) {
bot.on('messageCreate', msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg)) return;
if (msg.author.bot) return;
if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return;
// Handle moderator messages in thread channels
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg)) return;
if (msg.author.bot) 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;
// "Bot was mentioned in #general-discussion"
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
});
}
});
// If the bot is mentioned on the main server, post a log message about it
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnMainServer(msg)) 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;
// 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
messageQueue.add(async () => {
let thread;
// 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;
}
let thread = await threads.findOpenThreadByUserId(msg.author.id);
if (! thread) {
// If there's no thread returned, this message was probably ignored (e.g. due to a common word)
// TODO: Move that logic here instead?
return;
thread = await threads.createNewThreadForUser(msg.author, msg);
}
if (thread._wasCreated) {
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));
}
thread.receiveUserReply(msg);
});
});
@ -150,122 +123,39 @@ bot.on('messageUpdate', async (msg, oldMessage) => {
// Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return;
const thread = await threads.getOpenThreadForUser(msg.author);
const thread = await threads.createNewThreadForUser(msg.author);
if (! thread) return;
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
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
// 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();
reply(msg, text, false);
thread.replyToUser(msg.member, text, msg.attachments, false);
});
bot.registerCommandAlias('r', 'reply');
// 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();
reply(msg, text, true);
thread.replyToUser(msg.member, text, msg.attachments, true);
});
bot.registerCommandAlias('ar', 'anonreply');
// 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;
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();
thread.close();
});
utils.addInboxCommand('block', (msg, args, thread) => {
addInboxServerCommand('block', (msg, args, thread) => {
async function block(userId) {
await blocked.block(userId);
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) {
await blocked.unblock(userId);
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) {
const infos = await logs.getLogsWithUrlByUserId(userId);
let message = `**Log files for <@${userId}>:**\n`;
const userThreads = await threads.getClosedThreadsByUserId(userId);
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 formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${info.url}>`;
}).join('\n');
const message = `**Log files for <@${userId}>:**\n${threadLines.join('\n')}`;
// Send the list of logs in chunks of 15 lines per message
const lines = message.split('\n');
@ -347,7 +238,7 @@ bot.on('messageCreate', async msg => {
});
// Show or add a snippet
utils.addInboxCommand('snippet', async (msg, args) => {
addInboxServerCommand('snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return
@ -376,7 +267,7 @@ utils.addInboxCommand('snippet', async (msg, args) => {
bot.registerCommandAlias('s', 'snippet');
utils.addInboxCommand('delete_snippet', async (msg, args) => {
addInboxServerCommand('delete_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
@ -392,7 +283,7 @@ utils.addInboxCommand('delete_snippet', async (msg, args) => {
bot.registerCommandAlias('ds', 'delete_snippet');
utils.addInboxCommand('edit_snippet', async (msg, args) => {
addInboxServerCommand('edit_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
@ -413,7 +304,7 @@ utils.addInboxCommand('edit_snippet', async (msg, args) => {
bot.registerCommandAlias('es', 'edit_snippet');
utils.addInboxCommand('snippets', async msg => {
addInboxServerCommand('snippets', async msg => {
const allSnippets = await snippets.all();
const shortcuts = Object.keys(allSnippets);
shortcuts.sort();
@ -424,7 +315,7 @@ utils.addInboxCommand('snippets', async msg => {
module.exports = {
start() {
bot.connect();
webserver.run();
// webserver.run();
greeting.init(bot);
}
};

View File

@ -2,7 +2,6 @@ const Eris = require('eris');
const bot = require('./bot');
const moment = require('moment');
const publicIp = require('public-ip');
const threads = require('./data/threads');
const attachments = require('./data/attachments');
const config = require('./config');
@ -86,23 +85,6 @@ function messageIsOnMainServer(msg) {
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
* @returns {Promise<string>}
@ -155,16 +137,15 @@ function disableLinkPreviews(str) {
/**
* Returns a URL to the bot's web server
* @param {String} path
* @returns {String}
* @returns {Promise<String>}
*/
function getSelfUrl(path = '') {
async function getSelfUrl(path = '') {
if (config.url) {
return Promise.resolve(`${config.url}/${path}`);
return `${config.url}/${path}`;
} else {
const port = config.port || 8890;
return publicIp.v4().then(ip => {
return `http://${ip}:${port}/${path}`;
});
const ip = await publicIp.v4();
return `http://${ip}:${port}/${path}`;
}
}
@ -206,7 +187,6 @@ module.exports = {
isStaff,
messageIsOnInboxServer,
messageIsOnMainServer,
addInboxCommand,
formatAttachment,