Continue rewrite. Modularize greeting, snippet, and web server functionality.

master
Dragory 2018-02-11 21:54:30 +02:00
parent bb6d8e5dbf
commit ad7aa66c99
17 changed files with 531 additions and 308 deletions

View File

@ -1,5 +1,11 @@
# Changelog # Changelog
## v2.0.0
* Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any!
* Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run.
* Fixed system messages like pins in DMs being relayed to the thread
* Fixed channels sometimes being created without a category
## Sep 22, 2017 ## Sep 22, 2017
* Added `newThreadCategoryId` option. This option can be set to a category ID to place all new threads in that category. * Added `newThreadCategoryId` option. This option can be set to a category ID to place all new threads in that category.

View File

@ -3,9 +3,9 @@ exports.up = async function(knex, Promise) {
table.string('id', 36).notNullable().primary(); table.string('id', 36).notNullable().primary();
table.integer('status').unsigned().notNullable().index(); table.integer('status').unsigned().notNullable().index();
table.integer('is_legacy').unsigned().notNullable(); table.integer('is_legacy').unsigned().notNullable();
table.bigInteger('user_id').unsigned().notNullable().index(); table.string('user_id', 20).notNullable().index();
table.string('user_name', 128).notNullable(); table.string('user_name', 128).notNullable();
table.bigInteger('channel_id').unsigned().nullable().unique(); table.string('channel_id', 20).nullable().unique();
table.dateTime('created_at').notNullable().index(); table.dateTime('created_at').notNullable().index();
}); });
@ -13,18 +13,18 @@ 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().nullable(); table.string('user_id', 20).nullable();
table.string('user_name', 128).notNullable(); table.string('user_name', 128).notNullable();
table.text('body').notNullable(); table.text('body').notNullable();
table.integer('is_anonymous').unsigned().notNullable(); table.integer('is_anonymous').unsigned().notNullable();
table.bigInteger('original_message_id').unsigned().nullable().unique(); table.string('original_message_id', 20).nullable().unique();
table.dateTime('created_at').notNullable().index(); table.dateTime('created_at').notNullable().index();
}); });
await knex.schema.createTableIfNotExists('blocked_users', table => { await knex.schema.createTableIfNotExists('blocked_users', table => {
table.bigInteger('user_id').unsigned().primary().notNullable(); table.string('user_id', 20).primary().notNullable();
table.string('user_name', 128).notNullable(); table.string('user_name', 128).notNullable();
table.bigInteger('blocked_by').unsigned().nullable(); table.string('blocked_by', 20).nullable();
table.dateTime('blocked_at').notNullable(); table.dateTime('blocked_at').notNullable();
}); });
@ -32,7 +32,7 @@ exports.up = async function(knex, Promise) {
table.string('trigger', 32).primary().notNullable(); table.string('trigger', 32).primary().notNullable();
table.text('body').notNullable(); table.text('body').notNullable();
table.integer('is_anonymous').unsigned().notNullable(); table.integer('is_anonymous').unsigned().notNullable();
table.bigInteger('created_by').unsigned().nullable(); table.string('created_by', 20).nullable();
table.dateTime('created_at').notNullable(); table.dateTime('created_at').notNullable();
}); });
}; };

30
package-lock.json generated
View File

@ -413,13 +413,13 @@
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
}, },
"eris": { "eris": {
"version": "0.7.2", "version": "0.8.4",
"resolved": "https://registry.npmjs.org/eris/-/eris-0.7.2.tgz", "resolved": "https://registry.npmjs.org/eris/-/eris-0.8.4.tgz",
"integrity": "sha512-YcgXHH81tk9/nbnwZZ47cQVtaAySjIJi/JJFt0lbIMTHhHa77zo682nLIJrBbU5P8u9LQUbWoqiFA/NabR3qww==", "integrity": "sha512-mhQlh5iamo3Ls8xk1xJsu9rHgiW7wkR79e76Nuhrwu1fswnmC7WA/KypGpI51G5h9BFPMxSeFYXy+tyladhJtQ==",
"requires": { "requires": {
"opusscript": "0.0.3", "opusscript": "0.0.4",
"tweetnacl": "1.0.0", "tweetnacl": "1.0.0",
"ws": "3.2.0" "ws": "3.3.3"
} }
}, },
"error-ex": { "error-ex": {
@ -1668,9 +1668,9 @@
} }
}, },
"opusscript": { "opusscript": {
"version": "0.0.3", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.3.tgz", "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.4.tgz",
"integrity": "sha1-zkZxf8jW+QHFGR5pSFyImgNqhnQ=", "integrity": "sha512-bEPZFE2lhUJYQD5yfTFO4RhbRZ937x6hRwBC1YoGacT35bwDVwKFP1+amU8NYfZL/v4EU7ZTU3INTqzYAnuP7Q==",
"optional": true "optional": true
}, },
"os-homedir": { "os-homedir": {
@ -3006,9 +3006,9 @@
"dev": true "dev": true
}, },
"ultron": { "ultron": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.0.tgz", "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",
"integrity": "sha1-sHoualQagV/Go0zNRTO67DB8qGQ=" "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og=="
}, },
"unc-path-regex": { "unc-path-regex": {
"version": "0.1.2", "version": "0.1.2",
@ -3120,13 +3120,13 @@
} }
}, },
"ws": { "ws": {
"version": "3.2.0", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-3.2.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz",
"integrity": "sha512-hTS3mkXm/j85jTQOIcwVz3yK3up9xHgPtgEhDBOH3G18LDOZmSAG1omJeXejLKJakx+okv8vS1sopgs7rw0kVw==", "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==",
"requires": { "requires": {
"async-limiter": "1.0.0", "async-limiter": "1.0.0",
"safe-buffer": "5.1.1", "safe-buffer": "5.1.1",
"ultron": "1.1.0" "ultron": "1.1.1"
}, },
"dependencies": { "dependencies": {
"safe-buffer": { "safe-buffer": {

View File

@ -1,17 +1,17 @@
{ {
"name": "modmailbot", "name": "modmailbot",
"version": "1.0.0", "version": "2.0.0",
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node --trace-warnings src/index.js", "start": "node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"lint": "./node_modules/.bin/eslint ./src" "lint": "./node_modules/.bin/eslint ./src"
}, },
"author": "", "author": "",
"dependencies": { "dependencies": {
"eris": "^0.7.2", "eris": "^0.8.4",
"humanize-duration": "^3.10.0", "humanize-duration": "^3.10.0",
"knex": "^0.14.2", "knex": "^0.14.2",
"mime": "^1.3.4", "mime": "^1.3.4",

View File

@ -1,8 +1,13 @@
const moment = require('moment');
const bot = require('../bot'); const bot = require('../bot');
const knex = require('../knex'); const knex = require('../knex');
const utils = require('../utils'); const utils = require('../utils');
const config = require('../config');
const attachments = require('./attachments'); const attachments = require('./attachments');
const ThreadMessage = require('./ThreadMessage');
const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants'); const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
/** /**
@ -12,17 +17,16 @@ const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
* @property {String} user_name * @property {String} user_name
* @property {String} channel_id * @property {String} channel_id
* @property {String} created_at * @property {String} created_at
* @property {Boolean} _wasCreated
*/ */
class Thread { class Thread {
constructor(props) { constructor(props) {
Object.assign(this, {_wasCreated: false}, props); Object.assign(this, props);
} }
/** /**
* @param {Eris.Member} moderator * @param {Eris~Member} moderator
* @param {String} text * @param {String} text
* @param {Eris.Attachment[]} replyAttachments * @param {Eris~Attachment[]} replyAttachments
* @param {Boolean} isAnonymous * @param {Boolean} isAnonymous
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
@ -81,6 +85,7 @@ class Thread {
user_id: moderator.id, user_id: moderator.id,
user_name: logModUsername, user_name: logModUsername,
body: logContent, body: logContent,
is_anonymous: (isAnonymous ? 1 : 0),
original_message_id: originalMessage.id original_message_id: originalMessage.id
}); });
} }
@ -92,7 +97,12 @@ class Thread {
async receiveUserReply(msg) { async receiveUserReply(msg) {
const timestamp = utils.getTimestamp(); const timestamp = utils.getTimestamp();
let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${msg.content}`; let content = msg.content;
if (msg.content.trim() === '' && msg.embeds.length) {
content = '<message contains embeds>';
}
let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`;
let logContent = msg.content; let logContent = msg.content;
let finalThreadContent; let finalThreadContent;
let attachmentSavePromise; let attachmentSavePromise;
@ -113,6 +123,7 @@ class Thread {
user_id: this.user_id, user_id: this.user_id,
user_name: `${msg.author.username}#${msg.author.discriminator}`, user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: logContent, body: logContent,
is_anonymous: 0,
original_message_id: msg.id original_message_id: msg.id
}); });
@ -128,8 +139,7 @@ class Thread {
* @returns {Promise<Eris.Message>} * @returns {Promise<Eris.Message>}
*/ */
async postToThreadChannel(text, file = null) { async postToThreadChannel(text, file = null) {
const channel = bot.getChannel(this.channel_id); return bot.createMessage(this.channel_id, text, file);
return channel.createMessage(text, file);
} }
/** /**
@ -143,10 +153,58 @@ class Thread {
user_id: null, user_id: null,
user_name: '', user_name: '',
body: text, body: text,
is_anonymous: 0,
original_message_id: msg.id original_message_id: msg.id
}); });
} }
/**
* @param {String} text
* @returns {Promise<void>}
*/
async postNonLogMessage(text) {
await this.postToThreadChannel(text);
}
/**
* @param {Eris.Message} msg
* @returns {Promise<void>}
*/
async saveChatMessage(msg) {
return this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.CHAT,
user_id: msg.author.id,
user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: msg.content,
is_anonymous: 0,
original_message_id: msg.id
});
}
/**
* @param {Eris.Message} msg
* @returns {Promise<void>}
*/
async updateChatMessage(msg) {
await knex('thread_messages')
.where('thread_id', this.id)
.where('original_message_id', msg.id)
.update({
content: msg.content
});
}
/**
* @param {String} messageId
* @returns {Promise<void>}
*/
async deleteChatMessage(messageId) {
await knex('thread_messages')
.where('thread_id', this.id)
.where('original_message_id', messageId)
.delete();
}
/** /**
* @param {Object} data * @param {Object} data
* @returns {Promise<void>} * @returns {Promise<void>}
@ -155,14 +213,29 @@ class Thread {
await knex('thread_messages').insert({ await knex('thread_messages').insert({
thread_id: this.id, thread_id: this.id,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'), created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
is_anonymous: 0,
...data ...data
}); });
} }
/**
* @returns {Promise<ThreadMessage[]>}
*/
async getThreadMessages() {
const threadMessages = await knex('thread_messages')
.where('thread_id', this.id)
.orderBy('created_at', 'ASC')
.orderBy('id', 'ASC')
.select();
return threadMessages.map(row => new ThreadMessage(row));
}
/** /**
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async close() { async close() {
console.log(`Closing thread ${this.id}`);
await this.postToThreadChannel('Closing thread...'); await this.postToThreadChannel('Closing thread...');
// Update DB status // Update DB status
@ -173,9 +246,10 @@ class Thread {
}); });
// Delete channel // Delete channel
console.log(`Deleting channel ${this.channel_id}`);
const channel = bot.getChannel(this.channel_id); const channel = bot.getChannel(this.channel_id);
if (channel) { if (channel) {
channel.delete('Thread closed'); await channel.delete('Thread closed');
} }
} }

18
src/data/ThreadMessage.js Normal file
View File

@ -0,0 +1,18 @@
/**
* @property {Number} id
* @property {String} thread_id
* @property {Number} message_type
* @property {String} user_id
* @property {String} user_name
* @property {String} body
* @property {Number} is_anonymous
* @property {Number} original_message_id
* @property {String} created_at
*/
class ThreadMessage {
constructor(props) {
Object.assign(this, props);
}
}
module.exports = ThreadMessage;

View File

@ -5,7 +5,7 @@ const config = require('../config');
const getUtils = () => require('../utils'); const getUtils = () => require('../utils');
const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`; const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;
/** /**
* Returns the filesystem path for the given attachment id * Returns the filesystem path for the given attachment id
@ -36,7 +36,7 @@ function saveAttachment(attachment, tries = 0) {
https.get(attachment.url, (res) => { https.get(attachment.url, (res) => {
res.pipe(writeStream); res.pipe(writeStream);
writeStream.on('finish', () => { writeStream.on('finish', () => {
writeStream.closeByChannelId() writeStream.end();
resolve(); resolve();
}); });
}).on('error', (err) => { }).on('error', (err) => {

View File

@ -12,6 +12,14 @@ const utils = require('../utils');
const Thread = require('./Thread'); const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants'); const {THREAD_STATUS} = require('./constants');
async function findById(id) {
const thread = await knex('threads')
.where('id', id)
.first();
return (thread ? new Thread(thread) : null);
}
/** /**
* @param {String} userId * @param {String} userId
* @returns {Promise<Thread>} * @returns {Promise<Thread>}
@ -20,7 +28,7 @@ async function findOpenThreadByUserId(userId) {
const thread = await knex('threads') const thread = await knex('threads')
.where('user_id', userId) .where('user_id', userId)
.where('status', THREAD_STATUS.OPEN) .where('status', THREAD_STATUS.OPEN)
.select(); .first();
return (thread ? new Thread(thread) : null); return (thread ? new Thread(thread) : null);
} }
@ -50,11 +58,7 @@ async function createNewThreadForUser(user) {
// 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 utils.getInboxGuild().createChannel(channelName); createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New ModMail thread', config.newThreadCategoryId);
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});
}
} catch (err) { } catch (err) {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
throw err; throw err;
@ -71,6 +75,10 @@ async function createNewThreadForUser(user) {
const newThread = await findById(newThreadId); const newThread = await findById(newThreadId);
// Post the log link to the beginning (but don't save it in thread messages)
const logUrl = await newThread.getLogUrl();
await newThread.postNonLogMessage(`Log URL: <${logUrl}>`);
// Post some info to the beginning of the new thread // Post some info to the beginning of the new thread
const mainGuild = utils.getMainGuild(); const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(user.id) : null); const member = (mainGuild ? mainGuild.members.get(user.id) : null);
@ -134,6 +142,19 @@ async function findByChannelId(channelId) {
return (thread ? new Thread(thread) : null); return (thread ? new Thread(thread) : null);
} }
/**
* @param {String} channelId
* @returns {Promise<Thread>}
*/
async function findOpenThreadByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.where('status', THREAD_STATUS.OPEN)
.first();
return (thread ? new Thread(thread) : null);
}
/** /**
* @param {String} userId * @param {String} userId
* @returns {Promise<Thread[]>} * @returns {Promise<Thread[]>}
@ -160,9 +181,20 @@ async function getClosedThreadCountByUserId(userId) {
return parseInt(row.thread_count, 10); return parseInt(row.thread_count, 10);
} }
async function findOrCreateThreadForUser(user) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) return existingThread;
return createNewThreadForUser(user);
}
module.exports = { module.exports = {
findById,
findOpenThreadByUserId, findOpenThreadByUserId,
findByChannelId, findByChannelId,
findOpenThreadByChannelId,
createNewThreadForUser, createNewThreadForUser,
getClosedThreadsByUserId, getClosedThreadsByUserId,
findOrCreateThreadForUser,
createThreadInDB
}; };

View File

@ -1,4 +1,21 @@
// Verify NodeJS version
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
if (nodeMajorVersion < 8) {
console.error('Unsupported NodeJS version! Please install NodeJS 8 or newer.');
process.exit(1);
}
// Verify node modules have been installed
const fs = require('fs');
const path = require('path'); const path = require('path');
try {
fs.accessSync(path.join(__dirname, '..', 'node_modules'));
} catch (e) {
console.error('Please run "npm install" before trying to start the bot.');
process.exit(1);
}
const config = require('./config'); const config = require('./config');
const utils = require('./utils'); const utils = require('./utils');
const main = require('./main'); const main = require('./main');

View File

@ -9,6 +9,8 @@ const config = require('../config');
const jsonDb = require('./jsonDb'); const jsonDb = require('./jsonDb');
const threads = require('../data/threads'); const threads = require('../data/threads');
const {THREAD_STATUS, THREAD_MESSAGE_TYPE} = require('../data/constants');
const readDir = promisify(fs.readdir); const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile); const readFile = promisify(fs.readFile);
const access = promisify(fs.access); const access = promisify(fs.access);
@ -75,7 +77,7 @@ async function migrateOpenThreads() {
if (existingOpenThread) return; if (existingOpenThread) return;
const newThread = { const newThread = {
status: threads.THREAD_STATUS.OPEN, status: THREAD_STATUS.OPEN,
user_id: oldThread.userId, user_id: oldThread.userId,
user_name: oldThread.username, user_name: oldThread.username,
channel_id: oldThread.channelId, channel_id: oldThread.channelId,
@ -103,7 +105,7 @@ async function migrateLogs() {
const newThread = { const newThread = {
id: threadId, id: threadId,
status: threads.THREAD_STATUS.CLOSED, status: THREAD_STATUS.CLOSED,
user_id: userId, user_id: userId,
user_name: '', user_name: '',
channel_id: null, channel_id: null,
@ -122,10 +124,11 @@ async function migrateLogs() {
await trx('thread_messages').insert({ await trx('thread_messages').insert({
thread_id: newThread.id, thread_id: newThread.id,
message_type: threads.THREAD_MESSAGE_TYPE.LEGACY, message_type: THREAD_MESSAGE_TYPE.LEGACY,
user_id: userId, user_id: userId,
user_name: '', user_name: '',
body: contents, body: contents,
is_anonymous: 0,
created_at: date created_at: date
}); });
}); });

View File

@ -5,48 +5,28 @@ const config = require('./config');
const bot = require('./bot'); const bot = require('./bot');
const Queue = require('./queue'); const Queue = require('./queue');
const utils = require('./utils'); const utils = require('./utils');
const threadUtils = require('./threadUtils');
const blocked = require('./data/blocked'); const blocked = require('./data/blocked');
const threads = require('./data/threads'); const threads = require('./data/threads');
const snippets = require('./data/snippets');
const webserver = require('./webserver'); const snippets = require('./plugins/snippets');
const greeting = require('./greeting'); const webserver = require('./plugins/webserver');
const Thread = require('./data/Thread'); const greeting = require('./plugins/greeting');
const messageQueue = new Queue(); const messageQueue = new Queue();
/** const addInboxServerCommand = (...args) => threadUtils.addInboxServerCommand(bot, ...args);
* @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');
}); });
// Handle moderator messages in thread channels /**
* When a moderator posts in a modmail thread...
* 1) If alwaysReply is enabled, reply to the user
* 2) If alwaysReply is disabled, save that message as a chat message in the thread
*/
bot.on('messageCreate', async msg => { bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return; if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg)) return; if (! utils.isStaff(msg)) return;
@ -62,17 +42,81 @@ bot.on('messageCreate', async msg => {
msg.delete(); msg.delete();
} else { } else {
// Otherwise just save the messages as "chat" in the logs // Otherwise just save the messages as "chat" in the logs
thread.addThreadMessageToDB({ thread.saveChatMessage(msg);
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 /**
* When we get a private message...
* 1) Find the open modmail thread for this user, or create a new one
* 2) Post the message as a user reply in the thread
*/
bot.on('messageCreate', async msg => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.bot) return;
if (msg.type !== 0) return; // Ignore pins etc.
if (await blocked.isBlocked(msg.author.id)) return;
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
messageQueue.add(async () => {
const thread = await threads.findOrCreateThreadForUser(msg.author);
await thread.receiveUserReply(msg);
});
});
/**
* When a message is edited...
* 1) If that message was in DMs, and we have a thread open with that user, post the edit as a system message in the thread
* 2) If that message was moderator chatter in the thread, update the corresponding chat message in the DB
*/
bot.on('messageUpdate', async (msg, oldMessage) => {
if (msg.author.bot) return;
if (await blocked.isBlocked(msg.author.id)) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
// Old message content doesn't persist between bot restarts
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
// Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return;
// 1) Edit in DMs
if (msg.channel instanceof Eris.PrivateChannel) {
const thread = await threads.findOpenThreadByUserId(msg.author.id);
const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
thread.postSystemMessage(editMessage);
}
// 2) Edit in the thread
else if (utils.messageIsOnInboxServer(msg) && utils.isStaff(msg.member)) {
const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
if (! thread) return;
thread.updateChatMessage(msg);
}
});
/**
* When a staff message is deleted in a modmail thread, delete it from the database as well
*/
bot.on('messageDelete', async msg => {
if (msg.author.bot) return;
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
if (! thread) return;
thread.deleteChatMessage(msg.id);
});
/**
* When the bot is mentioned on the main server, ping staff in the log channel 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;
@ -89,62 +133,25 @@ bot.on('messageCreate', async msg => {
}); });
}); });
// When we get a private message, forward the contents to the corresponding modmail thread
bot.on('messageCreate', async msg => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
// 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 = await threads.findOpenThreadByUserId(msg.author.id);
if (! thread) {
thread = await threads.createNewThreadForUser(msg.author, msg);
}
thread.receiveUserReply(msg);
});
});
// Edits in DMs
bot.on('messageUpdate', async (msg, oldMessage) => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
let oldContent = oldMessage.content;
const newContent = msg.content;
// Old message content doesn't persist between bot restarts
if (oldContent == null) oldContent = '*Unavailable due to bot restart*';
// Ignore bogus edit events with no changes
if (newContent.trim() === oldContent.trim()) return;
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);
});
// 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
addInboxServerCommand('reply', (msg, args, thread) => { addInboxServerCommand('reply', async (msg, args, thread) => {
if (! thread) return; if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
thread.replyToUser(msg.member, text, msg.attachments, false); await thread.replyToUser(msg.member, text, msg.attachments, false);
msg.delete();
}); });
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
addInboxServerCommand('anonreply', (msg, args, thread) => { addInboxServerCommand('anonreply', async (msg, args, thread) => {
if (! thread) return; if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
thread.replyToUser(msg.member, text, msg.attachments, true); await thread.replyToUser(msg.member, text, msg.attachments, true);
msg.delete();
}); });
bot.registerCommandAlias('ar', 'anonreply'); bot.registerCommandAlias('ar', 'anonreply');
@ -168,7 +175,7 @@ addInboxServerCommand('block', (msg, args, thread) => {
block(userId); block(userId);
} else if (thread) { } else if (thread) {
// Calling !block without args in a modmail thread blocks the user of that thread // Calling !block without args in a modmail thread blocks the user of that thread
block(thread.userId); block(thread.user_id);
} }
}); });
@ -185,7 +192,7 @@ addInboxServerCommand('unblock', (msg, args, thread) => {
unblock(userId); unblock(userId);
} else if (thread) { } else if (thread) {
// Calling !unblock without args in a modmail thread unblocks the user of that thread // Calling !unblock without args in a modmail thread unblocks the user of that thread
unblock(thread.userId); unblock(thread.user_id);
} }
}); });
@ -217,105 +224,21 @@ addInboxServerCommand('logs', (msg, args, thread) => {
getLogs(userId); getLogs(userId);
} else if (thread) { } else if (thread) {
// Calling !logs without args in a modmail thread returns the logs of the user of that thread // Calling !logs without args in a modmail thread returns the logs of the user of that thread
getLogs(thread.userId); getLogs(thread.user_id);
} }
}); });
// Snippets
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
if (msg.author.bot) return;
if (! msg.content) return;
if (! msg.content.startsWith(config.snippetPrefix)) return;
const shortcut = msg.content.replace(config.snippetPrefix, '').toLowerCase();
const snippet = await snippets.get(shortcut);
if (! snippet) return;
reply(msg, snippet.text, snippet.isAnonymous);
});
// Show or add a snippet
addInboxServerCommand('snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return
const text = args.slice(1).join(' ').trim();
const snippet = await snippets.get(shortcut);
if (snippet) {
if (text) {
// If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
msg.channel.createMessage(`Snippet "${shortcut}" already exists! You can edit or delete it with ${prefix}edit_snippet and ${prefix}delete_snippet respectively.`);
} else {
// If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
msg.channel.createMessage(`\`${config.snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`);
}
} else {
if (text) {
// If the snippet doesn't exist and the user wants to create it, create it
await snippets.add(shortcut, text, false);
msg.channel.createMessage(`Snippet "${shortcut}" created!`);
} else {
// If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist! You can create it with \`${prefix}snippet ${shortcut} text\``);
}
}
});
bot.registerCommandAlias('s', 'snippet');
addInboxServerCommand('delete_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
msg.channel.createMessage(`Snippet "${shortcut}" deleted!`);
});
bot.registerCommandAlias('ds', 'delete_snippet');
addInboxServerCommand('edit_snippet', async (msg, args) => {
const shortcut = args[0];
if (! shortcut) return;
const text = args.slice(1).join(' ').trim();
if (! text) return;
const snippet = await snippets.get(shortcut);
if (! snippet) {
msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`);
return;
}
await snippets.del(shortcut);
await snippets.add(shortcut, text, snippet.isAnonymous);
msg.channel.createMessage(`Snippet "${shortcut}" edited!`);
});
bot.registerCommandAlias('es', 'edit_snippet');
addInboxServerCommand('snippets', async msg => {
const allSnippets = await snippets.all();
const shortcuts = Object.keys(allSnippets);
shortcuts.sort();
msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${shortcuts.join(', ')}`);
});
module.exports = { module.exports = {
start() { async start() {
bot.connect(); // Load plugins
// webserver.run(); console.log('Loading plugins...');
greeting.init(bot); await snippets(bot);
await greeting(bot);
await webserver(bot);
console.log('Connecting to Discord...');
await bot.connect();
console.log('Done! Now listening to DMs.');
} }
}; };

View File

@ -1,12 +1,12 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const config = require('./config'); const config = require('../config');
const greetingGuildId = config.mainGuildId || config.greetingGuildId; module.exports = bot => {
function init(bot) {
if (! config.enableGreeting) return; if (! config.enableGreeting) return;
const greetingGuildId = config.mainGuildId || config.greetingGuildId;
bot.on('guildMemberAdd', (guild, member) => { bot.on('guildMemberAdd', (guild, member) => {
if (guild.id !== greetingGuildId) return; if (guild.id !== greetingGuildId) return;
@ -24,11 +24,7 @@ function init(bot) {
sendGreeting(file); sendGreeting(file);
}); });
} else { } else {
sendGreeting(); sendGreeting();
} }
}); });
}
module.exports = {
init,
}; };

106
src/plugins/snippets.js Normal file
View File

@ -0,0 +1,106 @@
const threads = require('../data/threads');
const snippets = require('../data/snippets');
const config = require('../config');
const utils = require('../utils');
const threadUtils = require('../threadUtils');
module.exports = bot => {
const addInboxServerCommand = (...args) => threadUtils.addInboxServerCommand(bot, ...args);
/**
* When a staff member uses a snippet (snippet prefix + trigger word), find the snippet and post it as a reply in the thread
*/
bot.on('messageCreate', async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
if (msg.author.bot) return;
if (! msg.content) return;
if (! msg.content.startsWith(config.snippetPrefix)) return;
const thread = await threads.findByChannelId(msg.channel.id);
if (! thread) return;
const trigger = msg.content.replace(config.snippetPrefix, '').toLowerCase();
const snippet = await snippets.get(trigger);
if (! snippet) return;
await thread.replyToUser(msg.member, snippet.body, [], !! snippet.is_anonymous);
msg.delete();
});
// Show or add a snippet
addInboxServerCommand('snippet', async (msg, args) => {
const trigger = args[0];
if (! trigger) return
const text = args.slice(1).join(' ').trim();
const snippet = await snippets.get(trigger);
if (snippet) {
if (text) {
// If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
msg.channel.createMessage(`Snippet "${trigger}" already exists! You can edit or delete it with ${config.prefix}edit_snippet and ${config.prefix}delete_snippet respectively.`);
} else {
// If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
msg.channel.createMessage(`\`${config.snippetPrefix}${trigger}\` replies ${snippet.is_anonymous ? 'anonymously ' : ''}with:\n${snippet.body}`);
}
} else {
if (text) {
// If the snippet doesn't exist and the user wants to create it, create it
await snippets.add(trigger, text, false);
msg.channel.createMessage(`Snippet "${trigger}" created!`);
} else {
// If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
msg.channel.createMessage(`Snippet "${trigger}" doesn't exist! You can create it with \`${config.prefix}snippet ${trigger} text\``);
}
}
});
bot.registerCommandAlias('s', 'snippet');
addInboxServerCommand('delete_snippet', async (msg, args) => {
const trigger = args[0];
if (! trigger) return;
const snippet = await snippets.get(trigger);
if (! snippet) {
msg.channel.createMessage(`Snippet "${trigger}" doesn't exist!`);
return;
}
await snippets.del(trigger);
msg.channel.createMessage(`Snippet "${trigger}" deleted!`);
});
bot.registerCommandAlias('ds', 'delete_snippet');
addInboxServerCommand('edit_snippet', async (msg, args) => {
const trigger = args[0];
if (! trigger) return;
const text = args.slice(1).join(' ').trim();
if (! text) return;
const snippet = await snippets.get(trigger);
if (! snippet) {
msg.channel.createMessage(`Snippet "${trigger}" doesn't exist!`);
return;
}
await snippets.del(trigger);
await snippets.add(trigger, text, snippet.isAnonymous);
msg.channel.createMessage(`Snippet "${trigger}" edited!`);
});
bot.registerCommandAlias('es', 'edit_snippet');
addInboxServerCommand('snippets', async msg => {
const allSnippets = await snippets.all();
const triggers = allSnippets.map(s => s.trigger);
triggers.sort();
msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`);
});
};

88
src/plugins/webserver.js Normal file
View File

@ -0,0 +1,88 @@
const http = require('http');
const mime = require('mime');
const url = require('url');
const fs = require('fs');
const moment = require('moment');
const config = require('../config');
const threads = require('../data/threads');
const attachments = require('../data/attachments');
const {THREAD_MESSAGE_TYPE} = require('../data/constants');
function notfound(res) {
res.statusCode = 404;
res.end('Page Not Found');
}
async function serveLogs(res, pathParts) {
const threadId = pathParts[pathParts.length - 1];
if (threadId.match(/^[0-9a-f\-]+$/) === null) return notfound(res);
const thread = await threads.findById(threadId);
if (! thread) return notfound(res);
const threadMessages = await thread.getThreadMessages();
const lines = threadMessages.map(message => {
// Legacy messages are the entire log in one message, so just serve them as they are
if (message.message_type === THREAD_MESSAGE_TYPE.LEGACY) {
return message.body;
}
let line = `[${moment(message.created_at).format('YYYY-MM-DD HH:mm:ss')}] `;
if (message.message_type === THREAD_MESSAGE_TYPE.SYSTEM) {
// System messages don't need the username
line += message.body;
} else if (message.message_type === THREAD_MESSAGE_TYPE.FROM_USER) {
line += `[FROM USER] ${message.user_name}: ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.TO_USER) {
line += `[TO USER] ${message.user_name}: ${message.body}`;
} else {
line += `${message.user_name}: ${message.body}`;
}
return line;
});
res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
res.end(lines.join('\n'));
}
function serveAttachments(res, pathParts) {
const desiredFilename = pathParts[pathParts.length - 1];
const id = pathParts[pathParts.length - 2];
if (id.match(/^[0-9]+$/) === null) return notfound(res);
if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return notfound(res);
const attachmentPath = attachments.getPath(id);
fs.access(attachmentPath, (err) => {
if (err) return notfound(res);
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);
})
}
module.exports = () => {
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);
} else if (parsedUrl.path.startsWith('/attachments/')) {
serveAttachments(res, pathParts);
} else {
notfound(res);
}
});
server.listen(config.port);
};

24
src/threadUtils.js Normal file
View File

@ -0,0 +1,24 @@
const threads = require('./data/threads');
const utils = require("./utils");
/**
* 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 {Eris~CommandClient} bot
* @param {String} cmd
* @param {Function} commandHandler
* @param {Eris~CommandOptions} opts
*/
function addInboxServerCommand(bot, cmd, commandHandler, opts) {
bot.registerCommand(cmd, async (msg, args) => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
const thread = await threads.findByChannelId(msg.channel.id);
commandHandler(msg, args, thread);
}, opts);
}
module.exports = {
addInboxServerCommand
};

View File

@ -13,12 +13,18 @@ let inboxGuild = null;
let mainGuild = null; let mainGuild = null;
let logChannel = null; let logChannel = null;
/**
* @returns {Eris~Guild}
*/
function getInboxGuild() { function getInboxGuild() {
if (! inboxGuild) inboxGuild = bot.guilds.find(g => g.id === config.mailGuildId); if (! inboxGuild) inboxGuild = bot.guilds.find(g => g.id === config.mailGuildId);
if (! inboxGuild) throw new BotError('The bot is not on the modmail (inbox) server!'); if (! inboxGuild) throw new BotError('The bot is not on the modmail (inbox) server!');
return inboxGuild; return inboxGuild;
} }
/**
* @returns {Eris~Guild}
*/
function getMainGuild() { function getMainGuild() {
if (! mainGuild) mainGuild = bot.guilds.find(g => g.id === config.mainGuildId); if (! mainGuild) mainGuild = bot.guilds.find(g => g.id === config.mainGuildId);
if (! mainGuild) console.warn('[WARN] The bot is not on the main server! If this is intentional, you can ignore this warning.'); if (! mainGuild) console.warn('[WARN] The bot is not on the main server! If this is intentional, you can ignore this warning.');
@ -28,7 +34,7 @@ function getMainGuild() {
/** /**
* Returns the designated log channel, or the default channel if none is set * Returns the designated log channel, or the default channel if none is set
* @param bot * @param bot
* @returns {object} * @returns {Eris~TextChannel}
*/ */
function getLogChannel() { function getLogChannel() {
const inboxGuild = getInboxGuild(); const inboxGuild = getInboxGuild();
@ -151,8 +157,8 @@ async function getSelfUrl(path = '') {
/** /**
* Returns the highest hoisted role of the given member * Returns the highest hoisted role of the given member
* @param {Eris.Member} member * @param {Eris~Member} member
* @returns {Eris.Role} * @returns {Eris~Role}
*/ */
function getMainRole(member) { function getMainRole(member) {
const roles = member.roles.map(id => member.guild.roles.get(id)); const roles = member.roles.map(id => member.guild.roles.get(id));

View File

@ -1,70 +0,0 @@
const http = require('http');
const mime = require('mime');
const url = require('url');
const fs = require('fs');
const config = require('./config');
const attachments = require('./data/attachments');
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();
logs.findLogFile(token).then(logFilename => {
if (logFilename === null) return res.end();
fs.readFile(logs.getLogFilePath(logFilename), {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 = attachments.getPath(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(port);
}
module.exports = {
run,
};