update
commit
21050bf497
|
@ -19,6 +19,8 @@
|
|||
"!": true,
|
||||
"!!": true
|
||||
}
|
||||
}]
|
||||
}],
|
||||
"quotes": ["error", "double"],
|
||||
"no-shadow": "error"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
target-branch: "dev"
|
||||
allow:
|
||||
- dependency-type: "direct"
|
|
@ -1,7 +1,10 @@
|
|||
/.vscode
|
||||
/.idea
|
||||
/node_modules
|
||||
/config.*
|
||||
!/config.example.ini
|
||||
/welcome.png
|
||||
/update.sh
|
||||
|
||||
# Config files
|
||||
/config.*
|
||||
*.config.ini
|
||||
!/config.example.ini
|
||||
|
|
272
CHANGELOG.md
272
CHANGELOG.md
|
@ -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))
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 Miikka Virtanen
|
||||
Copyright (c) 2017–2021 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
|
||||
|
|
12
README.md
12
README.md
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
# ----------------------------------
|
|
@ -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');
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
};
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
12
docs/faq.md
12
docs/faq.md
|
@ -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`
|
||||
|
|
|
@ -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}}
|
|
@ -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
|
|
@ -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 |
|
@ -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
|
||||
|
|
|
@ -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)**
|
|
@ -1,2 +1,2 @@
|
|||
const config = require('./src/config');
|
||||
module.exports = config.knex;
|
||||
const knexConfig = require("./src/knexConfig");
|
||||
module.exports = knexConfig;
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
"apps": [{
|
||||
"name": "ModMailBot",
|
||||
"cwd": "./",
|
||||
"script": "npm",
|
||||
"args": "start"
|
||||
"script": "src/index.js"
|
||||
}]
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
class BotError extends Error {}
|
||||
|
||||
module.exports = {
|
||||
BotError,
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
class PluginInstallationError extends Error {}
|
||||
|
||||
module.exports = {
|
||||
PluginInstallationError,
|
||||
};
|
45
src/bot.js
45
src/bot.js
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
module.exports = require("yargs-parser")(process.argv.slice(2));
|
|
@ -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());
|
||||
|
|
|
@ -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,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;
|
|
@ -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;
|
File diff suppressed because it is too large
Load Diff
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]
|
||||
*/
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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" });
|
|
@ -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,
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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) {
|
||||
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
};
|
|
@ -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");
|
||||
});
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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");
|
||||
};
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
140
src/index.js
140
src/index.js
|
@ -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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
const config = require('./config');
|
||||
module.exports = require('knex')(config.knex);
|
||||
const knexConfig = require("./knexConfig");
|
||||
module.exports = require("knex")(knexConfig);
|
||||
|
|
|
@ -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}`);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
276
src/main.js
276
src/main.js
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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] },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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`);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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"));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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}.***`);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>`);
|
||||
|
|
|
@ -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"]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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\`.`);
|
||||
});
|
||||
};
|
|
@ -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
Loading…
Reference in New Issue