Refactor large parts of the bot
parent
8e388f93bb
commit
bb6d8e5dbf
|
@ -3,7 +3,10 @@
|
|||
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 8,
|
||||
"sourceType": "module"
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"experimentalObjectRestSpread": true
|
||||
}
|
||||
},
|
||||
|
||||
"env": {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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')
|
||||
|
|
|
@ -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'
|
||||
],
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
@ -82,7 +82,7 @@ async function migrateOpenThreads() {
|
|||
is_legacy: 1
|
||||
};
|
||||
|
||||
return threads.create(newThread);
|
||||
return threads.createThreadInDB(newThread);
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
|
|
267
src/main.js
267
src/main.js
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
30
src/utils.js
30
src/utils.js
|
@ -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,
|
||||
|
||||
|
|
Loading…
Reference in New Issue