aboutsummaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
authorSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
committerSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
commit2a53887abba882bf7b63aeda8dfa55fdb3ab8792 (patch)
treead7a95eb41faa6ff13c3142285cdc0eb3ca92183 /src/modules
downloadmodmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.tar.xz
modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.zip
clean this up when home
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/alert.js11
-rw-r--r--src/modules/block.js99
-rw-r--r--src/modules/close.js153
-rw-r--r--src/modules/greeting.js37
-rw-r--r--src/modules/id.js5
-rw-r--r--src/modules/logs.js69
-rw-r--r--src/modules/move.js82
-rw-r--r--src/modules/newthread.js23
-rw-r--r--src/modules/reply.js32
-rw-r--r--src/modules/snippets.js138
-rw-r--r--src/modules/suspend.js77
-rw-r--r--src/modules/typingProxy.js33
-rw-r--r--src/modules/version.js53
-rw-r--r--src/modules/webserver.js92
14 files changed, 904 insertions, 0 deletions
diff --git a/src/modules/alert.js b/src/modules/alert.js
new file mode 100644
index 0000000..a844230
--- /dev/null
+++ b/src/modules/alert.js
@@ -0,0 +1,11 @@
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxThreadCommand('alert', '[opt:string]', async (msg, args, thread) => {
+ if (args.opt && args.opt.startsWith('c')) {
+ await thread.setAlert(null);
+ await thread.postSystemMessage(`Cancelled new message alert`);
+ } else {
+ await thread.setAlert(msg.author.id);
+ await thread.postSystemMessage(`Pinging ${msg.author.username}#${msg.author.discriminator} when this thread gets a new reply`);
+ }
+ });
+};
diff --git a/src/modules/block.js b/src/modules/block.js
new file mode 100644
index 0000000..94913c4
--- /dev/null
+++ b/src/modules/block.js
@@ -0,0 +1,99 @@
+const humanizeDuration = require('humanize-duration');
+const moment = require('moment');
+const blocked = require("../data/blocked");
+const utils = require("../utils");
+
+module.exports = ({ bot, knex, config, commands }) => {
+ async function removeExpiredBlocks() {
+ const expiredBlocks = await blocked.getExpiredBlocks();
+ const logChannel = utils.getLogChannel();
+ for (const userId of expiredBlocks) {
+ await blocked.unblock(userId);
+ logChannel.createMessage(`Block of <@!${userId}> (id \`${userId}\`) expired`);
+ }
+ }
+
+ async function expiredBlockLoop() {
+ try {
+ removeExpiredBlocks();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(expiredBlockLoop, 2000);
+ }
+
+ bot.on('ready', expiredBlockLoop);
+
+ const blockCmd = async (msg, args, thread) => {
+ const userIdToBlock = args.userId || (thread && thread.user_id);
+ if (! userIdToBlock) return;
+
+ const isBlocked = await blocked.isBlocked(userIdToBlock);
+ if (isBlocked) {
+ msg.channel.createMessage('User is already blocked');
+ return;
+ }
+
+ const expiresAt = args.blockTime
+ ? moment.utc().add(args.blockTime, 'ms').format('YYYY-MM-DD HH:mm:ss')
+ : null;
+
+ const user = bot.users.get(userIdToBlock);
+ await blocked.block(userIdToBlock, (user ? `${user.username}#${user.discriminator}` : ''), msg.author.id, expiresAt);
+
+ if (expiresAt) {
+ const humanized = humanizeDuration(args.blockTime, { largest: 2, round: true });
+ msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail for ${humanized}`);
+ } else {
+ msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail indefinitely`);
+ }
+ };
+
+ commands.addInboxServerCommand('block', '<userId:userId> [blockTime:delay]', blockCmd);
+ commands.addInboxServerCommand('block', '[blockTime:delay]', blockCmd);
+
+ const unblockCmd = async (msg, args, thread) => {
+ const userIdToUnblock = args.userId || (thread && thread.user_id);
+ if (! userIdToUnblock) return;
+
+ const isBlocked = await blocked.isBlocked(userIdToUnblock);
+ if (! isBlocked) {
+ msg.channel.createMessage('User is not blocked');
+ return;
+ }
+
+ const unblockAt = args.unblockDelay
+ ? moment.utc().add(args.unblockDelay, 'ms').format('YYYY-MM-DD HH:mm:ss')
+ : null;
+
+ const user = bot.users.get(userIdToUnblock);
+ if (unblockAt) {
+ const humanized = humanizeDuration(args.unblockDelay, { largest: 2, round: true });
+ await blocked.updateExpiryTime(userIdToUnblock, unblockAt);
+ msg.channel.createMessage(`Scheduled <@${userIdToUnblock}> (id \`${userIdToUnblock}\`) to be unblocked in ${humanized}`);
+ } else {
+ await blocked.unblock(userIdToUnblock);
+ msg.channel.createMessage(`Unblocked <@${userIdToUnblock}> (id ${userIdToUnblock}) from modmail`);
+ }
+ };
+
+ commands.addInboxServerCommand('unblock', '<userId:userId> [unblockDelay:delay]', unblockCmd);
+ commands.addInboxServerCommand('unblock', '[unblockDelay:delay]', unblockCmd);
+
+ commands.addInboxServerCommand('is_blocked', '[userId:userId]',async (msg, args, thread) => {
+ const userIdToCheck = args.userId || (thread && thread.user_id);
+ if (! userIdToCheck) return;
+
+ const blockStatus = await blocked.getBlockStatus(userIdToCheck);
+ if (blockStatus.isBlocked) {
+ if (blockStatus.expiresAt) {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked until ${blockStatus.expiresAt} (UTC)`);
+ } else {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked indefinitely`);
+ }
+ } else {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is NOT blocked`);
+ }
+ });
+};
diff --git a/src/modules/close.js b/src/modules/close.js
new file mode 100644
index 0000000..b3e7421
--- /dev/null
+++ b/src/modules/close.js
@@ -0,0 +1,153 @@
+const moment = require('moment');
+const Eris = require('eris');
+const config = require('../config');
+const utils = require('../utils');
+const threads = require('../data/threads');
+const blocked = require('../data/blocked');
+const {messageQueue} = require('../queue');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // Check for threads that are scheduled to be closed and close them
+ async function applyScheduledCloses() {
+ const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed();
+ for (const thread of threadsToBeClosed) {
+ if (config.closeMessage && ! thread.scheduled_close_silent) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ await thread.close(false, thread.scheduled_close_silent);
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}
+ Logs: ${logUrl}
+ `));
+ }
+ }
+
+ async function scheduledCloseLoop() {
+ try {
+ await applyScheduledCloses();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(scheduledCloseLoop, 2000);
+ }
+
+ scheduledCloseLoop();
+
+ // Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
+ commands.addGlobalCommand('close', '[opts...]', async (msg, args) => {
+ let thread, closedBy;
+
+ let hasCloseMessage = !! config.closeMessage;
+ let silentClose = false;
+
+ if (msg.channel instanceof Eris.PrivateChannel) {
+ // User is closing the thread by themselves (if enabled)
+ if (! config.allowUserClose) return;
+ if (await blocked.isBlocked(msg.author.id)) return;
+
+ thread = await threads.findOpenThreadByUserId(msg.author.id);
+ if (! thread) return;
+
+ // We need to add this operation to the message queue so we don't get a race condition
+ // between showing the close command in the thread and closing the thread
+ await messageQueue.add(async () => {
+ thread.postSystemMessage('Thread closed by user, closing...');
+ await thread.close(true);
+ });
+
+ closedBy = 'the user';
+ } else {
+ // A staff member is closing the thread
+ if (! utils.messageIsOnInboxServer(msg)) return;
+ if (! utils.isStaff(msg.member)) return;
+
+ thread = await threads.findOpenThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ if (args.opts && args.opts.length) {
+ if (args.opts.includes('cancel') || args.opts.includes('c')) {
+ // Cancel timed close
+ if (thread.scheduled_close_at) {
+ await thread.cancelScheduledClose();
+ thread.postSystemMessage(`Cancelled scheduled closing`);
+ }
+
+ return;
+ }
+
+ // Silent close (= no close message)
+ if (args.opts.includes('silent') || args.opts.includes('s')) {
+ silentClose = true;
+ }
+
+ // Timed close
+ const delayStringArg = args.opts.find(arg => utils.delayStringRegex.test(arg));
+ if (delayStringArg) {
+ const delay = utils.convertDelayStringToMS(delayStringArg);
+ if (delay === 0 || delay === null) {
+ thread.postSystemMessage(`Invalid delay specified. Format: "1h30m"`);
+ return;
+ }
+
+ const closeAt = moment.utc().add(delay, 'ms');
+ await thread.scheduleClose(closeAt.format('YYYY-MM-DD HH:mm:ss'), msg.author, silentClose ? 1 : 0);
+
+ let response;
+ if (silentClose) {
+ response = `Thread is now scheduled to be closed silently in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
+ } else {
+ response = `Thread is now scheduled to be closed in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
+ }
+
+ thread.postSystemMessage(response);
+
+ return;
+ }
+ }
+
+ // Regular close
+ await thread.close(false, silentClose);
+ closedBy = msg.author.username;
+ }
+
+ // Send close message (unless suppressed with a silent close)
+ if (hasCloseMessage && ! silentClose) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}
+ Logs: ${logUrl}
+ `));
+ });
+
+ // Auto-close threads if their channel is deleted
+ bot.on('channelDelete', async (channel) => {
+ if (! (channel instanceof Eris.TextChannel)) return;
+ if (channel.guild.id !== utils.getInboxGuild().id) return;
+
+ const thread = await threads.findOpenThreadByChannelId(channel.id);
+ if (! thread) return;
+
+ console.log(`[INFO] Auto-closing thread with ${thread.user_name} because the channel was deleted`);
+ if (config.closeMessage) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ await thread.close(true);
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted
+ Logs: ${logUrl}
+ `));
+ });
+};
diff --git a/src/modules/greeting.js b/src/modules/greeting.js
new file mode 100644
index 0000000..f85bde9
--- /dev/null
+++ b/src/modules/greeting.js
@@ -0,0 +1,37 @@
+const path = require('path');
+const fs = require('fs');
+const config = require('../config');
+const utils = require('../utils');
+
+module.exports = ({ bot }) => {
+ if (! config.enableGreeting) return;
+
+ bot.on('guildMemberAdd', (guild, member) => {
+ const guildGreeting = config.guildGreetings[guild.id];
+ if (! guildGreeting || (! guildGreeting.message && ! guildGreeting.attachment)) return;
+
+ function sendGreeting(message, file) {
+ bot.getDMChannel(member.id).then(channel => {
+ if (! channel) return;
+
+ channel.createMessage(message || '', file)
+ .catch(e => {
+ if (e.code === 50007) return;
+ throw e;
+ });
+ });
+ }
+
+ const greetingMessage = utils.readMultilineConfigValue(guildGreeting.message);
+
+ if (guildGreeting.attachment) {
+ const filename = path.basename(guildGreeting.attachment);
+ fs.readFile(guildGreeting.attachment, (err, data) => {
+ const file = {file: data, name: filename};
+ sendGreeting(greetingMessage, file);
+ });
+ } else {
+ sendGreeting(greetingMessage);
+ }
+ });
+};
diff --git a/src/modules/id.js b/src/modules/id.js
new file mode 100644
index 0000000..e3cf2e2
--- /dev/null
+++ b/src/modules/id.js
@@ -0,0 +1,5 @@
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxThreadCommand('id', [], async (msg, args, thread) => {
+ thread.postSystemMessage(thread.user_id);
+ });
+};
diff --git a/src/modules/logs.js b/src/modules/logs.js
new file mode 100644
index 0000000..0664c07
--- /dev/null
+++ b/src/modules/logs.js
@@ -0,0 +1,69 @@
+const threads = require("../data/threads");
+const moment = require('moment');
+const utils = require("../utils");
+
+const LOG_LINES_PER_PAGE = 10;
+
+module.exports = ({ bot, knex, config, commands }) => {
+ const logsCmd = async (msg, args, thread) => {
+ let userId = args.userId || (thread && thread.user_id);
+ if (! userId) return;
+
+ let userThreads = await threads.getClosedThreadsByUserId(userId);
+
+ // Descending by date
+ userThreads.sort((a, b) => {
+ if (a.created_at > b.created_at) return -1;
+ if (a.created_at < b.created_at) return 1;
+ return 0;
+ });
+
+ // Pagination
+ const totalUserThreads = userThreads.length;
+ const maxPage = Math.ceil(totalUserThreads / LOG_LINES_PER_PAGE);
+ const inputPage = args.page;
+ const page = Math.max(Math.min(inputPage ? parseInt(inputPage, 10) : 1, maxPage), 1); // Clamp page to 1-<max page>
+ const isPaginated = totalUserThreads > LOG_LINES_PER_PAGE;
+ const start = (page - 1) * LOG_LINES_PER_PAGE;
+ const end = page * LOG_LINES_PER_PAGE;
+ userThreads = userThreads.slice((page - 1) * LOG_LINES_PER_PAGE, page * LOG_LINES_PER_PAGE);
+
+ 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}>`;
+ }));
+
+ let message = isPaginated
+ ? `**Log files for <@${userId}>** (page **${page}/${maxPage}**, showing logs **${start + 1}-${end}/${totalUserThreads}**):`
+ : `**Log files for <@${userId}>:**`;
+
+ message += `\n${threadLines.join('\n')}`;
+
+ if (isPaginated) {
+ message += `\nTo view more, add a page number to the end of the command`;
+ }
+
+ // Send the list of logs in chunks of 15 lines per message
+ const lines = message.split('\n');
+ const chunks = utils.chunk(lines, 15);
+
+ let root = Promise.resolve();
+ chunks.forEach(lines => {
+ root = root.then(() => msg.channel.createMessage(lines.join('\n')));
+ });
+ };
+
+ commands.addInboxServerCommand('logs', '<userId:userId> [page:number]', logsCmd);
+ commands.addInboxServerCommand('logs', '[page:number]', logsCmd);
+
+ commands.addInboxServerCommand('loglink', [], async (msg, args, thread) => {
+ if (! thread) {
+ thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+ }
+
+ const logUrl = await thread.getLogUrl();
+ thread.postSystemMessage(`Log URL: ${logUrl}`);
+ });
+};
diff --git a/src/modules/move.js b/src/modules/move.js
new file mode 100644
index 0000000..973143d
--- /dev/null
+++ b/src/modules/move.js
@@ -0,0 +1,82 @@
+const config = require('../config');
+const Eris = require('eris');
+const transliterate = require("transliteration");
+const erisEndpoints = require('eris/lib/rest/Endpoints');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ if (! config.allowMove) return;
+
+ commands.addInboxThreadCommand('move', '<category:string$>', async (msg, args, thread) => {
+ const searchStr = args.category;
+ const normalizedSearchStr = transliterate.slugify(searchStr);
+
+ const categories = msg.channel.guild.channels.filter(c => {
+ // Filter to categories that are not the thread's current parent category
+ return (c instanceof Eris.CategoryChannel) && (c.id !== msg.channel.parentID);
+ });
+
+ if (categories.length === 0) return;
+
+ // See if any category name contains a part of the search string
+ const containsRankings = categories.map(cat => {
+ const normalizedCatName = transliterate.slugify(cat.name);
+
+ let i = 0;
+ do {
+ if (! normalizedCatName.includes(normalizedSearchStr.slice(0, i + 1))) break;
+ i++;
+ } while (i < normalizedSearchStr.length);
+
+ if (i > 0 && normalizedCatName.startsWith(normalizedSearchStr.slice(0, i))) {
+ // Slightly prioritize categories that *start* with the search string
+ i += 0.5;
+ }
+
+ return [cat, i];
+ });
+
+ // Sort by best match
+ containsRankings.sort((a, b) => {
+ return a[1] > b[1] ? -1 : 1;
+ });
+
+ if (containsRankings[0][1] === 0) {
+ thread.postSystemMessage('No matching category');
+ return;
+ }
+
+ const targetCategory = containsRankings[0][0];
+
+ try {
+ await bot.editChannel(thread.channel_id, {
+ parentID: targetCategory.id
+ });
+ } catch (e) {
+ thread.postSystemMessage(`Failed to move thread: ${e.message}`);
+ return;
+ }
+
+ // If enabled, sync thread channel permissions with the category it's moved to
+ if (config.syncPermissionsOnMove) {
+ const newPerms = Array.from(targetCategory.permissionOverwrites.map(ow => {
+ return {
+ id: ow.id,
+ type: ow.type,
+ allow: ow.allow,
+ deny: ow.deny
+ };
+ }));
+
+ try {
+ await bot.requestHandler.request("PATCH", erisEndpoints.CHANNEL(thread.channel_id), true, {
+ permission_overwrites: newPerms
+ });
+ } catch (e) {
+ thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}, but failed to sync permissions: ${e.message}`);
+ return;
+ }
+ }
+
+ thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}`);
+ });
+};
diff --git a/src/modules/newthread.js b/src/modules/newthread.js
new file mode 100644
index 0000000..aca6f54
--- /dev/null
+++ b/src/modules/newthread.js
@@ -0,0 +1,23 @@
+const utils = require("../utils");
+const threads = require("../data/threads");
+
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxServerCommand('newthread', '<userId:userId>', async (msg, args, thread) => {
+ const user = bot.users.get(args.userId);
+ if (! user) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, 'User not found!');
+ return;
+ }
+
+ const existingThread = await threads.findOpenThreadByUserId(user.id);
+ if (existingThread) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Cannot create a new thread; there is another open thread with this user: <#${existingThread.channel_id}>`);
+ return;
+ }
+
+ const createdThread = await threads.createNewThreadForUser(user, true, true);
+ createdThread.postSystemMessage(`Thread was opened by ${msg.author.username}#${msg.author.discriminator}`);
+
+ msg.channel.createMessage(`Thread opened: <#${createdThread.channel_id}>`);
+ });
+};
diff --git a/src/modules/reply.js b/src/modules/reply.js
new file mode 100644
index 0000000..bc2afe3
--- /dev/null
+++ b/src/modules/reply.js
@@ -0,0 +1,32 @@
+const attachments = require("../data/attachments");
+const utils = require('../utils');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // 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
+ commands.addInboxThreadCommand('reply', '[text$]', async (msg, args, thread) => {
+ if (! args.text && msg.attachments.length === 0) {
+ utils.postError(msg.channel, 'Text or attachment required');
+ return;
+ }
+
+ const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, false);
+ if (replied) msg.delete();
+ }, {
+ aliases: ['r']
+ });
+
+
+ // Anonymous replies only show the role, not the username
+ commands.addInboxThreadCommand('anonreply', '[text$]', async (msg, args, thread) => {
+ if (! args.text && msg.attachments.length === 0) {
+ utils.postError(msg.channel, 'Text or attachment required');
+ return;
+ }
+
+ const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, true);
+ if (replied) msg.delete();
+ }, {
+ aliases: ['ar']
+ });
+};
diff --git a/src/modules/snippets.js b/src/modules/snippets.js
new file mode 100644
index 0000000..e9c1271
--- /dev/null
+++ b/src/modules/snippets.js
@@ -0,0 +1,138 @@
+const threads = require('../data/threads');
+const snippets = require('../data/snippets');
+const config = require('../config');
+const utils = require('../utils');
+const { parseArguments } = require('knub-command-manager');
+
+const whitespaceRegex = /\s/;
+const quoteChars = ["'", '"'];
+
+module.exports = ({ bot, knex, config, commands }) => {
+ /**
+ * "Renders" a snippet by replacing all argument placeholders e.g. {1} {2} with their corresponding arguments.
+ * The number in the placeholder is the argument's order in the argument list, i.e. {1} is the first argument (= index 0)
+ * @param {String} body
+ * @param {String[]} args
+ * @returns {String}
+ */
+ function renderSnippet(body, args) {
+ return body
+ .replace(/(?<!\\){\d+}/g, match => {
+ const index = parseInt(match.slice(1, -1), 10) - 1;
+ return (args[index] != null ? args[index] : match);
+ })
+ .replace(/\\{/g, '{');
+ }
+
+ /**
+ * 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) && ! msg.content.startsWith(config.snippetPrefixAnon)) return;
+
+ let snippetPrefix, isAnonymous;
+
+ if (config.snippetPrefixAnon.length > config.snippetPrefix.length) {
+ // Anonymous prefix is longer -> check it first
+ if (msg.content.startsWith(config.snippetPrefixAnon)) {
+ snippetPrefix = config.snippetPrefixAnon;
+ isAnonymous = true;
+ } else {
+ snippetPrefix = config.snippetPrefix;
+ isAnonymous = false;
+ }
+ } else {
+ // Regular prefix is longer -> check it first
+ if (msg.content.startsWith(config.snippetPrefix)) {
+ snippetPrefix = config.snippetPrefix;
+ isAnonymous = false;
+ } else {
+ snippetPrefix = config.snippetPrefixAnon;
+ isAnonymous = true;
+ }
+ }
+
+ const thread = await threads.findByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ let [, trigger, rawArgs] = msg.content.slice(snippetPrefix.length).match(/(\S+)(?:\s+(.*))?/s);
+ trigger = trigger.toLowerCase();
+
+ const snippet = await snippets.get(trigger);
+ if (! snippet) return;
+
+ let args = rawArgs ? parseArguments(rawArgs) : [];
+ args = args.map(arg => arg.value);
+ const rendered = renderSnippet(snippet.body, args);
+
+ const replied = await thread.replyToUser(msg.member, rendered, [], isAnonymous);
+ if (replied) msg.delete();
+ });
+
+ // Show or add a snippet
+ commands.addInboxServerCommand('snippet', '<trigger> [text$]', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+
+ if (snippet) {
+ if (args.text) {
+ // If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.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
+ utils.postSystemMessageWithFallback(msg.channel, thread, `\`${config.snippetPrefix}${args.trigger}\` replies with: \`\`\`${utils.disableCodeBlocks(snippet.body)}\`\`\``);
+ }
+ } else {
+ if (args.text) {
+ // If the snippet doesn't exist and the user wants to create it, create it
+ await snippets.add(args.trigger, args.text, msg.author.id);
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" created!`);
+ } else {
+ // If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist! You can create it with \`${config.prefix}snippet ${args.trigger} text\``);
+ }
+ }
+ }, {
+ aliases: ['s']
+ });
+
+ commands.addInboxServerCommand('delete_snippet', '<trigger>', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+ if (! snippet) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
+ return;
+ }
+
+ await snippets.del(args.trigger);
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" deleted!`);
+ }, {
+ aliases: ['ds']
+ });
+
+ commands.addInboxServerCommand('edit_snippet', '<trigger> [text$]', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+ if (! snippet) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
+ return;
+ }
+
+ await snippets.del(args.trigger);
+ await snippets.add(args.trigger, args.text, msg.author.id);
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" edited!`);
+ }, {
+ aliases: ['es']
+ });
+
+ commands.addInboxServerCommand('snippets', [], async (msg, args, thread) => {
+ const allSnippets = await snippets.all();
+ const triggers = allSnippets.map(s => s.trigger);
+ triggers.sort();
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`);
+ });
+};
diff --git a/src/modules/suspend.js b/src/modules/suspend.js
new file mode 100644
index 0000000..7edd096
--- /dev/null
+++ b/src/modules/suspend.js
@@ -0,0 +1,77 @@
+const moment = require('moment');
+const threads = require("../data/threads");
+const utils = require('../utils');
+const config = require('../config');
+
+const {THREAD_STATUS} = require('../data/constants');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // Check for threads that are scheduled to be suspended and suspend them
+ async function applyScheduledSuspensions() {
+ const threadsToBeSuspended = await threads.getThreadsThatShouldBeSuspended();
+ for (const thread of threadsToBeSuspended) {
+ if (thread.status === THREAD_STATUS.OPEN) {
+ await thread.suspend();
+ await thread.postSystemMessage(`**Thread suspended** as scheduled by ${thread.scheduled_suspend_name}. This thread will act as closed until unsuspended with \`!unsuspend\``);
+ }
+ }
+ }
+
+ async function scheduledSuspendLoop() {
+ try {
+ await applyScheduledSuspensions();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(scheduledSuspendLoop, 2000);
+ }
+
+ scheduledSuspendLoop();
+
+ commands.addInboxThreadCommand('suspend cancel', [], async (msg, args, thread) => {
+ // Cancel timed suspend
+ if (thread.scheduled_suspend_at) {
+ await thread.cancelScheduledSuspend();
+ thread.postSystemMessage(`Cancelled scheduled suspension`);
+ } else {
+ thread.postSystemMessage(`Thread is not scheduled to be suspended`);
+ }
+ });
+
+ commands.addInboxThreadCommand('suspend', '[delay:delay]', async (msg, args, thread) => {
+ if (args.delay) {
+ const suspendAt = moment.utc().add(args.delay, 'ms');
+ await thread.scheduleSuspend(suspendAt.format('YYYY-MM-DD HH:mm:ss'), msg.author);
+
+ thread.postSystemMessage(`Thread will be suspended in ${utils.humanizeDelay(args.delay)}. Use \`${config.prefix}suspend cancel\` to cancel.`);
+
+ return;
+ }
+
+ await thread.suspend();
+ thread.postSystemMessage(`**Thread suspended!** This thread will act as closed until unsuspended with \`!unsuspend\``);
+ });
+
+ commands.addInboxServerCommand('unsuspend', [], async (msg, args, thread) => {
+ if (thread) {
+ thread.postSystemMessage(`Thread is not suspended`);
+ return;
+ }
+
+ thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
+ if (! thread) {
+ thread.postSystemMessage(`Not in a thread`);
+ return;
+ }
+
+ const otherOpenThread = await threads.findOpenThreadByUserId(thread.user_id);
+ if (otherOpenThread) {
+ thread.postSystemMessage(`Cannot unsuspend; there is another open thread with this user: <#${otherOpenThread.channel_id}>`);
+ return;
+ }
+
+ await thread.unsuspend();
+ thread.postSystemMessage(`**Thread unsuspended!**`);
+ });
+};
diff --git a/src/modules/typingProxy.js b/src/modules/typingProxy.js
new file mode 100644
index 0000000..5881808
--- /dev/null
+++ b/src/modules/typingProxy.js
@@ -0,0 +1,33 @@
+const config = require('../config');
+const threads = require("../data/threads");
+const Eris = require("eris");
+
+module.exports = ({ bot }) => {
+ // Typing proxy: forwarding typing events between the DM and the modmail thread
+ if(config.typingProxy || config.typingProxyReverse) {
+ bot.on("typingStart", async (channel, user) => {
+ // config.typingProxy: forward user typing in a DM to the modmail thread
+ if (config.typingProxy && (channel instanceof Eris.PrivateChannel)) {
+ const thread = await threads.findOpenThreadByUserId(user.id);
+ if (! thread) return;
+
+ try {
+ await bot.sendChannelTyping(thread.channel_id);
+ } catch (e) {}
+ }
+
+ // config.typingProxyReverse: forward moderator typing in a thread to the DM
+ else if (config.typingProxyReverse && (channel instanceof Eris.GuildChannel) && ! user.bot) {
+ const thread = await threads.findByChannelId(channel.id);
+ if (! thread) return;
+
+ const dmChannel = await thread.getDMChannel();
+ if (! dmChannel) return;
+
+ try {
+ await bot.sendChannelTyping(dmChannel.id);
+ } catch(e) {}
+ }
+ });
+ }
+};
diff --git a/src/modules/version.js b/src/modules/version.js
new file mode 100644
index 0000000..033fbf0
--- /dev/null
+++ b/src/modules/version.js
@@ -0,0 +1,53 @@
+const path = require('path');
+const fs = require('fs');
+const {promisify} = require('util');
+const utils = require("../utils");
+const updates = require('../data/updates');
+const config = require('../config');
+
+const access = promisify(fs.access);
+const readFile = promisify(fs.readFile);
+
+const GIT_DIR = path.join(__dirname, '..', '..', '.git');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxServerCommand('version', [], async (msg, args, thread) => {
+ const packageJson = require('../../package.json');
+ const packageVersion = packageJson.version;
+
+ let response = `Modmail v${packageVersion}`;
+
+ let isGit;
+ try {
+ await access(GIT_DIR);
+ isGit = true;
+ } catch (e) {
+ isGit = false;
+ }
+
+ if (isGit) {
+ let commitHash;
+ const HEAD = await readFile(path.join(GIT_DIR, 'HEAD'), {encoding: 'utf8'});
+
+ if (HEAD.startsWith('ref:')) {
+ // Branch
+ const ref = HEAD.match(/^ref: (.*)$/m)[1];
+ commitHash = (await readFile(path.join(GIT_DIR, ref), {encoding: 'utf8'})).trim();
+ } else {
+ // Detached head
+ commitHash = HEAD.trim();
+ }
+
+ response += ` (${commitHash.slice(0, 7)})`;
+ }
+
+ if (config.updateNotifications) {
+ const availableUpdate = await updates.getAvailableUpdate();
+ if (availableUpdate) {
+ response += ` (version ${availableUpdate} available)`;
+ }
+ }
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, response);
+ });
+};
diff --git a/src/modules/webserver.js b/src/modules/webserver.js
new file mode 100644
index 0000000..5d8b2c9
--- /dev/null
+++ b/src/modules/webserver.js
@@ -0,0 +1,92 @@
+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.utc(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.getLocalAttachmentPath(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.getType(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.on('error', err => {
+ console.log('[WARN] Web server error:', err.message);
+ });
+
+ server.listen(config.port);
+};