diff --git a/REACTION_FEATURE_README.md b/REACTION_FEATURE_README.md new file mode 100644 index 000000000..47305262e --- /dev/null +++ b/REACTION_FEATURE_README.md @@ -0,0 +1,58 @@ +# User Reaction Notification Feature + +## Overview + +This feature allows moderators to be notified when users react to modmail messages. When a user reacts to a message sent by the bot in their DMs, a notification will appear in the corresponding modmail thread. + +## Configuration + +Add these settings to your `config.ini` file: + +```ini +# Enable reaction notifications +notifyOnReaction = on + +# Enable reaction removal notifications (optional) +notifyOnReactionRemoval = on +``` + +## How it works + +1. **When enabled**: Staff members will see notifications like: + + - `Janooba reacted to message 17 with :thumbsup:` + - `Janooba removed reaction :thumbsdown: from message 17` + +2. **Only tracks reactions to staff replies**: The bot only notifies about reactions to messages sent FROM staff TO users (not reactions to user messages) + +3. **Works with both Unicode and custom emojis**: + - Unicode: 👍, 😊, etc. + - Custom: <:kekw:123456789> + +## Required Discord Permissions + +The bot now requests these additional intents: + +- `directMessageReactions` - For DM reaction notifications +- `guildMessageReactions` - For server reaction notifications + +## Files Modified + +- `src/main.js` - Added reaction event handlers +- `src/bot.js` - Added required Discord intents +- `src/data/cfg.schema.json` - Added configuration options +- `src/data/cfg.jsdoc.js` - Added JSDoc documentation +- `docs/configuration.md` - Added documentation +- `config.example.ini` - Added example configuration + +## Testing + +1. Enable the feature in your config +2. Start the bot +3. Send a reply to a user from a modmail thread +4. Have the user react to the message in their DMs +5. Check the modmail thread for the notification + +## Error Handling + +The feature includes try-catch blocks to prevent crashes and logs errors to the console if something goes wrong. diff --git a/docs/configuration.md b/docs/configuration.md index a5b3a493e..878ef8712 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,7 +1,9 @@ # 📝 Configuration + Haven't set up the bot yet? Check out [Setting up the bot](setup.md) first! ## Table of contents + - [Configuration file](#configuration-file) (start here) - [Adding new options](#adding-new-options) - [Required options](#required-options) @@ -11,6 +13,7 @@ Haven't set up the bot yet? Check out [Setting up the bot](setup.md) first! - [Environment variables](#environment-variables) ## Configuration file + All bot options are saved in a configuration file in the bot's folder. This is created during the [setup](setup.md) and is generally either `config.ini` or, if you've been using the bot for longer, `config.json`. @@ -21,24 +24,29 @@ Note that the format of `.ini` and `.json` are different -- you can't simply ren 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, `mainServerId = 1234`. **You need to restart the bot for configuration changes to take effect!** You can add comments in the config file by prefixing the line with `#`. Example: + ```ini # This is a comment option = value ``` ### 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` + +- 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" + Some options are marked as "**Accepts multiple values**". To give these options multiple values, write the option as `option[] = value` and repeat for every value. For example: @@ -50,135 +58,166 @@ inboxServerPermission[] = manageMessages You can also give these options a single value in the usual way, i.e. `inboxServerPermission = kickMembers` ### Multiple lines of text + For some options, such as `greetingMessage`, you might want to add text that spans multiple lines. To do that, use the same format as with "Accepts multiple values" above: ```ini greetingMessage[] = Welcome to the server! greetingMessage[] = This is the second line of the greeting. -greetingMessage[] = +greetingMessage[] = greetingMessage[] = Fourth line! With an empty line in the middle. ``` ## Required options #### token + The bot user's token from [Discord Developer Portal](https://discord.com/developers/). #### mainServerId + **Accepts multiple values**. Your server's ID. #### 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 ## Other options #### accountAgeDeniedMessage + **Default:** `Your Discord account is not old enough to contact modmail.` See `requiredAccountAge` below #### allowMove + **Default:** `off` If enabled, allows you to move threads between categories using `!move ` #### allowUserClose + **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` #### updateMessagesLive + **Default:** `off` If enabled, messages edited and deleted by the user will be updated accordingly in the thread, but will still be available in the logs #### 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 }}. +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. #### allowNotes + **Default:** `on` If enabled, moderators can add notes on users using the `!note` command. #### alwaysReply + **Default:** `off` If enabled, all messages in modmail threads will be sent to the user without having to use `!r`. To send internal messages in the thread when this option is enabled, add your command prefix (e.g. `!`) and a space at the beginning of the messages. For example, `! This is an internal message`. #### alwaysReplyAnon + **Default:** `off` If `alwaysReply` is enabled, this option controls whether the auto-reply is anonymous #### forceAnon + **Default:** `off` If enabled, all replies (including regular `!reply` and snippets) are anonymous #### anonymizeChannelName + **Default:** `off` If enabled, channel names will be the user's name salted with the current time, then hashed to protect the user's privacy #### attachmentStorage + **Default:** `original` Controls how attachments in modmail threads are stored. Possible values: -* `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. + +- `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* + +**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* + +**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* + +**Default:** _None_ ID of the category where new threads are opened. Also functions as a fallback for `categoryAutomation.newThreadFromServer`. #### categoryAutomation.newThreadFromGuild.SERVER_ID + Alias for [`categoryAutomation.newThreadFromServer`](#categoryAutomationNewThreadFromServerServer_id) #### categoryAutomation.newThreadFromServer.SERVER_ID -**Default:** *None* + +**Default:** _None_ 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.newThreadFromServer.94882524378968064 = 360863035130249235 @@ -187,18 +226,22 @@ categoryAutomation.newThreadFromServer.541484311354933258 = 542780020972716042 ``` #### closeMessage -**Default:** *None* + +**Default:** _None_ If set, the bot sends this message to the user when the modmail thread is closed. #### commandAliases -**Default:** *None* + +**Default:** _None_ Custom aliases/shortcuts for commands. Example: + ```ini # !mv is an alias/shortcut for !move commandAliases.mv = move # !x is an alias/shortcut for !close commandAliases.x = close ``` + Note that you can combine different commands and parameters together: ```ini @@ -209,165 +252,215 @@ commandAliases.replysus = suspend ``` #### enableGreeting + **Default:** `off` 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* + +**Default:** _None_ Role name to display in moderator replies if the moderator doesn't have a hoisted role #### breakFormattingForNames + **Default:** `on` Whether or not to escape formatting characters in usernames, such as `~~` for strikethrough, `__` for underlined etc. #### greetingAttachment -**Default:** *None* + +**Default:** _None_ Path to an image or other attachment to send as a greeting. Requires `enableGreeting` to be enabled. #### greetingMessage -**Default:** *None* + +**Default:** _None_ Message to send as a greeting. Requires `enableGreeting` to be enabled. Example: + ```ini greetingMessage[] = Welcome to the server! greetingMessage[] = Remember to read the rules. ``` #### guildGreetings + 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:** `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 + +- `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:** `none` **Accepts multiple values.** Role that is mentioned when new threads are created or the bot is mentioned. Accepted values are `none`, `here`, `everyone`, or a role id. Set to `none` to disable these pings entirely. #### mentionUserInThreadHeader + **Default:** `off` If enabled, mentions the user messaging modmail in the modmail thread's header. #### newThreadCategoryId -**Default:** *None* + +**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 #### overrideRoleNameDisplay + **Default:** `None` Role name to display in all replies. This completely overrides normal role selection, all replies will contain the string entered. For example; `overrideRoleNameDisplay = Moderator` - #### 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* + +**Default:** _None_ **Accepts multiple values.** External plugins to load on startup. See [Plugins](plugins.md) for more information. #### port + **Default:** `8890` Port to use for attachments (when `attachmentStorage` is set to `local`) and logs. Make sure to do the necessary [port forwarding](https://portforward.com/) and add any needed firewall exceptions so the port is accessible from the internet. #### prefix + **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. +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. +#### notifyOnReaction + +**Default:** `off` +If enabled, the bot will notify staff in the modmail thread when a user reacts to a message sent to them by the bot. + +#### notifyOnReactionRemoval + +**Default:** `off` +If enabled, the bot will notify staff in the modmail thread when a user removes a reaction from a message sent to them by the bot. +Requires `notifyOnReaction` to be enabled for reactions to be tracked. + #### relaySmallAttachmentsAsAttachments + **Default:** `off` If enabled, small attachments from users are sent as real attachments rather than links in modmail threads. The limit for "small" is 2MB by default; you can change this with the `smallAttachmentLimit` option. #### requiredAccountAge -**Default:** *None* + +**Default:** _None_ Required account age for contacting modmail (in hours). If the account is not old enough, a new thread will not be created and the bot will reply with `accountAgeDeniedMessage` (if set) instead. #### requiredTimeOnServer -**Default:** *None* + +**Default:** _None_ Required amount of time (in minutes) the user must be a member of the server before being able to contact modmail. If the user hasn't been a member of the server for the specified time, a new thread will not be created and the bot will reply with `timeOnServerDeniedMessage` (if set) instead. #### 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. 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* + +**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 @@ -377,103 +470,128 @@ 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) #### snippetPrefix + **Default:** `!!` Prefix for snippets #### snippetPrefixAnon + **Default:** `!!!` Prefix to use snippets anonymously #### status + **Default:** `Message me for help` The bot's status text. Set to `none` to disable. #### statusType + **Default:** `playing` The bot's status type. One of `playing`, `watching`, `listening`, `streaming`. #### statusUrl + **Default:** [nothing] The bot's Twitch url used for streaming status type. Must look like `https://twitch.tv/yourname`. #### 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. #### blockMessage -**Default** *None* + +**Default** _None_ Message to send to a user when they are blocked indefinitely. This message is also used for timed blocks if timedBlockMessage is not set. #### timedBlockMessage -**Default** *None* + +**Default** _None_ Message to send to a user when they are blocked for a specific duration. -* You can use `{duration}` in the text to include the duration (e.g. `4 weeks, 2 days`) -* You can use `{timestamp}` in the text to create a Discord timestamp of the time the user is blocked until (e.g. `` would become `June 3, 2022 at 11:50 PM`) + +- You can use `{duration}` in the text to include the duration (e.g. `4 weeks, 2 days`) +- You can use `{timestamp}` in the text to create a Discord timestamp of the time the user is blocked until (e.g. `` would become `June 3, 2022 at 11:50 PM`) #### unblockMessage -**Default** *None* + +**Default** _None_ Message to send to a user when they are unblocked immediately. This message is also used for timed unblocks if timedUnblockMessage is not set. #### timedUnblockMessage -**Default** *None* -Message to send to a user when they are scheduled to be unblocked after a specific amount of time. -* You can use `{delay}` in the text to include the time until the user will be unblocked (e.g. `4 weeks, 2 days`) -* You can use `{timestamp}` in the text to create a Discord timestamp of the unblock time (e.g. `<t:{timestamp}:f>` would become `June 3, 2022 at 11:50 PM`) + +**Default** _None_ +Message to send to a user when they are scheduled to be unblocked after a specific amount of time. + +- You can use `{delay}` in the text to include the time until the user will be unblocked (e.g. `4 weeks, 2 days`) +- You can use `{timestamp}` in the text to create a Discord timestamp of the unblock time (e.g. `<t:{timestamp}:f>` would become `June 3, 2022 at 11:50 PM`) #### blockedReply -**Default** *None* + +**Default** _None_ Message that the bot replies with if a user tries to message the bot while blocked. #### threadTimestamps + **Default:** `off` If enabled, modmail threads will show accurate UTC timestamps for each message, in addition to Discord's own timestamps. Logs show these always, regardless of this setting. #### typingProxy + **Default:** `off` If enabled, any time a user is typing to modmail in their DMs, the modmail thread will show the bot as "typing" #### typingProxyReverse + **Default:** `off` If enabled, any time a moderator is typing in a modmail thread, the user will see the bot "typing" in their DMs #### updateNotifications + **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* + +**Default:** _None_ URL to use for attachment and log links. Defaults to `http://IP:PORT/`. #### useNicknames + **Default:** `off` If enabled, mod replies will use their nicknames (on the inbox server) instead of their usernames #### useDisplaynames + **Default:** `on` If enabled, the bot will use the users display name instead of their username where it makes sense -Setting `useNicknames` to `on` will override this in most cases +Setting `useNicknames` to `on` will override this in most cases #### 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. @@ -481,109 +599,128 @@ This is useful if you are installing plugins from private repositories that requ ## Advanced options #### extraIntents -**Default:** *None* + +**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. +- `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* + +**Default:** _None_ Required if using `mysql` for `dbType`. MySQL user to connect with. ##### mysqlOptions.password -**Default:** *None* + +**Default:** _None_ Required if using `mysql` for `dbType`. Password for the MySQL user specified above. ##### mysqlOptions.database -**Default:** *None* + +**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.** However, there are some differences between `config.ini` and `config.json`. ### Formatting -*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. `"mainServerId": "94882524378968064"` -* In `config.json`, all numbers (other than IDs) are written without quotes, e.g. `"port": 3000` +_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. `"mainServerId": "94882524378968064"` +- In `config.json`, all numbers (other than IDs) are written without quotes, e.g. `"port": 3000` ### Toggle options + In `config.json`, valid values for toggle options are `true` and `false` (not quoted), which correspond to `on` and `off` in `config.ini`. ### "Accepts multiple values" + Multiple values are specified in `config.json` using arrays: + ```json { - "inboxPermission": [ - "kickMembers", - "manageMessages" - ] + "inboxPermission": ["kickMembers", "manageMessages"] } ``` ### Multiple lines of text + Since `config.json` is parsed using [JSON5](https://json5.org/), multiple lines of text are supported by escaping the newline with a backslash (`\ `): + ```json5 { - "greetingMessage": "Welcome to the server!\ -This is the second line of the greeting." + greetingMessage: "Welcome to the server!\ +This is the second line of the greeting.", } ``` ## Other formats + Loading config values programmatically is also supported. Create a `config.js` in the bot's folder and export the config object with `module.exports`. All other configuration files take precedence, so make sure you don't have both. ## Environment variables + Config options can be passed via environment variables. -To get the name of the corresponding environment variable for an option, convert the option to SNAKE_CASE with periods -being replaced by two underscores and add `MM_` as a prefix. If adding multiple values for the same option, separate the -values with two pipe characters: `||`. +To get the name of the corresponding environment variable for an option, convert the option to SNAKE*CASE with periods +being replaced by two underscores and add `MM*`as a prefix. If adding multiple values for the same option, separate the +values with two pipe characters:`||`. Examples: -* `mainServerId` -> `MM_MAIN_SERVER_ID` -* `commandAliases.mv` -> `MM_COMMAND_ALIASES__MV` -* From: + +- `mainServerId` -> `MM_MAIN_SERVER_ID` +- `commandAliases.mv` -> `MM_COMMAND_ALIASES__MV` +- From: ```ini inboxServerPermission[] = kickMembers inboxServerPermission[] = manageMessages - ``` + ``` To: `MM_INBOX_SERVER_PERMISSION=kickMembers||manageMessages` diff --git a/src/bot.js b/src/bot.js index 58ffbb752..1d12a085f 100644 --- a/src/bot.js +++ b/src/bot.js @@ -14,6 +14,8 @@ const intents = [ "guildMessageTyping", // For typing indicators "directMessageTyping", // For typing indicators "guildBans", // For join/leave notification Ban message + "directMessageReactions", // For user reaction notifications + "guildMessageReactions", // For server reaction notifications // EXTRA INTENTS (from the config) ...config.extraIntents, @@ -36,7 +38,7 @@ const SAFE_TO_IGNORE_ERROR_CODES = [ "ECONNRESET", // Pretty much the same as above ]; -bot.on("error", err => { +bot.on("error", (err) => { if (SAFE_TO_IGNORE_ERROR_CODES.includes(err.code)) { return; } diff --git a/src/data/cfg.jsdoc.js b/src/data/cfg.jsdoc.js index fc38bb9c4..4569c5bb2 100644 --- a/src/data/cfg.jsdoc.js +++ b/src/data/cfg.jsdoc.js @@ -64,6 +64,8 @@ * @property {object} [commandAliases] * @property {boolean} [reactOnSeen=false] * @property {string} [reactOnSeenEmoji="📨"] + * @property {boolean} [notifyOnReaction=false] + * @property {boolean} [notifyOnReactionRemoval=false] * @property {boolean} [createThreadOnMention=false] * @property {string} [blockMessage=null] * @property {string} [timedBlockMessage=null] diff --git a/src/data/cfg.schema.json b/src/data/cfg.schema.json index c012ac184..975889d2c 100644 --- a/src/data/cfg.schema.json +++ b/src/data/cfg.schema.json @@ -334,6 +334,16 @@ "default": "\uD83D\uDCE8" }, + "notifyOnReaction": { + "$ref": "#/definitions/customBoolean", + "default": false + }, + + "notifyOnReactionRemoval": { + "$ref": "#/definitions/customBoolean", + "default": false + }, + "createThreadOnMention": { "$ref": "#/definitions/customBoolean", "default": false @@ -488,10 +498,7 @@ }, "dbType": { - "anyOf": [ - { "const": "sqlite" }, - { "const": "mysql" } - ], + "anyOf": [{ "const": "sqlite" }, { "const": "mysql" }], "default": "sqlite" }, @@ -535,7 +542,13 @@ "allOf": [ { "$comment": "Base required values", - "required": ["token", "mainServerId", "inboxServerId", "logChannelId", "dbType"] + "required": [ + "token", + "mainServerId", + "inboxServerId", + "logChannelId", + "dbType" + ] }, { "$comment": "Make attachmentStorageChannelId required if attachmentStorage is set to 'discord'", diff --git a/src/main.js b/src/main.js index bce3385bc..f8039b72d 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ const bot = require("./bot"); const knex = require("./knex"); const { messageQueue } = require("./queue"); const utils = require("./utils"); -const { formatters } = require("./formatters") +const { formatters } = require("./formatters"); const { createCommandManager } = require("./commands"); const { getPluginAPI, installPlugins, loadPlugins } = require("./plugins"); const ThreadMessage = require("./data/ThreadMessage"); @@ -15,8 +15,11 @@ const blocked = require("./data/blocked"); const threads = require("./data/threads"); const updates = require("./data/updates"); -const { ACCIDENTAL_THREAD_MESSAGES } = require("./data/constants"); -const {getOrFetchChannel} = require("./utils"); +const { + ACCIDENTAL_THREAD_MESSAGES, + THREAD_MESSAGE_TYPE, +} = require("./data/constants"); +const { getOrFetchChannel } = require("./utils"); module.exports = { async start() { @@ -28,20 +31,30 @@ module.exports = { bot.once("ready", async () => { console.log("Connected! Waiting for servers to become available..."); - await (new Promise(resolve => { + await new Promise((resolve) => { const waitNoteTimeout = setTimeout(() => { - console.log("Servers did not become available after 15 seconds, continuing start-up anyway"); + console.log( + "Servers did not become available after 15 seconds, continuing start-up anyway" + ); console.log(""); - const isSingleServer = config.mainServerId.includes(config.inboxServerId); + const isSingleServer = config.mainServerId.includes( + config.inboxServerId + ); if (isSingleServer) { - console.log("WARNING: The bot will not work before it's invited to the server."); + 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."); + 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( + "WARNING: The bot will not function correctly until it's invited to *both* the main server and the inbox server." + ); } } @@ -51,13 +64,13 @@ module.exports = { }, 15 * 1000); Promise.all([ - ...config.mainServerId.map(id => waitForGuild(id)), + ...config.mainServerId.map((id) => waitForGuild(id)), waitForGuild(config.inboxServerId), ]).then(() => { clearTimeout(waitNoteTimeout); resolve(); }); - })); + }); console.log("Initializing..."); initStatus(); @@ -66,7 +79,9 @@ module.exports = { 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( + `Loaded ${pluginResult.loadedCount} plugins (${pluginResult.baseCount} built-in plugins, ${pluginResult.externalCount} external plugins)` + ); console.log(""); console.log("Done! Now listening to DMs."); @@ -77,13 +92,15 @@ module.exports = { try { await thread.recoverDowntimeMessages(); } catch (err) { - console.error(`Error while recovering messages for ${thread.user_id}: ${err}`); + console.error( + `Error while recovering messages for ${thread.user_id}: ${err}` + ); } } }); bot.connect(); - } + }, }; function waitForGuild(guildId) { @@ -91,8 +108,8 @@ function waitForGuild(guildId) { return Promise.resolve(); } - return new Promise(resolve => { - bot.on("guildAvailable", guild => { + return new Promise((resolve) => { + bot.on("guildAvailable", (guild) => { if (guild.id === guildId) { resolve(); } @@ -102,21 +119,31 @@ function waitForGuild(guildId) { function initStatus() { function applyStatus() { - const type = { - "playing": Eris.Constants.ActivityTypes.GAME, - "watching": Eris.Constants.ActivityTypes.WATCHING, - "listening": Eris.Constants.ActivityTypes.LISTENING, - "streaming": Eris.Constants.ActivityTypes.STREAMING, - }[config.statusType] || Eris.Constants.ActivityTypes.GAME; + const type = + { + playing: Eris.Constants.ActivityTypes.GAME, + watching: Eris.Constants.ActivityTypes.WATCHING, + listening: Eris.Constants.ActivityTypes.LISTENING, + streaming: Eris.Constants.ActivityTypes.STREAMING, + }[config.statusType] || Eris.Constants.ActivityTypes.GAME; if (type === Eris.Constants.ActivityTypes.STREAMING) { - bot.editStatus(null, { name: config.status, type, url: config.statusUrl }); + bot.editStatus(null, { + name: config.status, + type, + url: config.statusUrl, + }); } else { bot.editStatus(null, { name: config.status, type }); } } - if (config.status == null || config.status === "" || config.status === "none" || config.status === "off") { + if ( + config.status == null || + config.status === "" || + config.status === "none" || + config.status === "off" + ) { return; } @@ -131,21 +158,31 @@ 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 => { - if (! await utils.messageIsOnInboxServer(bot, msg)) return; + bot.on("messageCreate", async (msg) => { + if (!(await utils.messageIsOnInboxServer(bot, msg))) return; if (msg.author.id === bot.user.id) return; const thread = await threads.findByChannelId(msg.channel.id); - if (! thread) return; + if (!thread) return; - if (! msg.author.bot && (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 (! msg.author.bot && 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 - - const replied = await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false, msg.messageReference); + if (!utils.isStaff(msg.member)) return; // Only staff are allowed to reply + + const replied = await thread.replyToUser( + msg.member, + msg.content.trim(), + msg.attachments, + config.alwaysReplyAnon || false, + msg.messageReference + ); if (replied) msg.delete(); } else { // Otherwise just save the messages as "chat" in the logs @@ -158,11 +195,15 @@ 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) => { const channel = await getOrFetchChannel(bot, msg.channel.id); - if (! (channel instanceof Eris.PrivateChannel)) return; + if (!(channel instanceof Eris.PrivateChannel)) return; if (msg.author.bot) return; - if (msg.type !== Eris.Constants.MessageTypes.DEFAULT && msg.type !== Eris.Constants.MessageTypes.REPLY) return; // Ignore pins etc. + if ( + msg.type !== Eris.Constants.MessageTypes.DEFAULT && + msg.type !== Eris.Constants.MessageTypes.REPLY + ) + return; // Ignore pins etc. if (await blocked.isBlocked(msg.author.id)) { if (config.blockedReply != null) { @@ -174,12 +215,17 @@ 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); + const createNewThread = thread == null; // New 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; + if ( + config.ignoreAccidentalThreads && + msg.content && + ACCIDENTAL_THREAD_MESSAGES.includes(msg.content.trim().toLowerCase()) + ) + return; thread = await threads.createNewThreadForUser(msg.author, { source: "dm", @@ -193,13 +239,20 @@ function initBaseMessageHandlers() { if (createNewThread) { // Send auto-reply to the user if (config.responseMessage) { - const responseMessage = utils.readMultilineConfigValue(config.responseMessage); + const responseMessage = utils.readMultilineConfigValue( + config.responseMessage + ); try { - const postToThreadChannel = config.showResponseMessageInThreadChannel; - await thread.sendSystemMessageToUser(responseMessage, { postToThreadChannel }); + 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}\``); + await thread.postSystemMessage( + `**NOTE:** Could not send auto-response to the user. The error given was: \`${err.message}\`` + ); } } } @@ -213,10 +266,10 @@ function initBaseMessageHandlers() { * 2) If that message was moderator chatter in the thread, update the corresponding chat message in the DB */ bot.on("messageUpdate", async (msg, oldMessage) => { - if (! msg || ! msg.content) return; + if (!msg || !msg.content) return; const threadMessage = await threads.findThreadMessageByDMMessageId(msg.id); - if (! threadMessage) { + if (!threadMessage) { return; } @@ -233,7 +286,9 @@ function initBaseMessageHandlers() { const newContent = msg.content; if (threadMessage.isFromUser()) { - const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`); + const editMessage = utils.disableLinkPreviews( + `**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}` + ); if (config.updateMessagesLive) { // When directly updating the message in the staff view, we still want to keep the original content in the logs. @@ -243,8 +298,16 @@ function initBaseMessageHandlers() { const threadMessageWithEdit = threadMessage.clone(); threadMessageWithEdit.body = newContent; - const formatted = await formatters.formatUserReplyThreadMessage(threadMessageWithEdit); - await bot.editMessage(thread.channel_id, threadMessage.inbox_message_id, formatted).catch(console.warn); + const formatted = await formatters.formatUserReplyThreadMessage( + threadMessageWithEdit + ); + await bot + .editMessage( + thread.channel_id, + threadMessage.inbox_message_id, + formatted + ) + .catch(console.warn); } else { await thread.postSystemMessage(editMessage); } @@ -255,15 +318,14 @@ function initBaseMessageHandlers() { } }); - /** * When a message is deleted... * 1) If that message was in DMs, and we have a thread open with that user, delete the thread message * 2) If that message was moderator chatter in the thread, delete it from the database as well */ - bot.on("messageDelete", async msg => { + bot.on("messageDelete", async (msg) => { const threadMessage = await threads.findThreadMessageByDMMessageId(msg.id); - if (! threadMessage) return; + if (!threadMessage) return; const thread = await threads.findById(threadMessage.thread_id); if (thread.isClosed()) { @@ -284,10 +346,10 @@ 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) => { const channel = await getOrFetchChannel(bot, msg.channel.id); - if (! await utils.messageIsOnMainServer(bot, msg)) return; - if (! msg.mentions.some(user => user.id === bot.user.id)) return; + if (!(await utils.messageIsOnMainServer(bot, msg))) return; + if (!msg.mentions.some((user) => user.id === bot.user.id)) return; if (msg.author.bot) return; if (await utils.messageIsOnInboxServer(bot, msg)) { @@ -304,16 +366,18 @@ function initBaseMessageHandlers() { let content; const mainGuilds = utils.getMainGuilds(); - const staffMention = (config.pingOnBotMention ? utils.getInboxMention() : ""); - const allowedMentions = (config.pingOnBotMention ? utils.getInboxMentionAllowedMentions() : undefined); + const staffMention = config.pingOnBotMention ? utils.getInboxMention() : ""; + const allowedMentions = config.pingOnBotMention + ? utils.getInboxMentionAllowedMentions() + : undefined; const userMentionStr = `**${msg.author.username}** (\`${msg.author.id}\`)`; const messageLink = `https:\/\/discord.com\/channels\/${channel.guild.id}\/${channel.id}\/${msg.id}`; if (mainGuilds.length === 1) { - content = `${staffMention}Bot mentioned in ${channel.mention} by ${userMentionStr}: "${msg.content}"\n\n<${messageLink}>`; + content = `${staffMention}Bot mentioned in ${channel.mention} by ${userMentionStr}: "${msg.content}"\n\n<${messageLink}>`; } else { - content = `${staffMention}Bot mentioned in ${channel.mention} (${channel.guild.name}) by ${userMentionStr}: "${msg.content}"\n\n<${messageLink}>`; + content = `${staffMention}Bot mentioned in ${channel.mention} (${channel.guild.name}) by ${userMentionStr}: "${msg.content}"\n\n<${messageLink}>`; } content = utils.chunkMessageLines(content); @@ -327,26 +391,129 @@ function initBaseMessageHandlers() { // Send an auto-response to the mention, if enabled if (config.botMentionResponse) { - const botMentionResponse = utils.readMultilineConfigValue(config.botMentionResponse); + const botMentionResponse = utils.readMultilineConfigValue( + config.botMentionResponse + ); bot.createMessage(channel.id, { - content: botMentionResponse.replace(/{userMention}/g, `<@${msg.author.id}>`), + content: botMentionResponse.replace( + /{userMention}/g, + `<@${msg.author.id}>` + ), allowedMentions: { - users: [msg.author.id] - } + users: [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) { + 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 <#${channel.id}>`); + const createdThread = await threads.createNewThreadForUser(msg.author, { + quiet: true, + }); + await createdThread.postSystemMessage( + `This thread was opened from a bot mention in <#${channel.id}>` + ); await createdThread.receiveUserReply(msg); } } }); + + /** + * When a user reacts to a DM from the bot, notify staff in the thread + */ + bot.on("messageReactionAdd", async (msg, emoji, reactor) => { + try { + if (!config.notifyOnReaction) return; + + const channel = await getOrFetchChannel(bot, msg.channel.id); + + if (!(channel instanceof Eris.PrivateChannel)) return; + + if (reactor.bot) return; + + const threadMessage = await threads.findThreadMessageByDMMessageId( + msg.id + ); + if (!threadMessage) return; + + const thread = await threads.findById(threadMessage.thread_id); + if (!thread || thread.isClosed()) return; + + if (threadMessage.message_type !== THREAD_MESSAGE_TYPE.TO_USER) return; + + // Format the emoji for display + let emojiDisplay; + if (emoji.id) { + // Custom emoji + emojiDisplay = `<:${emoji.name}:${emoji.id}>`; + } else { + // Unicode emoji + emojiDisplay = emoji.name; + } + + // Get the user's display name + const userName = config.useDisplaynames + ? reactor.globalName || reactor.username + : reactor.username; + + // Post notification in the thread + await thread.postSystemMessage( + `${userName} reacted to message ${threadMessage.message_number} with ${emojiDisplay}` + ); + } catch (err) { + console.error("Error in messageReactionAdd handler:", err); + } + }); + + /** + * When a user removes a reaction from a DM from the bot, notify staff in the thread + */ + bot.on("messageReactionRemove", async (msg, emoji, reactor) => { + try { + if (!config.notifyOnReactionRemoval) return; + + const channel = await getOrFetchChannel(bot, msg.channel.id); + if (!(channel instanceof Eris.PrivateChannel)) return; + + if (reactor.bot) return; + + const threadMessage = await threads.findThreadMessageByDMMessageId( + msg.id + ); + if (!threadMessage) return; + + const thread = await threads.findById(threadMessage.thread_id); + if (!thread || thread.isClosed()) return; + + if (threadMessage.message_type !== THREAD_MESSAGE_TYPE.TO_USER) return; + + // Format the emoji for display + let emojiDisplay; + if (emoji.id) { + // Custom emoji + emojiDisplay = `<:${emoji.name}:${emoji.id}>`; + } else { + // Unicode emoji + emojiDisplay = emoji.name; + } + + const userName = config.useDisplaynames + ? reactor.globalName || reactor.username + : reactor.username; + + // Post notification in the thread + await thread.postSystemMessage( + `${userName} removed reaction ${emojiDisplay} from message ${threadMessage.message_number}` + ); + } catch (err) { + console.error("Error in messageReactionRemove handler:", err); + } + }); } function initUpdateNotifications() {