Add support for alternative log storage types

cshd
Dragory 2020-09-23 03:16:26 +03:00
parent a7e863da6a
commit 3a7f7ffc90
No known key found for this signature in database
GPG Key ID: 5F387BA66DF8AAC1
13 changed files with 356 additions and 53 deletions

View File

@ -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.` **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. 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 #### mainGuildId
Alias for [mainServerId](#mainServerId) Alias for [mainServerId](#mainServerId)

View File

@ -12,6 +12,8 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu
<dd></dd> <dd></dd>
<dt><a href="#PluginAttachmentsAPI">PluginAttachmentsAPI</a> : <code>object</code></dt> <dt><a href="#PluginAttachmentsAPI">PluginAttachmentsAPI</a> : <code>object</code></dt>
<dd></dd> <dd></dd>
<dt><a href="#PluginLogsAPI">PluginLogsAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginHooksAPI">PluginHooksAPI</a> : <code>object</code></dt> <dt><a href="#PluginHooksAPI">PluginHooksAPI</a> : <code>object</code></dt>
<dd></dd> <dd></dd>
</dl> </dl>
@ -29,6 +31,7 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu
| config | <code>ModmailConfig</code> | | config | <code>ModmailConfig</code> |
| commands | [<code>PluginCommandsAPI</code>](#PluginCommandsAPI) | | commands | [<code>PluginCommandsAPI</code>](#PluginCommandsAPI) |
| attachments | [<code>PluginAttachmentsAPI</code>](#PluginAttachmentsAPI) | | attachments | [<code>PluginAttachmentsAPI</code>](#PluginAttachmentsAPI) |
| logs | [<code>PluginLogsAPI</code>](#PluginLogsAPI) |
| hooks | [<code>PluginHooksAPI</code>](#PluginHooksAPI) | | hooks | [<code>PluginHooksAPI</code>](#PluginHooksAPI) |
| formats | <code>FormattersExport</code> | | formats | <code>FormattersExport</code> |
@ -57,6 +60,20 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu
| addStorageType | <code>AddAttachmentStorageTypeFn</code> | | addStorageType | <code>AddAttachmentStorageTypeFn</code> |
| downloadAttachment | <code>DownloadAttachmentFn</code> | | downloadAttachment | <code>DownloadAttachmentFn</code> |
<a name="PluginLogsAPI"></a>
## PluginLogsAPI : <code>object</code>
**Kind**: global typedef
**Properties**
| Name | Type |
| --- | --- |
| addStorageType | <code>AddLogStorageTypeFn</code> |
| saveLogToStorage | <code>SaveLogToStorageFn</code> |
| getLogUrl | <code>GetLogUrlFn</code> |
| getLogFile | <code>GetLogFileFn</code> |
| getLogCustomResponse | <code>GetLogCustomResponseFn</code> |
<a name="PluginHooksAPI"></a> <a name="PluginHooksAPI"></a>
## PluginHooksAPI : <code>object</code> ## PluginHooksAPI : <code>object</code>
@ -66,4 +83,5 @@ Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plu
| Name | Type | | Name | Type |
| --- | --- | | --- | --- |
| beforeNewThread | <code>AddBeforeNewThreadHookFn</code> | | beforeNewThread | <code>AddBeforeNewThreadHookFn</code> |
| afterThreadClose | <code>AddAfterThreadCloseHookFn</code> |

View File

@ -34,6 +34,25 @@ module.exports = function({ attachments }) {
``` ```
To use this custom attachment storage type, you would set the `attachmentStorage` config option to `"original"`. 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 ### Plugin API
The first and only argument to the plugin function is an object with the following properties: 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 | | `config` | The loaded config |
| `commands` | An object with functions to add and manage commands | | `commands` | An object with functions to add and manage commands |
| `attachments` | An object with functions to save attachments and manage attachment storage types | | `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 | | `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 | | `formats` | An object with functions that allow you to replace the default functions used for formatting messages and logs |

View File

@ -121,6 +121,10 @@ if (! config.sqliteOptions) {
}; };
} }
if (! config.logOptions) {
config.logOptions = {};
}
// categoryAutomation.newThreadFromGuild => categoryAutomation.newThreadFromServer // categoryAutomation.newThreadFromGuild => categoryAutomation.newThreadFromServer
if (config.categoryAutomation && config.categoryAutomation.newThreadFromGuild && ! config.categoryAutomation.newThreadFromServer) { if (config.categoryAutomation && config.categoryAutomation.newThreadFromGuild && ! config.categoryAutomation.newThreadFromServer) {
config.categoryAutomation.newThreadFromServer = config.categoryAutomation.newThreadFromGuild; config.categoryAutomation.newThreadFromServer = config.categoryAutomation.newThreadFromGuild;

View File

@ -727,13 +727,6 @@ class Thread {
await this._deleteThreadMessage(threadMessage.id); await this._deleteThreadMessage(threadMessage.id);
} }
/**
* @returns {Promise<String>}
*/
getLogUrl() {
return utils.getSelfUrl(`logs/${this.id}`);
}
} }
module.exports = Thread; module.exports = Thread;

View File

@ -56,6 +56,10 @@
* @property {boolean} [createThreadOnMention=false] * @property {boolean} [createThreadOnMention=false]
* @property {boolean} [notifyOnMainServerLeave=true] * @property {boolean} [notifyOnMainServerLeave=true]
* @property {boolean} [notifyOnMainServerJoin=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 {number} [port=8890]
* @property {string} [url] * @property {string} [url]
* @property {array} [extraIntents=[]] * @property {array} [extraIntents=[]]

View File

@ -316,6 +316,26 @@
"default": true "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": { "port": {
"type": "number", "type": "number",
"maximum": 65535, "maximum": 65535,

184
src/data/logs.js Normal file
View File

@ -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<void>}
*/
/**
* @callback LogStorageTypeHandlerGetUrlFn
* @param {string} threadId
* @return {string|Promise<string>|null|Promise<null>}
*/
/**
* @callback LogStorageTypeHandlerGetFileFn
* @param {string} threadId
* @return {Eris.MessageFile|Promise<Eris.MessageFile>|null|Promise<null>>}
*/
/**
* @typedef {object} LogStorageTypeHandlerGetCustomResult
* @property {Eris.MessageContent?} content
* @property {Eris.MessageFile?} file
*/
/**
* @callback LogStorageTypeHandlerGetCustomResponseFn
* @param {string} threadId
* @return {LogStorageTypeHandlerGetCustomResponseResult|Promise<LogStorageTypeHandlerGetCustomResponseResult>|null|Promise<null>>}
*/
/**
* @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<void>}
*/
/**
* @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<string|null>}
*/
/**
* @type {GetLogUrlFn}
*/
const getLogUrl = async (threadId) => {
const { getUrl } = logStorageTypes[config.logStorage];
return getUrl
? getUrl(threadId)
: null;
};
/**
* @callback GetLogFileFn
* @param {string} threadId
* @returns {Promise<Eris.MessageFile|null>}
*/
/**
* @type {GetLogFileFn}
*/
const getLogFile = async (threadId) => {
const { getFile } = logStorageTypes[config.logStorage];
return getFile
? getFile(threadId)
: null;
};
/**
* @callback GetLogCustomResponseFn
* @param {string} threadId
* @returns {Promise<LogStorageTypeHandlerGetCustomResult|null>}
*/
/**
* @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,
};

View File

@ -1,12 +1,38 @@
const moment = require("moment"); const moment = require("moment");
const Eris = require("eris"); const Eris = require("eris");
const config = require("../cfg");
const utils = require("../utils"); const utils = require("../utils");
const threads = require("../data/threads"); const threads = require("../data/threads");
const blocked = require("../data/blocked"); 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 }) => { 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 // Check for threads that are scheduled to be closed and close them
async function applyScheduledCloses() { async function applyScheduledCloses() {
const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed(); const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed();
@ -18,11 +44,7 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.close(false, thread.scheduled_close_silent); await thread.close(false, thread.scheduled_close_silent);
const logUrl = await thread.getLogUrl(); await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}`);
utils.postLog(utils.trimAll(`
Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}
Logs: ${logUrl}
`));
} }
} }
@ -121,11 +143,7 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.sendSystemMessageToUser(closeMessage).catch(() => {}); await thread.sendSystemMessageToUser(closeMessage).catch(() => {});
} }
const logUrl = await thread.getLogUrl(); await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}`);
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 // Auto-close threads if their channel is deleted
@ -144,10 +162,6 @@ module.exports = ({ bot, knex, config, commands }) => {
await thread.close(true); await thread.close(true);
const logUrl = await thread.getLogUrl(); await sendCloseNotification(thread.id, `Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted`);
utils.postLog(utils.trimAll(`
Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted
Logs: ${logUrl}
`));
}); });
}; };

View File

@ -1,10 +1,11 @@
const threads = require("../data/threads"); const threads = require("../data/threads");
const moment = require("moment"); const moment = require("moment");
const utils = require("../utils"); const utils = require("../utils");
const { getLogUrl, getLogFile, getLogCustomResponse, saveLogToStorage } = require("../data/logs");
const LOG_LINES_PER_PAGE = 10; 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) => { const logsCmd = async (msg, args, thread) => {
let userId = args.userId || (thread && thread.user_id); let userId = args.userId || (thread && thread.user_id);
if (! userId) return; 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); userThreads = userThreads.slice((page - 1) * LOG_LINES_PER_PAGE, page * LOG_LINES_PER_PAGE);
const threadLines = await Promise.all(userThreads.map(async thread => { 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]"); const formattedDate = moment.utc(thread.created_at).format("MMM Do [at] HH:mm [UTC]");
return `\`${formattedDate}\`: <${logUrl}>`; return `\`${formattedDate}\`: ${formattedLogUrl}`;
})); }));
let message = isPaginated 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", "<userId:userId> [page:number]", logsCmd); commands.addInboxServerCommand("logs", "<userId:userId> [page:number]", logsCmd);
commands.addInboxServerCommand("logs", "[page:number]", logsCmd); commands.addInboxServerCommand("logs", "[page:number]", logsCmd);
commands.addInboxServerCommand("loglink", [], async (msg, args, thread) => { commands.addInboxServerCommand("log", "[threadId:string]", logCmd);
if (! thread) { commands.addInboxServerCommand("loglink", "[threadId:string]", logCmd);
thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
if (! thread) return;
}
const logUrl = await thread.getLogUrl(); hooks.afterThreadClose(async ({ threadId }) => {
const query = []; const thread = await threads.findById(threadId);
if (args.verbose) query.push("verbose=1"); const threadMessages = await thread.getThreadMessages();
if (args.simple) query.push("simple=1"); await saveLogToStorage(thread, threadMessages);
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,
},
],
}); });
}; };

View File

@ -9,6 +9,7 @@ const Knex = require("knex");
* @property {ModmailConfig} config * @property {ModmailConfig} config
* @property {PluginCommandsAPI} commands * @property {PluginCommandsAPI} commands
* @property {PluginAttachmentsAPI} attachments * @property {PluginAttachmentsAPI} attachments
* @property {PluginLogsAPI} logs
* @property {PluginHooksAPI} hooks * @property {PluginHooksAPI} hooks
* @property {FormattersExport} formats * @property {FormattersExport} formats
*/ */
@ -28,6 +29,15 @@ const Knex = require("knex");
* @property {DownloadAttachmentFn} downloadAttachment * @property {DownloadAttachmentFn} downloadAttachment
*/ */
/**
* @typedef {object} PluginLogsAPI
* @property {AddLogStorageTypeFn} addStorageType
* @property {SaveLogToStorageFn} saveLogToStorage
* @property {GetLogUrlFn} getLogUrl
* @property {GetLogFileFn} getLogFile
* @property {GetLogCustomResponseFn} getLogCustomResponse
*/
/** /**
* @typedef {object} PluginHooksAPI * @typedef {object} PluginHooksAPI
* @property {AddBeforeNewThreadHookFn} beforeNewThread * @property {AddBeforeNewThreadHookFn} beforeNewThread

View File

@ -1,4 +1,5 @@
const attachments = require("./data/attachments"); const attachments = require("./data/attachments");
const logs = require("./data/logs");
const { beforeNewThread } = require("./hooks/beforeNewThread"); const { beforeNewThread } = require("./hooks/beforeNewThread");
const { afterThreadClose } = require("./hooks/afterThreadClose"); const { afterThreadClose } = require("./hooks/afterThreadClose");
const formats = require("./formatters"); const formats = require("./formatters");
@ -27,6 +28,13 @@ module.exports = {
addStorageType: attachments.addStorageType, addStorageType: attachments.addStorageType,
downloadAttachment: attachments.downloadAttachment downloadAttachment: attachments.downloadAttachment
}, },
logs: {
addStorageType: logs.addStorageType,
saveLogToStorage: logs.saveLogToStorage,
getLogUrl: logs.getLogUrl,
getLogFile: logs.getLogFile,
getLogCustomResponse: logs.getLogCustomResponse,
},
hooks: { hooks: {
beforeNewThread, beforeNewThread,
afterThreadClose, afterThreadClose,

View File

@ -61,7 +61,7 @@ function getLogChannel() {
} }
function postLog(...args) { function postLog(...args) {
getLogChannel().createMessage(...args); return getLogChannel().createMessage(...args);
} }
function postError(channel, str, opts = {}) { function postError(channel, str, opts = {}) {