cshd
Matthew 2021-09-23 22:26:49 -04:00
commit 21050bf497
No known key found for this signature in database
GPG Key ID: 210AF32ADE3B5C4B
108 changed files with 16612 additions and 3469 deletions

View File

@ -19,6 +19,8 @@
"!": true,
"!!": true
}
}]
}],
"quotes": ["error", "double"],
"no-shadow": "error"
}
}

9
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,9 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"
target-branch: "dev"
allow:
- dependency-type: "direct"

7
.gitignore vendored
View File

@ -1,7 +1,10 @@
/.vscode
/.idea
/node_modules
/config.*
!/config.example.ini
/welcome.png
/update.sh
# Config files
/config.*
*.config.ini
!/config.example.ini

2
.nvmrc
View File

@ -1 +1 @@
10
14

View File

@ -1,20 +1,280 @@
# Changelog
For instructions on how to update the bot, see **[✨ Updating the bot](docs/updating.md)**
## v2.31.0-beta.0
This is a beta release. It is not available on the Releases page and bugs are expected.
## v3.3.2
* Fix database warning when updating to v3.3.1 or higher
* Add support for Node.js 13
* Support for Node.js 14 is coming in a future update that will also drop Node.js 10 support.
This mirrors the `sqlite3` library's version support.
* Log formatting is now more consistent and easier to parse with automated tools
## v3.3.1
* Fix crash when a user joins or leaves a [stage channel](https://blog.discord.com/captivate-your-community-with-stage-channels-46bbb756e89b)
* Fix global moderator display role overrides (i.e. `!role` used outside of a thread) not working
## v3.3.0
**Breaking changes:**
* The default value for [`inboxServerPermission`](docs/configuration.md#inboxServerPermission) is now `manageMessages`
* This means that after updating, if you haven't set `inboxServerPermission` in your `config.ini`,
only those members with the "Manage Messages" permission on the inbox server will be able to use the bot's commands
* The default value for [`mentionRole`](docs/configuration.md#mentionRole) is now `none`
* To turn `@here` pings back on for new threads, set `mentionRole = here` in your `config.ini`
**General changes:**
* New option: `showResponseMessageInThreadChannel`
* Controls whether the bot's response message is shown in the thread channel
* Bot and Node.js version is now shown on bot start-up
* `!close silent` can now also be used with `!close -silent` and `!close -s` ([#528](https://github.com/Dragory/modmailbot/pull/528) by [@SnowyLuma](https://github.com/SnowyLuma))
* `!close cancel` can now also be used with `!close -cancel` and `!close -c`
* `config.example.ini` now contains several common options by default
* When starting the bot via command line, you can now specify which config file to load with the `--config`/`-c` CLI option
* E.g. `npm start -- -c my-other-config.ini` (note the `--` between `npm start` and the option)
* Updated bot dependencies
**Plugins:**
* Plugins are now installed before connecting to the Discord Gateway
* Fix GitHub-based NPM plugins requiring Git to be installed to work
* If you need to install GitHub-based plugins with Git after this change, set `useGitForGitHubPlugins = on` in your config
* Plugin installation errors are no longer truncated
## v3.2.0
**General changes:**
* Updated to Eris 0.14.0, which changes the API url used to `discord.com` instead of `discordapp.com`
* API calls to `discordapp.com` are being phased out on Nov 7, 2020
* This means that bot versions prior to v3.2.0 *might stop working* on Nov 7, 2020
* New options: `allowBlock`, `allowSuspend`, `allowSnippets` ([#498](https://github.com/Dragory/modmailbot/pull/498) by [@lirolake](https://github.com/lirolake))
* These all default to `on`
* Improved error messages and error handling
* Removes at least one instance of ECONNRESET errors
* Fixed issue where NPM plugins would not install on Windows
* Fixed issue where mentions by the bot were not working in certain situations ([#496](https://github.com/Dragory/modmailbot/pull/496) by [@DarkView](https://github.com/DarkView))
* Fixed issue where long system messages (primarily from plugins) would not get chunked properly and would throw an error instead
**Plugins:**
* Make sure to check the [Eris 0.14.0 changelog](https://github.com/abalabahaha/eris/releases/tag/0.14.0) for any changes that might affect your plugins
* The `attachments` object now includes a new `saveAttachment()` function to save arbitrary attachments using the bot's `attachmentStorage`
* Fixed the `ignoreHooks` option for `threads.createNewThreadForUser()` not working
* Fixed `!newthread` throwing an error if a plugin cancels thread creation in the `beforeNewThread` hook
## v3.1.0
* Each thread is now assigned a number, increasing by 1 each thread. This can be used in commands like `!log` in place of the full thread ID.
* New option: `autoAlert`
* When enabled, the last moderator to reply to a modmail thread will be automatically alerted when the thread gets a new reply
* Auto-alert kicks in after a small delay after the reply to prevent alerts in the middle of an ongoing conversation. This delay is set by the option `autoAlertDelay`.
* New option: `pinThreadHeader`
* When enabled, the bot will automatically pin the "thread header" message that contains the user's details
* `!thread` is now an alias for `!log`/`!loglink`
* Fix some bugs with the `mentionRole` option
* `mentionRole = off` now behaves the same as `mentionRole = none`
* Fixed snippet previews (via `!snippet snippet_name_here`) sometimes cutting off the first word ([#491](https://github.com/Dragory/modmailbot/pull/491) by @Gugu7264)
* When calling `threads.createNewThreadForUser()`, plugins can now specify `mentionRole` as one of the options to override the default mentionRole config option value for the new thread
## v3.0.3
* Fix inline snippets only working once per reply
## v3.0.2
* Fix `npm ci` and `start.bat` failing when Git is not installed
## v3.0.1
* Fix local attachments not being accessible through the bot's links
## v3.0.0
*This changelog also includes changes from v2.31.0-beta.1 and v2.31.0-beta.2*
**General changes:**
* **BREAKING CHANGE:** Logs from Modmail versions prior to Feb 2018 are no longer converted automatically
* To update from a Modmail version from before Feb 2018, update to `v2.30.1` and run the bot once first. Then you can update to version v3.0.0 and later.
* **BREAKING CHANGE:** Added support for Node.js 13 and 14, **dropped support for Node.js 10 and 11**
* The supported versions are now 12, 13, and 14
* **BREAKING CHANGE:** The bot now requests the necessary [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents)
* **This includes the privileged "Server Members Intent"**, which is used for server greetings/welcome messages.
This means that [**you need to turn on "Server Members Intent"**](docs/server-members-intent-2.png) on the bot's page on the Discord Developer Portal.
* Added support for editing and deleting staff replies via new `!edit` and `!delete` commands
* This is **enabled by default**
* This can be disabled with the `allowStaffEdit` and `allowStaffDelete` options
* Only the staff member who sent the reply can edit/delete it
* Renamed the following options. Old names are still supported as aliases, so old config files won't break.
* `mainGuildId` => `mainServerId`
* `mailGuildId` => `inboxServerId`
* `categoryAutomation.newThreadFromGuild` => `categoryAutomation.newThreadFromServer`
* `guildGreetings` => `serverGreetings`
* Moderators can now set the role they'd like to be displayed with their replies ("display role") by default and on a per-thread basis by using `!role`
* Moderators can only choose roles they currently have
* You can view your current display role by using `!role`
* If you're in a modmail thread, this will show your display role for that thread
* If you're *not* in a modmail thread, this will show your *default* display role
* You can set the display role by using `!role <role name>`, e.g. `!role Interviewer`
* If you're in a modmail thread, this will set your display role for that thread
* If you're *not* in a modmail thread, this will set your *default* display role
* You can reset the display role by using `!role reset`
* If you're in a modmail thread, this will reset your display role for that thread to the default
* If you're *not* in a modmail thread, this will reset your *default* display role
* This feature can be disabled by setting `allowChangingDisplayRole = off`
* New option: `fallbackRoleName`
* Sets the role name to display in moderator replies if the moderator doesn't have a hoisted role
* New option `logStorage`
* Allows changing how logs are stored
* Possible values are `local` (default), `attachment`, and `none`
* New **default** attachment storage option: `original`
* This option simply links the original attachment and does not rehost it in any way
* New option `reactOnSeen` ([#398](https://github.com/Dragory/modmailbot/pull/398) by @Eegras)
* When enabled, the bot will react to user DMs with a checkmark when they have been received
* The reaction emoji can be customized with the `reactOnSeenEmoji` option
* New option `createThreadOnMention` ([#397](https://github.com/Dragory/modmailbot/pull/397) by @dopeghoti)
* When enabled, a new modmail thread will be created whenever a user mentions/pings the bot on the main server
* As with `pingOnBotMention`, staff members are automatically ignored
* New option `statusType`
* Allows changing the bot's status type between "Playing", "Watching", "Listening"
* Possible values are `playing` (default), `watching`, `listening`
* New option `anonymizeChannelName` ([#457](https://github.com/Dragory/modmailbot/pull/457) by @funkyhippo)
* Off by default. When enabled, instead of using the user's name as the channel name, uses a random channel name instead.
* Useful on single-server setups where people with modified clients can see the names of even hidden channels
* New option `updateNotificationsForBetaVersions`
* Off by default. When enabled, also shows update notifications for beta versions.
* By default, update notifications are only shown for stable releases
* Snippets can now be included *within* messages by wrapping the snippet name in curly braces
* E.g. `!r Hello! {{rules}}` to include the snippet `rules` in the place of `{{rules}}`
* The symbols used can be changed with the `inlineSnippetStart` and `inlineSnippetEnd` options
* This feature can be disabled by setting `allowInlineSnippets = off` in your config
* By default, the bot will refuse to send a reply with an unknown inline snippet. To disable this behavior, set `errorOnUnknownInlineSnippet = off`.
* `mentionRole` can now be set to `none`
* Removed the long-deprecated `logDir` option
* The bot now notifies if the user leaves/joins the server ([#437](https://github.com/Dragory/modmailbot/pull/437) by @DarkView)
* Replies are now limited in length to the Discord message limit (including the moderator name and role in the DM sent to the user)
* This is to fix issues with `!edit` and `!delete` when a reply spanned multiple messages
* DM channel and message IDs are now stored
* Use `!loglink -v` to view these in logs
* Use `!dm_channel_id` in an inbox thread to view the DM channel ID
* *DM channel and message IDs are primarily useful for Discord T&S reports*
* Unless `fallbackRoleName` is set, anonymous replies without a role will no longer display "Moderator:" at the beginning of the message
* Plugins can now also be installed from NPM modules
* Example: `plugins[] = npm:some-plugin-package`
* "Connection reset by peer" error (code 1006) is now handled gracefully in the background and no longer crashes the bot
* Multiple people can now sign up for reply alerts (`!alert`) simultaneously ([#373](https://github.com/Dragory/modmailbot/pull/373) by @DarkView)
* The bot now displays a note if the user sent an application invite, e.g. an invite to listen along on Spotify
* The bot now displays a note if the user sent a sticker, including the sticker's name
* Log formatting is now more consistent and easier to parse with automated tools
* Messages in modmail threads by other bots are no longer ignored, and are displayed in logs
* Added official support for MySQL databases. Refer to the documentation on `dbType` for more details.
* This change also means the following options are **no longer supported:**
* `dbDir` (use `sqliteOptions.filename` to specify the database file instead)
* `knex` (see `dbType` documentation for more details)
* System messages sent to the user, such as `responseMessage` and `closeMessage`, are now shown in the thread channel
* Fixed `!edit_snippet` crashing the bot when leaving the new snippet text empty
* Fix crash when using `!newthread` with the bot's own ID (fixes [#452](https://github.com/Dragory/modmailbot/issues/452))
* Fix occasional bug with expiring blocks where the bot would send the expiry message multiple times
* Fix bug with long messages being cut off and only the last part being shown in the thread (most evident in long DMs and e.g. !edit notifications of long messages)
* Fix messages containing *only* a large number (e.g. an ID) rounding the number
* Several common errors are now handled silently in the background, such as "Connection reset by peer"
**Plugins:**
* Added support for replacing default message formatting in threads, DMs, and logs
* Added support for *hooks*. Hooks can be used to run custom plugin code before/after specific moments.
* Two hooks are initially available: `beforeNewThread` and `afterThreadClose`
* See plugin documentation for mode details
* If your plugin requires special gateway intents, use the new `extraIntents` config option
* Plugins can now access the bot's web server via a new `webserver` property in plugin arguments
* Plugins can now store *metadata* in threads and thread messages via new `setMetadataValue` and `getMetadataValue` functions on `Thread` and `ThreadMessage` objects
* Plugins can access the API for setting/getting moderator display roles via a new `displayRoles` property in plugin arguments
* System messages now have a formatter
* The `beforeNewThread` hook's parameters now also include the original DM message object
* Plugins can now access the `threads` module (via `pluginApi.threads`) to create and fetch threads
* Plugins can now access the `displayRoles` module (via `pluginApi.displayRoles`) to get, set, and reset display role overrides for moderators,
and to get the final role that will be displayed in moderator replies (by default or per-thread)
**Internal/technical updates:**
* Updated Eris to v0.13.3
* Updated several other dependencies
* New JSON Schema based config parser that validates each option and their types more strictly to prevent undefined behavior
* Database migrations are now stored under `src/`
* Modmail now uses [Express](https://expressjs.com/) as its web server for logs/attachments
* Unhandled rejections are now handled the same as uncaught exceptions, and *will* crash the bot
## v2.31.0-beta.2
**This is a beta release, bugs are expected.**
Please report any bugs you encounter by [creating a GitHub issue](https://github.com/Dragory/modmailbot/issues/new)!
**General changes:**
* **BREAKING CHANGE:** Logs from Modmail versions prior to Feb 2018 are no longer converted automatically
* To update from a Modmail version from before Feb 2018, update to `v2.30.1` and run the bot once first. Then you can update to later versions.
* New option `logStorage`
* Allows changing how logs are stored
* Possible values are `local` (default), `attachment`, and `none`
* New option `statusType`
* Allows changing the bot's status type between "Playing", "Watching", "Listening"
* Possible values are `playing` (default), `watching`, `listening`
* New option `anonymizeChannelName` ([#457](https://github.com/Dragory/modmailbot/pull/457) by @funkyhippo)
* Off by default. When enabled, instead of using the user's name as the channel name, uses a random channel name instead.
* Useful on single-server setups where people with modified clients can see the names of even hidden channels
* New option `updateNotificationsForBetaVersions`
* Off by default. When enabled, also shows update notifications for beta versions.
* By default, update notifications are only shown for stable releases
* `mentionRole` can now be set to `none`
* The bot now notifies if the user leaves/joins the server ([#437](https://github.com/Dragory/modmailbot/pull/437) by @DarkView)
* Fix crash when using `!newthread` with the bot's own ID (fixes [#452](https://github.com/Dragory/modmailbot/issues/452))
**Plugins:**
* New hook: `afterThreadClose`
* Called right after a thread is closed with the thread's id
* You can now add custom log storage types
**Internal/technical updates:**
* Database migrations are now stored under `src/`
## v2.31.0-beta.1
**This is a beta release, bugs are expected.**
Please report any bugs you encounter by [creating a GitHub issue](https://github.com/Dragory/modmailbot/issues/new)!
**General changes:**
* **BREAKING CHANGE:** Added support for Node.js 13 and 14, dropped support for Node.js 10 and 11
* The supported versions are now 12, 13, and 14
* **BREAKING CHANGE:** The bot now requests the necessary [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents)
* **This includes the privileged "Server Members Intent"**, which is used for server greetings/welcome messages.
This means that [**you need to turn on "Server Members Intent"**](docs/server-members-intent-2.png) on the bot's page on the Discord Developer Portal.
* Renamed the following options. Old names are still supported as aliases, so old config files won't break.
* `mainGuildId` => `mainServerId`
* `mailGuildId` => `inboxServerId`
* `categoryAutomation.newThreadFromGuild` => `categoryAutomation.newThreadFromServer`
* `guildGreetings` => `serverGreetings`
* Added support for editing and deleting staff replies
* This is **enabled by default**
* This can be disabled with the `allowStaffEdit` and `allowStaffDelete` options
* Only the staff member who sent the reply can edit/delete it
* New option `reactOnSeen` ([#398](https://github.com/Dragory/modmailbot/pull/398) by @Eegras)
* When enabled, the bot will react to user DMs with a checkmark when they have been received
* The reaction emoji can be customized with the `reactOnSeenEmoji` option
* New option `createThreadOnMention` ([#397](https://github.com/Dragory/modmailbot/pull/397) by @dopeghoti)
* When enabled, a new modmail thread will be created whenever a user mentions/pings the bot on the main server
* As with `pingOnBotMention`, staff members are automatically ignored
* New **default** attachment storage option: `original`
* This option simply links the original attachment and does not rehost it in any way
* DM channel and message IDs are now stored
* Use `!loglink -v` to view these in logs
* Use `!dm_channel_id` in an inbox thread to view the DM channel ID
* *DM channel and message IDs are primarily useful for Discord T&S reports*
* Multiple people can now sign up for reply alerts (`!alert`) simultaneously ([#373](https://github.com/Dragory/modmailbot/pull/373) by @DarkView)
* The bot now displays a note if the user sent an application invite, e.g. an invite to listen along on Spotify
* Log formatting is now more consistent and easier to parse with automated tools
* Messages in modmail threads by other bots are no longer ignored, and are displayed in logs
* Added official support for MySQL databases. Refer to the documentation on `dbType` for more details.
* This change also means the following options are **no longer supported:**
* `dbDir` (use `sqliteOptions.filename` to specify the database file instead)
* `knex` (see `dbType` documentation for more details)
* Removed the long-deprecated `logDir` option
* Fixed `!edit_snippet` crashing the bot when leaving the new snippet text empty
**Plugins:**
* Added support for replacing default message formatting in threads, DMs, and logs
* Added support for *hooks*. Hooks can be used to run custom plugin code before/after specific moments.
* Initially only the `beforeNewThread` hook is available. See plugin documentation for more details.
* If your plugin requires special gateway intents, use the new `extraIntents` config option
* Some code reorganisation related to threads and thread messages.
If you have a plugin that interacts with Thread or ThreadMessage objects,
test them before running this update in production!
**Internal/technical updates:**
* Updated Eris to v0.13.3
* Updated several other dependencies
* New JSON Schema based config parser that validates each option and their types more strictly to prevent undefined behavior
## v2.30.1
* Fix crash with `responseMessage` and `closeMessage` introduced in v2.30.0
([#369](https://github.com/Dragory/modmailbot/pull/369))

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 Miikka Virtanen
Copyright (c) 20172021 Miikka (Dragory)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,19 +1,25 @@
# Modmail for Discord
Modmail Bot is a bot for [Discord](https://discordapp.com/) that allows users to DM the bot to contact the server's moderators/staff
Modmail Bot is a bot for [Discord](https://discord.com/) that allows users to DM the bot to contact the server's moderators/staff
without messaging them individually or pinging them publically on the server.
These DMs get relayed to modmail *threads*, channels where staff members can reply to and talk with the user.
To the user, the entire process happens in DMs with the bot.
Inspired by Reddit's modmail system.
**⚠ Note on updating to v3.0.0:** If you're currently using a *very* old version of the bot, from before February 2018, you'll first need to update to v2.30.1 and run the bot once before updating to v3.0.0.
Always take a backup of your `db/data.sqlite` file before updating the bot.
## Getting started
* **[🛠️ Setting up the bot](docs/setup.md)**
* **[✨ Updating the bot](docs/updating.md)**
* **[🙋 Frequently Asked Questions](docs/faq.md)**
* [📝 Configuration](docs/configuration.md)
* [🤖 Commands](docs/commands.md)
* [📋 Snippets](docs/snippets.md)
* [🧩 Plugins](docs/plugins.md)
* [🙋 Frequently Asked Questions](docs/faq.md)
* [Release notes](CHANGELOG.md)
* [📌 Release notes](CHANGELOG.md)
* [📚 **Community Guides & Resources**](https://github.com/Dragory/modmailbot-community-resources)
## Support server
If you need help with setting up the bot or would like to discuss other things related to it, join the support server on Discord here:

View File

@ -1,9 +0,0 @@
# Required settings
# -----------------
token = REPLACE_WITH_TOKEN
mainGuildId = REPLACE_WITH_MAIN_SERVER_ID
mailGuildId = REPLACE_WITH_INBOX_SERVER_ID
logChannelId = REPLACE_WITH_LOG_CHANNEL_ID
# Add new options below this line:
# ----------------------------------

View File

@ -1,45 +0,0 @@
exports.up = async function(knex, Promise) {
await knex.schema.createTableIfNotExists('threads', table => {
table.string('id', 36).notNullable().primary();
table.integer('status').unsigned().notNullable().index();
table.integer('is_legacy').unsigned().notNullable();
table.string('user_id', 20).notNullable().index();
table.string('user_name', 128).notNullable();
table.string('channel_id', 20).nullable().unique();
table.dateTime('created_at').notNullable().index();
});
await knex.schema.createTableIfNotExists('thread_messages', table => {
table.increments('id');
table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE');
table.integer('message_type').unsigned().notNullable();
table.string('user_id', 20).nullable();
table.string('user_name', 128).notNullable();
table.mediumtext('body').notNullable();
table.integer('is_anonymous').unsigned().notNullable();
table.string('dm_message_id', 20).nullable().unique();
table.dateTime('created_at').notNullable().index();
});
await knex.schema.createTableIfNotExists('blocked_users', table => {
table.string('user_id', 20).primary().notNullable();
table.string('user_name', 128).notNullable();
table.string('blocked_by', 20).nullable();
table.dateTime('blocked_at').notNullable();
});
await knex.schema.createTableIfNotExists('snippets', table => {
table.string('trigger', 32).primary().notNullable();
table.text('body').notNullable();
table.integer('is_anonymous').unsigned().notNullable();
table.string('created_by', 20).nullable();
table.dateTime('created_at').notNullable();
});
};
exports.down = async function(knex, Promise) {
await knex.schema.dropTableIfExists('thread_messages');
await knex.schema.dropTableIfExists('threads');
await knex.schema.dropTableIfExists('blocked_users');
await knex.schema.dropTableIfExists('snippets');
};

View File

@ -1,15 +0,0 @@
exports.up = async function (knex, Promise) {
await knex.schema.table('threads', table => {
table.dateTime('scheduled_close_at').index().nullable().defaultTo(null).after('channel_id');
table.string('scheduled_close_id', 20).nullable().defaultTo(null).after('channel_id');
table.string('scheduled_close_name', 128).nullable().defaultTo(null).after('channel_id');
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.dropColumn('scheduled_close_at');
table.dropColumn('scheduled_close_id');
table.dropColumn('scheduled_close_name');
});
};

View File

@ -1,11 +0,0 @@
exports.up = async function (knex, Promise) {
await knex.schema.table('threads', table => {
table.string('alert_id', 20).nullable().defaultTo(null).after('scheduled_close_name');
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.dropColumn('alert_id');
});
};

View File

@ -1,11 +0,0 @@
exports.up = async function (knex, Promise) {
await knex.schema.table('snippets', table => {
table.dropColumn('is_anonymous');
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('snippets', table => {
table.integer('is_anonymous').unsigned().notNullable();
});
};

View File

@ -1,11 +0,0 @@
exports.up = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.integer('scheduled_close_silent').nullable().after('scheduled_close_name');
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.dropColumn('scheduled_close_silent');
});
};

View File

@ -1,15 +0,0 @@
exports.up = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.dateTime('scheduled_suspend_at').index().nullable().defaultTo(null).after('channel_id');
table.string('scheduled_suspend_id', 20).nullable().defaultTo(null).after('channel_id');
table.string('scheduled_suspend_name', 128).nullable().defaultTo(null).after('channel_id');
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('threads', table => {
table.dropColumn('scheduled_suspend_at');
table.dropColumn('scheduled_suspend_id');
table.dropColumn('scheduled_suspend_name');
});
};

View File

@ -1,14 +0,0 @@
exports.up = async function(knex, Promise) {
if (! await knex.schema.hasTable('updates')) {
await knex.schema.createTable('updates', table => {
table.string('available_version', 16).nullable();
table.dateTime('last_checked').nullable();
});
}
};
exports.down = async function(knex, Promise) {
if (await knex.schema.hasTable('updates')) {
await knex.schema.dropTable('updates');
}
};

View File

@ -1,11 +0,0 @@
exports.up = async function(knex, Promise) {
await knex.schema.table('blocked_users', table => {
table.dateTime('expires_at').nullable();
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table('blocked_users', table => {
table.dropColumn('expires_at');
});
};

View File

@ -1,19 +0,0 @@
const Knex = require('knex');
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.integer('message_number').unsigned().nullable();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.dropColumn('message_number');
});
};

View File

@ -1,19 +0,0 @@
const Knex = require('knex');
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.string('inbox_message_id', 20).nullable().unique();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.dropColumn('inbox_message_id');
});
};

View File

@ -1,19 +0,0 @@
const Knex = require('knex');
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.string('dm_channel_id', 20).nullable();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table('thread_messages', table => {
table.dropColumn('dm_channel_id');
});
};

View File

@ -13,7 +13,7 @@ Send a reply to the user.
**Example:** `!r How can I help you?`
To reply automatically without using `!reply`, [enable `alwaysReply` in bot settings](configuration.md).
To reply automatically without using `!reply`, [turn on `alwaysReply` in bot settings](configuration.md).
### `!anonreply <text>` / `!ar <text>`
Send an anonymous reply to the user. Anonymous replies only show the moderator's role in the reply.
@ -28,6 +28,9 @@ Close the Modmail thread after a timer. Sending a message to the user or receivi
**Example:** `!close 15m`
### `!close -s` / `!close -s <time>`
Close the Modmail thread without notifying the user that it was closed.
### `!close cancel`
Cancel a timed close.
@ -62,6 +65,23 @@ Pings you when the thread gets a new reply.
### `!alert cancel`
Cancel the ping set by `!alert`.
### `!edit <number> <new text>`
Edit your own previous reply sent with `!reply`.
`<number>` is the message number shown in front of staff replies in the thread channel.
### `!delete <number>`
Delete your own previous reply sent with `!reply`.
`<number>` is the message number shown in front of staff replies in the thread channel.
### `!role`
View your display role for the thread - the role that is shown in front of your name in your replies
### `!role reset`
Reset your display role for the thread to the default
### `!role <role name>`
Change your display role for the thread to any role you currently have
### `!loglink`
Get a link to the open Modmail thread's log.
@ -75,6 +95,13 @@ This is mainly useful when reporting messages to Discord's Trust & Safety team.
### `!id`
Prints the user's ID.
### `!dm_channel_id`
Prints the ID of the current DM channel with the user
### `!message <number>`
Shows the DM channel ID, DM message ID, and message link of the specified user reply.
`<number>` is the message number shown in front of staff replies in the thread channel.
## Anywhere on the inbox server
These commands can be used anywhere on the inbox server, even outside Modmail threads.
@ -108,6 +135,15 @@ Check if the specified user is blocked.
**Example:** `!is_blocked 106391128718245888`
### `!role`
(Outside a modmail thread) View your default display role - the role that is shown in front of your name in your replies
### `!role reset`
(Outside a modmail thread) Reset your default display role
### `!role <role name>`
(Outside a modmail thread) Change your default display role to any role you currently have
### `!version`
Show the Modmail bot's version.

View File

@ -22,7 +22,7 @@ vice versa.
## Adding new options
To add a new option to your `config.ini`, open the file in a text editor such as notepad.
Each option is put on a new line, and follows the format `option = value`. For example, `mainGuildId = 1234`.
Each option is put on a new line, and follows the format `option = value`. For example, `mainServerId = 1234`.
**You need to restart the bot for configuration changes to take effect!**
@ -32,10 +32,10 @@ You can add comments in the config file by prefixing the line with `#`. Example:
option = value
```
### Toggle options
Some options like `allowMove` are "**Toggle options**": they control whether certain features are enabled (on) or not (off).
* To enable a toggle option, set its value to `on`, `true`, or `1`
* To disable a toggle option, set its value to `off`, `false`, or `0`
### Toggled options
Some options like `allowMove` can only be turned on or off.
* To turn on a toggled option, set its value to `on`, `true`, or `1`
* To turn off a toggled option, set its value to `off`, `false`, or `0`
* E.g. `allowMove = on` or `allowMove = off`
### "Accepts multiple values"
@ -65,12 +65,12 @@ greetingMessage[] = Fourth line! With an empty line in the middle.
#### token
The bot user's token from [Discord Developer Portal](https://discordapp.com/developers/).
#### mainGuildId
Your server's ID, wrapped in quotes.
#### mainServerId
**Accepts multiple values** Your server's ID.
#### mailGuildId
For a two-server setup, the inbox server's ID.
For a single-server setup, same as [mainGuildId](#mainguildid).
#### inboxServerId
For a single-server setup, same as [mainServerId](#mainServerId).
For a two-server setup, the inbox server's ID.
#### logChannelId
ID of a channel on the inbox server where logs are posted after a modmail thread is closed
@ -89,6 +89,37 @@ If enabled, allows you to move threads between categories using `!move <category
**Default:** `off`
If enabled, users can use the close command to close threads by themselves from their DMs with the bot
#### allowStaffDelete
**Default:** `on`
If enabled, staff members can delete their own replies in modmail threads with `!delete`
#### allowStaffEdit
**Default:** `on`
If enabled, staff members can edit their own replies in modmail threads with `!edit`
#### allowBlock
**Default:** `on`
If enabled, staff members can block a user from using modmail with `!block`
#### allowSuspend
**Default:** `on`
If enabled, staff members can suspend a user from using modmail with `!suspend`
#### allowSnippets
**Default:** `on`
If enabled, staff members can use [Snippets](snippets.md)
#### allowInlineSnippets
**Default:** `on`
If `allowSnippets` is enabled, this option controls whether the snippets can be included *within* replies by wrapping the snippet's name in {{ and }}.
E.g. `!r Hello! {{rules}}`
See [inlineSnippetStart](#inlineSnippetStart) and [inlineSnippetEnd](#inlineSnippetEnd) to customize the symbols used.
#### allowChangingDisplayRole
**Default:** `on`
If enabled, moderators can change the role that's shown with their replies to any role they currently have using the `!role` command.
#### alwaysReply
**Default:** `off`
If enabled, all messages in modmail threads will be sent to the user without having to use `!r`.
@ -99,32 +130,49 @@ e.g. `!note This is an internal message`.
**Default:** `off`
If `alwaysReply` is enabled, this option controls whether the auto-reply is anonymous
#### anonymizeChannelName
**Default:** `off`
If enabled, channel names will be the user's name and discriminator salted with the current time, then hashed to protect the user's privacy
#### attachmentStorage
**Default:** `local`
**Default:** `original`
Controls how attachments in modmail threads are stored. Possible values:
* **local** - Files are saved locally on the machine running the bot
* **discord** - Files are saved as attachments on a special channel on the inbox server. Requires `attachmentStorageChannelId` to be set.
* `original` - The original attachment is linked directly
* `local` - Files are saved locally on the machine running the bot and served via a local web server
* `discord` - Files are saved as attachments on a special channel on the inbox server. Requires `attachmentStorageChannelId` to be set.
#### attachmentStorageChannelId
**Default:** *None*
When using attachmentStorage is set to "discord", the id of the channel on the inbox server where attachments are saved
#### autoAlert
**Default:** `off`
If enabled, the last moderator to reply to a modmail thread will be automatically alerted when the thread gets a new reply.
This alert kicks in after a delay, set by the `autoAlertDelay` option below.
#### autoAlertDelay
**Default:** `2m`
The delay after which `autoAlert` kicks in. Uses the same format as timed close; for example `1m30s` for 1 minute and 30 seconds.
#### botMentionResponse
**Default:** *None*
If set, the bot auto-replies to bot mentions (pings) with this message. Use `{userMention}` in the text to ping the user back.
#### categoryAutomation.newThread
**Default:** *None*
ID of the category where new threads are opened. Also functions as a fallback for `categoryAutomation.newThreadFromGuild`.
ID of the category where new threads are opened. Also functions as a fallback for `categoryAutomation.newThreadFromServer`.
#### categoryAutomation.newThreadFromGuild.GUILDID
#### categoryAutomation.newThreadFromGuild.SERVER_ID
Alias for [`categoryAutomation.newThreadFromServer`](#categoryAutomationNewThreadFromServerServer_id)
#### categoryAutomation.newThreadFromServer.SERVER_ID
**Default:** *None*
When running the bot on multiple main servers, this allows you to specify new thread categories for users from each guild. Example:
When running the bot on multiple main servers, this allows you to specify which category to use for modmail threads from each server. Example:
```ini
# When the user is from the server ID 94882524378968064, their modmail thread will be placed in the category ID 360863035130249235
categoryAutomation.newThreadFromGuild.94882524378968064 = 360863035130249235
categoryAutomation.newThreadFromServer.94882524378968064 = 360863035130249235
# When the user is from the server ID 541484311354933258, their modmail thread will be placed in the category ID 542780020972716042
categoryAutomation.newThreadFromGuild.541484311354933258 = 542780020972716042
categoryAutomation.newThreadFromServer.541484311354933258 = 542780020972716042
```
#### closeMessage
@ -143,7 +191,16 @@ commandAliases.x = close
#### enableGreeting
**Default:** `off`
When enabled, the bot will send a greeting DM to users that join the main server.
If enabled, the bot will send a greeting DM to users that join the main server
#### errorOnUnknownInlineSnippet
**Default:** `on`
If enabled, the bot will refuse to send any reply with an unknown inline snippet.
See [allowInlineSnippets](#allowInlineSnippets) for more details.
#### fallbackRoleName
**Default:** *None*
Role name to display in moderator replies if the moderator doesn't have a hoisted role
#### greetingAttachment
**Default:** *None*
@ -158,35 +215,59 @@ greetingMessage[] = Remember to read the rules.
```
#### guildGreetings
**Default:** *None*
When running the bot on multiple main servers, this allows you to set different greetings for each server. Example:
```ini
guildGreetings.94882524378968064.message = Welcome to server ID 94882524378968064!
guildGreetings.94882524378968064.attachment = greeting.png
guildGreetings.541484311354933258.message[] = Welcome to server ID 541484311354933258!
guildGreetings.541484311354933258.message[] = Second line of the greeting.
```
Alias for [`serverGreetings`](#serverGreetings)
#### ignoreAccidentalThreads
**Default:** `off`
If enabled, the bot attempts to ignore common "accidental" messages that would start a new thread, such as "ok", "thanks", etc.
#### inboxServerPermission
**Default:** *None*
**Default:** `manageMessages`
**Accepts multiple values.** Permission name, user id, or role id required to use bot commands on the inbox server.
See ["Permissions" on this page](https://abal.moe/Eris/docs/reference) for supported permission names (e.g. `kickMembers`).
#### inlineSnippetStart
**Default:** `{{`
Symbol(s) to use at the beginning of an inline snippet. See [allowInlineSnippets](#allowInlineSnippets) for more details.
#### inlineSnippetEnd
**Default:** `}}`
Symbol(s) to use at the end of an inline snippet. See [allowInlineSnippets](#allowInlineSnippets) for more details.
#### timeOnServerDeniedMessage
**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)
#### mailGuildId
Alias for [inboxServerId](#inboxServerId)
#### mentionRole
**Default:** `here`
**Default:** `none`
**Accepts multiple values.** Role that is mentioned when new threads are created or the bot is mentioned.
Accepted values are "here", "everyone", or a role id.
Requires `pingOnBotMention` to be enabled.
Set to an empty value (`mentionRole=`) to disable these pings entirely.
Accepted values are `none`, `here`, `everyone`, or a role id.
Set to `none` to disable these pings entirely.
#### mentionUserInThreadHeader
**Default:** `off`
@ -196,10 +277,22 @@ If enabled, mentions the user messaging modmail in the modmail thread's header.
**Default:** *None*
**Deprecated.** Same as `categoryAutomation.newThread`.
#### notifyOnMainServerJoin
**Default:** `on`
If enabled, a system message will be posted into any open threads if the user joins a main server
#### notifyOnMainServerLeave
**Default:** `on`
If enabled, a system message will be posted into any open threads if the user leaves a main server
#### pingOnBotMention
**Default:** `on`
If enabled, the bot will mention staff (see `mentionRole` option) on the inbox server when the bot is mentioned on the main server.
#### pinThreadHeader
**Default:** `off`
If enabled, the bot will automatically pin the "thread header" message that contains the user's details
#### plugins
**Default:** *None*
**Accepts multiple values.** External plugins to load on startup. See [Plugins](plugins.md) for more information.
@ -213,6 +306,15 @@ Make sure to do the necessary [port forwarding](https://portforward.com/) and ad
**Default:** `!`
Prefix for bot commands
#### reactOnSeen
**Default:** `off`
If enabled, the bot will react to messages sent to it with the emoji defined in `reactOnSeenEmoji`
#### reactOnSeenEmoji
**Default:** `📨`
The emoji that the bot will react with when it sees a message. Requires `reactOnSeen` to be enabled.
Must be pasted in the config file as the Emoji representation and not as a unicode codepoint. Use `emojiName:emojiID` for custom emoji.
#### relaySmallAttachmentsAsAttachments
**Default:** `off`
If enabled, small attachments from users are sent as real attachments rather than links in modmail threads.
@ -228,12 +330,29 @@ Required amount of time (in minutes) the user must be a member of the server bef
#### responseMessage
**Default:** `Thank you for your message! Our mod team will reply to you here as soon as possible.`
The bot's response to the user when they message the bot and open a new modmail thread
The bot's response to the user when they message the bot and open a new modmail thread.
If you have a multi-line or otherwise long `responseMessage`, you might want to turn off [showResponseMessageInThreadChannel](#showResponseMessageInThreadChannel) to reduce clutter in the thread channel on the inbox server.
#### rolesInThreadHeader
**Default:** `off`
If enabled, the user's roles will be shown in the modmail thread header
#### serverGreetings
**Default:** *None*
When running the bot on multiple main servers, this allows you to set different greetings for each server. Example:
```ini
serverGreetings.94882524378968064.message = Welcome to server ID 94882524378968064!
serverGreetings.94882524378968064.attachment = greeting.png
serverGreetings.541484311354933258.message[] = Welcome to server ID 541484311354933258!
serverGreetings.541484311354933258.message[] = Second line of the greeting.
```
#### showResponseMessageInThreadChannel
**Default:** `on`
Whether to show the [responseMessage](#responseMessage) sent to the user in the thread channel on the inbox server as well.
If you have a multi-line or otherwise long `responseMessage`, it might be a good idea to turn this off to reduce clutter.
#### smallAttachmentLimit
**Default:** `2097152`
Size limit of `relaySmallAttachmentsAsAttachments` in bytes (default is 2MB)
@ -248,12 +367,20 @@ Prefix to use snippets anonymously
#### status
**Default:** `Message me for help`
The bot's "Playing" text
The bot's status text. Set to `none` to disable.
#### statusType
**Default:** `playing`
The bot's status type. One of `playing`, `watching`, `listening`.
#### syncPermissionsOnMove
**Default:** `on`
If enabled, channel permissions for the thread are synchronized with the category when using `!move`. Requires `allowMove` to be enabled.
#### createThreadOnMention
**Default:** `off`
If enabled, the bot will automatically create a new thread for a user who pings it.
#### threadTimestamps
**Default:** `off`
If enabled, modmail threads will show accurate UTC timestamps for each message, in addition to Discord's own timestamps.
@ -271,6 +398,10 @@ If enabled, any time a moderator is typing in a modmail thread, the user will se
**Default:** `on`
If enabled, the bot will automatically check for new bot updates periodically and notify about them at the top of new modmail threads
#### updateNotificationsForBetaVersions
**Default:** `off`
If enabled, update notifications will also be given for new beta versions
#### url
**Default:** *None*
URL to use for attachment and log links. Defaults to `http://IP:PORT`.
@ -279,6 +410,60 @@ URL to use for attachment and log links. Defaults to `http://IP:PORT`.
**Default:** `off`
If enabled, mod replies will use their nicknames (on the inbox server) instead of their usernames
#### useGitForGitHubPlugins
**Default:** `off`
If enabled, GitHub plugins will be installed with Git rather than by downloading the archive's tarball.
This is useful if you are installing plugins from private repositories that require ssh keys for authentication.
## Advanced options
#### extraIntents
**Default:** *None*
If you're using or developing a plugin that requires extra [Gateway Intents](https://discord.com/developers/docs/topics/gateway#gateway-intents),
you can specify them here.
Example:
```ini
extraIntents[] = guildPresences
extraIntents[] = guildMembers
```
#### dbType
**Default:** `sqlite`
Specifies the type of database to use. Valid options:
* `sqlite` (see also [sqliteOptions](#sqliteOptions) below)
* `mysql` (see also [mysqlOptions](#mysqlOptions) below)
Other databases are *not* currently supported.
#### sqliteOptions
Object with SQLite-specific options
##### sqliteOptions.filename
**Default:** `db/data.sqlite`
Can be used to specify the path to the database file
#### mysqlOptions
Object with MySQL-specific options
##### mysqlOptions.host
**Default:** `localhost`
##### mysqlOptions.port
**Default:** `3306`
##### mysqlOptions.user
**Default:** *None*
Required if using `mysql` for `dbType`. MySQL user to connect with.
##### mysqlOptions.password
**Default:** *None*
Required if using `mysql` for `dbType`. Password for the MySQL user specified above.
##### mysqlOptions.database
**Default:** *None*
Required if using `mysql` for `dbType`. Name of the MySQL database to use.
## config.ini vs config.json
Earlier versions of the bot instructed you to create a `config.json` instead of a `config.ini`.
**This is still fully supported, and will be in the future as well.**
@ -288,7 +473,7 @@ However, there are some differences between `config.ini` and `config.json`.
*See [the example on the Wikipedia page for JSON](https://en.wikipedia.org/wiki/JSON#Example)
for a general overview of the JSON format.*
* In `config.json`, all text values and IDs need to be wrapped in quotes, e.g. `"mainGuildId": "94882524378968064"`
* In `config.json`, all text values and IDs need to be wrapped in quotes, e.g. `"mainServerId": "94882524378968064"`
* In `config.json`, all numbers (other than IDs) are written without quotes, e.g. `"port": 3000`
### Toggle options
@ -329,7 +514,7 @@ being replaced by two underscores and add `MM_` as a prefix. If adding multiple
values with two pipe characters: `||`.
Examples:
* `mainGuildId` -> `MM_MAIN_GUILD_ID`
* `mainServerId` -> `MM_MAIN_SERVER_ID`
* `commandAliases.mv` -> `MM_COMMAND_ALIASES__MV`
* From:
```ini

View File

@ -1,21 +1,21 @@
# 🙋 Frequently Asked Questions
## What are these numbers in front of staff replies in modmail threads?
Each staff reply gets an internal number. This number can be used with
`!edit`, `!delete`, `!message` and potentially other commands in the future.
## In a [single-server setup](setup.md#single-server-setup), how do I hide modmails from regular users?
1. Create a private category for modmail threads that only your server staff and the bot can see and set the option
`categoryAutomation.newThread = 1234` (replace `1234` with the ID of the category)
2. Set the `inboxServerPermission` option to limit who can use bot commands.
[Click here for more information.](configuration.md#inboxserverpermission)
## My logs and/or attachments aren't loading!
Since logs and attachments are both stored and sent directly from the machine running the bot, you'll need to make sure
## My logs aren't loading!
Since logs are stored and sent directly from the machine running the bot, you'll need to make sure
that the machine doesn't have a firewall blocking the bot and has the appropriate port forwardings set up.
[You can find more information and instructions for port forwarding here.](https://portforward.com/)
By default, the bot uses the port **8890**.
## I don't want attachments saved on my computer
As an alternative to storing modmail attachments on the machine running the bot, they can be stored in a special Discord
channel instead. Create a new text channel and then set the options `attachmentStorage = discord` and
`attachmentStorageChannelId = 1234` (replace `1234` with the ID of the new channel).
## I want to categorize my modmail threads in multiple categories
Set `allowMove = on` to allow your staff to move threads to other categories with `!move`

View File

@ -0,0 +1,6 @@
# Plugin API
**NOTE:** This file is generated automatically.
Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plugins.
{{>main}}

119
docs/plugin-api.md Normal file
View File

@ -0,0 +1,119 @@
# Plugin API
**NOTE:** This file is generated automatically.
Scroll down to [PluginAPI](#PluginAPI) for a list of properties available to plugins.
## Typedefs
<dl>
<dt><a href="#PluginAPI">PluginAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginCommandsAPI">PluginCommandsAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginAttachmentsAPI">PluginAttachmentsAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginLogsAPI">PluginLogsAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginHooksAPI">PluginHooksAPI</a> : <code>object</code></dt>
<dd></dd>
<dt><a href="#PluginDisplayRolesAPI">PluginDisplayRolesAPI</a> : <code>displayRoles</code></dt>
<dd></dd>
<dt><a href="#PluginThreadsAPI">PluginThreadsAPI</a> : <code>threads</code></dt>
<dd></dd>
<dt><a href="#PluginWebServerAPI">PluginWebServerAPI</a> : <code>express.Application</code></dt>
<dd></dd>
<dt><a href="#PluginFormattersAPI">PluginFormattersAPI</a> : <code>FormattersExport</code></dt>
<dd></dd>
</dl>
<a name="PluginAPI"></a>
## PluginAPI : <code>object</code>
**Kind**: global typedef
**Properties**
| Name | Type |
| --- | --- |
| bot | <code>Client</code> |
| knex | <code>Knex</code> |
| config | <code>ModmailConfig</code> |
| commands | [<code>PluginCommandsAPI</code>](#PluginCommandsAPI) |
| attachments | [<code>PluginAttachmentsAPI</code>](#PluginAttachmentsAPI) |
| logs | [<code>PluginLogsAPI</code>](#PluginLogsAPI) |
| hooks | [<code>PluginHooksAPI</code>](#PluginHooksAPI) |
| formats | [<code>PluginFormattersAPI</code>](#PluginFormattersAPI) |
| webserver | [<code>PluginWebServerAPI</code>](#PluginWebServerAPI) |
| threads | [<code>PluginThreadsAPI</code>](#PluginThreadsAPI) |
| displayRoles | [<code>PluginDisplayRolesAPI</code>](#PluginDisplayRolesAPI) |
<a name="PluginCommandsAPI"></a>
## PluginCommandsAPI : <code>object</code>
**Kind**: global typedef
**Properties**
| Name | Type |
| --- | --- |
| manager | <code>CommandManager</code> |
| addGlobalCommand | <code>AddGlobalCommandFn</code> |
| addInboxServerCommand | <code>AddInboxServerCommandFn</code> |
| addInboxThreadCommand | <code>AddInboxThreadCommandFn</code> |
| addAlias | <code>AddAliasFn</code> |
<a name="PluginAttachmentsAPI"></a>
## PluginAttachmentsAPI : <code>object</code>
**Kind**: global typedef
**Properties**
| Name | Type |
| --- | --- |
| addStorageType | <code>AddAttachmentStorageTypeFn</code> |
| downloadAttachment | <code>DownloadAttachmentFn</code> |
| saveAttachment | <code>SaveAttachmentFn</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>
## PluginHooksAPI : <code>object</code>
**Kind**: global typedef
**Properties**
| Name | Type |
| --- | --- |
| beforeNewThread | <code>AddBeforeNewThreadHookFn</code> |
| afterThreadClose | <code>AddAfterThreadCloseHookFn</code> |
<a name="PluginDisplayRolesAPI"></a>
## PluginDisplayRolesAPI : <code>displayRoles</code>
**Kind**: global typedef
**See**: https://github.com/Dragory/modmailbot/blob/master/src/data/displayRoles.js
<a name="PluginThreadsAPI"></a>
## PluginThreadsAPI : <code>threads</code>
**Kind**: global typedef
**See**: https://github.com/Dragory/modmailbot/blob/master/src/data/threads.js
<a name="PluginWebServerAPI"></a>
## PluginWebServerAPI : <code>express.Application</code>
**Kind**: global typedef
**See**: https://expressjs.com/en/api.html#app
<a name="PluginFormattersAPI"></a>
## PluginFormattersAPI : <code>FormattersExport</code>
**Kind**: global typedef
**See**: https://github.com/Dragory/modmailbot/blob/master/src/formatters.js

View File

@ -2,22 +2,22 @@
The bot supports loading external plugins.
## Specifying plugins to load
For each plugin file you'd like to load, add the file path to the [`plugins` option](configuration.md#plugins).
The path is relative to the bot's folder.
Plugins are automatically loaded on bot startup.
Plugins can be loaded either from local files or NPM. Examples:
```ini
# Local file
plugins[] = ./path/to/plugin.js
# NPM package
plugins[] = npm:some-plugin-package
```
Paths to local files are always relative to the bot's folder.
NPM plugins are automatically installed on bot start-up.
## Creating a plugin
Create a `.js` file that exports a function.
This function will be called when the plugin is loaded, with 1 argument: an object that has the following properties:
* `bot` - the [Eris Client object](https://abal.moe/Eris/docs/Client)
* `knex` - the [Knex database object](https://knexjs.org/#Builder)
* `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
Plugins are simply `.js` files that export a function that gets called when the plugin is loaded.
See [src/plugins.js#L4](../src/plugins.js#L4) for more details
For details about the function arguments, see [Plugin API](#plugin-api) below.
### Example plugin file
### Example plugin
This example adds a command `!mycommand` that replies with `"Reply from my custom plugin!"` when the command is used inside a modmail inbox thread channel.
```js
module.exports = function({ bot, knex, config, commands }) {
@ -40,8 +40,45 @@ module.exports = function({ attachments }) {
```
To use this custom attachment storage type, you would set the `attachmentStorage` config option to `"original"`.
## Work in progress
The current plugin API is fairly rudimentary and will be expanded on in the future.
The API can change in non-major releases during this early stage. Keep an eye on [CHANGELOG.md](../CHANGELOG.md) for any changes.
### Example of a custom log storage type
This example adds a custom type for the `logStorage` option called `"pastebin"` that uploads logs to Pastebin.
```js
module.exports = function({ logs, formatters }) {
logs.addStorageType('pastebin', {
async save(thread, threadMessages) {
const formatLogResult = await formatters.formatLog(thread, threadMessages);
const pastebinUrl = await saveToPastebin(formatLogResult); // saveToPastebin is an example function that returns the pastebin URL for the saved log
return { url: pastebinUrl };
},
getUrl(thread) {
return thread.log_storage_data.url;
}
});
};
```
### Plugin API
The first and only argument to the plugin function is an object with the following properties:
| Property | Description |
| -------- | ----------- |
| `bot` | [Eris Client instance](https://abal.moe/Eris/docs/Client) |
| `knex` | [Knex database object](https://knexjs.org/#Builder) |
| `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 |
| `webserver` | An [Express Application object](https://expressjs.com/en/api.html#app) that functions as the bot's web server |
| `threads` | An object with functions to find and create threads |
| `displayRoles` | An object with functions to set and get moderators' display roles |
See the auto-generated [Plugin API](plugin-api.md) page for details.
## Plugin API stability
Bot releases may contain changes to the plugin API. Make sure to check the [CHANGELOG](../CHANGELOG.md) before upgrading!
Please send any feature suggestions to the [issue tracker](https://github.com/Dragory/modmailbot/issues)!

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -12,25 +12,29 @@ To keep it online, you need to keep the bot process running.
* A **user**, in modmail's context, is a Discord user who is contacting modmail by DMing the bot
## Prerequisites
1. Create a bot account through the [Discord Developer Portal](https://discordapp.com/developers/)
2. Invite the created bot to your server
3. Install Node.js 10, 11, or 12
- Node.js 13 is currently not supported
4. Download the latest bot version from [the releases page](https://github.com/Dragory/modmailbot/releases) and extract it to a folder
5. In the bot's folder, make a copy of the file `config.example.ini` and rename the copy to `config.ini`
1. Create a bot on the [Discord Developer Portal](https://discordapp.com/developers/)
2. Turn on **Server Members Intent** in the bot's settings page on the developer portal ([Image](server-members-intent-2.png))
3. Install Node.js 14 (LTS)
* Node.js 15 is not currently officially supported
4. [Download the latest bot release here](https://github.com/Dragory/modmailbot/releases/latest) (click on "Source code (zip)")
5. Extract the downloaded Zip file to a new folder
6. In the bot's folder (that you extracted from the zip file), make a copy of the file `config.example.ini` and rename the copy to `config.ini`
* If you're on Windows, the file may be named `config.example` (without `.ini` at the end)
## Single-server setup
In this setup, modmail threads are opened on the main server in a special category.
This is the recommended setup for small and medium sized servers.
1. **Go through the [prerequisites](#prerequisites) above first!**
2. Open `config.ini` in a text editor and fill in the required values. `mainGuildId` and `mailGuildId` should both be set to your server's id.
3. On a new line at the end of `config.ini`, add `categoryAutomation.newThread = CATEGORY_ID_HERE`
- Replace `CATEGORY_ID_HERE` with the ID of the category where new modmail threads should go
4. Make sure the bot has `Manage Channels`, `Manage Messages`, and `Attach Files` permissions in the category
5. **[🏃 Start the bot!](starting-the-bot.md)**
6. Want to change other bot options? See **[📝 Configuration](configuration.md)**
7. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
2. Open `config.ini` in a text editor and fill in the required values. `mainServerId` and `inboxServerId` should both be set to your server's id.
3. Invite the bot to the server
4. On a new line at the end of `config.ini`, add `categoryAutomation.newThread = CATEGORY_ID_HERE`
* Replace `CATEGORY_ID_HERE` with the ID of the category where new modmail threads should go
5. Make sure the bot has `Manage Channels`, `Manage Messages`, and `Attach Files` permissions in the category
* It is not recommended to give the bot Administrator permissions under *any* circumstances
6. **[🏃 Start the bot!](starting-the-bot.md)**
7. Want to change other bot options? See **[📝 Configuration](configuration.md)**
8. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
**[join the support server!](../README.md#support-server)**
## Two-server setup
@ -40,12 +44,16 @@ You might also want this setup for privacy concerns*.
1. **Go through the [prerequisites](#prerequisites) above first!**
2. Create an inbox server on Discord
3. Invite the bot to the inbox server.
4. Open `config.ini` in a text editor and fill in the values
5. Make sure the bot has the `Manage Channels`, `Manage Messages`, and `Attach Files` permissions on the **inbox** server
6. **[🏃 Start the bot!](starting-the-bot.md)**
7. Want to change other bot options? See **[📝 Configuration](configuration.md)**
8. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
3. Open `config.ini` in a text editor and fill in the required values
* Set `mainServerId` to the ID of the *main* server where users will message the bot from
* Set `inboxServerId` to the ID of the *inbox* server created in step 2
4. Invite the bot to both the main server and the newly-created inbox server
5. Open `config.ini` in a text editor and fill in the values
6. Make sure the bot has the `Manage Channels`, `Manage Messages`, and `Attach Files` permissions on the **inbox** server
* The bot does not need any permissions on the main server
7. **[🏃 Start the bot!](starting-the-bot.md)**
8. Want to change other bot options? See **[📝 Configuration](configuration.md)**
9. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
**[join the support server!](../README.md#support-server)**
*\* Since all channel names, even for channels you can't see, are public information through the API, a user with a

21
docs/updating.md Normal file
View File

@ -0,0 +1,21 @@
# ✨ Updating the bot
**Before updating the bot, always take a backup of your `db/data.sqlite` file.**
**⚠ Note on updating to v3.0.0:** If you're currently using a *very* old version of the bot, from before February 2018, you'll first need to update to v2.30.1 and run the bot once before updating to v3.0.0.
## To update the bot, follow these steps:
1. Shut down the bot
2. Take a backup of your `db/data.sqlite` file
* If you're using a different supported database, take database backups from there
3. Download the latest version of the bot from https://github.com/Dragory/modmailbot/releases/latest
4. Extract the new version's files over the old files
5. Read the [CHANGELOG](https://github.com/Dragory/modmailbot/blob/master/CHANGELOG.md) to see if there are any config changes you have to make
* Especially note changes to supported Node.js versions!
* If you're updating from a version prior to v3.0.0, make sure to enable the **Server Members** intent on the bot's Discord Developer Portal page ([Image](https://raw.githubusercontent.com/Dragory/modmailbot/master/docs/server-members-intent-2.png))
6. Start the bot:
* If you're using `start.bat` to run the bot, just run it again
* If you're running the bot via command line, first run `npm ci` and then start the bot again
👉 If you run into any issues, **[join the support server for help!](https://discord.gg/vRuhG9R)**

View File

@ -1,2 +1,2 @@
const config = require('./src/config');
module.exports = config.knex;
const knexConfig = require("./src/knexConfig");
module.exports = knexConfig;

View File

@ -2,7 +2,6 @@
"apps": [{
"name": "ModMailBot",
"cwd": "./",
"script": "npm",
"args": "start"
"script": "src/index.js"
}]
}

11819
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,19 @@
{
"name": "modmailbot",
"version": "2.31.0-beta.0",
"version": "3.3.2",
"description": "",
"license": "MIT",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"watch": "nodemon -w src src/index.js",
"watch": "supervisor -n exit -w src --inspect=0.0.0.0:9229 src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint ./src"
"lint": "eslint ./src ./db/migrations",
"lint-fix": "eslint --fix ./src ./db/migrations",
"generate-config-jsdoc": "node src/data/generateCfgJsdoc.js",
"generate-plugin-api-docs": "jsdoc2md -t docs/plugin-api-template.hbs src/pluginApi.js > docs/plugin-api.md",
"create-migration": "knex migrate:make",
"run-migrations": "knex migrate:latest"
},
"repository": {
"type": "git",
@ -16,26 +21,34 @@
},
"dependencies": {
"axios": "^0.19.2",
"eris": "^0.13.2",
"humanize-duration": "^3.12.1",
"ini": "^1.3.5",
"json5": "^2.1.1",
"knex": "^0.20.3",
"ajv": "^6.12.4",
"eris": "https://github.com/Dragory/eris/archive/0.14.0-stage-hotfix.tar.gz",
"express": "^4.17.1",
"helmet": "^4.1.1",
"humanize-duration": "^3.23.1",
"ini": "^1.3.6",
"json5": "^2.1.3",
"knex": "^0.21.5",
"knub-command-manager": "^6.1.0",
"mime": "^2.4.4",
"moment": "^2.24.0",
"mime": "^2.4.6",
"moment": "^2.27.0",
"mv": "^2.1.1",
"public-ip": "^4.0.0",
"sqlite3": "^4.2.0",
"mysql2": "^2.1.0",
"pacote": "^11.1.11",
"public-ip": "^4.0.2",
"sqlite3": "^5.0.0",
"tmp": "^0.1.0",
"transliteration": "^2.1.7",
"uuid": "^3.3.3"
"transliteration": "^2.1.11",
"uuid": "^8.3.0",
"yargs-parser": "^20.2.4"
},
"devDependencies": {
"eslint": "^6.7.2",
"nodemon": "^2.0.1"
"eslint": "^7.7.0",
"jsdoc-to-markdown": "^6.0.1",
"json-schema-to-jsdoc": "^1.1.0",
"supervisor": "https://github.com/petruisfan/node-supervisor/archive/fb89a695779770d3cd63b624ef4b1ab2908c105d.tar.gz"
},
"engines": {
"node": ">=10.0.0 <14.0.0"
"node": ">=12.0.0 <14.0.0"
}
}

54
package.json.orig Normal file
View File

@ -0,0 +1,54 @@
{
"name": "modmailbot",
"version": "3.3.2",
"description": "",
"license": "MIT",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"watch": "supervisor -n exit -w src --inspect=0.0.0.0:9229 src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint ./src ./db/migrations",
"lint-fix": "eslint --fix ./src ./db/migrations",
"generate-config-jsdoc": "node src/data/generateCfgJsdoc.js",
"generate-plugin-api-docs": "jsdoc2md -t docs/plugin-api-template.hbs src/pluginApi.js > docs/plugin-api.md",
"create-migration": "knex migrate:make",
"run-migrations": "knex migrate:latest"
},
"repository": {
"type": "git",
"url": "https://github.com/Dragory/modmailbot"
},
"dependencies": {
"axios": "^0.19.2",
"ajv": "^6.12.4",
"eris": "https://github.com/Dragory/eris/archive/0.14.0-stage-hotfix.tar.gz",
"express": "^4.17.1",
"helmet": "^4.1.1",
"humanize-duration": "^3.23.1",
"ini": "^1.3.6",
"json5": "^2.1.3",
"knex": "^0.21.5",
"knub-command-manager": "^6.1.0",
"mime": "^2.4.6",
"moment": "^2.27.0",
"mv": "^2.1.1",
"mysql2": "^2.1.0",
"pacote": "^11.1.11",
"public-ip": "^4.0.2",
"sqlite3": "^5.0.0",
"tmp": "^0.1.0",
"transliteration": "^2.1.11",
"uuid": "^8.3.0",
"yargs-parser": "^20.2.4"
},
"devDependencies": {
"eslint": "^7.7.0",
"jsdoc-to-markdown": "^6.0.1",
"json-schema-to-jsdoc": "^1.1.0",
"supervisor": "https://github.com/petruisfan/node-supervisor/archive/fb89a695779770d3cd63b624ef4b1ab2908c105d.tar.gz"
},
"engines": {
"node": ">=12.0.0 <14.0.0"
}
}

5
src/BotError.js Normal file
View File

@ -0,0 +1,5 @@
class BotError extends Error {}
module.exports = {
BotError,
};

View File

@ -0,0 +1,5 @@
class PluginInstallationError extends Error {}
module.exports = {
PluginInstallationError,
};

View File

@ -1,9 +1,48 @@
const Eris = require('eris');
const config = require('./config');
const Eris = require("eris");
const config = require("./cfg");
const intents = [
// PRIVILEGED INTENTS
"guildMembers", // For server greetings
// REGULAR INTENTS
"directMessages", // For core functionality
"guildMessages", // For bot commands and mentions
"guilds", // For core functionality
"guildVoiceStates", // For member information in the thread header
"guildMessageTyping", // For typing indicators
"directMessageTyping", // For typing indicators
// EXTRA INTENTS (from the config)
...config.extraIntents,
];
const bot = new Eris.Client(config.token, {
getAllUsers: true,
restMode: true,
intents: Array.from(new Set(intents)),
allowedMentions: {
everyone: false,
roles: false,
users: false,
},
});
// Eris allegedly handles these internally, so we can ignore them
const SAFE_TO_IGNORE_ERROR_CODES = [
1001, // "CloudFlare WebSocket proxy restarting"
1006, // "Connection reset by peer"
"ECONNRESET", // Pretty much the same as above
];
bot.on("error", err => {
if (SAFE_TO_IGNORE_ERROR_CODES.includes(err.code)) {
return;
}
throw err;
});
/**
* @type {Eris.Client}
*/
module.exports = bot;

40
src/botVersion.js Normal file
View File

@ -0,0 +1,40 @@
const fs = require("fs");
const path = require("path");
const gitDir = path.resolve(__dirname, "..", ".git");
function getPackageVersion() {
const packageJson = require("../package.json");
return packageJson.version;
}
function getHeadCommitHash() {
try {
fs.accessSync(gitDir);
} catch (e) {
return null;
}
// Find HEAD ref and read the commit hash from that ref
const headRefInfo = fs.readFileSync(path.resolve(gitDir, "HEAD"), { encoding: "utf8" });
if (headRefInfo.startsWith("ref:")) {
const refPath = headRefInfo.slice(5).trim(); // ref: refs/heads/... to refs/heads/...
return fs.readFileSync(path.resolve(gitDir, refPath), { encoding: "utf8" }).trim();
} else {
// Detached head, just the commit hash
return headRefInfo.trim();
}
}
function getPrettyVersion() {
const packageVersion = getPackageVersion();
const headCommitHash = getHeadCommitHash();
return headCommitHash
? `v${packageVersion} (${headCommitHash.slice(0, 7)})`
: packageVersion;
}
module.exports = {
getPrettyVersion,
};

277
src/cfg.js Normal file
View File

@ -0,0 +1,277 @@
const fs = require("fs");
const path = require("path");
const Ajv = require("ajv");
const schema = require("./data/cfg.schema.json");
const cliOpts = require("./cliOpts");
/** @type {ModmailConfig} */
let config = {};
// Auto-detected config files, in priority order
const configFilesToSearch = [
"config.ini",
"config.json",
"config.json5",
"config.js",
// Possible config files when file extensions are hidden
"config.ini.ini",
"config.ini.txt",
"config.json.json",
"config.json.txt",
"config.json.ini",
];
let configFileToLoad;
const requestedConfigFile = cliOpts.config || cliOpts.c; // CLI option --config/-c
if (requestedConfigFile) {
try {
// Config files specified with --config/-c are loaded from cwd
fs.accessSync(requestedConfigFile);
configFileToLoad = requestedConfigFile;
} catch (e) {
if (e.code === "ENOENT") {
console.error(`Specified config file was not found: ${requestedConfigFile}`);
} else {
console.error(`Error reading specified config file ${requestedConfigFile}: ${e.message}`);
}
process.exit(1);
}
} else {
for (const configFile of configFilesToSearch) {
try {
// Auto-detected config files are always loaded from the bot's folder, even if the cwd differs
const relativePath = path.relative(process.cwd(), path.resolve(__dirname, "..", configFile));
fs.accessSync(relativePath);
configFileToLoad = relativePath;
break;
} catch (e) {}
}
}
// Load config values from a config file (if any)
if (configFileToLoad) {
const srcRelativePath = path.resolve(__dirname, process.cwd(), configFileToLoad);
console.log(`Loading configuration from ${configFileToLoad}...`);
try {
if (configFileToLoad.endsWith(".js")) {
config = require(srcRelativePath);
} else {
const raw = fs.readFileSync(configFileToLoad, {encoding: "utf8"});
if (configFileToLoad.endsWith(".ini") || configFileToLoad.endsWith(".ini.txt")) {
config = require("ini").decode(raw);
} else {
config = require("json5").parse(raw);
}
}
} catch (e) {
throw new Error(`Error reading config file! The error given was: ${e.message}`);
}
}
// Set dynamic default values which can't be set in the schema directly
config.dbDir = path.join(__dirname, "..", "db");
config.logDir = path.join(__dirname, "..", "logs"); // Only used for migrating data from older Modmail versions
// 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("__", ".");
config[configKey] = value.includes("||")
? value.split("||")
: value;
loadedEnvValues++;
}
if (process.env.PORT && ! process.env.MM_PORT) {
// Special case: allow common "PORT" environment variable without prefix
config.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(config)) {
if (! key.includes(".")) continue;
const keys = key.split(".");
let cursor = config;
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 config[key];
}
// mainGuildId => mainServerId
// mailGuildId => inboxServerId
if (config.mainGuildId && ! config.mainServerId) {
config.mainServerId = config.mainGuildId;
}
if (config.mailGuildId && ! config.inboxServerId) {
config.inboxServerId = config.mailGuildId;
}
if (! config.dbType) {
config.dbType = "sqlite";
}
if (! config.sqliteOptions) {
config.sqliteOptions = {
filename: path.resolve(__dirname, "..", "db", "data.sqlite"),
};
}
if (! config.logOptions) {
config.logOptions = {};
}
// categoryAutomation.newThreadFromGuild => categoryAutomation.newThreadFromServer
if (config.categoryAutomation && config.categoryAutomation.newThreadFromGuild && ! config.categoryAutomation.newThreadFromServer) {
config.categoryAutomation.newThreadFromServer = config.categoryAutomation.newThreadFromGuild;
}
// guildGreetings => serverGreetings
if (config.guildGreetings && ! config.serverGreetings) {
config.serverGreetings = config.guildGreetings;
}
// Move greetingMessage/greetingAttachment to the serverGreetings 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 serverGreetings. This retains backwards compatibility while allowing you to override
// greetings for specific servers in serverGreetings.
if (config.greetingMessage || config.greetingAttachment) {
config.serverGreetings = config.serverGreetings || {};
for (const guildId of config.mainServerId) {
if (config.serverGreetings[guildId]) continue;
config.serverGreetings[guildId] = {
message: config.greetingMessage,
attachment: config.greetingAttachment
};
}
}
// newThreadCategoryId is syntactic sugar for categoryAutomation.newThread
if (config.newThreadCategoryId) {
config.categoryAutomation = config.categoryAutomation || {};
config.categoryAutomation.newThread = config.newThreadCategoryId;
delete config.newThreadCategoryId;
}
// Delete empty string options (i.e. "option=" without a value in config.ini)
for (const [key, value] of Object.entries(config)) {
if (value === "") {
delete config[key];
}
}
// Validate config and assign defaults (if missing)
const ajv = new Ajv({
useDefaults: true,
coerceTypes: "array",
extendRefs: true, // Hides an error about ignored keywords when using $ref with $comment
});
// https://github.com/ajv-validator/ajv/issues/141#issuecomment-270692820
const truthyValues = ["1", "true", "on", "yes"];
const falsyValues = ["0", "false", "off", "no"];
ajv.addKeyword("coerceBoolean", {
compile(value) {
return (data, dataPath, parentData, parentKey) => {
if (! value) {
// Disabled -> no coercion
return true;
}
// https://github.com/ajv-validator/ajv/issues/141#issuecomment-270777250
// The "data" argument doesn't update within the same set of schemas inside "allOf",
// so we're referring to the original property instead.
// This also means we can't use { "type": "boolean" }, as it would test the un-updated data value.
const realData = parentData[parentKey];
if (typeof realData === "boolean") {
return true;
}
if (truthyValues.includes(realData)) {
parentData[parentKey] = true;
} else if (falsyValues.includes(realData)) {
parentData[parentKey] = false;
} else {
return false;
}
return true;
};
},
});
ajv.addKeyword("multilineString", {
compile(value) {
return (data, dataPath, parentData, parentKey) => {
if (! value) {
// Disabled -> no coercion
return true;
}
const realData = parentData[parentKey];
if (typeof realData === "string") {
return true;
}
parentData[parentKey] = realData.join("\n");
return true;
};
},
});
const validate = ajv.compile(schema);
const configIsValid = validate(config);
if (! configIsValid) {
console.error("");
console.error("NOTE! Issues with configuration:");
for (const error of validate.errors) {
if (error.params.missingProperty) {
console.error(`- Missing required option: "${error.params.missingProperty.slice(1)}"`);
} else {
console.error(`- The "${error.dataPath.slice(1)}" option ${error.message}`);
}
}
console.error("");
console.error("Please restart the bot after fixing the issues mentioned above.");
console.error("");
process.exit(1);
}
console.log("Configuration ok!");
/**
* @type {ModmailConfig}
*/
module.exports = config;

1
src/cliOpts.js Normal file
View File

@ -0,0 +1 @@
module.exports = require("yargs-parser")(process.argv.slice(2));

View File

@ -1,7 +1,61 @@
const { CommandManager, defaultParameterTypes, TypeConversionError } = require('knub-command-manager');
const config = require('./config');
const utils = require('./utils');
const threads = require('./data/threads');
const { CommandManager, defaultParameterTypes, TypeConversionError, IParameter, ICommandConfig } = require("knub-command-manager");
const Eris = require("eris");
const config = require("./cfg");
const utils = require("./utils");
const threads = require("./data/threads");
const Thread = require("./data/Thread");
/**
* @callback CommandFn
* @param {Eris.Message} msg
* @param {object} args
*/
/**
* @callback InboxServerCommandHandler
* @param {Eris.Message} msg
* @param {object} args
* @param {Thread} [thread]
*/
/**
* @callback InboxThreadCommandHandler
* @param {Eris.Message} msg
* @param {object} args
* @param {Thread} thread
*/
/**
* @callback AddGlobalCommandFn
* @param {string} trigger
* @param {string} parameters
* @param {CommandFn} handler
* @param {ICommandConfig} commandConfig
*/
/**
* @callback AddInboxServerCommandFn
* @param {string} trigger
* @param {string} parameters
* @param {InboxServerCommandHandler} handler
* @param {ICommandConfig} commandConfig
*/
/**
* @callback AddInboxThreadCommandFn
* Add a command that can only be invoked in a thread on the inbox server
*
* @param {string} trigger
* @param {string} parameters
* @param {InboxThreadCommandHandler} handler
* @param {ICommandConfig} commandConfig
*/
/**
* @callback AddAliasFn
* @param {string} originalCmd
* @param {string} alias
*/
module.exports = {
createCommandManager(bot) {
@ -25,7 +79,7 @@ module.exports = {
const handlers = {};
const aliasMap = new Map();
bot.on('messageCreate', async msg => {
bot.on("messageCreate", async msg => {
if (msg.author.bot) return;
if (msg.author.id === bot.user.id) return;
if (! msg.content) return;
@ -50,6 +104,7 @@ module.exports = {
/**
* Add a command that can be invoked anywhere
* @type {AddGlobalCommandFn}
*/
const addGlobalCommand = (trigger, parameters, handler, commandConfig = {}) => {
let aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
@ -61,6 +116,7 @@ module.exports = {
/**
* Add a command that can only be invoked on the inbox server
* @type {AddInboxServerCommandFn}
*/
const addInboxServerCommand = (trigger, parameters, handler, commandConfig = {}) => {
const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
@ -86,6 +142,7 @@ module.exports = {
/**
* Add a command that can only be invoked in a thread on the inbox server
* @type {AddInboxThreadCommandFn}
*/
const addInboxThreadCommand = (trigger, parameters, handler, commandConfig = {}) => {
const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
@ -111,6 +168,9 @@ module.exports = {
};
};
/**
* @type {AddAliasFn}
*/
const addAlias = (originalCmd, alias) => {
if (! aliasMap.has(originalCmd)) {
aliasMap.set(originalCmd, new Set());

290
src/config_BACKUP_19.js Normal file
View File

@ -0,0 +1,290 @@
/**
* !!! 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,
"errorChannelId": 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 configuration value: ${opt}`);
process.exit(1);
}
}
console.log("Configuration ok!");
module.exports = finalConfig;

289
src/config_BASE_19.js Normal file
View File

@ -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 configuration value: ${opt}`);
process.exit(1);
}
}
console.log("Configuration ok!");
module.exports = finalConfig;

290
src/config_LOCAL_19.js Normal file
View File

@ -0,0 +1,290 @@
/**
* !!! 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,
"errorChannelId": 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 configuration value: ${opt}`);
process.exit(1);
}
}
console.log("Configuration ok!");
module.exports = finalConfig;

0
src/config_REMOTE_19.js Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@ -7,16 +7,80 @@ const utils = require("../utils");
* @property {Number} message_number
* @property {String} user_id
* @property {String} user_name
* @property {String} role_name
* @property {String} body
* @property {Number} is_anonymous
* @property {String[]} attachments
* @property {String[]} small_attachments The subset of attachments that were relayed when relaySmallAttachmentsAsAttachments is enabled
* @property {String} dm_channel_id
* @property {String} dm_message_id
* @property {String} inbox_message_id
* @property {String} created_at
* @property {Number} use_legacy_format
*/
class ThreadMessage {
constructor(props) {
utils.setDataModelProps(this, props);
if (props.attachments) {
if (typeof props.attachments === "string") {
this.attachments = JSON.parse(props.attachments);
}
} else {
this.attachments = [];
}
if (props.small_attachments) {
if (typeof props.small_attachments === "string") {
this.small_attachments = JSON.parse(props.small_attachments);
}
} else {
this.small_attachments = [];
}
if (props.metadata) {
if (typeof props.metadata === "string") {
this.metadata = JSON.parse(props.metadata);
}
}
}
getSQLProps() {
return Object.entries(this).reduce((obj, [key, value]) => {
if (typeof value === "function") return obj;
if (typeof value === "object" && value != null) {
obj[key] = JSON.stringify(value);
} else {
obj[key] = value;
}
return obj;
}, {});
}
/**
* @param {string} key
* @param {*} value
* @return {Promise<void>}
*/
async setMetadataValue(key, value) {
this.metadata = this.metadata || {};
this.metadata[key] = value;
if (this.id) {
await knex("thread_messages")
.where("id", this.id)
.update({
metadata: this.getSQLProps().metadata,
});
}
}
/**
* @param {string} key
* @returns {*}
*/
getMetadataValue(key) {
return this.metadata ? this.metadata[key] : null;
}
}

View File

@ -1,13 +1,13 @@
const Eris = require('eris');
const fs = require('fs');
const https = require('https');
const {promisify} = require('util');
const tmp = require('tmp');
const config = require('../config');
const utils = require('../utils');
const mv = promisify(require('mv'));
const Eris = require("eris");
const fs = require("fs");
const https = require("https");
const {promisify} = require("util");
const tmp = require("tmp");
const config = require("../cfg");
const utils = require("../utils");
const mv = promisify(require("mv"));
const getUtils = () => require('../utils');
const getUtils = () => require("../utils");
const access = promisify(fs.access);
const readFile = promisify(fs.readFile);
@ -20,18 +20,67 @@ const attachmentStorageTypes = {};
function getErrorResult(msg = null) {
return {
url: `Attachment could not be saved${msg ? ': ' + msg : ''}`,
url: `Attachment could not be saved${msg ? ": " + msg : ""}`,
failed: true
};
}
/**
* Attempts to download and save the given attachement
* @param {Object} attachment
* @param {Number=0} tries
* @callback AddAttachmentStorageTypeFn
* @param {string} name
* @param {AttachmentStorageTypeHandler} handler
*/
/**
* @callback AttachmentStorageTypeHandler
* @param {Eris.Attachment} attachment
* @return {AttachmentStorageTypeResult|Promise<AttachmentStorageTypeResult>}
*/
/**
* @typedef {object} AttachmentStorageTypeResult
* @property {string} url
*/
/**
* @callback DownloadAttachmentFn
* @param {Eris.Attachment} attachment
* @param {number?} tries Used internally, don't pass
* @return {Promise<DownloadAttachmentResult>}
*/
/**
* @typedef {object} DownloadAttachmentResult
* @property {string} path
* @property {DownloadAttachmentCleanupFn} cleanup
*/
/**
* @callback DownloadAttachmentCleanupFn
* @return {void}
*/
/**
* Saves the given attachment based on the configured storage system
* @callback SaveAttachmentFn
* @param {Eris.Attachment} attachment
* @returns {Promise<{ url: string }>}
*/
async function saveLocalAttachment(attachment) {
/**
* @type {AttachmentStorageTypeHandler}
*/
let passthroughOriginalAttachment; // Workaround to inconsistent IDE bug with @type and anonymous functions
passthroughOriginalAttachment = (attachment) => {
return { url: attachment.url };
};
/**
* An attachment storage option that downloads each attachment and serves them from a local web server
* @type {AttachmentStorageTypeHandler}
*/
let saveLocalAttachment; // Workaround to inconsistent IDE bug with @type and anonymous functions
saveLocalAttachment = async (attachment) => {
const targetPath = getLocalAttachmentPath(attachment.id);
try {
@ -51,18 +100,16 @@ async function saveLocalAttachment(attachment) {
const url = await getLocalAttachmentUrl(attachment.id, attachment.filename);
return { url };
}
};
/**
* @param {Object} attachment
* @param {Number} tries
* @returns {Promise<{ path: string, cleanup: function }>}
* @type {DownloadAttachmentFn}
*/
function downloadAttachment(attachment, tries = 0) {
const downloadAttachment = (attachment, tries = 0) => {
return new Promise((resolve, reject) => {
if (tries > 3) {
console.error('Attachment download failed after 3 tries:', attachment);
reject('Attachment download failed after 3 tries');
console.error("Attachment download failed after 3 tries:", attachment);
reject("Attachment download failed after 3 tries");
return;
}
@ -71,21 +118,21 @@ function downloadAttachment(attachment, tries = 0) {
https.get(attachment.url, (res) => {
res.pipe(writeStream);
writeStream.on('finish', () => {
writeStream.on("finish", () => {
writeStream.end();
resolve({
path: filepath,
cleanup: cleanupCallback
});
});
}).on('error', (err) => {
}).on("error", (err) => {
fs.unlink(filepath);
console.error('Error downloading attachment, retrying');
console.error("Error downloading attachment, retrying");
resolve(downloadAttachment(attachment, tries++));
});
});
});
}
};
/**
* Returns the filesystem path for the given attachment id
@ -103,29 +150,31 @@ function getLocalAttachmentPath(attachmentId) {
* @returns {Promise<String>}
*/
function getLocalAttachmentUrl(attachmentId, desiredName = null) {
if (desiredName == null) desiredName = 'file.bin';
if (desiredName == null) desiredName = "file.bin";
return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
}
/**
* @param {Object} attachment
* @returns {Promise<{ url: string }>}
* An attachment storage option that downloads each attachment and re-posts them to a specified Discord channel.
* The re-posted attachment is then linked in the actual thread.
* @type {AttachmentStorageTypeHandler}
*/
async function saveDiscordAttachment(attachment) {
let saveDiscordAttachment; // Workaround to inconsistent IDE bug with @type and anonymous functions
saveDiscordAttachment = async (attachment) => {
if (attachment.size > 1024 * 1024 * 8) {
return getErrorResult('attachment too large (max 8MB)');
return getErrorResult("attachment too large (max 8MB)");
}
const attachmentChannelId = config.attachmentStorageChannelId;
const inboxGuild = utils.getInboxGuild();
if (! inboxGuild.channels.has(attachmentChannelId)) {
throw new Error('Attachment storage channel not found!');
throw new Error("Attachment storage channel not found!");
}
const attachmentChannel = inboxGuild.channels.get(attachmentChannelId);
if (! (attachmentChannel instanceof Eris.TextChannel)) {
throw new Error('Attachment storage channel must be a text channel!');
throw new Error("Attachment storage channel must be a text channel!");
}
const file = await attachmentToDiscordFileObject(attachment);
@ -133,7 +182,7 @@ async function saveDiscordAttachment(attachment) {
if (! savedAttachment) return getErrorResult();
return { url: savedAttachment.url };
}
};
async function createDiscordAttachmentMessage(channel, file, tries = 0) {
tries++;
@ -153,22 +202,20 @@ async function createDiscordAttachmentMessage(channel, file, tries = 0) {
/**
* Turns the given attachment into a file object that can be sent forward as a new attachment
* @param {Object} attachment
* @returns {Promise<{file, name: string}>}
* @param {Eris.Attachment} attachment
* @returns {Promise<Eris.MessageFile>}
*/
async function attachmentToDiscordFileObject(attachment) {
const downloadResult = await downloadAttachment(attachment);
const data = await readFile(downloadResult.path);
downloadResult.cleanup();
return {file: data, name: attachment.filename};
return { file: data, name: attachment.filename };
}
/**
* Saves the given attachment based on the configured storage system
* @param {Object} attachment
* @returns {Promise<{ url: string }>}
* @type {SaveAttachmentFn}
*/
function saveAttachment(attachment) {
const saveAttachment = (attachment) => {
if (attachmentSavePromises[attachment.id]) {
return attachmentSavePromises[attachment.id];
}
@ -184,14 +231,18 @@ function saveAttachment(attachment) {
});
return attachmentSavePromises[attachment.id];
}
};
function addStorageType(name, handler) {
/**
* @type AddAttachmentStorageTypeFn
*/
const addStorageType = (name, handler) => {
attachmentStorageTypes[name] = handler;
}
};
attachmentStorageTypes.local = saveLocalAttachment;
attachmentStorageTypes.discord = saveDiscordAttachment;
addStorageType("original", passthroughOriginalAttachment);
addStorageType("local", saveLocalAttachment);
addStorageType("discord", saveDiscordAttachment);
module.exports = {
getLocalAttachmentPath,

View File

@ -1,13 +1,13 @@
const moment = require('moment');
const knex = require('../knex');
const moment = require("moment");
const knex = require("../knex");
/**
* @param {String} userId
* @returns {Promise<{ isBlocked: boolean, expiresAt: string }>}
*/
async function getBlockStatus(userId) {
const row = await knex('blocked_users')
.where('user_id', userId)
const row = await knex("blocked_users")
.where("user_id", userId)
.first();
return {
@ -32,15 +32,15 @@ async function isBlocked(userId) {
* @param {String} blockedBy
* @returns {Promise}
*/
async function block(userId, userName = '', blockedBy = null, expiresAt = null) {
async function block(userId, userName = "", blockedBy = null, expiresAt = null) {
if (await isBlocked(userId)) return;
return knex('blocked_users')
return knex("blocked_users")
.insert({
user_id: userId,
user_name: userName,
blocked_by: blockedBy,
blocked_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
blocked_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
expires_at: expiresAt
});
}
@ -51,8 +51,8 @@ async function block(userId, userName = '', blockedBy = null, expiresAt = null)
* @returns {Promise}
*/
async function unblock(userId) {
return knex('blocked_users')
.where('user_id', userId)
return knex("blocked_users")
.where("user_id", userId)
.delete();
}
@ -63,8 +63,8 @@ async function unblock(userId) {
* @returns {Promise<void>}
*/
async function updateExpiryTime(userId, expiresAt) {
return knex('blocked_users')
.where('user_id', userId)
return knex("blocked_users")
.where("user_id", userId)
.update({
expires_at: expiresAt
});
@ -74,11 +74,11 @@ async function updateExpiryTime(userId, expiresAt) {
* @returns {String[]}
*/
async function getExpiredBlocks() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const now = moment.utc().format("YYYY-MM-DD HH:mm:ss");
const blocks = await knex('blocked_users')
.whereNotNull('expires_at')
.where('expires_at', '<=', now)
const blocks = await knex("blocked_users")
.whereNotNull("expires_at")
.where("expires_at", "<=", now)
.select();
return blocks.map(block => block.user_id);

90
src/data/cfg.jsdoc.js Normal file
View File

@ -0,0 +1,90 @@
/**
* @typedef {object} ModmailConfig
* @property {string} [token]
* @property {array} [mainServerId]
* @property {string} [inboxServerId]
* @property {string} [logChannelId]
* @property {array} [mainGuildId]
* @property {string} [mailGuildId]
* @property {string} [prefix="!"]
* @property {string} [snippetPrefix="!!"]
* @property {string} [snippetPrefixAnon="!!!"]
* @property {string} [status="Message me for help!"]
* @property {"playing"|"watching"|"listening"} [statusType="playing"]
* @property {string} [responseMessage="Thank you for your message! Our mod team will reply to you here as soon as possible."]
* @property {string} [closeMessage]
* @property {boolean} [allowUserClose=false]
* @property {string} [newThreadCategoryId]
* @property {string} [mentionRole="none"]
* @property {boolean} [pingOnBotMention=true]
* @property {string} [botMentionResponse]
* @property {array} [inboxServerPermission=["manageMessages"]]
* @property {boolean} [alwaysReply=false]
* @property {boolean} [alwaysReplyAnon=false]
* @property {boolean} [useNicknames=false]
* @property {boolean} [anonymizeChannelName=false]
* @property {boolean} [ignoreAccidentalThreads=false]
* @property {boolean} [threadTimestamps=false]
* @property {boolean} [allowMove=false]
* @property {boolean} [syncPermissionsOnMove=true]
* @property {boolean} [typingProxy=false]
* @property {boolean} [typingProxyReverse=false]
* @property {boolean} [mentionUserInThreadHeader=false]
* @property {boolean} [rolesInThreadHeader=false]
* @property {boolean} [allowStaffEdit=true]
* @property {boolean} [allowStaffDelete=true]
* @property {boolean} [allowBlock=true]
* @property {boolean} [allowSuspend=true]
* @property {boolean} [allowSnippets=true]
* @property {boolean} [enableGreeting=false]
* @property {string} [greetingMessage]
* @property {string} [greetingAttachment]
* @property {*} [serverGreetings={}]
* @property {*} [guildGreetings]
* @property {number} [requiredAccountAge] Required account age to message Modmail, in hours
* @property {string} [accountAgeDeniedMessage="Your Discord account is not old enough to contact modmail."]
* @property {number} [requiredTimeOnServer] Required time on server to message Modmail, in minutes
* @property {string} [timeOnServerDeniedMessage="You haven't been a member of the server for long enough to contact modmail."]
* @property {boolean} [relaySmallAttachmentsAsAttachments=false]
* @property {number} [smallAttachmentLimit=2097152] Max size of attachment to relay directly. Default is 2MB.
* @property {string} [attachmentStorage="original"]
* @property {string} [attachmentStorageChannelId]
* @property {*} [categoryAutomation={}]
* @property {boolean} [updateNotifications=true]
* @property {boolean} [updateNotificationsForBetaVersions=false]
* @property {array} [plugins=[]]
* @property {*} [commandAliases]
* @property {boolean} [reactOnSeen=false]
* @property {string} [reactOnSeenEmoji="📨"]
* @property {boolean} [createThreadOnMention=false]
* @property {boolean} [notifyOnMainServerLeave=true]
* @property {boolean} [notifyOnMainServerJoin=true]
* @property {boolean} [allowInlineSnippets=true]
* @property {string} [inlineSnippetStart="{{"]
* @property {string} [inlineSnippetEnd="}}"]
* @property {boolean} [errorOnUnknownInlineSnippet=true]
* @property {boolean} [allowChangingDisplayRole=true]
* @property {string} [fallbackRoleName=null]
* @property {boolean} [autoAlert=false]
* @property {string} [autoAlertDelay="2m"] Delay before auto-alert kicks in. Uses the same format as timed close; for example 1m30s for 1 minute and 30 seconds.
* @property {boolean} [pinThreadHeader=false]
* @property {boolean} [showResponseMessageInThreadChannel=true]
* @property {string} [logStorage="local"]
* @property {object} [logOptions]
* @property {string} logOptions.attachmentDirectory
* @property {*} [logOptions.allowAttachmentUrlFallback=false]
* @property {number} [port=8890]
* @property {string} [url]
* @property {boolean} [useGitForGitHubPlugins=false]
* @property {array} [extraIntents=[]]
* @property {*} [dbType="sqlite"]
* @property {object} [sqliteOptions]
* @property {string} sqliteOptions.filename
* @property {object} [mysqlOptions]
* @property {string} mysqlOptions.host
* @property {number} mysqlOptions.port
* @property {string} mysqlOptions.user
* @property {string} mysqlOptions.password
* @property {string} mysqlOptions.database
* @property {string} [mysqlOptions.timezone]
*/

513
src/data/cfg.schema.json Normal file
View File

@ -0,0 +1,513 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ModmailConfig",
"type": "object",
"definitions": {
"stringArray": {
"type": "array",
"items": {
"type": "string"
}
},
"multilineString": {
"allOf": [
{
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
{
"$comment": "See definition of multilineString in cfg.js",
"multilineString": true
}
]
},
"customBoolean": {
"allOf": [
{
"type": ["boolean", "string"]
},
{
"$comment": "See definition of coerceBoolean in cfg.js",
"coerceBoolean": true
}
]
}
},
"properties": {
"token": {
"type": "string"
},
"mainServerId": {
"$ref": "#/definitions/stringArray"
},
"inboxServerId": {
"type": "string"
},
"logChannelId": {
"type": "string"
},
"mainGuildId": {
"$comment": "Alias for mainServerId",
"$ref": "#/definitions/stringArray"
},
"mailGuildId": {
"$comment": "Alias for inboxServerId",
"type": "string"
},
"prefix": {
"type": "string",
"default": "!"
},
"snippetPrefix": {
"type": "string",
"default": "!!"
},
"snippetPrefixAnon": {
"type": "string",
"default": "!!!"
},
"status": {
"type": "string",
"default": "Message me for help!"
},
"statusType": {
"type": "string",
"enum": ["playing", "watching", "listening"],
"default": "playing"
},
"responseMessage": {
"$ref": "#/definitions/multilineString",
"default": "Thank you for your message! Our mod team will reply to you here as soon as possible."
},
"closeMessage": {
"$ref": "#/definitions/multilineString"
},
"allowUserClose": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"newThreadCategoryId": {
"type": "string"
},
"mentionRole": {
"type": "string",
"default": "none"
},
"pingOnBotMention": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"botMentionResponse": {
"$ref": "#/definitions/multilineString"
},
"inboxServerPermission": {
"$ref": "#/definitions/stringArray",
"default": ["manageMessages"]
},
"alwaysReply": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"alwaysReplyAnon": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"useNicknames": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"anonymizeChannelName": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"ignoreAccidentalThreads": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"threadTimestamps": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"allowMove": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"syncPermissionsOnMove": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"typingProxy": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"typingProxyReverse": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"mentionUserInThreadHeader": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"rolesInThreadHeader": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"allowStaffEdit": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowStaffDelete": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowBlock": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowSuspend": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowSnippets": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"enableGreeting": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"greetingMessage": {
"$ref": "#/definitions/multilineString"
},
"greetingAttachment": {
"type": "string"
},
"serverGreetings": {
"patternProperties": {
"^\\d+$": {
"type": "object",
"properties": {
"message": {
"$ref": "#/definitions/multilineString"
},
"attachment": {
"type": "string"
}
}
}
},
"default": {}
},
"guildGreetings": {
"$comment": "Alias for serverGreetings",
"$ref": "#/properties/serverGreetings"
},
"requiredAccountAge": {
"description": "Required account age to message Modmail, in hours",
"type": "number"
},
"accountAgeDeniedMessage": {
"$ref": "#/definitions/multilineString",
"default": "Your Discord account is not old enough to contact modmail."
},
"requiredTimeOnServer": {
"description": "Required time on server to message Modmail, in minutes",
"type": "number"
},
"timeOnServerDeniedMessage": {
"$ref": "#/definitions/multilineString",
"default": "You haven't been a member of the server for long enough to contact modmail."
},
"relaySmallAttachmentsAsAttachments": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"smallAttachmentLimit": {
"description": "Max size of attachment to relay directly. Default is 2MB.",
"type": "number",
"default": 2097152,
"maximum": 8388608,
"minimum": 0
},
"attachmentStorage": {
"type": "string",
"default": "original"
},
"attachmentStorageChannelId": {
"type": "string"
},
"categoryAutomation": {
"properties": {
"newThread": {
"type": "string"
},
"newThreadFromServer": {
"type": "object",
"patternProperties": {
"^.+$": {
"type": "string",
"pattern": "^\\d+$"
}
}
},
"newThreadFromGuild": {
"$comment": "Alias for categoryAutomation.newThreadFromServer",
"$ref": "#/properties/categoryAutomation/properties/newThreadFromServer"
}
},
"default": {}
},
"updateNotifications": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"updateNotificationsForBetaVersions": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"plugins": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"commandAliases": {
"patternProperties": {
"^.+$": {
"type": "string"
}
}
},
"reactOnSeen": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"reactOnSeenEmoji": {
"type": "string",
"default": "\uD83D\uDCE8"
},
"createThreadOnMention": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"notifyOnMainServerLeave": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"notifyOnMainServerJoin": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowInlineSnippets": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"inlineSnippetStart": {
"type": "string",
"default": "{{"
},
"inlineSnippetEnd": {
"type": "string",
"default": "}}"
},
"errorOnUnknownInlineSnippet": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"allowChangingDisplayRole": {
"$ref": "#/definitions/customBoolean",
"default": true
},
"fallbackRoleName": {
"type": "string",
"default": null
},
"autoAlert": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"autoAlertDelay": {
"type": "string",
"default": "2m",
"description": "Delay before auto-alert kicks in. Uses the same format as timed close; for example 1m30s for 1 minute and 30 seconds."
},
"pinThreadHeader": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"showResponseMessageInThreadChannel": {
"$ref": "#/definitions/customBoolean",
"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,
"minimum": 1,
"default": 8890
},
"url": {
"type": "string"
},
"useGitForGitHubPlugins": {
"$ref": "#/definitions/customBoolean",
"default": false
},
"extraIntents": {
"$ref": "#/definitions/stringArray",
"default": []
},
"dbType": {
"anyOf": [
{ "const": "sqlite" },
{ "const": "mysql" }
],
"default": "sqlite"
},
"sqliteOptions": {
"type": "object",
"properties": {
"filename": {
"type": "string"
}
},
"required": ["filename"]
},
"mysqlOptions": {
"type": "object",
"properties": {
"host": {
"type": "string",
"default": "localhost"
},
"port": {
"type": "number",
"default": "3306"
},
"user": {
"type": "string"
},
"password": {
"type": "string"
},
"database": {
"type": "string"
},
"timezone": {
"type": "string"
}
},
"required": ["host", "port", "user", "password", "database"]
}
},
"allOf": [
{
"$comment": "Base required values",
"required": ["token", "mainServerId", "inboxServerId", "logChannelId", "dbType"]
},
{
"$comment": "Make attachmentStorageChannelId required if attachmentStorage is set to 'discord'",
"if": {
"properties": {
"attachmentStorage": {
"const": "discord"
}
},
"required": ["attachmentStorage"]
},
"then": {
"required": ["attachmentStorageChannelId"]
}
},
{
"$comment": "Make sqliteOptions required if dbType is set to 'sqlite'",
"if": {
"properties": {
"dbType": {
"const": "sqlite"
}
}
},
"then": {
"required": ["sqliteOptions"]
}
},
{
"$comment": "Make mysqlOptions required if dbType is set to 'mysql'",
"if": {
"properties": {
"dbType": {
"const": "mysql"
}
}
},
"then": {
"required": ["mysqlOptions"]
}
}
]
}

View File

@ -12,45 +12,66 @@ module.exports = {
TO_USER: 4,
LEGACY: 5,
COMMAND: 6,
SYSTEM_TO_USER: 7
SYSTEM_TO_USER: 7,
REPLY_EDITED: 8,
REPLY_DELETED: 9,
},
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
DISOCRD_CHANNEL_TYPES: {
GUILD_TEXT: 0,
DM: 1,
GUILD_VOICE: 2,
GROUP_DM: 3,
GUILD_CATEGORY: 4,
GUILD_NEWS: 5,
GUILD_STORE: 6,
},
// https://discord.com/developers/docs/resources/channel#message-object-message-activity-types
DISCORD_MESSAGE_ACTIVITY_TYPES: {
JOIN: 1,
SPECTATE: 2,
LISTEN: 3,
JOIN_REQUEST: 5,
},
ACCIDENTAL_THREAD_MESSAGES: [
'ok',
'okay',
'thanks',
'ty',
'k',
'kk',
'thank you',
'thanx',
'thnx',
'thx',
'tnx',
'ok thank you',
'ok thanks',
'ok ty',
'ok thanx',
'ok thnx',
'ok thx',
'ok no problem',
'ok np',
'okay thank you',
'okay thanks',
'okay ty',
'okay thanx',
'okay thnx',
'okay thx',
'okay no problem',
'okay np',
'okey thank you',
'okey thanks',
'okey ty',
'okey thanx',
'okey thnx',
'okey thx',
'okey no problem',
'okey np',
'cheers'
"ok",
"okay",
"thanks",
"ty",
"k",
"kk",
"thank you",
"thanx",
"thnx",
"thx",
"tnx",
"ok thank you",
"ok thanks",
"ok ty",
"ok thanx",
"ok thnx",
"ok thx",
"ok no problem",
"ok np",
"okay thank you",
"okay thanks",
"okay ty",
"okay thanx",
"okay thnx",
"okay thx",
"okay no problem",
"okay np",
"okey thank you",
"okey thanks",
"okey ty",
"okey thanx",
"okey thnx",
"okey thx",
"okey no problem",
"okey np",
"cheers"
],
};

165
src/data/displayRoles.js Normal file
View File

@ -0,0 +1,165 @@
const knex = require("../knex");
const Eris = require("eris");
const utils = require("../utils");
const config = require("../cfg");
/**
* @param {string} moderatorId
* @returns {Promise<string|null>}
*/
async function getModeratorDefaultRoleOverride(moderatorId) {
const roleOverride = await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.whereNull("thread_id")
.first();
return roleOverride ? roleOverride.role_id : null;
}
/**
* @param {string} moderatorId
* @param {string} roleId
* @returns {Promise<void>}
*/
async function setModeratorDefaultRoleOverride(moderatorId, roleId) {
const existingGlobalOverride = await getModeratorDefaultRoleOverride(moderatorId);
if (existingGlobalOverride) {
await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.whereNull("thread_id")
.update({ role_id: roleId });
} else {
await knex("moderator_role_overrides")
.insert({
moderator_id: moderatorId,
thread_id: null,
role_id: roleId,
});
}
}
/**
* @param {string} moderatorId
* @returns {Promise<void>}
*/
async function resetModeratorDefaultRoleOverride(moderatorId) {
await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.whereNull("thread_id")
.delete();
}
/**
* @param {string} moderatorId
* @param {string} threadId
* @returns {Promise<string|null>}
*/
async function getModeratorThreadRoleOverride(moderatorId, threadId) {
const roleOverride = await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.where("thread_id", threadId)
.first();
return roleOverride ? roleOverride.role_id : null;
}
/**
* @param {string} moderatorId
* @param {string} threadId
* @param {string} roleId
* @returns {Promise<void>}
*/
async function setModeratorThreadRoleOverride(moderatorId, threadId, roleId) {
const existingGlobalOverride = await getModeratorThreadRoleOverride(moderatorId, threadId);
if (existingGlobalOverride) {
await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.where("thread_id", threadId)
.update({ role_id: roleId });
} else {
await knex("moderator_role_overrides")
.insert({
moderator_id: moderatorId,
thread_id: threadId,
role_id: roleId,
});
}
}
/**
* @param {string} moderatorId
* @param {string} threadId
* @returns {Promise<void>}
*/
async function resetModeratorThreadRoleOverride(moderatorId, threadId) {
await knex("moderator_role_overrides")
.where("moderator_id", moderatorId)
.where("thread_id", threadId)
.delete();
}
/**
* @param {Eris.Member} moderator
* @returns {Promise<Eris.Role|null>}
*/
async function getModeratorDefaultDisplayRole(moderator) {
const globalOverrideRoleId = await getModeratorDefaultRoleOverride(moderator.id);
if (globalOverrideRoleId && moderator.roles.includes(globalOverrideRoleId)) {
return moderator.guild.roles.get(globalOverrideRoleId);
}
return utils.getMainRole(moderator);
}
/**
* @param {Eris.Member} moderator
* @returns {Promise<string|null>}
*/
async function getModeratorDefaultDisplayRoleName(moderator) {
const defaultDisplayRole = await getModeratorDefaultDisplayRole(moderator);
return defaultDisplayRole
? defaultDisplayRole.name
: (config.fallbackRoleName || null);
}
/**
* @param {Eris.Member} moderator
* @param {string} threadId
* @returns {Promise<Eris.Role|null>}
*/
async function getModeratorThreadDisplayRole(moderator, threadId) {
const threadOverrideRoleId = await getModeratorThreadRoleOverride(moderator.id, threadId);
if (threadOverrideRoleId && moderator.roles.includes(threadOverrideRoleId)) {
return moderator.guild.roles.get(threadOverrideRoleId);
}
return getModeratorDefaultDisplayRole(moderator);
}
/**
* @param {Eris.Member} moderator
* @param {string} threadId
* @returns {Promise<string|null>}
*/
async function getModeratorThreadDisplayRoleName(moderator, threadId) {
const threadDisplayRole = await getModeratorThreadDisplayRole(moderator, threadId);
return threadDisplayRole
? threadDisplayRole.name
: (config.fallbackRoleName || null);
}
module.exports = {
getModeratorDefaultRoleOverride,
setModeratorDefaultRoleOverride,
resetModeratorDefaultRoleOverride,
getModeratorThreadRoleOverride,
setModeratorThreadRoleOverride,
resetModeratorThreadRoleOverride,
getModeratorDefaultDisplayRole,
getModeratorDefaultDisplayRoleName,
getModeratorThreadDisplayRole,
getModeratorThreadDisplayRoleName,
};

View File

@ -0,0 +1,23 @@
const path = require("path");
const fs = require("fs");
const toJsdoc = require("json-schema-to-jsdoc");
const schema = require("./cfg.schema.json");
const target = path.join(__dirname, "cfg.jsdoc.js");
// Fix up some custom types for the JSDoc conversion
const schemaCopy = JSON.parse(JSON.stringify(schema));
for (const propertyDef of Object.values(schemaCopy.properties)) {
if (propertyDef.$ref === "#/definitions/stringArray") {
propertyDef.type = "array";
delete propertyDef.$ref;
} else if (propertyDef.$ref === "#/definitions/customBoolean") {
propertyDef.type = "boolean";
delete propertyDef.$ref;
} else if (propertyDef.$ref === "#/definitions/multilineString") {
propertyDef.type = "string";
delete propertyDef.$ref;
}
}
const result = toJsdoc(schemaCopy);
fs.writeFileSync(target, result, { encoding: "utf8" });

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

@ -0,0 +1,206 @@
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 {LogStorageTypeHandlerShouldSaveFn?} shouldSave
* @property {LogStorageTypeHandlerGetUrlFn?} getUrl
* @property {LogStorageTypeHandlerGetFileFn?} getFile
* @property {LogStorageTypeHandlerGetCustomResponseFn?} getCustomResponse
*/
/**
* @callback LogStorageTypeHandlerSaveFn
* @param {Thread} thread
* @param {ThreadMessage[]} threadMessages
* @return {Object|Promise<Object>|null|Promise<null>} Information about the saved log that can be used to retrieve the log later
*/
/**
* @callback LogStorageTypeHandlerShouldSaveFn
* @param {Thread} thread
* @return {boolean|Promise<boolean>} Whether the log should be saved at this time
*/
/**
* @callback LogStorageTypeHandlerGetUrlFn
* @param {Thread} thread
* @return {string|Promise<string>|null|Promise<null>}
*/
/**
* @callback LogStorageTypeHandlerGetFileFn
* @param {Thread} thread
* @return {Eris.MessageFile|Promise<Eris.MessageFile>|null|Promise<null>>}
*/
/**
* @typedef {object} LogStorageTypeHandlerGetCustomResult
* @property {Eris.MessageContent?} content
* @property {Eris.MessageFile?} file
*/
/**
* @callback LogStorageTypeHandlerGetCustomResponseFn
* @param {Thread} thread
* @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, overrideType = null) => {
const storageType = overrideType || config.logStorage;
const { save, shouldSave } = logStorageTypes[storageType] || {};
if (shouldSave && ! await shouldSave(thread)) return;
if (save) {
const threadMessages = await thread.getThreadMessages();
const storageData = await save(thread, threadMessages);
await thread.updateLogStorageValues(storageType, storageData);
}
};
/**
* @callback GetLogUrlFn
* @param {Thread} thread
* @returns {Promise<string|null>}
*/
/**
* @type {GetLogUrlFn}
*/
const getLogUrl = async (thread) => {
if (! thread.log_storage_type) {
await saveLogToStorage(thread);
}
const { getUrl } = logStorageTypes[thread.log_storage_type] || {};
return getUrl
? getUrl(thread)
: null;
};
/**
* @callback GetLogFileFn
* @param {Thread} thread
* @returns {Promise<Eris.MessageFile|null>}
*/
/**
* @type {GetLogFileFn}
*/
const getLogFile = async (thread) => {
if (! thread.log_storage_type) {
await saveLogToStorage(thread);
}
const { getFile } = logStorageTypes[thread.log_storage_type] || {};
return getFile
? getFile(thread)
: null;
};
/**
* @callback GetLogCustomResponseFn
* @param {Thread} threadId
* @returns {Promise<LogStorageTypeHandlerGetCustomResult|null>}
*/
/**
* @type {GetLogCustomResponseFn}
*/
const getLogCustomResponse = async (thread) => {
if (! thread.log_storage_type) {
await saveLogToStorage(thread);
}
const { getCustomResponse } = logStorageTypes[thread.log_storage_type] || {};
return getCustomResponse
? getCustomResponse(thread)
: null;
};
addStorageType("local", {
save() {
return null;
},
getUrl(thread) {
return utils.getSelfUrl(`logs/${thread.id}`);
},
});
const getLogAttachmentFilename = threadId => {
const filename = `${threadId}.txt`;
const fullPath = path.join(config.logOptions.attachmentDirectory, filename);
return { filename, fullPath };
};
addStorageType("attachment", {
shouldSave(thread) {
return thread.status === THREAD_STATUS.CLOSED;
},
async save(thread, threadMessages) {
const { fullPath, filename } = getLogAttachmentFilename(thread.id);
const formatLogResult = await formatters.formatLog(thread, threadMessages);
fs.writeFileSync(fullPath, formatLogResult.content, { encoding: "utf8" });
return { fullPath, filename };
},
async getFile(thread) {
const { fullPath, filename } = thread.log_storage_data || {};
if (! fullPath) return;
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

@ -0,0 +1,64 @@
exports.up = async function(knex, Promise) {
if (! await knex.schema.hasTable("threads")) {
await knex.schema.createTable("threads", table => {
table.string("id", 36).notNullable().primary();
table.integer("status").unsigned().notNullable().index();
table.integer("is_legacy").unsigned().notNullable();
table.string("user_id", 20).notNullable().index();
table.string("user_name", 128).notNullable();
table.string("channel_id", 20).nullable().unique();
table.dateTime("created_at").notNullable().index();
});
}
if (! await knex.schema.hasTable("thread_messages")) {
await knex.schema.createTable("thread_messages", table => {
table.increments("id");
table.string("thread_id", 36).notNullable().index().references("id").inTable("threads").onDelete("CASCADE");
table.integer("message_type").unsigned().notNullable();
table.string("user_id", 20).nullable();
table.string("user_name", 128).notNullable();
table.mediumtext("body").notNullable();
table.integer("is_anonymous").unsigned().notNullable();
table.string("dm_message_id", 20).nullable().unique();
table.dateTime("created_at").notNullable().index();
});
}
if (! await knex.schema.hasTable("blocked_users")) {
await knex.schema.createTable("blocked_users", table => {
table.string("user_id", 20).primary().notNullable();
table.string("user_name", 128).notNullable();
table.string("blocked_by", 20).nullable();
table.dateTime("blocked_at").notNullable();
});
}
if (! await knex.schema.hasTable("snippets")) {
await knex.schema.createTable("snippets", table => {
table.string("trigger", 32).primary().notNullable();
table.text("body").notNullable();
table.integer("is_anonymous").unsigned().notNullable();
table.string("created_by", 20).nullable();
table.dateTime("created_at").notNullable();
});
}
};
exports.down = async function(knex, Promise) {
if (await knex.schema.hasTable("thread_messages")) {
await knex.schema.dropTable("thread_messages");
}
if (await knex.schema.hasTable("threads")) {
await knex.schema.dropTable("threads");
}
if (await knex.schema.hasTable("blocked_users")) {
await knex.schema.dropTable("blocked_users");
}
if (await knex.schema.hasTable("snippets")) {
await knex.schema.dropTable("snippets");
}
};

View File

@ -0,0 +1,15 @@
exports.up = async function (knex, Promise) {
await knex.schema.table("threads", table => {
table.dateTime("scheduled_close_at").index().nullable().defaultTo(null).after("channel_id");
table.string("scheduled_close_id", 20).nullable().defaultTo(null).after("channel_id");
table.string("scheduled_close_name", 128).nullable().defaultTo(null).after("channel_id");
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.dropColumn("scheduled_close_at");
table.dropColumn("scheduled_close_id");
table.dropColumn("scheduled_close_name");
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function (knex, Promise) {
await knex.schema.table("threads", table => {
table.string("alert_id", 20).nullable().defaultTo(null).after("scheduled_close_name");
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.dropColumn("alert_id");
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function (knex, Promise) {
await knex.schema.table("snippets", table => {
table.dropColumn("is_anonymous");
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("snippets", table => {
table.integer("is_anonymous").unsigned().notNullable();
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.integer("scheduled_close_silent").nullable().after("scheduled_close_name");
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.dropColumn("scheduled_close_silent");
});
};

View File

@ -0,0 +1,15 @@
exports.up = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.dateTime("scheduled_suspend_at").index().nullable().defaultTo(null).after("channel_id");
table.string("scheduled_suspend_id", 20).nullable().defaultTo(null).after("channel_id");
table.string("scheduled_suspend_name", 128).nullable().defaultTo(null).after("channel_id");
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("threads", table => {
table.dropColumn("scheduled_suspend_at");
table.dropColumn("scheduled_suspend_id");
table.dropColumn("scheduled_suspend_name");
});
};

View File

@ -0,0 +1,14 @@
exports.up = async function(knex, Promise) {
if (! await knex.schema.hasTable("updates")) {
await knex.schema.createTable("updates", table => {
table.string("available_version", 16).nullable();
table.dateTime("last_checked").nullable();
});
}
};
exports.down = async function(knex, Promise) {
if (await knex.schema.hasTable("updates")) {
await knex.schema.dropTable("updates");
}
};

View File

@ -0,0 +1,11 @@
exports.up = async function(knex, Promise) {
await knex.schema.table("blocked_users", table => {
table.dateTime("expires_at").nullable();
});
};
exports.down = async function(knex, Promise) {
await knex.schema.table("blocked_users", table => {
table.dropColumn("expires_at");
});
};

View File

@ -0,0 +1,19 @@
const Knex = require("knex");
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.integer("message_number").unsigned().nullable();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.dropColumn("message_number");
});
};

View File

@ -0,0 +1,19 @@
const Knex = require("knex");
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.string("inbox_message_id", 20).nullable().unique();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.dropColumn("inbox_message_id");
});
};

View File

@ -0,0 +1,19 @@
const Knex = require("knex");
/**
* @param {Knex} knex
*/
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.string("dm_channel_id", 20).nullable();
});
};
/**
* @param {Knex} knex
*/
exports.down = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.dropColumn("dm_channel_id");
});
};

View File

@ -0,0 +1,21 @@
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.string("role_name", 255).nullable();
table.text("attachments").nullable();
table.text("small_attachments").nullable();
table.boolean("use_legacy_format").nullable();
});
await knex("thread_messages").update({
use_legacy_format: 1,
});
};
exports.down = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.dropColumn("role_name");
table.dropColumn("attachments");
table.dropColumn("small_attachments");
table.dropColumn("use_legacy_format");
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.integer("next_message_number").defaultTo(1);
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("next_message_number");
});
};

View File

@ -0,0 +1,21 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.text("alert_ids").nullable();
});
await knex("threads")
.update({
alert_ids: knex.raw("alert_id"),
});
await knex.schema.table("threads", table => {
table.dropColumn("alert_id");
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("alert_ids");
table.text("alert_id").nullable();
});
};

View File

@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.string("log_storage_type", 255).nullable().defaultTo(null);
table.text("log_storage_data").nullable().defaultTo(null);
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("log_storage_type");
table.dropColumn("log_storage_data");
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.text("metadata").nullable().defaultTo(null);
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("metadata");
});
};

View File

@ -0,0 +1,11 @@
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.text("metadata").nullable().defaultTo(null);
});
};
exports.down = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.dropColumn("metadata");
});
};

View File

@ -0,0 +1,19 @@
exports.up = async function(knex) {
await knex.schema.table("thread_messages", table => {
table.text("temp_body");
});
await knex.raw("UPDATE thread_messages SET temp_body = body");
await knex.schema.table("thread_messages", table => {
table.dropColumn("body");
});
await knex.schema.table("thread_messages", table => {
table.renameColumn("temp_body", "body");
});
};
exports.down = async function(knex) {
};

View File

@ -0,0 +1,17 @@
exports.up = async function(knex, Promise) {
if (! await knex.schema.hasTable("moderator_role_overrides")) {
await knex.schema.createTable("moderator_role_overrides", table => {
table.string("moderator_id", 20);
table.string("thread_id", 36).nullable().defaultTo(null);
table.string("role_id", 20);
table.primary(["moderator_id", "thread_id"]);
});
}
};
exports.down = async function(knex, Promise) {
if (await knex.schema.hasTable("moderator_role_overrides")) {
await knex.schema.dropTable("moderator_role_overrides");
}
};

View File

@ -0,0 +1,12 @@
exports.up = async function(knex) {
await knex.schema.table("threads", table => {
table.integer("thread_number");
table.unique("thread_number");
});
};
exports.down = async function(knex) {
await knex.schema.table("threads", table => {
table.dropColumn("thread_number");
});
};

View File

@ -0,0 +1,16 @@
exports.up = async function(knex) {
const threads = await knex.table("threads")
.orderBy("created_at", "ASC")
.select(["id"]);
let threadNumber = 0;
for (const { id } of threads) {
await knex.table("threads")
.where("id", id)
.update({ thread_number: ++threadNumber });
}
};
exports.down = async function(knex) {
// Nothing
};

View File

@ -0,0 +1,45 @@
exports.up = async function(knex) {
await knex.schema.renameTable("moderator_role_overrides", "old_moderator_role_overrides");
await knex.schema.createTable("moderator_role_overrides", table => {
table.increments("id");
table.string("moderator_id", 20).notNullable();
table.string("thread_id", 36).nullable().defaultTo(null);
table.string("role_id", 20).notNullable();
table.unique(["moderator_id", "thread_id"]);
});
const rows = await knex.table("old_moderator_role_overrides")
.select();
if (rows.length) {
await knex.table("moderator_role_overrides").insert(rows);
}
await knex.schema.dropTable("old_moderator_role_overrides");
};
exports.down = async function(knex) {
await knex.schema.renameTable("moderator_role_overrides", "new_moderator_role_overrides");
await knex.schema.createTable("moderator_role_overrides", table => {
table.string("moderator_id", 20);
table.string("thread_id", 36).nullable().defaultTo(null);
table.string("role_id", 20);
table.primary(["moderator_id", "thread_id"]);
});
const rows = await knex.table("new_moderator_role_overrides")
.select();
if (rows.length) {
await knex.table("moderator_role_overrides").insert(rows.map(r => {
delete r.id;
return r;
}));
}
await knex.schema.dropTable("new_moderator_role_overrides");
};

View File

@ -1,14 +1,14 @@
const moment = require('moment');
const knex = require('../knex');
const Snippet = require('./Snippet');
const moment = require("moment");
const knex = require("../knex");
const Snippet = require("./Snippet");
/**
* @param {String} trigger
* @returns {Promise<Snippet>}
*/
async function getSnippet(trigger) {
const snippet = await knex('snippets')
.where('trigger', trigger)
const snippet = await knex("snippets")
.where(knex.raw("LOWER(`trigger`)"), trigger.toLowerCase())
.first();
return (snippet ? new Snippet(snippet) : null);
@ -22,11 +22,11 @@ async function getSnippet(trigger) {
async function addSnippet(trigger, body, createdBy = 0) {
if (await getSnippet(trigger)) return;
return knex('snippets').insert({
return knex("snippets").insert({
trigger,
body,
created_by: createdBy,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss")
});
}
@ -35,8 +35,8 @@ async function addSnippet(trigger, body, createdBy = 0) {
* @returns {Promise<void>}
*/
async function deleteSnippet(trigger) {
return knex('snippets')
.where('trigger', trigger)
return knex("snippets")
.where(knex.raw("LOWER(`trigger`)"), trigger.toLowerCase())
.delete();
}
@ -44,7 +44,7 @@ async function deleteSnippet(trigger) {
* @returns {Promise<Snippet[]>}
*/
async function getAllSnippets() {
const snippets = await knex('snippets')
const snippets = await knex("snippets")
.select();
return snippets.map(s => new Snippet(s));

View File

@ -1,29 +1,55 @@
const {User, Member} = require('eris');
const {User, Member, Message} = require("eris");
const transliterate = require('transliteration');
const moment = require('moment');
const uuid = require('uuid');
const humanizeDuration = require('humanize-duration');
const transliterate = require("transliteration");
const moment = require("moment");
const uuid = require("uuid");
const humanizeDuration = require("humanize-duration");
const crypto = require("crypto");
const bot = require('../bot');
const knex = require('../knex');
const config = require('../config');
const utils = require('../utils');
const updates = require('./updates');
const bot = require("../bot");
const knex = require("../knex");
const config = require("../cfg");
const utils = require("../utils");
const updates = require("./updates");
const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants');
const Thread = require("./Thread");
const {callBeforeNewThreadHooks} = require("../hooks/beforeNewThread");
const {THREAD_STATUS, DISOCRD_CHANNEL_TYPES} = require("./constants");
const MINUTES = 60 * 1000;
const HOURS = 60 * MINUTES;
let threadCreationQueue = Promise.resolve();
function _addToThreadCreationQueue(fn) {
threadCreationQueue = threadCreationQueue
.then(fn)
.catch(err => {
console.error(`Error while creating thread: ${err.message}`);
});
return threadCreationQueue;
}
/**
* @param {String} id
* @returns {Promise<Thread>}
*/
async function findById(id) {
const thread = await knex('threads')
.where('id', id)
const thread = await knex("threads")
.where("id", id)
.first();
return (thread ? new Thread(thread) : null);
}
/**
* @param {number} threadNumber
* @returns {Promise<Thread>}
*/
async function findByThreadNumber(threadNumber) {
const thread = await knex("threads")
.where("thread_number", threadNumber)
.first();
return (thread ? new Thread(thread) : null);
@ -34,9 +60,9 @@ async function findById(id) {
* @returns {Promise<Thread>}
*/
async function findOpenThreadByUserId(userId) {
const thread = await knex('threads')
.where('user_id', userId)
.where('status', THREAD_STATUS.OPEN)
const thread = await knex("threads")
.where("user_id", userId)
.where("status", THREAD_STATUS.OPEN)
.first();
return (thread ? new Thread(thread) : null);
@ -49,211 +75,242 @@ function getHeaderGuildInfo(member) {
};
}
/**
* @typedef CreateNewThreadForUserOpts
* @property {boolean} [quiet] If true, doesn't ping mentionRole
* @property {boolean} [ignoreRequirements] If true, creates a new thread even if the account doesn't meet requiredAccountAge
* @property {boolean} [ignoreHooks] If true, doesn't call beforeNewThread hooks
* @property {Message} [message] Original DM message that is trying to start the thread, if there is one
* @property {string} [categoryId] Category where to open the thread
* @property {string} [source] A string identifying the source of the new thread
* @property {string} [mentionRole] Override the mentionRole option for this thread
*/
/**
* Creates a new modmail thread for the specified user
* @param {User} user
* @param {Member} member
* @param {Boolean} quiet If true, doesn't ping mentionRole or reply with responseMessage
* @param {CreateNewThreadForUserOpts} opts
* @returns {Promise<Thread|undefined>}
* @throws {Error}
*/
async function createNewThreadForUser(user, quiet = false, ignoreRequirements = false) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) {
throw new Error('Attempted to create a new thread for a user with an existing open thread!');
}
async function createNewThreadForUser(user, opts = {}) {
return _addToThreadCreationQueue(async () => {
const quiet = opts.quiet != null ? opts.quiet : false;
const ignoreRequirements = opts.ignoreRequirements != null ? opts.ignoreRequirements : false;
const ignoreHooks = opts.ignoreHooks != null ? opts.ignoreHooks : false;
// If set in config, check that the user's account is old enough (time since they registered on Discord)
// If the account is too new, don't start a new thread and optionally reply to them with a message
if (config.requiredAccountAge && ! ignoreRequirements) {
if (user.createdAt > moment() - config.requiredAccountAge * HOURS){
if (config.accountAgeDeniedMessage) {
const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(accountAgeDeniedMessage);
}
return;
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) {
throw new Error("Attempted to create a new thread for a user with an existing open thread!");
}
}
// Find which main guilds this user is part of
const mainGuilds = utils.getMainGuilds();
const userGuildData = new Map();
for (const guild of mainGuilds) {
let member = guild.members.get(user.id);
if (! member) {
try {
member = await bot.getRESTGuildMember(guild.id, user.id);
} catch (e) {
continue;
// If set in config, check that the user's account is old enough (time since they registered on Discord)
// If the account is too new, don't start a new thread and optionally reply to them with a message
if (config.requiredAccountAge && ! ignoreRequirements) {
if (user.createdAt > moment() - config.requiredAccountAge * HOURS){
if (config.accountAgeDeniedMessage) {
const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(accountAgeDeniedMessage);
}
return;
}
}
if (member) {
userGuildData.set(guild.id, { guild, member });
}
}
// Find which main guilds this user is part of
const mainGuilds = utils.getMainGuilds();
const userGuildData = new Map();
// If set in config, check that the user has been a member of one of the main guilds long enough
// If they haven't, don't start a new thread and optionally reply to them with a message
if (config.requiredTimeOnServer && ! ignoreRequirements) {
// Check if the user joined any of the main servers a long enough time ago
// If we don't see this user on any of the main guilds (the size check below), assume we're just missing some data and give the user the benefit of the doubt
const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => {
return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES;
for (const guild of mainGuilds) {
let member = guild.members.get(user.id);
if (! member) {
try {
member = await bot.getRESTGuildMember(guild.id, user.id);
} catch (e) {
continue;
}
}
if (member) {
userGuildData.set(guild.id, { guild, member });
}
}
// If set in config, check that the user has been a member of one of the main guilds long enough
// If they haven't, don't start a new thread and optionally reply to them with a message
if (config.requiredTimeOnServer && ! ignoreRequirements) {
// Check if the user joined any of the main servers a long enough time ago
// If we don't see this user on any of the main guilds (the size check below), assume we're just missing some data and give the user the benefit of the doubt
const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => {
return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES;
});
if (! isAllowed) {
if (config.timeOnServerDeniedMessage) {
const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(timeOnServerDeniedMessage);
}
return;
}
}
let hookResult;
if (! ignoreHooks) {
// Call any registered beforeNewThreadHooks
hookResult = await callBeforeNewThreadHooks({
user,
opts,
message: opts.message
});
if (hookResult.cancelled) return;
}
// Use the user's name+discrim for the thread channel's name
// Channel names are particularly picky about what characters they allow, so we gotta do some clean-up
let cleanName = transliterate.slugify(user.username);
if (cleanName === "") cleanName = "unknown";
cleanName = cleanName.slice(0, 95); // Make sure the discrim fits
let channelName = `${cleanName}-${user.discriminator}`;
if (config.anonymizeChannelName) {
channelName = crypto.createHash("md5").update(channelName + Date.now()).digest("hex").slice(0, 12);
}
console.log(`[NOTE] Creating new thread channel ${channelName}`);
// Figure out which category we should place the thread channel in
let newThreadCategoryId = (hookResult && hookResult.categoryId) || opts.categoryId || null;
if (! newThreadCategoryId && config.categoryAutomation.newThreadFromServer) {
// Categories for specific source guilds (in case of multiple main guilds)
for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromServer)) {
if (userGuildData.has(guildId)) {
newThreadCategoryId = categoryId;
break;
}
}
}
if (! newThreadCategoryId && config.categoryAutomation.newThread) {
// Blanket category id for all new threads (also functions as a fallback for the above)
newThreadCategoryId = config.categoryAutomation.newThread;
}
// Attempt to create the inbox channel for this thread
let createdChannel;
try {
createdChannel = await utils.getInboxGuild().createChannel(channelName, DISOCRD_CHANNEL_TYPES.GUILD_TEXT, {
reason: "New Modmail thread",
parentID: newThreadCategoryId,
});
} catch (err) {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
throw err;
}
// Save the new thread in the database
const newThreadId = await createThreadInDB({
status: THREAD_STATUS.OPEN,
user_id: user.id,
user_name: `${user.username}#${user.discriminator}`,
channel_id: createdChannel.id,
created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss")
});
if (! isAllowed) {
if (config.timeOnServerDeniedMessage) {
const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage);
const privateChannel = await user.getDMChannel();
await privateChannel.createMessage(timeOnServerDeniedMessage);
}
return;
}
}
const newThread = await findById(newThreadId);
// Use the user's name+discrim for the thread channel's name
// Channel names are particularly picky about what characters they allow, so we gotta do some clean-up
let cleanName = transliterate.slugify(user.username);
if (cleanName === '') cleanName = 'unknown';
cleanName = cleanName.slice(0, 95); // Make sure the discrim fits
if (! quiet) {
// Ping moderators of the new thread
const staffMention = opts.mentionRole
? utils.mentionRolesToMention(utils.getValidMentionRoles(opts.mentionRole))
: utils.getInboxMention();
const channelName = `${cleanName}-${user.discriminator}`;
if (staffMention.trim() !== "") {
const allowedMentions = opts.mentionRole
? utils.mentionRolesToAllowedMentions(utils.getValidMentionRoles(opts.mentionRole))
: utils.getInboxMentionAllowedMentions();
console.log(`[NOTE] Creating new thread channel ${channelName}`);
// Figure out which category we should place the thread channel in
let newThreadCategoryId;
if (config.categoryAutomation.newThreadFromGuild) {
// Categories for specific source guilds (in case of multiple main guilds)
for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromGuild)) {
if (userGuildData.has(guildId)) {
newThreadCategoryId = categoryId;
break;
}
}
}
if (! newThreadCategoryId && config.categoryAutomation.newThread) {
// Blanket category id for all new threads (also functions as a fallback for the above)
newThreadCategoryId = config.categoryAutomation.newThread;
}
// Attempt to create the inbox channel for this thread
let createdChannel;
try {
createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New Modmail thread', newThreadCategoryId);
} catch (err) {
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
throw err;
}
// Save the new thread in the database
const newThreadId = await createThreadInDB({
status: THREAD_STATUS.OPEN,
user_id: user.id,
user_name: `${user.username}#${user.discriminator}`,
channel_id: createdChannel.id,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
const newThread = await findById(newThreadId);
let responseMessageError = null;
if (! quiet) {
// Ping moderators of the new thread
if (config.mentionRole) {
await newThread.postNonLogMessage({
content: `${utils.getInboxMention()}New modmail thread (${newThread.user_name})`,
disableEveryone: false
});
}
// Send auto-reply to the user
if (config.responseMessage) {
const responseMessage = utils.readMultilineConfigValue(config.responseMessage);
try {
await newThread.sendSystemMessageToUser(responseMessage);
} catch (err) {
responseMessageError = err;
}
}
}
// Post some info to the beginning of the new thread
const infoHeaderItems = [];
// Account age
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true});
infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`);
// User id (and mention, if enabled)
if (config.mentionUserInThreadHeader) {
infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`);
} else {
infoHeaderItems.push(`ID **${user.id}**`);
}
let infoHeader = infoHeaderItems.join(', ');
// Guild member info
for (const [guildId, guildData] of userGuildData.entries()) {
const {nickname, joinDate} = getHeaderGuildInfo(guildData.member);
const headerItems = [
`NICKNAME **${utils.escapeMarkdown(nickname)}**`,
`JOINED **${joinDate}** ago`
];
if (guildData.member.voiceState.channelID) {
const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID);
if (voiceChannel) {
headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`);
await newThread.postNonLogMessage({
content: `${staffMention}New modmail thread (${newThread.user_name})`,
allowedMentions,
});
}
}
if (config.rolesInThreadHeader && guildData.member.roles.length) {
const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean);
headerItems.push(`ROLES **${roles.map(r => r.name).join(', ')}**`);
}
// Post some info to the beginning of the new thread
const infoHeaderItems = [];
const headerStr = headerItems.join(', ');
// Account age
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true});
infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`);
if (mainGuilds.length === 1) {
infoHeader += `\n${headerStr}`;
// User id (and mention, if enabled)
if (config.mentionUserInThreadHeader) {
infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`);
} else {
infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`;
infoHeaderItems.push(`ID **${user.id}**`);
}
}
// Modmail history / previous logs
const userLogCount = await getClosedThreadCountByUserId(user.id);
if (userLogCount > 0) {
infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`;
}
let infoHeader = infoHeaderItems.join(", ");
infoHeader += '\n────────────────';
// Guild member info
for (const [guildId, guildData] of userGuildData.entries()) {
const {nickname, joinDate} = getHeaderGuildInfo(guildData.member);
const headerItems = [
`NICKNAME **${utils.escapeMarkdown(nickname)}**`,
`JOINED **${joinDate}** ago`
];
await newThread.postSystemMessage(infoHeader);
if (guildData.member.voiceState.channelID) {
const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID);
if (voiceChannel) {
headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`);
}
}
if (config.updateNotifications) {
const availableUpdate = await updates.getAvailableUpdate();
if (availableUpdate) {
await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
if (config.rolesInThreadHeader && guildData.member.roles.length) {
const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean);
headerItems.push(`ROLES **${roles.map(r => r.name).join(", ")}**`);
}
const headerStr = headerItems.join(", ");
if (mainGuilds.length === 1) {
infoHeader += `\n${headerStr}`;
} else {
infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`;
}
}
}
// If there were errors sending a response to the user, note that
if (responseMessageError) {
await newThread.postSystemMessage(`**NOTE:** Could not send auto-response to the user. The error given was: \`${responseMessageError.message}\``);
}
// Modmail history / previous logs
const userLogCount = await getClosedThreadCountByUserId(user.id);
if (userLogCount > 0) {
infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`;
}
// Return the thread
return newThread;
infoHeader += "\n────────────────";
const { message: threadHeaderMessage } = await newThread.postSystemMessage(infoHeader, {
allowedMentions: config.mentionUserInThreadHeader ? { users: [user.id] } : undefined,
});
if (config.pinThreadHeader) {
await threadHeaderMessage.pin();
}
if (config.updateNotifications) {
const availableUpdate = await updates.getAvailableUpdate();
if (availableUpdate) {
await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
}
}
// Return the thread
return newThread;
});
}
/**
@ -263,10 +320,18 @@ async function createNewThreadForUser(user, quiet = false, ignoreRequirements =
*/
async function createThreadInDB(data) {
const threadId = uuid.v4();
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId});
const now = moment.utc().format("YYYY-MM-DD HH:mm:ss");
const latestThreadNumberRow = await knex("threads")
.orderBy("thread_number", "DESC")
.first();
const latestThreadNumber = latestThreadNumberRow ? latestThreadNumberRow.thread_number : 0;
const finalData = Object.assign(
{created_at: now, is_legacy: 0},
data,
{id: threadId, thread_number: latestThreadNumber + 1}
);
await knex('threads').insert(finalData);
await knex("threads").insert(finalData);
return threadId;
}
@ -276,8 +341,8 @@ async function createThreadInDB(data) {
* @returns {Promise<Thread>}
*/
async function findByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
const thread = await knex("threads")
.where("channel_id", channelId)
.first();
return (thread ? new Thread(thread) : null);
@ -288,9 +353,9 @@ async function findByChannelId(channelId) {
* @returns {Promise<Thread>}
*/
async function findOpenThreadByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.where('status', THREAD_STATUS.OPEN)
const thread = await knex("threads")
.where("channel_id", channelId)
.where("status", THREAD_STATUS.OPEN)
.first();
return (thread ? new Thread(thread) : null);
@ -301,9 +366,9 @@ async function findOpenThreadByChannelId(channelId) {
* @returns {Promise<Thread>}
*/
async function findSuspendedThreadByChannelId(channelId) {
const thread = await knex('threads')
.where('channel_id', channelId)
.where('status', THREAD_STATUS.SUSPENDED)
const thread = await knex("threads")
.where("channel_id", channelId)
.where("status", THREAD_STATUS.SUSPENDED)
.first();
return (thread ? new Thread(thread) : null);
@ -314,9 +379,9 @@ async function findSuspendedThreadByChannelId(channelId) {
* @returns {Promise<Thread[]>}
*/
async function getClosedThreadsByUserId(userId) {
const threads = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
const threads = await knex("threads")
.where("status", THREAD_STATUS.CLOSED)
.where("user_id", userId)
.select();
return threads.map(thread => new Thread(thread));
@ -327,40 +392,45 @@ async function getClosedThreadsByUserId(userId) {
* @returns {Promise<number>}
*/
async function getClosedThreadCountByUserId(userId) {
const row = await knex('threads')
.where('status', THREAD_STATUS.CLOSED)
.where('user_id', userId)
.first(knex.raw('COUNT(id) AS thread_count'));
const row = await knex("threads")
.where("status", THREAD_STATUS.CLOSED)
.where("user_id", userId)
.first(knex.raw("COUNT(id) AS thread_count"));
return parseInt(row.thread_count, 10);
}
async function findOrCreateThreadForUser(user) {
/**
* @param {User} user
* @param {CreateNewThreadForUserOpts} opts
* @returns {Promise<Thread|undefined>}
*/
async function findOrCreateThreadForUser(user, opts = {}) {
const existingThread = await findOpenThreadByUserId(user.id);
if (existingThread) return existingThread;
return createNewThreadForUser(user);
return createNewThreadForUser(user, opts);
}
async function getThreadsThatShouldBeClosed() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const threads = await knex('threads')
.where('status', THREAD_STATUS.OPEN)
.whereNotNull('scheduled_close_at')
.where('scheduled_close_at', '<=', now)
.whereNotNull('scheduled_close_at')
const now = moment.utc().format("YYYY-MM-DD HH:mm:ss");
const threads = await knex("threads")
.where("status", THREAD_STATUS.OPEN)
.whereNotNull("scheduled_close_at")
.where("scheduled_close_at", "<=", now)
.whereNotNull("scheduled_close_at")
.select();
return threads.map(thread => new Thread(thread));
}
async function getThreadsThatShouldBeSuspended() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const threads = await knex('threads')
.where('status', THREAD_STATUS.OPEN)
.whereNotNull('scheduled_suspend_at')
.where('scheduled_suspend_at', '<=', now)
.whereNotNull('scheduled_suspend_at')
const now = moment.utc().format("YYYY-MM-DD HH:mm:ss");
const threads = await knex("threads")
.where("status", THREAD_STATUS.OPEN)
.whereNotNull("scheduled_suspend_at")
.where("scheduled_suspend_at", "<=", now)
.whereNotNull("scheduled_suspend_at")
.select();
return threads.map(thread => new Thread(thread));
@ -368,6 +438,7 @@ async function getThreadsThatShouldBeSuspended() {
module.exports = {
findById,
findByThreadNumber,
findOpenThreadByUserId,
findByChannelId,
findOpenThreadByChannelId,

View File

@ -1,16 +1,16 @@
const url = require('url');
const https = require('https');
const moment = require('moment');
const knex = require('../knex');
const config = require('../config');
const url = require("url");
const https = require("https");
const moment = require("moment");
const knex = require("../knex");
const config = require("../cfg");
const UPDATE_CHECK_FREQUENCY = 12; // In hours
let updateCheckPromise = null;
async function initUpdatesTable() {
const row = await knex('updates').first();
const row = await knex("updates").first();
if (! row) {
await knex('updates').insert({
await knex("updates").insert({
available_version: null,
last_checked: null,
});
@ -24,48 +24,51 @@ async function initUpdatesTable() {
*/
async function refreshVersions() {
await initUpdatesTable();
const { last_checked } = await knex('updates').first();
const { last_checked } = await knex("updates").first();
// Only refresh available version if it's been more than UPDATE_CHECK_FREQUENCY since our last check
if (last_checked != null && last_checked > moment.utc().subtract(UPDATE_CHECK_FREQUENCY, 'hours').format('YYYY-MM-DD HH:mm:ss')) return;
if (last_checked != null && last_checked > moment.utc().subtract(UPDATE_CHECK_FREQUENCY, "hours").format("YYYY-MM-DD HH:mm:ss")) return;
const packageJson = require('../../package.json');
const packageJson = require("../../package.json");
const repositoryUrl = packageJson.repository && packageJson.repository.url;
if (! repositoryUrl) return;
const parsedUrl = url.parse(repositoryUrl);
if (parsedUrl.hostname !== 'github.com') return;
if (parsedUrl.hostname !== "github.com") return;
const [, owner, repo] = parsedUrl.pathname.split('/');
const [, owner, repo] = parsedUrl.pathname.split("/");
if (! owner || ! repo) return;
https.get(
{
hostname: 'api.github.com',
path: `/repos/${owner}/${repo}/tags`,
hostname: "api.github.com",
path: `/repos/${owner}/${repo}/releases`,
headers: {
'User-Agent': `Modmail Bot (https://github.com/${owner}/${repo}) (${packageJson.version})`
"User-Agent": `Modmail Bot (https://github.com/${owner}/${repo}) (${packageJson.version})`
}
},
async res => {
if (res.statusCode !== 200) {
await knex('updates').update({
last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss')
await knex("updates").update({
last_checked: moment.utc().format("YYYY-MM-DD HH:mm:ss")
});
console.warn(`[WARN] Got status code ${res.statusCode} when checking for available updates`);
return;
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', async () => {
let data = "";
res.on("data", chunk => data += chunk);
res.on("end", async () => {
const parsed = JSON.parse(data);
if (! Array.isArray(parsed) || parsed.length === 0) return;
const latestVersion = parsed[0].name;
await knex('updates').update({
const latestMatchingRelease = parsed.find(r => ! r.draft && (config.updateNotificationsForBetaVersions || ! r.prerelease));
if (! latestMatchingRelease) return;
const latestVersion = latestMatchingRelease.name;
await knex("updates").update({
available_version: latestVersion,
last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss')
last_checked: moment.utc().format("YYYY-MM-DD HH:mm:ss")
});
});
}
@ -78,11 +81,11 @@ async function refreshVersions() {
* @returns {Number} 1 if version a is larger than b, -1 is version a is smaller than b, 0 if they are equal
*/
function compareVersions(a, b) {
const aParts = a.split('.');
const bParts = b.split('.');
const aParts = a.split(".");
const bParts = b.split(".");
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
let aPart = parseInt((aParts[i] || '0').match(/\d+/)[0] || '0', 10);
let bPart = parseInt((bParts[i] || '0').match(/\d+/)[0] || '0', 10);
let aPart = parseInt((aParts[i] || "0").match(/\d+/)[0] || "0", 10);
let bPart = parseInt((bParts[i] || "0").match(/\d+/)[0] || "0", 10);
if (aPart > bPart) return 1;
if (aPart < bPart) return -1;
}
@ -92,9 +95,9 @@ function compareVersions(a, b) {
async function getAvailableUpdate() {
await initUpdatesTable();
const packageJson = require('../../package.json');
const packageJson = require("../../package.json");
const currentVersion = packageJson.version;
const { available_version: availableVersion } = await knex('updates').first();
const { available_version: availableVersion } = await knex("updates").first();
if (availableVersion == null) return null;
if (currentVersion == null) return availableVersion;

386
src/formatters.js Normal file
View File

@ -0,0 +1,386 @@
const Eris = require("eris");
const axios = require ("axios");
const utils = require("./utils");
const config = require("./cfg");
const ThreadMessage = require("./data/ThreadMessage");
const {THREAD_MESSAGE_TYPE} = require("./data/constants");
const moment = require("moment");
const bot = require("./bot");
/**
* Function to format the DM that is sent to the user when a staff member replies to them via !reply
* @callback FormatStaffReplyDM
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to send as a DM
*/
/**
* Function to format a staff reply in a thread channel
* @callback FormatStaffReplyThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format a user reply in a thread channel
* @callback FormatUserReplyThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format the inbox channel notification for a staff reply edit
* @callback FormatStaffReplyEditNotificationThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format the inbox channel notification for a staff reply deletion
* @callback FormatStaffReplyDeletionNotificationThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format a system message in a thread channel
* @callback FormatSystemThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format a system message sent to the user in a thread channel
* @callback FormatSystemToUserThreadMessage
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to post in the thread channel
*/
/**
* Function to format the DM that is sent to the user when the bot sends a system message to the user
* @callback FormatSystemToUserDM
* @param {ThreadMessage} threadMessage
* @return {Eris.MessageContent} Message content to send as a DM
*/
/**
* @typedef {Object} FormatLogOptions
* @property {Boolean?} simple
* @property {Boolean?} verbose
*/
/**
* @typedef {Object} FormatLogResult
* @property {String} content Contents of the entire log
* @property {*?} extra
*/
/**
* Function to format the inbox channel notification for a staff reply deletion
* @callback FormatLog
* @param {Thread} thread
* @param {ThreadMessage[]} threadMessages
* @param {FormatLogOptions={}} opts
* @return {FormatLogResult}
*/
/**
* @typedef MessageFormatters
* @property {FormatStaffReplyDM} formatStaffReplyDM
* @property {FormatStaffReplyThreadMessage} formatStaffReplyThreadMessage
* @property {FormatUserReplyThreadMessage} formatUserReplyThreadMessage
* @property {FormatStaffReplyEditNotificationThreadMessage} formatStaffReplyEditNotificationThreadMessage
* @property {FormatStaffReplyDeletionNotificationThreadMessage} formatStaffReplyDeletionNotificationThreadMessage
* @property {FormatSystemThreadMessage} formatSystemThreadMessage
* @property {FormatSystemToUserThreadMessage} formatSystemToUserThreadMessage
* @property {FormatSystemToUserDM} formatSystemToUserDM
* @property {FormatLog} formatLog
*/
/**
* @type {MessageFormatters}
*/
const defaultFormatters = {
/*formatStaffReplyDM(threadMessage) {
const roleName = threadMessage.role_name || config.fallbackRoleName;
const modInfo = threadMessage.is_anonymous
? roleName
: (roleName ? `(${roleName}) ${threadMessage.user_name}` : threadMessage.user_name);
return modInfo
? `**${modInfo}:** ${threadMessage.body}`
: threadMessage.body;
},*/
async formatStaffReplyDM(threadMessage) {
let req = await axios.get("https://loc.sh/int/directory");
req = req.data;
const find = req.find(mem => mem.userID === threadMessage.user_id);
const roleName = threadMessage.role_name || config.fallbackRoleName;
// const modName = (config.useNicknames ? moderator.nick || moderator.user.username : moderator.user.username);
const modInfo = threadMessage.is_anonymous
? (roleName || "Staff")
: (roleName ? `__**${threadMessage.user_name}**, ${find.pn.join(", ")}__\n*${roleName}*` : roleName);
return `${modInfo}\n_ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ____ __ __ ___\n\n${threadMessage.body}`;
},
formatStaffReplyThreadMessage(threadMessage) {
const roleName = threadMessage.role_name || config.fallbackRoleName;
const modInfo = threadMessage.is_anonymous
? (roleName ? `(Anonymous) (${threadMessage.user_name}) ${roleName}` : `(Anonymous) (${threadMessage.user_name})`)
: (roleName ? `(${roleName}) ${threadMessage.user_name}` : threadMessage.user_name);
let result = modInfo
? `**${modInfo}:** ${threadMessage.body}`
: threadMessage.body;
if (config.threadTimestamps) {
const formattedTimestamp = utils.getTimestamp(threadMessage.created_at);
result = `[${formattedTimestamp}] ${result}`;
}
result = `\`${threadMessage.message_number}\` ${result}`;
return result;
},
formatUserReplyThreadMessage(threadMessage) {
let result = `**${threadMessage.user_name}:** ${threadMessage.body}`;
for (const link of threadMessage.attachments) {
result += `\n\n${link}`;
}
if (config.threadTimestamps) {
const formattedTimestamp = utils.getTimestamp(threadMessage.created_at);
result = `[${formattedTimestamp}] ${result}`;
}
return result;
},
formatStaffReplyEditNotificationThreadMessage(threadMessage) {
const originalThreadMessage = threadMessage.getMetadataValue("originalThreadMessage");
const newBody = threadMessage.getMetadataValue("newBody");
let content = `**${originalThreadMessage.user_name}** (\`${originalThreadMessage.user_id}\`) edited reply \`${originalThreadMessage.message_number}\``;
if (originalThreadMessage.body.length < 200 && newBody.length < 200) {
// Show edits of small messages inline
content += ` from \`${utils.disableInlineCode(originalThreadMessage.body)}\` to \`${newBody}\``;
} else {
// Show edits of long messages in two code blocks
content += ":";
content += `\n\nBefore:\n\`\`\`${utils.disableCodeBlocks(originalThreadMessage.body)}\`\`\``;
content += `\nAfter:\n\`\`\`${utils.disableCodeBlocks(newBody)}\`\`\``;
}
return content;
},
formatStaffReplyDeletionNotificationThreadMessage(threadMessage) {
const originalThreadMessage = threadMessage.getMetadataValue("originalThreadMessage");
let content = `**${originalThreadMessage.user_name}** (\`${originalThreadMessage.user_id}\`) deleted reply \`${originalThreadMessage.message_number}\``;
if (originalThreadMessage.body.length < 200) {
// Show the original content of deleted small messages inline
content += ` (message content: \`${utils.disableInlineCode(originalThreadMessage.body)}\`)`;
} else {
// Show the original content of deleted large messages in a code block
content += ":\n```" + utils.disableCodeBlocks(originalThreadMessage.body) + "```";
}
return content;
},
formatSystemThreadMessage(threadMessage) {
let result = threadMessage.body;
for (const link of threadMessage.attachments) {
result += `\n\n${link}`;
}
return result;
},
formatSystemToUserThreadMessage(threadMessage) {
let result = `**⚙️ ${bot.user.username}:** ${threadMessage.body}`;
for (const link of threadMessage.attachments) {
result += `\n\n${link}`;
}
return result;
},
formatSystemToUserDM(threadMessage) {
let result = threadMessage.body;
for (const link of threadMessage.attachments) {
result += `\n\n${link}`;
}
return result;
},
formatLog(thread, threadMessages, opts = {}) {
if (opts.simple) {
threadMessages = threadMessages.filter(message => {
return (
message.message_type !== THREAD_MESSAGE_TYPE.SYSTEM
&& message.message_type !== THREAD_MESSAGE_TYPE.SYSTEM_TO_USER
&& message.message_type !== THREAD_MESSAGE_TYPE.CHAT
&& message.message_type !== THREAD_MESSAGE_TYPE.COMMAND
);
});
}
const lines = threadMessages.map(message => {
// Legacy messages (from 2018) are the entire log in one message, so just serve them as they are
if (message.message_type === THREAD_MESSAGE_TYPE.LEGACY) {
return message.body;
}
let line = `[${moment.utc(message.created_at).format("YYYY-MM-DD HH:mm:ss")}]`;
if (opts.verbose) {
if (message.dm_channel_id) {
line += ` [DM CHA ${message.dm_channel_id}]`;
}
if (message.dm_message_id) {
line += ` [DM MSG ${message.dm_message_id}]`;
}
}
if (message.message_type === THREAD_MESSAGE_TYPE.FROM_USER) {
line += ` [FROM USER] [${message.user_name}] ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.TO_USER) {
if (opts.verbose) {
line += ` [TO USER] [${message.message_number || "0"}] [${message.user_name}]`;
} else {
line += ` [TO USER] [${message.user_name}]`;
}
if (message.use_legacy_format) {
// Legacy format (from pre-2.31.0) includes the role and username in the message body, so serve that as is
line += ` ${message.body}`;
} else if (message.is_anonymous) {
if (message.role_name) {
line += ` (Anonymous) ${message.role_name}: ${message.body}`;
} else {
line += ` (Anonymous) Moderator: ${message.body}`;
}
} else {
if (message.role_name) {
line += ` (${message.role_name}) ${message.user_name}: ${message.body}`;
} else {
line += ` ${message.user_name}: ${message.body}`;
}
}
} else if (message.message_type === THREAD_MESSAGE_TYPE.SYSTEM) {
line += ` [BOT] ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.SYSTEM_TO_USER) {
line += ` [BOT TO USER] ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.CHAT) {
line += ` [CHAT] [${message.user_name}] ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.COMMAND) {
line += ` [COMMAND] [${message.user_name}] ${message.body}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.REPLY_EDITED) {
const originalThreadMessage = message.getMetadataValue("originalThreadMessage");
line += ` [REPLY EDITED] ${originalThreadMessage.user_name} edited reply ${originalThreadMessage.message_number}:`;
line += `\n\nBefore:\n${originalThreadMessage.body}`;
line += `\n\nAfter:\n${message.getMetadataValue("newBody")}`;
} else if (message.message_type === THREAD_MESSAGE_TYPE.REPLY_DELETED) {
const originalThreadMessage = message.getMetadataValue("originalThreadMessage");
line += ` [REPLY DELETED] ${originalThreadMessage.user_name} deleted reply ${originalThreadMessage.message_number}:`;
line += `\n\n${originalThreadMessage.body}`;
} else {
line += ` [${message.user_name}] ${message.body}`;
}
if (message.attachments.length) {
line += "\n\n";
line += message.attachments.join("\n");
}
return line;
});
const openedAt = moment(thread.created_at).format("YYYY-MM-DD HH:mm:ss");
const header = `# Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) started at ${openedAt}. All times are in UTC+0.`;
const fullResult = header + "\n\n" + lines.join("\n");
return {
content: fullResult,
};
},
};
/**
* @type {MessageFormatters}
*/
const formatters = { ...defaultFormatters };
/**
* @typedef {object} FormattersExport
* @property {MessageFormatters} formatters Read only
* @property {function(FormatStaffReplyDM): Promise<void>} setStaffReplyDMFormatter
* @property {function(FormatStaffReplyThreadMessage): void} setStaffReplyThreadMessageFormatter
* @property {function(FormatUserReplyThreadMessage): void} setUserReplyThreadMessageFormatter
* @property {function(FormatStaffReplyEditNotificationThreadMessage): void} setStaffReplyEditNotificationThreadMessageFormatter
* @property {function(FormatStaffReplyDeletionNotificationThreadMessage): void} setStaffReplyDeletionNotificationThreadMessageFormatter
* @property {function(FormatSystemThreadMessage): void} setSystemThreadMessageFormatter
* @property {function(FormatSystemToUserThreadMessage): void} setSystemToUserThreadMessageFormatter
* @property {function(FormatSystemToUserDM): void} setSystemToUserDMFormatter
* @property {function(FormatLog): void} setLogFormatter
*/
/**
* @type {FormattersExport}
*/
module.exports = {
formatters: new Proxy(formatters, {
set() {
throw new Error("Please use the formatter setter functions instead of modifying the formatters directly");
},
}),
setStaffReplyDMFormatter(fn) {
formatters.formatStaffReplyDM = fn;
},
setStaffReplyThreadMessageFormatter(fn) {
formatters.formatStaffReplyThreadMessage = fn;
},
setUserReplyThreadMessageFormatter(fn) {
formatters.formatUserReplyThreadMessage = fn;
},
setStaffReplyEditNotificationThreadMessageFormatter(fn) {
formatters.formatStaffReplyEditNotificationThreadMessage = fn;
},
setStaffReplyDeletionNotificationThreadMessageFormatter(fn) {
formatters.formatStaffReplyDeletionNotificationThreadMessage = fn;
},
setSystemThreadMessageFormatter(fn) {
formatters.formatSystemThreadMessage = fn;
},
setSystemToUserThreadMessageFormatter(fn) {
formatters.formatSystemToUserThreadMessage = fn;
},
setSystemToUserDMFormatter(fn) {
formatters.formatSystemToUserDM = fn;
},
setLogFormatter(fn) {
formatters.formatLog = fn;
},
};

View File

@ -0,0 +1,46 @@
const Eris = require("eris");
/**
* @typedef AfterThreadCloseHookData
* @property {string} threadId
*/
/**
* @callback AfterThreadCloseHookFn
* @param {AfterThreadCloseHookData} data
* @return {void|Promise<void>}
*/
/**
* @callback AddAfterThreadCloseHookFn
* @param {AfterThreadCloseHookFn} fn
* @return {void}
*/
/**
* @type AfterThreadCloseHookFn[]
*/
const afterThreadCloseHooks = [];
/**
* @type {AddAfterThreadCloseHookFn}
*/
let afterThreadClose; // Workaround to inconsistent IDE bug with @type and anonymous functions
afterThreadClose = (fn) => {
afterThreadCloseHooks.push(fn);
};
/**
* @param {AfterThreadCloseHookData} input
* @return {Promise<void>}
*/
async function callAfterThreadCloseHooks(input) {
for (const hook of afterThreadCloseHooks) {
await hook(input);
}
}
module.exports = {
afterThreadClose,
callAfterThreadCloseHooks,
};

View File

@ -0,0 +1,91 @@
const Eris = require("eris");
/**
* @callback BeforeNewThreadHook_SetCategoryId
* @param {String} categoryId
* @return void
*/
/**
* @typedef BeforeNewThreadHookData
* @property {Eris.User} user
* @property {Eris.Message} [message]
* @property {CreateNewThreadForUserOpts} opts
* @property {Function} cancel
* @property {BeforeNewThreadHook_SetCategoryId} setCategoryId
*/
/**
* @typedef BeforeNewThreadHookResult
* @property {boolean} cancelled
* @property {string|null} categoryId
*/
/**
* @callback BeforeNewThreadHookFn
* @param {BeforeNewThreadHookData} data
* @return {void|Promise<void>}
*/
/**
* @callback AddBeforeNewThreadHookFn
* @param {BeforeNewThreadHookFn} fn
* @return {void}
*/
/**
* @type BeforeNewThreadHookFn[]
*/
const beforeNewThreadHooks = [];
/**
* @type {AddBeforeNewThreadHookFn}
*/
let beforeNewThread; // Workaround to inconsistent IDE bug with @type and anonymous functions
beforeNewThread = (fn) => {
beforeNewThreadHooks.push(fn);
};
/**
* @param {{
* user: Eris.User,
* message?: Eris.Message,
* opts: CreateNewThreadForUserOpts,
* }} input
* @return {Promise<BeforeNewThreadHookResult>}
*/
async function callBeforeNewThreadHooks(input) {
/**
* @type {BeforeNewThreadHookResult}
*/
const result = {
cancelled: false,
categoryId: null,
};
/**
* @type {BeforeNewThreadHookData}
*/
const data = {
...input,
cancel() {
result.cancelled = true;
},
setCategoryId(value) {
result.categoryId = value;
},
};
for (const hook of beforeNewThreadHooks) {
await hook(data);
}
return result;
}
module.exports = {
beforeNewThread,
callBeforeNewThreadHooks,
};

View File

@ -1,89 +1,109 @@
// Verify NodeJS version
const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
if (nodeMajorVersion < 10) {
console.error('Unsupported NodeJS version! Please install NodeJS 10 or newer.');
const nodeMajorVersion = parseInt(process.versions.node.split(".")[0], 10);
if (nodeMajorVersion < 12) {
console.error("Unsupported NodeJS version! Please install Node.js 12, 13, or 14.");
process.exit(1);
}
// Print out bot and Node.js version
const { getPrettyVersion } = require("./botVersion");
console.log(`Starting Modmail ${getPrettyVersion()} on Node.js ${process.versions.node} (${process.arch})`);
// Verify node modules have been installed
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
try {
fs.accessSync(path.join(__dirname, '..', 'node_modules'));
fs.accessSync(path.join(__dirname, "..", "node_modules"));
} catch (e) {
console.error('Please run "npm ci" before starting the bot');
console.error("Please run \"npm ci\" before starting the bot");
process.exit(1);
}
let testedPackage = '';
const { BotError } = require("./BotError");
const { PluginInstallationError } = require("./PluginInstallationError");
// Error handling
// Force crash on unhandled rejections and uncaught exceptions.
// Use something like forever/pm2 to restart.
const MAX_STACK_TRACE_LINES = 8;
function errorHandler(err) {
// Unknown message types (nitro boosting messages at the time) should be safe to ignore
if (err && err.message && err.message.startsWith("Unhandled MESSAGE_CREATE type")) {
return;
}
if (err) {
if (typeof err === "string") {
console.error(`Error: ${err}`);
} else if (err instanceof BotError) {
// Leave out stack traces for BotErrors (the message has enough info)
console.error(`Error: ${err.message}`);
} else if (err.message === "Disallowed intents specified") {
let fullMessage = "Error: Disallowed intents specified";
fullMessage += "\n\n";
fullMessage += "To run the bot, you must enable 'Server Members Intent' on your bot's page in the Discord Developer Portal:";
fullMessage += "\n\n";
fullMessage += "1. Go to https://discord.com/developers/applications"
fullMessage += "2. Click on your bot"
fullMessage += "3. Click 'Bot' on the sidebar"
fullMessage += "4. Turn on 'Server Members Intent'"
console.error(fullMessage);
} else if (err instanceof PluginInstallationError) {
// Don't truncate PluginInstallationErrors as they can get lengthy
console.error(err);
} else {
// Truncate long stack traces for other errors
const stack = err.stack || "";
let stackLines = stack.split("\n");
if (stackLines.length > (MAX_STACK_TRACE_LINES + 2)) {
stackLines = stackLines.slice(0, MAX_STACK_TRACE_LINES);
stackLines.push(` ...stack trace truncated to ${MAX_STACK_TRACE_LINES} lines`);
}
const finalStack = stackLines.join("\n");
if (err.code) {
console.error(`Error ${err.code}: ${finalStack}`);
} else {
console.error(`Error: ${finalStack}`);
}
}
} else {
console.error("Unknown error occurred");
}
process.exit(1);
}
process.on("uncaughtException", errorHandler);
process.on("unhandledRejection", errorHandler);
let testedPackage = "";
try {
const packageJson = require('../package.json');
const packageJson = require("../package.json");
const modules = Object.keys(packageJson.dependencies);
modules.forEach(mod => {
testedPackage = mod;
fs.accessSync(path.join(__dirname, '..', 'node_modules', mod))
fs.accessSync(path.join(__dirname, "..", "node_modules", mod))
});
} catch (e) {
console.error(`Please run "npm ci" again! Package "${testedPackage}" is missing.`);
process.exit(1);
}
const config = require('./config');
const utils = require('./utils');
const main = require('./main');
const knex = require('./knex');
const legacyMigrator = require('./legacy/legacyMigrator');
const { client } = require('./knex');
// Error handling
process.on('uncaughtException', err => {
console.error(err);
// utils.postError(config.errorChannelId, `\`\`\`js\n${err.stack}\n\`\`\``);
});
// Force crash on unhandled rejections (use something like forever/pm2 to restart)
process.on('unhandledRejection', err => {
if ((err && err.code)) {
// We ignore stack traces for network errors from Eris (their stack traces are unreadably long)
err = new Error(err.message);
}
console.error(err);
// utils.postError(config.errorChannelId, `\`\`\`js\n${err.stack}\n\`\`\``);
});
(async function() {
require("./cfg");
const main = require("./main");
const knex = require("./knex");
// Make sure the database is up to date
const [completed, newMigrations] = await knex.migrate.list();
if (newMigrations.length > 0) {
console.log('Updating database. This can take a while. Don\'t close the bot!');
console.log("Updating database. This can take a while. Don't close the bot!");
await knex.migrate.latest();
console.log('Done!');
}
// Migrate legacy data if we need to
if (await legacyMigrator.shouldMigrate()) {
console.log('=== MIGRATING LEGACY DATA ===');
console.log('Do not close the bot!');
console.log('');
await legacyMigrator.migrate();
const relativeDbDir = (path.isAbsolute(config.dbDir) ? config.dbDir : path.resolve(process.cwd(), config.dbDir));
const relativeLogDir = (path.isAbsolute(config.logDir) ? config.logDir : path.resolve(process.cwd(), config.logDir));
console.log('');
console.log('=== LEGACY DATA MIGRATION FINISHED ===');
console.log('');
console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.');
console.log('Once you\'ve done that, the following files/directories are no longer needed. I would recommend keeping a backup of them, however.');
console.log('');
console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
console.log('FILE: ' + path.resolve(relativeDbDir, 'snippets.json'));
console.log('DIRECTORY: ' + relativeLogDir);
console.log('');
console.log('Starting the bot...');
console.log("Done!");
}
// Start the bot

View File

@ -1,2 +1,2 @@
const config = require('./config');
module.exports = require('knex')(config.knex);
const knexConfig = require("./knexConfig");
module.exports = require("knex")(knexConfig);

50
src/knexConfig.js Normal file
View File

@ -0,0 +1,50 @@
const path = require("path");
const config = require("./cfg");
let knexOptions;
if (config.dbType === "sqlite") {
const resolvedPath = path.resolve(process.cwd(), config.sqliteOptions.filename);
console.log(`Using an SQLite database:\n ${resolvedPath}`);
knexOptions = {
client: "sqlite",
connection: {
...config.sqliteOptions,
},
};
} else if (config.dbType === "mysql") {
const host = config.mysqlOptions.host || "localhost";
const port = config.mysqlOptions.port || 3306;
const mysqlStr = `${config.mysqlOptions.user}@${host}:${port}/${config.mysqlOptions.database}`;
console.log(`Using a MySQL database:\n ${mysqlStr}`);
knexOptions = {
client: "mysql2",
connection: {
...config.mysqlOptions,
},
};
}
module.exports = {
...knexOptions,
useNullAsDefault: true,
migrations: {
directory: path.resolve(__dirname, "data", "migrations"),
},
log: {
warn(message) {
if (message.startsWith("FS-related option specified for migration configuration")) {
return;
}
if (message === "Connection Error: Error: read ECONNRESET") {
// Knex automatically handles the reconnection
return;
}
console.warn(`[DATABASE WARNING] ${message}`);
},
},
};

View File

@ -1,71 +0,0 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
const dbDir = config.dbDir;
const databases = {};
/**
* @deprecated Only used for migrating legacy data
*/
class JSONDB {
constructor(path, def = {}, useCloneByDefault = false) {
this.path = path;
this.useCloneByDefault = useCloneByDefault;
this.load = new Promise(resolve => {
fs.readFile(path, {encoding: 'utf8'}, (err, data) => {
if (err) return resolve(def);
let unserialized;
try { unserialized = JSON.parse(data); }
catch (e) { unserialized = def; }
resolve(unserialized);
});
});
}
get(clone) {
if (clone == null) clone = this.useCloneByDefault;
return this.load.then(data => {
if (clone) return JSON.parse(JSON.stringify(data));
else return data;
});
}
save(newData) {
const serialized = JSON.stringify(newData);
this.load = new Promise((resolve, reject) => {
fs.writeFile(this.path, serialized, {encoding: 'utf8'}, () => {
resolve(newData);
});
});
return this.get();
}
}
function getDb(dbName, def) {
if (! databases[dbName]) {
const dbPath = path.resolve(dbDir, `${dbName}.json`);
databases[dbName] = new JSONDB(dbPath, def);
}
return databases[dbName];
}
function get(dbName, def) {
return getDb(dbName, def).get();
}
function save(dbName, data) {
return getDb(dbName, data).save(data);
}
module.exports = {
get,
save,
};

View File

@ -1,222 +0,0 @@
const fs = require('fs');
const path = require('path');
const promisify = require('util').promisify;
const moment = require('moment');
const Eris = require('eris');
const knex = require('../knex');
const config = require('../config');
const jsonDb = require('./jsonDb');
const threads = require('../data/threads');
const {THREAD_STATUS, THREAD_MESSAGE_TYPE} = require('../data/constants');
const readDir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);
const access = promisify(fs.access);
const writeFile = promisify(fs.writeFile);
async function migrate() {
console.log('Migrating open threads...');
await migrateOpenThreads();
console.log('Migrating logs...');
await migrateLogs();
console.log('Migrating blocked users...');
await migrateBlockedUsers();
console.log('Migrating snippets...');
await migrateSnippets();
await writeFile(path.join(config.dbDir, '.migrated_legacy'), '');
}
async function shouldMigrate() {
// If there is a file marking a finished migration, assume we don't need to migrate
const migrationFile = path.join(config.dbDir, '.migrated_legacy');
try {
await access(migrationFile);
return false;
} catch (e) {}
// If there are any old threads, we need to migrate
const oldThreads = await jsonDb.get('threads', []);
if (oldThreads.length) {
return true;
}
// If there are any old blocked users, we need to migrate
const blockedUsers = await jsonDb.get('blocked', []);
if (blockedUsers.length) {
return true;
}
// If there are any old snippets, we need to migrate
const snippets = await jsonDb.get('snippets', {});
if (Object.keys(snippets).length) {
return true;
}
// If the log file dir exists and has logs in it, we need to migrate
try {
const files = await readDir(config.logDir);
if (files.length > 1) return true; // > 1, since .gitignore is one of them
} catch(e) {}
return false;
}
async function migrateOpenThreads() {
const bot = new Eris.Client(config.token);
const toReturn = new Promise(resolve => {
bot.on('ready', async () => {
const oldThreads = await jsonDb.get('threads', []);
const promises = oldThreads.map(async oldThread => {
const existingOpenThread = await knex('threads')
.where('channel_id', oldThread.channelId)
.first();
if (existingOpenThread) return;
const oldChannel = bot.getChannel(oldThread.channelId);
if (! oldChannel) return;
const threadMessages = await oldChannel.getMessages(1000);
const log = threadMessages.reverse().map(msg => {
const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`;
}).join('\n') + '\n';
const newThread = {
status: THREAD_STATUS.OPEN,
user_id: oldThread.userId,
user_name: oldThread.username,
channel_id: oldThread.channelId,
is_legacy: 1
};
const threadId = await threads.createThreadInDB(newThread);
await knex('thread_messages').insert({
thread_id: threadId,
message_type: THREAD_MESSAGE_TYPE.LEGACY,
user_id: oldThread.userId,
user_name: '',
body: log,
is_anonymous: 0,
created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
});
});
resolve(Promise.all(promises));
});
bot.connect();
});
await toReturn;
bot.disconnect();
}
async function migrateLogs() {
const logDir = config.logDir || `${__dirname}/../../logs`;
const logFiles = await readDir(logDir);
for (let i = 0; i < logFiles.length; i++) {
const logFile = logFiles[i];
if (! logFile.endsWith('.txt')) continue;
const [rawDate, userId, threadId] = logFile.slice(0, -4).split('__');
const date = `${rawDate.slice(0, 10)} ${rawDate.slice(11).replace('-', ':')}`;
const fullPath = path.join(logDir, logFile);
const contents = await readFile(fullPath, {encoding: 'utf8'});
const newThread = {
id: threadId,
status: THREAD_STATUS.CLOSED,
user_id: userId,
user_name: '',
channel_id: null,
is_legacy: 1,
created_at: date
};
await knex.transaction(async trx => {
const existingThread = await trx('threads')
.where('id', newThread.id)
.first();
if (existingThread) return;
await trx('threads').insert(newThread);
await trx('thread_messages').insert({
thread_id: newThread.id,
message_type: THREAD_MESSAGE_TYPE.LEGACY,
user_id: userId,
user_name: '',
body: contents,
is_anonymous: 0,
created_at: date
});
});
// Progress indicator for servers with tons of logs
if ((i + 1) % 500 === 0) {
console.log(` ${i + 1}...`);
}
}
}
async function migrateBlockedUsers() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const blockedUsers = await jsonDb.get('blocked', []);
for (const userId of blockedUsers) {
const existingBlockedUser = await knex('blocked_users')
.where('user_id', userId)
.first();
if (existingBlockedUser) return;
await knex('blocked_users').insert({
user_id: userId,
user_name: '',
blocked_by: null,
blocked_at: now
});
}
}
async function migrateSnippets() {
const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
const snippets = await jsonDb.get('snippets', {});
const promises = Object.entries(snippets).map(async ([trigger, data]) => {
const existingSnippet = await knex('snippets')
.where('trigger', trigger)
.first();
if (existingSnippet) return;
return knex('snippets').insert({
trigger,
body: data.text,
is_anonymous: data.isAnonymous ? 1 : 0,
created_by: null,
created_at: now
});
});
return Promise.all(promises);
}
module.exports = {
migrate,
shouldMigrate,
};

View File

@ -1,58 +1,77 @@
const Eris = require('eris');
const path = require('path');
const Eris = require("eris");
const path = require("path");
const config = require('./config');
const bot = require('./bot');
const knex = require('./knex');
const {messageQueue} = require('./queue');
const utils = require('./utils');
const { createCommandManager } = require('./commands');
const { getPluginAPI, loadPlugin } = require('./plugins');
const config = require("./cfg");
const bot = require("./bot");
const knex = require("./knex");
const {messageQueue} = require("./queue");
const utils = require("./utils");
const { createCommandManager } = require("./commands");
const { getPluginAPI, installPlugins, loadPlugins } = require("./plugins");
const { callBeforeNewThreadHooks } = require("./hooks/beforeNewThread");
const blocked = require('./data/blocked');
const threads = require('./data/threads');
const updates = require('./data/updates');
const blocked = require("./data/blocked");
const threads = require("./data/threads");
const updates = require("./data/updates");
const reply = require('./modules/reply');
const close = require('./modules/close');
const snippets = require('./modules/snippets');
const logs = require('./modules/logs');
const move = require('./modules/move');
const block = require('./modules/block');
const suspend = require('./modules/suspend');
const webserver = require('./modules/webserver');
const greeting = require('./modules/greeting');
const typingProxy = require('./modules/typingProxy');
const version = require('./modules/version');
const newthread = require('./modules/newthread');
const idModule = require('./modules/id');
const alert = require('./modules/alert');
const ping = require('./modules/ping');
const {ACCIDENTAL_THREAD_MESSAGES} = require('./data/constants');
const {ACCIDENTAL_THREAD_MESSAGES} = require("./data/constants");
module.exports = {
async start() {
console.log('Connecting to Discord...');
console.log("Preparing plugins...");
await installAllPlugins();
bot.once('ready', async () => {
console.log('Connected! Waiting for guilds to become available...');
await Promise.all([
...config.mainGuildId.map(id => waitForGuild(id)),
waitForGuild(config.mailGuildId)
]);
console.log("Connecting to Discord...");
console.log('Initializing...');
bot.once("ready", async () => {
console.log("Connected! Waiting for servers to become available...");
await (new Promise(resolve => {
const waitNoteTimeout = setTimeout(() => {
console.log("Servers did not become available after 15 seconds, continuing start-up anyway");
console.log("");
const isSingleServer = config.mainServerId.includes(config.inboxServerId);
if (isSingleServer) {
console.log("WARNING: The bot will not work before it's invited to the server.");
} else {
const hasMultipleMainServers = config.mainServerId.length > 1;
if (hasMultipleMainServers) {
console.log("WARNING: The bot will not function correctly until it's invited to *all* main servers and the inbox server.");
} else {
console.log("WARNING: The bot will not function correctly until it's invited to *both* the main server and the inbox server.");
}
}
console.log("");
resolve();
}, 15 * 1000);
Promise.all([
...config.mainServerId.map(id => waitForGuild(id)),
waitForGuild(config.inboxServerId),
]).then(() => {
clearTimeout(waitNoteTimeout);
resolve();
});
}));
console.log("Initializing...");
initStatus();
initBaseMessageHandlers();
initPlugins();
initUpdateNotifications();
console.log('');
console.log('Done! Now listening to DMs.');
console.log('');
console.log("Loading plugins...");
const pluginResult = await loadAllPlugins();
console.log(`Loaded ${pluginResult.loadedCount} plugins (${pluginResult.baseCount} built-in plugins, ${pluginResult.externalCount} external plugins)`);
console.log("");
console.log("Done! Now listening to DMs.");
console.log("");
});
bot.on('error', err => {
bot.on("error", err => {
console.error(err);
process.exit(1);
});
@ -67,7 +86,7 @@ function waitForGuild(guildId) {
}
return new Promise(resolve => {
bot.on('guildAvailable', guild => {
bot.on("guildAvailable", guild => {
if (guild.id === guildId) {
resolve();
}
@ -77,7 +96,16 @@ function waitForGuild(guildId) {
function initStatus() {
function applyStatus() {
bot.editStatus(null, {name: config.status});
const type = {
"playing": 0,
"watching": 3,
"listening": 2,
}[config.statusType] || 0;
bot.editStatus(null, {name: config.status, type});
}
if (config.status == null || config.status === "" || config.status === "none" || config.status === "off") {
return;
}
// Set the bot status initially, then reapply it every hour since in some cases it gets unset
@ -91,17 +119,17 @@ function initBaseMessageHandlers() {
* 1) If alwaysReply is enabled, reply to the user
* 2) If alwaysReply is disabled, save that message as a chat message in the thread
*/
bot.on('messageCreate', async msg => {
bot.on("messageCreate", async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (msg.author.bot) return;
if (msg.author.id === bot.user.id) return;
const thread = await threads.findByChannelId(msg.channel.id);
if (! thread) return;
if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) {
if (! msg.author.bot && (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix))) {
// Save commands as "command messages"
thread.saveCommandMessageToLogs(msg);
} else if (config.alwaysReply) {
} else if (! msg.author.bot && config.alwaysReply) {
// AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies
if (! utils.isStaff(msg.member)) return; // Only staff are allowed to reply
@ -118,7 +146,7 @@ function initBaseMessageHandlers() {
* 1) Find the open modmail thread for this user, or create a new one
* 2) Post the message as a user reply in the thread
*/
bot.on('messageCreate', async msg => {
bot.on("messageCreate", async msg => {
if (! (msg.channel instanceof Eris.PrivateChannel)) return;
if (msg.author.bot) return;
if (msg.type !== 0) return; // Ignore pins etc.
@ -128,18 +156,35 @@ function initBaseMessageHandlers() {
// Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
messageQueue.add(async () => {
let thread = await threads.findOpenThreadByUserId(msg.author.id);
const createNewThread = (thread == null);
// New thread
if (! thread) {
if (createNewThread) {
// Ignore messages that shouldn't usually open new threads, such as "ok", "thanks", etc.
if (config.ignoreAccidentalThreads && msg.content && ACCIDENTAL_THREAD_MESSAGES.includes(msg.content.trim().toLowerCase())) return;
thread = await threads.createNewThreadForUser(msg.author);
thread = await threads.createNewThreadForUser(msg.author, {
source: "dm",
message: msg,
});
}
if (thread) {
await thread.receiveUserReply(msg);
if (createNewThread) {
// Send auto-reply to the user
if (config.responseMessage) {
const responseMessage = utils.readMultilineConfigValue(config.responseMessage);
try {
const postToThreadChannel = config.showResponseMessageInThreadChannel;
await thread.sendSystemMessageToUser(responseMessage, { postToThreadChannel });
} catch (err) {
await thread.postSystemMessage(`**NOTE:** Could not send auto-response to the user. The error given was: \`${err.message}\``);
}
}
}
}
});
});
@ -149,20 +194,21 @@ function initBaseMessageHandlers() {
* 1) If that message was in DMs, and we have a thread open with that user, post the edit as a system message in the thread
* 2) If that message was moderator chatter in the thread, update the corresponding chat message in the DB
*/
bot.on('messageUpdate', async (msg, oldMessage) => {
bot.on("messageUpdate", async (msg, oldMessage) => {
if (! msg || ! msg.author) return;
if (msg.author.bot) return;
if (msg.author.id === bot.user.id) return;
if (await blocked.isBlocked(msg.author.id)) return;
if (! msg.content) return;
// Old message content doesn't persist between bot restarts
const oldContent = oldMessage && oldMessage.content || '*Unavailable due to bot restart*';
const oldContent = oldMessage && oldMessage.content || "*Unavailable due to bot restart*";
const newContent = msg.content;
// Ignore edit events with changes only in embeds etc.
if (newContent.trim() === oldContent.trim()) return;
// 1) If this edit was in DMs
if (msg.channel instanceof Eris.PrivateChannel) {
if (! msg.author.bot && msg.channel instanceof Eris.PrivateChannel) {
const thread = await threads.findOpenThreadByUserId(msg.author.id);
if (! thread) return;
@ -171,7 +217,7 @@ function initBaseMessageHandlers() {
}
// 2) If this edit was a chat message in the thread
else if (utils.messageIsOnInboxServer(msg) && utils.isStaff(msg.member)) {
else if (utils.messageIsOnInboxServer(msg) && (msg.author.bot || utils.isStaff(msg.member))) {
const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
if (! thread) return;
@ -182,11 +228,11 @@ function initBaseMessageHandlers() {
/**
* When a staff message is deleted in a modmail thread, delete it from the database as well
*/
bot.on('messageDelete', async msg => {
bot.on("messageDelete", async msg => {
if (! msg.author) return;
if (msg.author.bot) return;
if (msg.author.id === bot.user.id) return;
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
if (! msg.author.bot && ! utils.isStaff(msg.member)) return;
const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
if (! thread) return;
@ -197,7 +243,7 @@ function initBaseMessageHandlers() {
/**
* When the bot is mentioned on the main server, ping staff in the log channel about it
*/
bot.on('messageCreate', async msg => {
bot.on("messageCreate", async msg => {
if (! utils.messageIsOnMainServer(msg)) return;
if (! msg.mentions.some(user => user.id === bot.user.id)) return;
if (msg.author.bot) return;
@ -216,18 +262,21 @@ function initBaseMessageHandlers() {
let content;
const mainGuilds = utils.getMainGuilds();
const staffMention = (config.pingOnBotMention ? utils.getInboxMention() : '');
const staffMention = (config.pingOnBotMention ? utils.getInboxMention() : "");
const allowedMentions = (config.pingOnBotMention ? utils.getInboxMentionAllowedMentions() : undefined);
const userMentionStr = `**${msg.author.username}#${msg.author.discriminator}** (\`${msg.author.id}\`)`;
const messageLink = `https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}`;
if (mainGuilds.length === 1) {
content = `${staffMention}Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`;
content = `${staffMention}Bot mentioned in ${msg.channel.mention} by ${userMentionStr}: "${msg.cleanContent}"\n\n<${messageLink}>`;
} else {
content = `${staffMention}Bot mentioned in ${msg.channel.mention} (${msg.channel.guild.name}) by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`;
content = `${staffMention}Bot mentioned in ${msg.channel.mention} (${msg.channel.guild.name}) by ${userMentionStr}: "${msg.cleanContent}"\n\n<${messageLink}>`;
}
bot.createMessage(utils.getLogChannel().id, {
content,
disableEveryone: false,
allowedMentions,
});
// Send an auto-response to the mention, if enabled
@ -235,10 +284,61 @@ function initBaseMessageHandlers() {
const botMentionResponse = utils.readMultilineConfigValue(config.botMentionResponse);
bot.createMessage(msg.channel.id, botMentionResponse.replace(/{userMention}/g, `<@${msg.author.id}>`));
}
// If configured, automatically open a new thread with a user who has pinged it
if (config.createThreadOnMention) {
const existingThread = await threads.findOpenThreadByUserId(msg.author.id);
if (! existingThread) {
// Only open a thread if we don't already have one
const createdThread = await threads.createNewThreadForUser(msg.author, { quiet: true });
await createdThread.postSystemMessage(`This thread was opened from a bot mention in <#${msg.channel.id}>`);
await createdThread.receiveUserReply(msg);
}
}
});
}
function initPlugins() {
function initUpdateNotifications() {
if (config.updateNotifications) {
updates.startVersionRefreshLoop();
}
}
function getBasePlugins() {
return [
"file:./src/modules/reply",
"file:./src/modules/close",
"file:./src/modules/logs",
"file:./src/modules/block",
"file:./src/modules/move",
"file:./src/modules/snippets",
"file:./src/modules/suspend",
"file:./src/modules/greeting",
"file:./src/modules/webserverPlugin",
"file:./src/modules/typingProxy",
"file:./src/modules/version",
"file:./src/modules/newthread",
"file:./src/modules/id",
"file:./src/modules/alert",
"file:./src/modules/joinLeaveNotification",
"file:./src/modules/roles",
];
}
function getExternalPlugins() {
return config.plugins;
}
function getAllPlugins() {
return [...getBasePlugins(), ...getExternalPlugins()];
}
async function installAllPlugins() {
const plugins = getAllPlugins();
await installPlugins(plugins);
}
async function loadAllPlugins() {
// Initialize command manager
const commands = createCommandManager(bot);
@ -250,40 +350,16 @@ function initPlugins() {
}
// Load plugins
console.log('Loading plugins');
const builtInPlugins = [
reply,
close,
logs,
block,
move,
snippets,
suspend,
greeting,
webserver,
typingProxy,
version,
newthread,
idModule,
alert,
ping
];
const plugins = [...builtInPlugins];
if (config.plugins && config.plugins.length) {
for (const plugin of config.plugins) {
const pluginFn = require(`../${plugin}`);
plugins.push(pluginFn);
}
}
const basePlugins = getBasePlugins();
const externalPlugins = getExternalPlugins();
const plugins = getAllPlugins();
const pluginApi = getPluginAPI({ bot, knex, config, commands });
plugins.forEach(pluginFn => loadPlugin(pluginFn, pluginApi));
await loadPlugins([...basePlugins, ...externalPlugins], pluginApi);
console.log(`Loaded ${plugins.length} plugins (${builtInPlugins.length} built-in plugins, ${plugins.length - builtInPlugins.length} external plugins)`);
if (config.updateNotifications) {
updates.startVersionRefreshLoop();
}
return {
loadedCount: plugins.length,
baseCount: basePlugins.length,
externalCount: externalPlugins.length,
};
}

View File

@ -1,10 +1,10 @@
module.exports = ({ bot, knex, config, commands }) => {
commands.addInboxThreadCommand('alert', '[opt:string]', async (msg, args, thread) => {
if (args.opt && args.opt.startsWith('c')) {
await thread.setAlert(null);
await thread.postSystemMessage(`Cancelled new message alert`);
commands.addInboxThreadCommand("alert", "[opt:string]", async (msg, args, thread) => {
if (args.opt && args.opt.startsWith("c")) {
await thread.removeAlert(msg.author.id)
await thread.postSystemMessage("Cancelled new message alert");
} else {
await thread.setAlert(msg.author.id);
await thread.addAlert(msg.author.id);
await thread.postSystemMessage(`Pinging ${msg.author.username}#${msg.author.discriminator} when this thread gets a new reply`);
}
});

View File

@ -1,21 +1,27 @@
const humanizeDuration = require('humanize-duration');
const moment = require('moment');
const humanizeDuration = require("humanize-duration");
const moment = require("moment");
const blocked = require("../data/blocked");
const utils = require("../utils");
module.exports = ({ bot, knex, config, commands }) => {
if (! config.allowBlock) return;
async function removeExpiredBlocks() {
const expiredBlocks = await blocked.getExpiredBlocks();
const logChannel = utils.getLogChannel();
for (const userId of expiredBlocks) {
await blocked.unblock(userId);
logChannel.createMessage(`Block of <@!${userId}> (id \`${userId}\`) expired`);
logChannel.createMessage({
content: `Block of <@!${userId}> (id \`${userId}\`) expired`,
allowedMentions: {
users: [userId],
},
});
}
}
async function expiredBlockLoop() {
try {
removeExpiredBlocks();
await removeExpiredBlocks();
} catch (e) {
console.error(e);
}
@ -23,7 +29,7 @@ module.exports = ({ bot, knex, config, commands }) => {
setTimeout(expiredBlockLoop, 2000);
}
bot.on('ready', expiredBlockLoop);
expiredBlockLoop();
const blockCmd = async (msg, args, thread) => {
const userIdToBlock = args.userId || (thread && thread.user_id);
@ -31,16 +37,16 @@ module.exports = ({ bot, knex, config, commands }) => {
const isBlocked = await blocked.isBlocked(userIdToBlock);
if (isBlocked) {
msg.channel.createMessage('User is already blocked');
msg.channel.createMessage("User is already blocked");
return;
}
const expiresAt = args.blockTime
? moment.utc().add(args.blockTime, 'ms').format('YYYY-MM-DD HH:mm:ss')
? moment.utc().add(args.blockTime, "ms").format("YYYY-MM-DD HH:mm:ss")
: null;
const user = bot.users.get(userIdToBlock);
await blocked.block(userIdToBlock, (user ? `${user.username}#${user.discriminator}` : ''), msg.author.id, expiresAt);
await blocked.block(userIdToBlock, (user ? `${user.username}#${user.discriminator}` : ""), msg.author.id, expiresAt);
if (expiresAt) {
const humanized = humanizeDuration(args.blockTime, { largest: 2, round: true });
@ -50,8 +56,8 @@ module.exports = ({ bot, knex, config, commands }) => {
}
};
commands.addInboxServerCommand('block', '<userId:userId> [blockTime:delay]', blockCmd);
commands.addInboxServerCommand('block', '[blockTime:delay]', blockCmd);
commands.addInboxServerCommand("block", "<userId:userId> [blockTime:delay]", blockCmd);
commands.addInboxServerCommand("block", "[blockTime:delay]", blockCmd);
const unblockCmd = async (msg, args, thread) => {
const userIdToUnblock = args.userId || (thread && thread.user_id);
@ -59,12 +65,12 @@ module.exports = ({ bot, knex, config, commands }) => {
const isBlocked = await blocked.isBlocked(userIdToUnblock);
if (! isBlocked) {
msg.channel.createMessage('User is not blocked');
msg.channel.createMessage("User is not blocked");
return;
}
const unblockAt = args.unblockDelay
? moment.utc().add(args.unblockDelay, 'ms').format('YYYY-MM-DD HH:mm:ss')
? moment.utc().add(args.unblockDelay, "ms").format("YYYY-MM-DD HH:mm:ss")
: null;
const user = bot.users.get(userIdToUnblock);
@ -78,22 +84,31 @@ module.exports = ({ bot, knex, config, commands }) => {
}
};
commands.addInboxServerCommand('unblock', '<userId:userId> [unblockDelay:delay]', unblockCmd);
commands.addInboxServerCommand('unblock', '[unblockDelay:delay]', unblockCmd);
commands.addInboxServerCommand("unblock", "<userId:userId> [unblockDelay:delay]", unblockCmd);
commands.addInboxServerCommand("unblock", "[unblockDelay:delay]", unblockCmd);
commands.addInboxServerCommand('is_blocked', '[userId:userId]',async (msg, args, thread) => {
commands.addInboxServerCommand("is_blocked", "[userId:userId]",async (msg, args, thread) => {
const userIdToCheck = args.userId || (thread && thread.user_id);
if (! userIdToCheck) return;
const blockStatus = await blocked.getBlockStatus(userIdToCheck);
if (blockStatus.isBlocked) {
if (blockStatus.expiresAt) {
msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked until ${blockStatus.expiresAt} (UTC)`);
msg.channel.createMessage({
content: `<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked until ${blockStatus.expiresAt} (UTC)`,
allowedMentions: { users: [userIdToCheck] },
});
} else {
msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked indefinitely`);
msg.channel.createMessage({
content: `<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked indefinitely`,
allowedMentions: { users: [userIdToCheck] },
});
}
} else {
msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is NOT blocked`);
msg.channel.createMessage({
content: `<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is NOT blocked`,
allowedMentions: { users: [userIdToCheck] },
});
}
});
};

View File

@ -1,12 +1,38 @@
const moment = require('moment');
const Eris = require('eris');
const config = require('../config');
const utils = require('../utils');
const threads = require('../data/threads');
const blocked = require('../data/blocked');
const {messageQueue} = require('../queue');
const moment = require("moment");
const Eris = require("eris");
const utils = require("../utils");
const threads = require("../data/threads");
const blocked = require("../data/blocked");
const { messageQueue } = require("../queue");
const { getLogUrl, getLogFile, getLogCustomResponse } = require("../data/logs");
module.exports = ({ bot, knex, config, commands }) => {
async function sendCloseNotification(thread, body) {
const logCustomResponse = await getLogCustomResponse(thread);
if (logCustomResponse) {
await utils.postLog(body);
await utils.postLog(logCustomResponse.content, logCustomResponse.file);
return;
}
const logUrl = await getLogUrl(thread);
if (logUrl) {
utils.postLog(utils.trimAll(`
${body}
Logs: ${logUrl}
`));
return;
}
const logFile = await getLogFile(thread);
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, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}`);
}
}
@ -39,11 +61,12 @@ module.exports = ({ bot, knex, config, commands }) => {
scheduledCloseLoop();
// Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
commands.addGlobalCommand('close', '[opts...]', async (msg, args) => {
commands.addGlobalCommand("close", "[opts...]", async (msg, args) => {
let thread, closedBy;
let hasCloseMessage = !! config.closeMessage;
let silentClose = false;
let suppressSystemMessages = false;
if (msg.channel instanceof Eris.PrivateChannel) {
// User is closing the thread by themselves (if enabled)
@ -56,11 +79,11 @@ module.exports = ({ bot, knex, config, commands }) => {
// We need to add this operation to the message queue so we don't get a race condition
// between showing the close command in the thread and closing the thread
await messageQueue.add(async () => {
thread.postSystemMessage('Thread closed by user, closing...');
await thread.close(true);
thread.postSystemMessage("Thread closed by user, closing...");
suppressSystemMessages = true;
});
closedBy = 'the user';
closedBy = "the user";
} else {
// A staff member is closing the thread
if (! utils.messageIsOnInboxServer(msg)) return;
@ -69,49 +92,48 @@ module.exports = ({ bot, knex, config, commands }) => {
thread = await threads.findOpenThreadByChannelId(msg.channel.id);
if (! thread) return;
if (args.opts && args.opts.length) {
if (args.opts.includes('cancel') || args.opts.includes('c')) {
// Cancel timed close
if (thread.scheduled_close_at) {
await thread.cancelScheduledClose();
thread.postSystemMessage(`Cancelled scheduled closing`);
}
const opts = args.opts || [];
if (args.cancel || opts.includes("cancel") || opts.includes("c")) {
// Cancel timed close
if (thread.scheduled_close_at) {
await thread.cancelScheduledClose();
thread.postSystemMessage("Cancelled scheduled closing");
}
return;
}
// Silent close (= no close message)
if (args.silent || opts.includes("silent") || opts.includes("s")) {
silentClose = true;
}
// Timed close
const delayStringArg = opts.find(arg => utils.delayStringRegex.test(arg));
if (delayStringArg) {
const delay = utils.convertDelayStringToMS(delayStringArg);
if (delay === 0 || delay === null) {
thread.postSystemMessage("Invalid delay specified. Format: \"1h30m\"");
return;
}
// Silent close (= no close message)
if (args.opts.includes('silent') || args.opts.includes('s')) {
silentClose = true;
const closeAt = moment.utc().add(delay, "ms");
await thread.scheduleClose(closeAt.format("YYYY-MM-DD HH:mm:ss"), msg.author, silentClose ? 1 : 0);
let response;
if (silentClose) {
response = `Thread is now scheduled to be closed silently in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
} else {
response = `Thread is now scheduled to be closed in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
}
// Timed close
const delayStringArg = args.opts.find(arg => utils.delayStringRegex.test(arg));
if (delayStringArg) {
const delay = utils.convertDelayStringToMS(delayStringArg);
if (delay === 0 || delay === null) {
thread.postSystemMessage(`Invalid delay specified. Format: "1h30m"`);
return;
}
thread.postSystemMessage(response);
const closeAt = moment.utc().add(delay, 'ms');
await thread.scheduleClose(closeAt.format('YYYY-MM-DD HH:mm:ss'), msg.author, silentClose ? 1 : 0);
let response;
if (silentClose) {
response = `Thread is now scheduled to be closed silently in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
} else {
response = `Thread is now scheduled to be closed in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
}
thread.postSystemMessage(response);
return;
}
return;
}
// Regular close
await thread.close(false, silentClose);
closedBy = msg.author.username;
}
@ -121,15 +143,18 @@ 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 thread.close(suppressSystemMessages, silentClose);
await sendCloseNotification(thread, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}`);
}, {
options: [
{ name: "silent", shortcut: "s", isSwitch: true },
{ name: "cancel", shortcut: "c", isSwitch: true },
],
});
// Auto-close threads if their channel is deleted
bot.on('channelDelete', async (channel) => {
bot.on("channelDelete", async (channel) => {
if (! (channel instanceof Eris.TextChannel)) return;
if (channel.guild.id !== utils.getInboxGuild().id) return;
@ -144,10 +169,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, `Modmail thread #${thread.thread_number} with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted`);
});
};

View File

@ -1,20 +1,20 @@
const path = require('path');
const fs = require('fs');
const config = require('../config');
const utils = require('../utils');
const path = require("path");
const fs = require("fs");
const config = require("../cfg");
const utils = require("../utils");
module.exports = ({ bot }) => {
if (! config.enableGreeting) return;
bot.on('guildMemberAdd', (guild, member) => {
const guildGreeting = config.guildGreetings[guild.id];
if (! guildGreeting || (! guildGreeting.message && ! guildGreeting.attachment)) return;
bot.on("guildMemberAdd", (guild, member) => {
const serverGreeting = config.serverGreetings[guild.id];
if (! serverGreeting || (! serverGreeting.message && ! serverGreeting.attachment)) return;
function sendGreeting(message, file) {
bot.getDMChannel(member.id).then(channel => {
if (! channel) return;
channel.createMessage(message || '', file)
channel.createMessage(message || "", file)
.catch(e => {
if (e.code === 50007) return;
throw e;
@ -22,11 +22,11 @@ module.exports = ({ bot }) => {
});
}
const greetingMessage = utils.readMultilineConfigValue(guildGreeting.message);
const greetingMessage = utils.readMultilineConfigValue(serverGreeting.message);
if (guildGreeting.attachment) {
const filename = path.basename(guildGreeting.attachment);
fs.readFile(guildGreeting.attachment, (err, data) => {
if (serverGreeting.attachment) {
const filename = path.basename(serverGreeting.attachment);
fs.readFile(serverGreeting.attachment, (err, data) => {
const file = {file: data, name: filename};
sendGreeting(greetingMessage, file);
});

View File

@ -1,10 +1,38 @@
const ThreadMessage = require("../data/ThreadMessage");
const utils = require("../utils");
module.exports = ({ bot, knex, config, commands }) => {
commands.addInboxThreadCommand('id', [], async (msg, args, thread) => {
commands.addInboxThreadCommand("id", [], async (msg, args, thread) => {
thread.postSystemMessage(thread.user_id);
});
commands.addInboxThreadCommand('dmid', [], async (msg, args, thread) => {
commands.addInboxThreadCommand("dm_channel_id", [], async (msg, args, thread) => {
const dmChannel = await thread.getDMChannel();
thread.postSystemMessage(dmChannel.id);
});
commands.addInboxThreadCommand("message", "<messageNumber:number>", async (msg, args, thread) => {
/** @type {ThreadMessage} */
const threadMessage = await thread.findThreadMessageByMessageNumber(args.messageNumber);
if (! threadMessage) {
thread.postSystemMessage("No message in this thread with that number");
return;
}
const channelId = threadMessage.dm_channel_id;
// In specific rare cases, such as createThreadOnMention, a thread message may originate from a main server
const channelIdServer = utils.getMainGuilds().find(g => g.channels.has(channelId));
const messageLink = channelIdServer
? `https://discord.com/channels/${channelIdServer.id}/${channelId}/${threadMessage.dm_message_id}`
: `https://discord.com/channels/@me/${channelId}/${threadMessage.dm_message_id}`;
const parts = [
`Details for message \`${threadMessage.message_number}\`:`,
`Channel ID: \`${channelId}\``,
`Message ID: \`${threadMessage.dm_message_id}\``,
`Link: <${messageLink}>`,
];
thread.postSystemMessage(parts.join("\n"));
});
};

View File

@ -0,0 +1,31 @@
const config = require("../cfg");
const threads = require("../data/threads");
const utils = require("../utils");
module.exports = ({ bot }) => {
// Join Notification: Post a message in the thread if the user joins a main server
if (config.notifyOnMainServerJoin) {
bot.on("guildMemberAdd", async (guild, member) => {
const mainGuilds = utils.getMainGuilds();
if (! mainGuilds.find(gld => gld.id === guild.id)) return;
const thread = await threads.findOpenThreadByUserId(member.id);
if (thread != null) {
await thread.postSystemMessage(`***The user joined the guild ${guild.name}.***`);
}
});
}
// Leave Notification: Post a message in the thread if the user leaves a main server
if (config.notifyOnMainServerLeave) {
bot.on("guildMemberRemove", async (guild, member) => {
const mainGuilds = utils.getMainGuilds();
if (! mainGuilds.find(gld => gld.id === guild.id)) return;
const thread = await threads.findOpenThreadByUserId(member.id);
if (thread != null) {
await thread.postSystemMessage(`***The user left the guild ${guild.name}.***`);
}
});
}
};

View File

@ -1,10 +1,25 @@
const threads = require("../data/threads");
const moment = require('moment');
const moment = require("moment");
const utils = require("../utils");
const { getLogUrl, getLogFile, getLogCustomResponse, saveLogToStorage } = require("../data/logs");
const { THREAD_STATUS } = require("../data/constants");
const LOG_LINES_PER_PAGE = 10;
module.exports = ({ bot, knex, config, commands }) => {
module.exports = ({ bot, knex, config, commands, hooks }) => {
const addOptQueryStringToUrl = (url, args) => {
const params = [];
if (args.verbose) params.push("verbose=1");
if (args.simple) params.push("simple=1");
if (params.length === 0) {
return url;
}
const hasQueryString = url.indexOf("?") > -1;
return url + (hasQueryString ? "&" : "?") + params.join("&");
};
const logsCmd = async (msg, args, thread) => {
let userId = args.userId || (thread && thread.user_id);
if (! userId) return;
@ -29,59 +44,79 @@ 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 formattedDate = moment.utc(thread.created_at).format('MMM Do [at] HH:mm [UTC]');
return `\`${formattedDate}\`: <${logUrl}>`;
const logUrl = await getLogUrl(thread);
const formattedLogUrl = logUrl
? `<${addOptQueryStringToUrl(logUrl, args)}>`
: `View log with \`${config.prefix}log ${thread.thread_number}\``
const formattedDate = moment.utc(thread.created_at).format("MMM Do [at] HH:mm [UTC]");
return `\`#${thread.thread_number}\` \`${formattedDate}\`: ${formattedLogUrl}`;
}));
let message = isPaginated
? `**Log files for <@${userId}>** (page **${page}/${maxPage}**, showing logs **${start + 1}-${end}/${totalUserThreads}**):`
: `**Log files for <@${userId}>:**`;
message += `\n${threadLines.join('\n')}`;
message += `\n${threadLines.join("\n")}`;
if (isPaginated) {
message += `\nTo view more, add a page number to the end of the command`;
message += "\nTo view more, add a page number to the end of the command";
}
// Send the list of logs in chunks of 15 lines per message
const lines = message.split('\n');
const lines = message.split("\n");
const chunks = utils.chunk(lines, 15);
let root = Promise.resolve();
chunks.forEach(lines => {
root = root.then(() => msg.channel.createMessage(lines.join('\n')));
root = root.then(() => msg.channel.createMessage(lines.join("\n")));
});
};
commands.addInboxServerCommand('logs', '<userId:userId> [page:number]', logsCmd);
commands.addInboxServerCommand('logs', '[page:number]', logsCmd);
const logCmd = async (msg, args, _thread) => {
const threadId = args.threadId || (_thread && _thread.id);
if (! threadId) return;
commands.addInboxServerCommand('loglink', [], async (msg, args, thread) => {
if (! thread) {
thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
if (! thread) return;
const thread = (await threads.findById(threadId)) || (await threads.findByThreadNumber(threadId));
if (! thread) return;
const customResponse = await getLogCustomResponse(thread);
if (customResponse && (customResponse.content || customResponse.file)) {
msg.channel.createMessage(customResponse.content, customResponse.file);
}
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('&')}` : '';
const logUrl = await getLogUrl(thread);
if (logUrl) {
msg.channel.createMessage(`Open the following link to view the log for thread #${thread.thread_number}:\n<${addOptQueryStringToUrl(logUrl, args)}>`);
return;
}
thread.postSystemMessage(`Log URL: ${logUrl}${qs}`);
}, {
options: [
{
name: 'verbose',
shortcut: 'v',
isSwitch: true,
},
{
name: 'simple',
shortcut: 's',
isSwitch: true,
},
],
const logFile = await getLogFile(thread);
if (logFile) {
msg.channel.createMessage(`Download the following file to view the log for thread #${thread.thread_number}:`, logFile);
return;
}
if (thread.status === THREAD_STATUS.OPEN) {
msg.channel.createMessage(`This thread's logs are not currently available, but it's open at <#${thread.channel_id}>`);
return;
}
msg.channel.createMessage("This thread's logs are not currently available");
};
const logCmdOptions = [
{ name: "verbose", shortcut: "v", isSwitch: true },
{ name: "simple", shortcut: "s", isSwitch: true },
];
commands.addInboxServerCommand("logs", "<userId:userId> [page:number]", logsCmd, { options: logCmdOptions });
commands.addInboxServerCommand("logs", "[page:number]", logsCmd, { options: logCmdOptions });
commands.addInboxServerCommand("log", "[threadId:string]", logCmd, { options: logCmdOptions, aliases: ["thread"] });
commands.addInboxServerCommand("loglink", "[threadId:string]", logCmd, { options: logCmdOptions });
hooks.afterThreadClose(async ({ threadId }) => {
const thread = await threads.findById(threadId);
await saveLogToStorage(thread);
});
};

View File

@ -1,12 +1,12 @@
const config = require('../config');
const Eris = require('eris');
const config = require("../cfg");
const Eris = require("eris");
const transliterate = require("transliteration");
const erisEndpoints = require('eris/lib/rest/Endpoints');
const erisEndpoints = require("../../node_modules/eris/lib/rest/Endpoints");
module.exports = ({ bot, knex, config, commands }) => {
if (! config.allowMove) return;
commands.addInboxThreadCommand('move', '<category:string$>', async (msg, args, thread) => {
commands.addInboxThreadCommand("move", "<category:string$>", async (msg, args, thread) => {
const searchStr = args.category;
const normalizedSearchStr = transliterate.slugify(searchStr);
@ -41,7 +41,7 @@ module.exports = ({ bot, knex, config, commands }) => {
});
if (containsRankings[0][1] === 0) {
thread.postSystemMessage('No matching category');
thread.postSystemMessage("No matching category");
return;
}

View File

@ -2,10 +2,15 @@ const utils = require("../utils");
const threads = require("../data/threads");
module.exports = ({ bot, knex, config, commands }) => {
commands.addInboxServerCommand('newthread', '<userId:userId>', async (msg, args, thread) => {
const user = bot.users.get(args.userId);
commands.addInboxServerCommand("newthread", "<userId:userId>", async (msg, args, thread) => {
const user = bot.users.get(args.userId) || await bot.getRESTUser(args.userId).catch(() => null);
if (! user) {
utils.postSystemMessageWithFallback(msg.channel, thread, 'User not found!');
utils.postSystemMessageWithFallback(msg.channel, thread, "User not found!");
return;
}
if (user.bot) {
utils.postSystemMessageWithFallback(msg.channel, thread, "Can't create a thread for a bot");
return;
}
@ -15,7 +20,13 @@ module.exports = ({ bot, knex, config, commands }) => {
return;
}
const createdThread = await threads.createNewThreadForUser(user, true, true);
const createdThread = await threads.createNewThreadForUser(user, {
quiet: true,
ignoreRequirements: true,
ignoreHooks: true,
source: "command",
});
createdThread.postSystemMessage(`Thread was opened by ${msg.author.username}#${msg.author.discriminator}`);
msg.channel.createMessage(`Thread opened: <#${createdThread.channel_id}>`);

View File

@ -1,19 +1,72 @@
const utils = require('../utils');
const attachments = require("../data/attachments");
const utils = require("../utils");
const Thread = require("../data/Thread");
module.exports = ({ bot, knex, config, commands }) => {
// Mods can reply to modmail threads using !r or !reply
// These messages get relayed back to the DM thread between the bot and the user
// Anonymous replies only show the role, not the username
commands.addInboxThreadCommand('reply', '[text$]', async (msg, args, thread) => {
commands.addInboxThreadCommand("reply", "[text$]", async (msg, args, thread) => {
if (! args.text && msg.attachments.length === 0) {
utils.postError(msg.channel, 'Text or attachment required');
utils.postError(msg.channel, "Text or attachment required");
return;
}
const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, false);
const replied = await thread.replyToUser(msg.member, args.text || "", msg.attachments, false);
if (replied) msg.delete();
}, {
aliases: ['r']
aliases: ["r"]
});
// Anonymous replies only show the role, not the username
commands.addInboxThreadCommand("anonreply", "[text$]", async (msg, args, thread) => {
if (! args.text && msg.attachments.length === 0) {
utils.postError(msg.channel, "Text or attachment required");
return;
}
const replied = await thread.replyToUser(msg.member, args.text || "", msg.attachments, true);
if (replied) msg.delete();
}, {
aliases: ["ar"]
});
if (config.allowStaffEdit) {
commands.addInboxThreadCommand("edit", "<messageNumber:number> <text:string$>", async (msg, args, thread) => {
const threadMessage = await thread.findThreadMessageByMessageNumber(args.messageNumber);
if (! threadMessage) {
utils.postError(msg.channel, "Unknown message number");
return;
}
if (threadMessage.user_id !== msg.author.id) {
utils.postError(msg.channel, "You can only edit your own replies");
return;
}
const edited = await thread.editStaffReply(msg.member, threadMessage, args.text);
if (edited) msg.delete().catch(utils.noop);
}, {
aliases: ["e"]
});
}
if (config.allowStaffDelete) {
commands.addInboxThreadCommand("delete", "<messageNumber:number>", async (msg, args, thread) => {
const threadMessage = await thread.findThreadMessageByMessageNumber(args.messageNumber);
if (! threadMessage) {
utils.postError(msg.channel, "Unknown message number");
return;
}
if (threadMessage.user_id !== msg.author.id) {
utils.postError(msg.channel, "You can only delete your own replies");
return;
}
await thread.deleteStaffReply(msg.member, threadMessage);
msg.delete().catch(utils.noop);
}, {
aliases: ["d"]
});
}
};

97
src/modules/roles.js Normal file
View File

@ -0,0 +1,97 @@
const utils = require("../utils");
const {
setModeratorDefaultRoleOverride,
resetModeratorDefaultRoleOverride,
setModeratorThreadRoleOverride,
resetModeratorThreadRoleOverride,
getModeratorThreadDisplayRoleName,
getModeratorDefaultDisplayRoleName,
} = require("../data/displayRoles");
module.exports = ({ bot, knex, config, commands }) => {
if (! config.allowChangingDisplayRole) {
return;
}
function resolveRoleInput(input) {
if (utils.isSnowflake(input)) {
return utils.getInboxGuild().roles.get(input);
}
return utils.getInboxGuild().roles.find(r => r.name.toLowerCase() === input.toLowerCase());
}
// Get display role for a thread
commands.addInboxThreadCommand("role", [], async (msg, args, thread) => {
const displayRole = await getModeratorThreadDisplayRoleName(msg.member, thread.id);
if (displayRole) {
thread.postSystemMessage(`Your display role in this thread is currently **${displayRole}**`);
} else {
thread.postSystemMessage("Your replies in this thread do not currently display a role");
}
});
// Reset display role for a thread
commands.addInboxThreadCommand("role reset", [], async (msg, args, thread) => {
await resetModeratorThreadRoleOverride(msg.member.id, thread.id);
const displayRole = await getModeratorThreadDisplayRoleName(msg.member, thread.id);
if (displayRole) {
thread.postSystemMessage(`Your display role for this thread has been reset. Your replies will now display the default role **${displayRole}**.`);
} else {
thread.postSystemMessage("Your display role for this thread has been reset. Your replies will no longer display a role.");
}
}, {
aliases: ["role_reset", "reset_role"],
});
// Set display role for a thread
commands.addInboxThreadCommand("role", "<role:string$>", async (msg, args, thread) => {
const role = resolveRoleInput(args.role);
if (! role || ! msg.member.roles.includes(role.id)) {
thread.postSystemMessage("No matching role found. Make sure you have the role before trying to set it as your display role in this thread.");
return;
}
await setModeratorThreadRoleOverride(msg.member.id, thread.id, role.id);
thread.postSystemMessage(`Your display role for this thread has been set to **${role.name}**. You can reset it with \`${config.prefix}role reset\`.`);
});
// Get default display role
commands.addInboxServerCommand("role", [], async (msg, args, thread) => {
const displayRole = await getModeratorDefaultDisplayRoleName(msg.member);
if (displayRole) {
msg.channel.createMessage(`Your default display role is currently **${displayRole}**`);
} else {
msg.channel.createMessage("Your replies do not currently display a role by default");
}
});
// Reset default display role
commands.addInboxServerCommand("role reset", [], async (msg, args, thread) => {
await resetModeratorDefaultRoleOverride(msg.member.id);
const displayRole = await getModeratorDefaultDisplayRoleName(msg.member);
if (displayRole) {
msg.channel.createMessage(`Your default display role has been reset. Your replies will now display the role **${displayRole}** by default.`);
} else {
msg.channel.createMessage("Your default display role has been reset. Your replies will no longer display a role by default.");
}
}, {
aliases: ["role_reset", "reset_role"],
});
// Set default display role
commands.addInboxServerCommand("role", "<role:string$>", async (msg, args, thread) => {
const role = resolveRoleInput(args.role);
if (! role || ! msg.member.roles.includes(role.id)) {
msg.channel.createMessage("No matching role found. Make sure you have the role before trying to set it as your default display role.");
return;
}
await setModeratorDefaultRoleOverride(msg.member.id, role.id);
msg.channel.createMessage(`Your default display role has been set to **${role.name}**. You can reset it with \`${config.prefix}role reset\`.`);
});
};

View File

@ -1,13 +1,13 @@
const threads = require('../data/threads');
const snippets = require('../data/snippets');
const config = require('../config');
const utils = require('../utils');
const { parseArguments } = require('knub-command-manager');
const threads = require("../data/threads");
const snippets = require("../data/snippets");
const utils = require("../utils");
const { parseArguments } = require("knub-command-manager");
const whitespaceRegex = /\s/;
const quoteChars = ["'", '"'];
const quoteChars = ["'", "\""];
module.exports = ({ bot, knex, config, commands }) => {
if (! config.allowSnippets) return;
/**
* "Renders" a snippet by replacing all argument placeholders e.g. {1} {2} with their corresponding arguments.
* The number in the placeholder is the argument's order in the argument list, i.e. {1} is the first argument (= index 0)
@ -21,13 +21,13 @@ module.exports = ({ bot, knex, config, commands }) => {
const index = parseInt(match.slice(1, -1), 10) - 1;
return (args[index] != null ? args[index] : match);
})
.replace(/\\{/g, '{');
.replace(/\\{/g, "{");
}
/**
* When a staff member uses a snippet (snippet prefix + trigger word), find the snippet and post it as a reply in the thread
*/
bot.on('messageCreate', async msg => {
bot.on("messageCreate", async msg => {
if (! utils.messageIsOnInboxServer(msg)) return;
if (! utils.isStaff(msg.member)) return;
@ -53,7 +53,7 @@ module.exports = ({ bot, knex, config, commands }) => {
});
// Show or add a snippet
commands.addInboxServerCommand('snippet', '<trigger> [text$]', async (msg, args, thread) => {
commands.addInboxServerCommand("snippet", "<trigger> [text$]", async (msg, args, thread) => {
const snippet = await snippets.get(args.trigger);
if (snippet) {
@ -62,7 +62,7 @@ module.exports = ({ bot, knex, config, commands }) => {
utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" already exists! You can edit or delete it with ${config.prefix}edit_snippet and ${config.prefix}delete_snippet respectively.`);
} else {
// If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
utils.postSystemMessageWithFallback(msg.channel, thread, `\`${config.snippetPrefix}${args.trigger}\` replies with: \`\`\`${utils.disableCodeBlocks(snippet.body)}\`\`\``);
utils.postSystemMessageWithFallback(msg.channel, thread, `\`${config.snippetPrefix}${args.trigger}\` replies with: \`\`\`\n${utils.disableCodeBlocks(snippet.body)}\`\`\``);
}
} else {
if (args.text) {
@ -75,10 +75,10 @@ module.exports = ({ bot, knex, config, commands }) => {
}
}
}, {
aliases: ['s']
aliases: ["s"]
});
commands.addInboxServerCommand('delete_snippet', '<trigger>', async (msg, args, thread) => {
commands.addInboxServerCommand("delete_snippet", "<trigger>", async (msg, args, thread) => {
const snippet = await snippets.get(args.trigger);
if (! snippet) {
utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
@ -88,10 +88,10 @@ module.exports = ({ bot, knex, config, commands }) => {
await snippets.del(args.trigger);
utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" deleted!`);
}, {
aliases: ['ds']
aliases: ["ds"]
});
commands.addInboxServerCommand('edit_snippet', '<trigger> [text$]', async (msg, args, thread) => {
commands.addInboxServerCommand("edit_snippet", "<trigger> <text$>", async (msg, args, thread) => {
const snippet = await snippets.get(args.trigger);
if (! snippet) {
utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
@ -103,14 +103,16 @@ module.exports = ({ bot, knex, config, commands }) => {
utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" edited!`);
}, {
aliases: ['es']
aliases: ["es"]
});
commands.addInboxServerCommand('snippets', [], async (msg, args, thread) => {
commands.addInboxServerCommand("snippets", [], async (msg, args, thread) => {
const allSnippets = await snippets.all();
const triggers = allSnippets.map(s => s.trigger);
triggers.sort();
utils.postSystemMessageWithFallback(msg.channel, thread, `Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`);
utils.postSystemMessageWithFallback(msg.channel, thread, `Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(", ")}`);
}, {
aliases: ["s"]
});
};

Some files were not shown because too many files have changed in this diff Show More