diff options
| author | Sin-MacBook <[email protected]> | 2020-08-10 23:44:20 +0200 |
|---|---|---|
| committer | Sin-MacBook <[email protected]> | 2020-08-10 23:44:20 +0200 |
| commit | 2a53887abba882bf7b63aeda8dfa55fdb3ab8792 (patch) | |
| tree | ad7a95eb41faa6ff13c3142285cdc0eb3ca92183 /src/modules | |
| download | modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.tar.xz modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.zip | |
clean this up when home
Diffstat (limited to 'src/modules')
| -rw-r--r-- | src/modules/alert.js | 11 | ||||
| -rw-r--r-- | src/modules/block.js | 99 | ||||
| -rw-r--r-- | src/modules/close.js | 153 | ||||
| -rw-r--r-- | src/modules/greeting.js | 37 | ||||
| -rw-r--r-- | src/modules/id.js | 5 | ||||
| -rw-r--r-- | src/modules/logs.js | 69 | ||||
| -rw-r--r-- | src/modules/move.js | 82 | ||||
| -rw-r--r-- | src/modules/newthread.js | 23 | ||||
| -rw-r--r-- | src/modules/reply.js | 32 | ||||
| -rw-r--r-- | src/modules/snippets.js | 138 | ||||
| -rw-r--r-- | src/modules/suspend.js | 77 | ||||
| -rw-r--r-- | src/modules/typingProxy.js | 33 | ||||
| -rw-r--r-- | src/modules/version.js | 53 | ||||
| -rw-r--r-- | src/modules/webserver.js | 92 |
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); +}; |