const Eris = require("eris"); const bot = require("./bot"); const moment = require("moment"); const humanizeDuration = require("humanize-duration"); const publicIp = require("public-ip"); const config = require("./cfg"); class BotError extends Error {} const userMentionRegex = /^<@!?([0-9]+?)>$/; let inboxGuild = null; let mainGuilds = []; let logChannel = null; /** * @returns {Eris~Guild} */ function getInboxGuild() { if (! inboxGuild) inboxGuild = bot.guilds.find(g => g.id === config.inboxServerId); if (! inboxGuild) throw new BotError("The bot is not on the modmail (inbox) server!"); return inboxGuild; } /** * @returns {Eris~Guild[]} */ function getMainGuilds() { if (mainGuilds.length === 0) { mainGuilds = bot.guilds.filter(g => config.mainServerId.includes(g.id)); } if (mainGuilds.length !== config.mainServerId.length) { if (config.mainServerId.length === 1) { console.warn("[WARN] The bot hasn't joined the main guild!"); } else { console.warn("[WARN] The bot hasn't joined one or more main guilds!"); } } return mainGuilds; } /** * Returns the designated log channel, or the default channel if none is set * @returns {Eris~TextChannel} */ function getLogChannel() { const _inboxGuild = getInboxGuild(); const _logChannel = _inboxGuild.channels.get(config.logChannelId); if (! _logChannel) { throw new BotError("Log channel (logChannelId) not found!"); } if (! (_logChannel instanceof Eris.TextChannel)) { throw new BotError("Make sure the logChannelId option is set to a text channel!"); } return _logChannel; } function postLog(...args) { return getLogChannel().createMessage(...args); } function postError(channel, str, opts = {}) { return channel.createMessage({ ...opts, content: `⚠ ${str}` }); } /** * Returns whether the given member has permission to use modmail commands * @param {Eris.Member} member * @returns {boolean} */ function isStaff(member) { if (! member) return false; if (config.inboxServerPermission.length === 0) return true; if (member.guild.ownerID === member.id) return true; return config.inboxServerPermission.some(perm => { if (isSnowflake(perm)) { // If perm is a snowflake, check it against the member's user id and roles if (member.id === perm) return true; if (member.roles.includes(perm)) return true; } else { // Otherwise assume perm is the name of a permission return member.permission.has(perm); } return false; }); } /** * Returns whether the given message is on the inbox server * @param msg * @returns {boolean} */ function messageIsOnInboxServer(msg) { if (! msg.channel.guild) return false; if (msg.channel.guild.id !== getInboxGuild().id) return false; return true; } /** * Returns whether the given message is on the main server * @param msg * @returns {boolean} */ function messageIsOnMainServer(msg) { if (! msg.channel.guild) return false; return getMainGuilds() .some(g => msg.channel.guild.id === g.id); } /** * @param attachment * @returns {Promise} */ async function formatAttachment(attachment, attachmentUrl) { let filesize = attachment.size || 0; filesize /= 1024; return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; } /** * Returns the user ID of the user mentioned in str, if any * @param {String} str * @returns {String|null} */ function getUserMention(str) { if (! str) return null; str = str.trim(); if (isSnowflake(str)) { // User ID return str; } else { let mentionMatch = str.match(userMentionRegex); if (mentionMatch) return mentionMatch[1]; } return null; } /** * Returns the current timestamp in an easily readable form * @returns {String} */ function getTimestamp(...momentArgs) { return moment.utc(...momentArgs).format("HH:mm"); } /** * Disables link previews in the given string by wrapping links in < > * @param {String} str * @returns {String} */ function disableLinkPreviews(str) { return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, "$1<$2>"); } /** * Returns a URL to the bot's web server * @param {String} path * @returns {Promise} */ async function getSelfUrl(path = "") { if (config.url) { return `${config.url}/${path}`; } else { const port = config.port || 8890; const ip = await publicIp.v4(); return `http://${ip}:${port}/${path}`; } } /** * Returns the highest hoisted role of the given member * @param {Eris~Member} member * @returns {Eris~Role} */ function getMainRole(member) { const roles = member.roles.map(id => member.guild.roles.get(id)); roles.sort((a, b) => a.position > b.position ? -1 : 1); return roles.find(r => r.hoist); } /** * Splits array items into chunks of the specified size * @param {Array|String} items * @param {Number} chunkSize * @returns {Array} */ function chunk(items, chunkSize) { const result = []; for (let i = 0; i < items.length; i += chunkSize) { result.push(items.slice(i, i + chunkSize)); } return result; } /** * Trims every line in the string * @param {String} str * @returns {String} */ function trimAll(str) { return str .split("\n") .map(_str => _str.trim()) .join("\n"); } const delayStringRegex = /^([0-9]+)(?:([dhms])[a-z]*)?/i; /** * Turns a "delay string" such as "1h30m" to milliseconds * @param {String} str * @returns {Number|null} */ function convertDelayStringToMS(str) { let match; let ms = 0; str = str.trim(); while (str !== "" && (match = str.match(delayStringRegex)) !== null) { if (match[2] === "d") ms += match[1] * 1000 * 60 * 60 * 24; else if (match[2] === "h") ms += match[1] * 1000 * 60 * 60; else if (match[2] === "s") ms += match[1] * 1000; else if (match[2] === "m" || ! match[2]) ms += match[1] * 1000 * 60; str = str.slice(match[0].length); } // Invalid delay string if (str !== "") { return null; } return ms; } function getValidMentionRoles() { const mentionRoles = Array.isArray(config.mentionRole) ? config.mentionRole : [config.mentionRole]; return mentionRoles.filter(roleStr => { return (roleStr !== null && roleStr !== "none" && roleStr !== "off" && roleStr !== ""); }); } function getInboxMention() { const mentionRoles = getValidMentionRoles(); const mentions = []; for (const role of mentionRoles) { if (role === "here") mentions.push("@here"); else if (role === "everyone") mentions.push("@everyone"); else mentions.push(`<@&${role}>`); } return mentions.join(" ") + " "; } function getInboxMentionAllowedMentions() { const mentionRoles = getValidMentionRoles(); const allowedMentions = { everyone: false, roles: [], }; for (const role of mentionRoles) { if (role === "here" || role === "everyone") allowedMentions.everyone = true; else allowedMentions.roles.push(role); } return allowedMentions; } function postSystemMessageWithFallback(channel, thread, text) { if (thread) { thread.postSystemMessage(text); } else { channel.createMessage(text); } } /** * A normalized way to set props in data models, fixing some inconsistencies between different DB drivers in knex * @param {Object} target * @param {Object} props */ function setDataModelProps(target, props) { for (const prop in props) { if (! props.hasOwnProperty(prop)) continue; // DATETIME fields are always returned as Date objects in MySQL/MariaDB if (props[prop] instanceof Date) { // ...even when NULL, in which case the date's set to unix epoch if (props[prop].getUTCFullYear() === 1970) { target[prop] = null; } else { // Set the value as a string in the same format it's returned in SQLite target[prop] = moment.utc(props[prop]).format("YYYY-MM-DD HH:mm:ss"); } } else { target[prop] = props[prop]; } } } const snowflakeRegex = /^[0-9]{17,}$/; function isSnowflake(str) { return str && snowflakeRegex.test(str); } const humanizeDelay = (delay, opts = {}) => humanizeDuration(delay, Object.assign({conjunction: " and "}, opts)); const markdownCharsRegex = /([\\_*|`~])/g; function escapeMarkdown(str) { return str.replace(markdownCharsRegex, "\\$1"); } function disableInlineCode(str) { return str.replace(/`/g, "'"); } function disableCodeBlocks(str) { return str.replace(/`/g, "`\u200b"); } function readMultilineConfigValue(str) { return Array.isArray(str) ? str.join("\n") : str; } function noop() {} // https://discord.com/developers/docs/resources/channel#create-message-params const MAX_MESSAGE_CONTENT_LENGTH = 2000; // https://discord.com/developers/docs/resources/channel#embed-limits const MAX_EMBED_CONTENT_LENGTH = 6000; /** * Checks if the given message content is within Discord's message length limits. * * Based on testing, Discord appears to enforce length limits (at least in the client) * the same way JavaScript does, using the UTF-16 byte count as the number of characters. * * @param {string|Eris.MessageContent} content */ function messageContentIsWithinMaxLength(content) { if (typeof content === "string") { content = { content }; } if (content.content && content.content.length > MAX_MESSAGE_CONTENT_LENGTH) { return false; } if (content.embed) { let embedContentLength = 0; if (content.embed.title) embedContentLength += content.embed.title.length; if (content.embed.description) embedContentLength += content.embed.description.length; if (content.embed.footer && content.embed.footer.text) { embedContentLength += content.embed.footer.text.length; } if (content.embed.author && content.embed.author.name) { embedContentLength += content.embed.author.name.length; } if (content.embed.fields) { for (const field of content.embed.fields) { if (field.title) embedContentLength += field.name.length; if (field.description) embedContentLength += field.value.length; } } if (embedContentLength > MAX_EMBED_CONTENT_LENGTH) { return false; } } return true; } /** * Splits a string into chunks, preferring to split at a newline * @param {string} str * @param {number} [maxChunkLength=2000] * @returns {string[]} */ function chunkByLines(str, maxChunkLength = 2000) { if (str.length < maxChunkLength) { return [str]; } const chunks = []; while (str.length) { if (str.length <= maxChunkLength) { chunks.push(str); break; } const slice = str.slice(0, maxChunkLength); const lastLineBreakIndex = slice.lastIndexOf("\n"); if (lastLineBreakIndex === -1) { chunks.push(str.slice(0, maxChunkLength)); str = str.slice(maxChunkLength); } else { chunks.push(str.slice(0, lastLineBreakIndex)); str = str.slice(lastLineBreakIndex + 1); } } return chunks; } /** * Chunks a long message to multiple smaller messages, retaining leading and trailing line breaks, open code blocks, etc. * * Default maxChunkLength is 1990, a bit under the message length limit of 2000, so we have space to add code block * shenanigans to the start/end when needed. Take this into account when choosing a custom maxChunkLength as well. */ function chunkMessageLines(str, maxChunkLength = 1990) { const chunks = chunkByLines(str, maxChunkLength); let openCodeBlock = false; return chunks.map(_chunk => { // If the chunk starts with a newline, add an invisible unicode char so Discord doesn't strip it away if (_chunk[0] === "\n") _chunk = "\u200b" + _chunk; // If the chunk ends with a newline, add an invisible unicode char so Discord doesn't strip it away if (_chunk[_chunk.length - 1] === "\n") _chunk = _chunk + "\u200b"; // If the previous chunk had an open code block, open it here again if (openCodeBlock) { openCodeBlock = false; if (_chunk.startsWith("```")) { // Edge case: chunk starts with a code block delimiter, e.g. the previous chunk and this one were split right before the end of a code block // Fix: just strip the code block delimiter away from here, we don't need it anymore _chunk = _chunk.slice(3); } else { _chunk = "```" + _chunk; } } // If the chunk has an open code block, close it and open it again in the next chunk const codeBlockDelimiters = _chunk.match(/```/g); if (codeBlockDelimiters && codeBlockDelimiters.length % 2 !== 0) { _chunk += "```"; openCodeBlock = true; } return _chunk; }); } module.exports = { BotError, getInboxGuild, getMainGuilds, getLogChannel, postError, postLog, isStaff, messageIsOnInboxServer, messageIsOnMainServer, formatAttachment, getUserMention, getTimestamp, disableLinkPreviews, getSelfUrl, getMainRole, delayStringRegex, convertDelayStringToMS, getInboxMention, getInboxMentionAllowedMentions, postSystemMessageWithFallback, chunk, trimAll, setDataModelProps, isSnowflake, humanizeDelay, escapeMarkdown, disableInlineCode, disableCodeBlocks, readMultilineConfigValue, messageContentIsWithinMaxLength, chunkMessageLines, noop, };