diff --git a/docs/configuration.md b/docs/configuration.md index b1ed910..52c1d77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -189,6 +189,25 @@ See ["Permissions" on this page](https://abal.moe/Eris/docs/reference) for suppo **Default:** `You haven't been a member of the server for long enough to contact modmail.` If `requiredTimeOnServer` is set, users that are too new will be sent this message if they try to message modmail. +#### logStorage +**Default:** `local` +Controls how logs are stored. Possible values: +* `local` - Logs are served from a local web server via links +* `attachment` - Logs are sent as attachments +* `none` - Logs are not available through the bot + +#### logOptions +Options for logs + +##### logOptions.attachmentDirectory +**Default:** `logs` +When using `logStorage = "attachment"`, the directory where the log files are stored + +##### logOptions.allowAttachmentUrlFallback +**Default:** `off` +When using `logStorage = "attachment"`, if enabled, threads that don't have a log file will send a log link instead. +Useful if transitioning from `logStorage = "local"` (the default). + #### mainGuildId Alias for [mainServerId](#mainServerId) diff --git a/docs/plugin-api.md b/docs/plugin-api.md index fa0fdee..fa62912 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,6 +12,8 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu
PluginAttachmentsAPI : object
+
PluginLogsAPI : object
+
PluginHooksAPI : object
@@ -29,6 +31,7 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu | config | ModmailConfig | | commands | [PluginCommandsAPI](#PluginCommandsAPI) | | attachments | [PluginAttachmentsAPI](#PluginAttachmentsAPI) | +| logs | [PluginLogsAPI](#PluginLogsAPI) | | hooks | [PluginHooksAPI](#PluginHooksAPI) | | formats | FormattersExport | @@ -57,6 +60,20 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu | addStorageType | AddAttachmentStorageTypeFn | | downloadAttachment | DownloadAttachmentFn | + + +## PluginLogsAPI : object +**Kind**: global typedef +**Properties** + +| Name | Type | +| --- | --- | +| addStorageType | AddLogStorageTypeFn | +| saveLogToStorage | SaveLogToStorageFn | +| getLogUrl | GetLogUrlFn | +| getLogFile | GetLogFileFn | +| getLogCustomResponse | GetLogCustomResponseFn | + ## PluginHooksAPI : object @@ -66,4 +83,5 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu | Name | Type | | --- | --- | | beforeNewThread | AddBeforeNewThreadHookFn | +| afterThreadClose | AddAfterThreadCloseHookFn | diff --git a/docs/plugins.md b/docs/plugins.md index bf60e09..c037e9e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -34,6 +34,25 @@ module.exports = function({ attachments }) { ``` To use this custom attachment storage type, you would set the `attachmentStorage` config option to `"original"`. +### Example of a custom log storage type +This example adds a custom type for the `logStorage` option called `"pastebin"` that uploads logs to Pastebin. +The code that handles API calls to Pastebin is left out, as it's not relevant to the example. +```js +module.exports = function({ logs, formatters }) { + logs.addStorageType('pastebin', { + async save(thread, threadMessages) { + const formatLogResult = await formatters.formatLog(thread, threadMessages); + // formatLogResult.content contains the log text + // Some code here that uploads the full text to Pastebin, and stores the Pastebin link in the database + }, + + getUrl(threadId) { + // Find the previously-saved Pastebin link from the database based on the thread id, and return it + } + }); +}; +``` + ### Plugin API The first and only argument to the plugin function is an object with the following properties: @@ -44,6 +63,7 @@ The first and only argument to the plugin function is an object with the followi | `config` | The loaded config | | `commands` | An object with functions to add and manage commands | | `attachments` | An object with functions to save attachments and manage attachment storage types | +| `logs` | An object with functions to get attachment URLs/files and manage log storage types | | `hooks` | An object with functions to add *hooks* that are called at specific times, e.g. before a new thread is created | | `formats` | An object with functions that allow you to replace the default functions used for formatting messages and logs | diff --git a/src/cfg.js b/src/cfg.js index 3fcceab..e559d13 100644 --- a/src/cfg.js +++ b/src/cfg.js @@ -121,6 +121,10 @@ if (! config.sqliteOptions) { }; } +if (! config.logOptions) { + config.logOptions = {}; +} + // categoryAutomation.newThreadFromGuild => categoryAutomation.newThreadFromServer if (config.categoryAutomation && config.categoryAutomation.newThreadFromGuild && ! config.categoryAutomation.newThreadFromServer) { config.categoryAutomation.newThreadFromServer = config.categoryAutomation.newThreadFromGuild; diff --git a/src/data/Thread.js b/src/data/Thread.js index 087c997..e3b7b1f 100644 --- a/src/data/Thread.js +++ b/src/data/Thread.js @@ -727,13 +727,6 @@ class Thread { await this._deleteThreadMessage(threadMessage.id); } - - /** - * @returns {Promise} - */ - getLogUrl() { - return utils.getSelfUrl(`logs/${this.id}`); - } } module.exports = Thread; diff --git a/src/data/cfg.jsdoc.js b/src/data/cfg.jsdoc.js index 7b86312..1d46881 100644 --- a/src/data/cfg.jsdoc.js +++ b/src/data/cfg.jsdoc.js @@ -56,6 +56,10 @@ * @property {boolean} [createThreadOnMention=false] * @property {boolean} [notifyOnMainServerLeave=true] * @property {boolean} [notifyOnMainServerJoin=true] + * @property {string} [logStorage="local"] + * @property {object} [logOptions] + * @property {string} logOptions.attachmentDirectory + * @property {*} [logOptions.allowAttachmentUrlFallback=false] * @property {number} [port=8890] * @property {string} [url] * @property {array} [extraIntents=[]] diff --git a/src/data/cfg.schema.json b/src/data/cfg.schema.json index 697d2f4..96c42fc 100644 --- a/src/data/cfg.schema.json +++ b/src/data/cfg.schema.json @@ -316,6 +316,26 @@ "default": true }, + "logStorage": { + "type": "string", + "default": "local" + }, + + "logOptions": { + "type": "object", + "properties": { + "attachmentDirectory": { + "type": "string", + "default": "logs" + }, + "allowAttachmentUrlFallback": { + "$ref": "#/definitions/customBoolean", + "default": false + } + }, + "required": ["attachmentDirectory"] + }, + "port": { "type": "number", "maximum": 65535, diff --git a/src/data/logs.js b/src/data/logs.js new file mode 100644 index 0000000..a47521e --- /dev/null +++ b/src/data/logs.js @@ -0,0 +1,184 @@ +const Thread = require("./Thread"); +const ThreadMessage = require("./ThreadMessage"); +const utils = require("../utils"); +const config = require("../cfg"); +const { THREAD_STATUS } = require("./constants"); +const path = require("path"); +const fs = require("fs"); +const { formatters } = require("../formatters"); + +/** + * @typedef {object} LogStorageTypeHandler + * @property {LogStorageTypeHandlerSaveFn?} save + * @property {LogStorageTypeHandlerGetUrlFn?} getUrl + * @property {LogStorageTypeHandlerGetFileFn?} getFile + * @property {LogStorageTypeHandlerGetCustomResponseFn?} getCustomResponse + */ + +/** + * @callback LogStorageTypeHandlerSaveFn + * @param {Thread} thread + * @param {ThreadMessage[]} threadMessages + * @return {void|Promise} + */ + +/** + * @callback LogStorageTypeHandlerGetUrlFn + * @param {string} threadId + * @return {string|Promise|null|Promise} + */ + +/** + * @callback LogStorageTypeHandlerGetFileFn + * @param {string} threadId + * @return {Eris.MessageFile|Promise|null|Promise>} + */ + +/** + * @typedef {object} LogStorageTypeHandlerGetCustomResult + * @property {Eris.MessageContent?} content + * @property {Eris.MessageFile?} file + */ + +/** + * @callback LogStorageTypeHandlerGetCustomResponseFn + * @param {string} threadId + * @return {LogStorageTypeHandlerGetCustomResponseResult|Promise|null|Promise>} + */ + +/** + * @callback AddLogStorageTypeFn + * @param {string} name + * @param {LogStorageTypeHandler} handler + */ + +const logStorageTypes = {}; + +/** + * @type AddLogStorageTypeFn + */ +const addStorageType = (name, handler) => { + logStorageTypes[name] = handler; +}; + +/** + * @callback SaveLogToStorageFn + * @param {Thread} thread + * @param {ThreadMessage[]} threadMessages + * @returns {Promise} + */ +/** + * @type {SaveLogToStorageFn} + */ +const saveLogToStorage = async (thread, threadMessages) => { + const { save } = logStorageTypes[config.logStorage]; + if (save) { + await save(thread, threadMessages); + } +}; + +/** + * @callback GetLogUrlFn + * @param {string} threadId + * @returns {Promise} + */ +/** + * @type {GetLogUrlFn} + */ +const getLogUrl = async (threadId) => { + const { getUrl } = logStorageTypes[config.logStorage]; + return getUrl + ? getUrl(threadId) + : null; +}; + +/** + * @callback GetLogFileFn + * @param {string} threadId + * @returns {Promise} + */ +/** + * @type {GetLogFileFn} + */ +const getLogFile = async (threadId) => { + const { getFile } = logStorageTypes[config.logStorage]; + return getFile + ? getFile(threadId) + : null; +}; + +/** + * @callback GetLogCustomResponseFn + * @param {string} threadId + * @returns {Promise} + */ +/** + * @type {GetLogCustomResponseFn} + */ +const getLogCustomResponse = async (threadId) => { + const { getCustomResponse } = logStorageTypes[config.logStorage]; + return getCustomResponse + ? getCustomResponse(threadId) + : null; +}; + +addStorageType("local", { + getUrl(threadId) { + return utils.getSelfUrl(`logs/${threadId}`); + }, +}); + +const getLogAttachmentFilename = threadId => { + const filename = `${threadId}.txt`; + const fullPath = path.resolve(config.logOptions.attachmentDirectory, filename); + + return { filename, fullPath }; +}; + +addStorageType("attachment", { + async save(thread, threadMessages) { + const { fullPath } = getLogAttachmentFilename(thread.id); + + const formatLogResult = await formatters.formatLog(thread, threadMessages); + fs.writeFileSync(fullPath, formatLogResult.content, { encoding: "utf8" }); + }, + + async getUrl(threadId) { + if (! config.logOptions.allowAttachmentUrlFallback) { + return null; + } + + const { fullPath } = getLogAttachmentFilename(threadId); + try { + fs.accessSync(fullPath); + return null; + } catch (e) { + return utils.getSelfUrl(`logs/${threadId}`); + } + }, + + async getFile(threadId) { + const { filename, fullPath } = getLogAttachmentFilename(threadId); + + try { + fs.accessSync(fullPath); + } catch (e) { + return null; + } + + return { + file: fs.readFileSync(fullPath, { encoding: "utf8" }), + name: filename, + }; + } +}); + +addStorageType("none", {}); + +module.exports = { + addStorageType, + saveLogToStorage, + getLogUrl, + getLogFile, + getLogCustomResponse, +}; diff --git a/src/modules/close.js b/src/modules/close.js index 19679aa..eadece2 100644 --- a/src/modules/close.js +++ b/src/modules/close.js @@ -1,12 +1,38 @@ const moment = require("moment"); const Eris = require("eris"); -const config = require("../cfg"); const utils = require("../utils"); const threads = require("../data/threads"); const blocked = require("../data/blocked"); -const {messageQueue} = require("../queue"); +const { messageQueue } = require("../queue"); +const { getLogUrl, getLogFile, getLogCustomResponse } = require("../data/logs"); module.exports = ({ bot, knex, config, commands }) => { + async function sendCloseNotification(threadId, body) { + const logCustomResponse = await getLogCustomResponse(threadId); + if (logCustomResponse) { + await utils.postLog(body); + await utils.postLog(logCustomResponse.content, logCustomResponse.file); + return; + } + + const logUrl = await getLogUrl(threadId); + if (logUrl) { + utils.postLog(utils.trimAll(` + ${body} + Logs: ${logUrl} + `)); + return; + } + + const logFile = await getLogFile(threadId); + if (logFile) { + utils.postLog(body, logFile); + return; + } + + utils.postLog(body); + } + // Check for threads that are scheduled to be closed and close them async function applyScheduledCloses() { const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed(); @@ -18,11 +44,7 @@ module.exports = ({ bot, knex, config, commands }) => { 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} - `)); + await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}`); } } @@ -121,11 +143,7 @@ module.exports = ({ bot, knex, config, commands }) => { await thread.sendSystemMessageToUser(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} - `)); + await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}`); }); // Auto-close threads if their channel is deleted @@ -144,10 +162,6 @@ module.exports = ({ bot, knex, config, commands }) => { 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} - `)); + await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted`); }); }; diff --git a/src/modules/logs.js b/src/modules/logs.js index 61b7729..916a80f 100644 --- a/src/modules/logs.js +++ b/src/modules/logs.js @@ -1,10 +1,11 @@ const threads = require("../data/threads"); const moment = require("moment"); const utils = require("../utils"); +const { getLogUrl, getLogFile, getLogCustomResponse, saveLogToStorage } = require("../data/logs"); const LOG_LINES_PER_PAGE = 10; -module.exports = ({ bot, knex, config, commands }) => { +module.exports = ({ bot, knex, config, commands, hooks }) => { const logsCmd = async (msg, args, thread) => { let userId = args.userId || (thread && thread.user_id); if (! userId) return; @@ -29,9 +30,12 @@ module.exports = ({ bot, knex, config, commands }) => { 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 logUrl = await getLogUrl(thread.id); + const formattedLogUrl = logUrl + ? `<${logUrl}>` + : `View log with \`${config.prefix}log ${thread.id}\`` const formattedDate = moment.utc(thread.created_at).format("MMM Do [at] HH:mm [UTC]"); - return `\`${formattedDate}\`: <${logUrl}>`; + return `\`${formattedDate}\`: ${formattedLogUrl}`; })); let message = isPaginated @@ -54,34 +58,39 @@ module.exports = ({ bot, knex, config, commands }) => { }); }; + const logCmd = async (msg, args, thread) => { + const threadId = args.threadId || (thread && thread.id); + if (! threadId) return; + + const customResponse = await getLogCustomResponse(threadId); + if (customResponse && (customResponse.content || customResponse.file)) { + msg.channel.createMessage(customResponse.content, customResponse.file); + } + + const logUrl = await getLogUrl(threadId); + if (logUrl) { + msg.channel.createMessage(`Open the following link to view the log:\n<${logUrl}>`); + return; + } + + const logFile = await getLogFile(threadId); + if (logFile) { + msg.channel.createMessage("Download the following file to view the log:", logFile); + return; + } + + msg.channel.createMessage("This thread's logs are not currently available"); + }; + commands.addInboxServerCommand("logs", " [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; - } + commands.addInboxServerCommand("log", "[threadId:string]", logCmd); + commands.addInboxServerCommand("loglink", "[threadId:string]", logCmd); - const logUrl = await thread.getLogUrl(); - const query = []; - if (args.verbose) query.push("verbose=1"); - if (args.simple) query.push("simple=1"); - let qs = query.length ? `?${query.join("&")}` : ""; - - thread.postSystemMessage(`Log URL: ${logUrl}${qs}`); - }, { - options: [ - { - name: "verbose", - shortcut: "v", - isSwitch: true, - }, - { - name: "simple", - shortcut: "s", - isSwitch: true, - }, - ], + hooks.afterThreadClose(async ({ threadId }) => { + const thread = await threads.findById(threadId); + const threadMessages = await thread.getThreadMessages(); + await saveLogToStorage(thread, threadMessages); }); }; diff --git a/src/pluginApi.js b/src/pluginApi.js index 2e4b381..662c832 100644 --- a/src/pluginApi.js +++ b/src/pluginApi.js @@ -9,6 +9,7 @@ const Knex = require("knex"); * @property {ModmailConfig} config * @property {PluginCommandsAPI} commands * @property {PluginAttachmentsAPI} attachments + * @property {PluginLogsAPI} logs * @property {PluginHooksAPI} hooks * @property {FormattersExport} formats */ @@ -28,6 +29,15 @@ const Knex = require("knex"); * @property {DownloadAttachmentFn} downloadAttachment */ +/** + * @typedef {object} PluginLogsAPI + * @property {AddLogStorageTypeFn} addStorageType + * @property {SaveLogToStorageFn} saveLogToStorage + * @property {GetLogUrlFn} getLogUrl + * @property {GetLogFileFn} getLogFile + * @property {GetLogCustomResponseFn} getLogCustomResponse + */ + /** * @typedef {object} PluginHooksAPI * @property {AddBeforeNewThreadHookFn} beforeNewThread diff --git a/src/plugins.js b/src/plugins.js index 71bcf28..4de156d 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -1,4 +1,5 @@ const attachments = require("./data/attachments"); +const logs = require("./data/logs"); const { beforeNewThread } = require("./hooks/beforeNewThread"); const { afterThreadClose } = require("./hooks/afterThreadClose"); const formats = require("./formatters"); @@ -27,6 +28,13 @@ module.exports = { addStorageType: attachments.addStorageType, downloadAttachment: attachments.downloadAttachment }, + logs: { + addStorageType: logs.addStorageType, + saveLogToStorage: logs.saveLogToStorage, + getLogUrl: logs.getLogUrl, + getLogFile: logs.getLogFile, + getLogCustomResponse: logs.getLogCustomResponse, + }, hooks: { beforeNewThread, afterThreadClose, diff --git a/src/utils.js b/src/utils.js index 47b92d3..d97447a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -61,7 +61,7 @@ function getLogChannel() { } function postLog(...args) { - getLogChannel().createMessage(...args); + return getLogChannel().createMessage(...args); } function postError(channel, str, opts = {}) {