From 3a7f7ffc9001c24637080790630a044af4a780e5 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Wed, 23 Sep 2020 03:16:26 +0300
Subject: [PATCH] Add support for alternative log storage types
---
docs/configuration.md | 19 ++++
docs/plugin-api.md | 18 ++++
docs/plugins.md | 20 +++++
src/cfg.js | 4 +
src/data/Thread.js | 7 --
src/data/cfg.jsdoc.js | 4 +
src/data/cfg.schema.json | 20 +++++
src/data/logs.js | 184 +++++++++++++++++++++++++++++++++++++++
src/modules/close.js | 48 ++++++----
src/modules/logs.js | 65 ++++++++------
src/pluginApi.js | 10 +++
src/plugins.js | 8 ++
src/utils.js | 2 +-
13 files changed, 356 insertions(+), 53 deletions(-)
create mode 100644 src/data/logs.js
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 = {}) {