aboutsummaryrefslogtreecommitdiff
path: root/src/config.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.js')
-rw-r--r--src/config.js289
1 files changed, 289 insertions, 0 deletions
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..a2e3beb
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,289 @@
+/**
+ * !!! NOTE !!!
+ *
+ * If you're setting up the bot, DO NOT EDIT THIS FILE DIRECTLY!
+ *
+ * Create a configuration file in the same directory as the example file.
+ * You never need to edit anything under src/ to use the bot.
+ *
+ * !!! NOTE !!!
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+let userConfig = {};
+
+// Config files to search for, in priority order
+const configFiles = [
+ 'config.ini',
+ 'config.ini.ini',
+ 'config.ini.txt',
+ 'config.json',
+ 'config.json5',
+ 'config.json.json',
+ 'config.json.txt',
+ 'config.js'
+];
+
+let foundConfigFile;
+for (const configFile of configFiles) {
+ try {
+ fs.accessSync(__dirname + '/../' + configFile);
+ foundConfigFile = configFile;
+ break;
+ } catch (e) {}
+}
+
+// Load config file
+if (foundConfigFile) {
+ console.log(`Loading configuration from ${foundConfigFile}...`);
+ try {
+ if (foundConfigFile.endsWith('.js')) {
+ userConfig = require(`../${foundConfigFile}`);
+ } else {
+ const raw = fs.readFileSync(__dirname + '/../' + foundConfigFile, {encoding: "utf8"});
+ if (foundConfigFile.endsWith('.ini') || foundConfigFile.endsWith('.ini.txt')) {
+ userConfig = require('ini').decode(raw);
+ } else {
+ userConfig = require('json5').parse(raw);
+ }
+ }
+ } catch (e) {
+ throw new Error(`Error reading config file! The error given was: ${e.message}`);
+ }
+}
+
+const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
+const numericOptions = ['requiredAccountAge', 'requiredTimeOnServer', 'smallAttachmentLimit', 'port'];
+
+const defaultConfig = {
+ "token": null,
+ "mailGuildId": null,
+ "mainGuildId": null,
+ "logChannelId": null,
+
+ "prefix": "!",
+ "snippetPrefix": "!!",
+ "snippetPrefixAnon": "!!!",
+
+ "status": "Message me for help!",
+ "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible.",
+ "closeMessage": null,
+ "allowUserClose": false,
+
+ "newThreadCategoryId": null,
+ "mentionRole": "here",
+ "pingOnBotMention": true,
+ "botMentionResponse": null,
+
+ "inboxServerPermission": null,
+ "alwaysReply": false,
+ "alwaysReplyAnon": false,
+ "useNicknames": false,
+ "ignoreAccidentalThreads": false,
+ "threadTimestamps": false,
+ "allowMove": false,
+ "syncPermissionsOnMove": true,
+ "typingProxy": false,
+ "typingProxyReverse": false,
+ "mentionUserInThreadHeader": false,
+ "rolesInThreadHeader": false,
+
+ "enableGreeting": false,
+ "greetingMessage": null,
+ "greetingAttachment": null,
+
+ "guildGreetings": {},
+
+ "requiredAccountAge": null, // In hours
+ "accountAgeDeniedMessage": "Your Discord account is not old enough to contact modmail.",
+
+ "requiredTimeOnServer": null, // In minutes
+ "timeOnServerDeniedMessage": "You haven't been a member of the server for long enough to contact modmail.",
+
+ "relaySmallAttachmentsAsAttachments": false,
+ "smallAttachmentLimit": 1024 * 1024 * 2,
+ "attachmentStorage": "local",
+ "attachmentStorageChannelId": null,
+
+ "categoryAutomation": {},
+
+ "updateNotifications": true,
+ "plugins": [],
+
+ "commandAliases": {},
+
+ "port": 8890,
+ "url": null,
+
+ "dbDir": path.join(__dirname, '..', 'db'),
+ "knex": null,
+
+ "logDir": path.join(__dirname, '..', 'logs'),
+};
+
+// Load config values from environment variables
+const envKeyPrefix = 'MM_';
+let loadedEnvValues = 0;
+
+for (const [key, value] of Object.entries(process.env)) {
+ if (! key.startsWith(envKeyPrefix)) continue;
+
+ // MM_CLOSE_MESSAGE -> closeMessage
+ // MM_COMMAND_ALIASES__MV => commandAliases.mv
+ const configKey = key.slice(envKeyPrefix.length)
+ .toLowerCase()
+ .replace(/([a-z])_([a-z])/g, (m, m1, m2) => `${m1}${m2.toUpperCase()}`)
+ .replace('__', '.');
+
+ userConfig[configKey] = value.includes('||')
+ ? value.split('||')
+ : value;
+
+ loadedEnvValues++;
+}
+
+if (process.env.PORT && !process.env.MM_PORT) {
+ // Special case: allow common "PORT" environment variable without prefix
+ userConfig.port = process.env.PORT;
+ loadedEnvValues++;
+}
+
+if (loadedEnvValues > 0) {
+ console.log(`Loaded ${loadedEnvValues} ${loadedEnvValues === 1 ? 'value' : 'values'} from environment variables`);
+}
+
+// Convert config keys with periods to objects
+// E.g. commandAliases.mv -> commandAliases: { mv: ... }
+for (const [key, value] of Object.entries(userConfig)) {
+ if (! key.includes('.')) continue;
+
+ const keys = key.split('.');
+ let cursor = userConfig;
+ for (let i = 0; i < keys.length; i++) {
+ if (i === keys.length - 1) {
+ cursor[keys[i]] = value;
+ } else {
+ cursor[keys[i]] = cursor[keys[i]] || {};
+ cursor = cursor[keys[i]];
+ }
+ }
+
+ delete userConfig[key];
+}
+
+// Combine user config with default config to form final config
+const finalConfig = Object.assign({}, defaultConfig);
+
+for (const [prop, value] of Object.entries(userConfig)) {
+ if (! defaultConfig.hasOwnProperty(prop)) {
+ throw new Error(`Unknown option: ${prop}`);
+ }
+
+ finalConfig[prop] = value;
+}
+
+// Default knex config
+if (! finalConfig['knex']) {
+ finalConfig['knex'] = {
+ client: 'sqlite',
+ connection: {
+ filename: path.join(finalConfig.dbDir, 'data.sqlite')
+ },
+ useNullAsDefault: true
+ };
+}
+
+// Make sure migration settings are always present in knex config
+Object.assign(finalConfig['knex'], {
+ migrations: {
+ directory: path.join(finalConfig.dbDir, 'migrations')
+ }
+});
+
+if (finalConfig.smallAttachmentLimit > 1024 * 1024 * 8) {
+ finalConfig.smallAttachmentLimit = 1024 * 1024 * 8;
+ console.warn('[WARN] smallAttachmentLimit capped at 8MB');
+}
+
+// Specific checks
+if (finalConfig.attachmentStorage === 'discord' && ! finalConfig.attachmentStorageChannelId) {
+ console.error('Config option \'attachmentStorageChannelId\' is required with attachment storage \'discord\'');
+ process.exit(1);
+}
+
+// Make sure mainGuildId is internally always an array
+if (! Array.isArray(finalConfig['mainGuildId'])) {
+ finalConfig['mainGuildId'] = [finalConfig['mainGuildId']];
+}
+
+// Make sure inboxServerPermission is always an array
+if (! Array.isArray(finalConfig['inboxServerPermission'])) {
+ if (finalConfig['inboxServerPermission'] == null) {
+ finalConfig['inboxServerPermission'] = [];
+ } else {
+ finalConfig['inboxServerPermission'] = [finalConfig['inboxServerPermission']];
+ }
+}
+
+// Move greetingMessage/greetingAttachment to the guildGreetings object internally
+// Or, in other words, if greetingMessage and/or greetingAttachment is set, it is applied for all servers that don't
+// already have something set up in guildGreetings. This retains backwards compatibility while allowing you to override
+// greetings for specific servers in guildGreetings.
+if (finalConfig.greetingMessage || finalConfig.greetingAttachment) {
+ for (const guildId of finalConfig.mainGuildId) {
+ if (finalConfig.guildGreetings[guildId]) continue;
+ finalConfig.guildGreetings[guildId] = {
+ message: finalConfig.greetingMessage,
+ attachment: finalConfig.greetingAttachment
+ };
+ }
+}
+
+// newThreadCategoryId is syntactic sugar for categoryAutomation.newThread
+if (finalConfig.newThreadCategoryId) {
+ finalConfig.categoryAutomation.newThread = finalConfig.newThreadCategoryId;
+ delete finalConfig.newThreadCategoryId;
+}
+
+// Turn empty string options to null (i.e. "option=" without a value in config.ini)
+for (const [key, value] of Object.entries(finalConfig)) {
+ if (value === '') {
+ finalConfig[key] = null;
+ }
+}
+
+// Cast numeric options to numbers
+for (const numericOpt of numericOptions) {
+ if (finalConfig[numericOpt] != null) {
+ const number = parseFloat(finalConfig[numericOpt]);
+ if (Number.isNaN(number)) {
+ console.error(`Invalid numeric value for ${numericOpt}: ${finalConfig[numericOpt]}`);
+ process.exit(1);
+ }
+ finalConfig[numericOpt] = number;
+ }
+}
+
+// Cast boolean options (on, true, 1) (off, false, 0)
+for (const [key, value] of Object.entries(finalConfig)) {
+ if (typeof value !== "string") continue;
+ if (["on", "true", "1"].includes(value)) {
+ finalConfig[key] = true;
+ } else if (["off", "false", "0"].includes(value)) {
+ finalConfig[key] = false;
+ }
+}
+
+// Make sure all of the required config options are present
+for (const opt of required) {
+ if (! finalConfig[opt]) {
+ console.error(`Missing required config.json value: ${opt}`);
+ process.exit(1);
+ }
+}
+
+console.log("Configuration ok!");
+
+module.exports = finalConfig;