diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec54e0f8a..ccff7773ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1664 +1,1664 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); -however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. - -# v4.1.2 - -### Fixed -- Members not caching correctly for large servers. ([PR #3365](https://github.com/modmail-dev/Modmail/pull/3365)) - -# v4.1.1 - -### Fixed -- `?msglink` now supports threads with multiple recipients. ([PR #3341](https://github.com/modmail-dev/Modmail/pull/3341)) -- Fixed persistent notes not working due to discord.py internal change. ([PR #3324](https://github.com/modmail-dev/Modmail/pull/3324)) - -### Added -- Support for custom activities with `?activity custom ` ([PR #3352](https://github.com/modmail-dev/Modmail/pull/3352)) - -# v4.1.0 - -Drops support for Python 3.9. Python 3.10 and Python 3.11 are now the only supported versions. - -### Fixed -- GIF stickers no longer cause the bot to crash. -- `?alias make/create` as aliases to `?alias add`. This improves continuity between the bot and its command structure. ([PR #3195](https://github.com/kyb3r/modmail/pull/3195)) -- Loading the blocked list with the `?blocked` command takes a long time when the list is large. ([PR #3242](https://github.com/kyb3r/modmail/pull/3242)) -- Reply not being forwarded from DM. ([PR #3239](https://github.com/modmail-dev/modmail/pull/3239)) -- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) -- Fixed a syntactic error in the close message when a thread is closed after a certain duration. ([PR #3233](https://github.com/modmail-dev/Modmail/pull/3233)) -- Removed an extra space in the help command title when the command has no parameters. ([PR #3271](https://github.com/modmail-dev/Modmail/pull/3271)) -- Corrected some incorrect config help descriptions. ([PR #3277](https://github.com/modmail-dev/Modmail/pull/3277)) -- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) -- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) - -### Added -- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) -- `REGISTRY_PLUGINS_ONLY`, environment variable, when set, restricts to only allow adding registry plugins. ([PR #3247](https://github.com/modmail-dev/modmail/pull/3247)) -- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) -- `STREAM_LOG_FORMAT` and `FILE_LOG_FORMAT` environment variable to set the log format of the stream and file handlers respectively. Possible options are `json` and `plain` (default). ([PR #3305](https://github.com/modmail-dev/Modmail/pull/3305)) -- `LOG_EXPIRATION` environment variable to set the expiration time of logs. ([PR #3257](https://github.com/modmail-dev/Modmail/pull/3257)) -- New registry plugins: [`autoreact`](https://github.com/martinbndr/kyb3r-modmail-plugins/tree/master/autoreact) and [`rename`](https://github.com/Nicklaus-s/modmail-plugins/tree/main/rename). -- Improved join/leave message for multiple servers. - -### Changed -- Repo moved to https://github.com/modmail-dev/modmail. -- Channel name no longer shows `-0` if the user has migrated to the new username system. -- `?note` and `?reply` now allows you to send a sticker without any message. -- Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) -- Discord.py internal logging is now enabled by default. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) -- The confirm-thread-creation dialog now uses buttons instead of reactions. ([PR #3273](https://github.com/modmail-dev/Modmail/pull/3273)) -- `?disable all` no longer overrides `?disable new`. ([PR #3278](https://github.com/modmail-dev/Modmail/pull/3278)) -- Dropped root privileges for Modmail running under Docker. ([PR #3284](https://github.com/modmail-dev/Modmail/pull/3284)) - -### Internal -- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) -- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. -- Various dependencies have been updated to their latest versions. - -# v4.0.2 - -### Breaking - -- Presence intent is now by-default OFF. You can turn it on by setting `ENABLE_PRESENCE_INTENT=true` in the environment variables. - -### Fixed - -- Not having a guild icon no longer raises an exception. ([PR #3235](https://github.com/modmail-dev/modmail/pull/3235)) - - When no icon is set, use the default user icon. -- Resolved an issue where `?logs` doesn't work when the thread has no title. ([PR #3201](https://github.com/modmail-dev/modmail/pull/3201)) -- AttributeError raised when failing to forward a reaction. ([GH #3218](https://github.com/modmail-dev/modmail/issues/3218)) - -### Changed - -- Plain messages no longer forces `()` around the respondent text. ([PR #3234](https://github.com/modmail-dev/modmail/pull/3234)) -- Added workflow to automatically build Docker image ([PR #3232](https://github.com/modmail-dev/modmail/pull/3228)) -- Updated installation guide to reflect new preferred hosting methods - -# v4.0.1 - -This is a hotfix release. - -### Improved - -- Error Messages - -### Fixed - -- Thread cooldown - -# v4.0.0 - -### Breaking - -- Modmail now requires [`Message Content` privileged intent](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots). -- Upgraded to discord.py v2.0 ([internal changes](https://discordpy.readthedocs.io/en/latest/migrating.html), [GH #2990](https://github.com/modmail-dev/modmail/issues/2990)). -- Python 3.8 or higher is required. -- Asyncio changes ([gist](https://gist.github.com/Rapptz/6706e1c8f23ac27c98cee4dd985c8120)) -- Plugin registry is purged and all developers have to re-apply due to breaking changes. - -### Added - -- `use_hoisted_top_role` config to use change how default mod tags work, see `v3.10.0#Added` for details. ([PR #3093](https://github.com/modmail-dev/modmail/pull/3093)) -- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/modmail-dev/modmail/issues/3107)) -- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/modmail-dev/modmail/issues/3083)) -- `?fpareply` and `?fpreply` to reply to messages with variables plainly. -- `use_nickname_channel_name` config to use nicknames instead of usernames for channel names. ([GH #3112](https://github.com/modmail-dev/modmail/issues/3112)) -- `use_random_channel_name` config to use random nicknames vaguely tied to user ID. It is unable to be computed in reverse. ([GH #3143](https://github.com/modmail-dev/modmail/issues/3143)) -- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/modmail-dev/modmail/issues/3122)) -- Select menus for certain paginators. -- `Title` field in `?logs`. ([GH #3142](https://github.com/modmail-dev/modmail/issues/3142)) -- Snippets can be used in aliases. ([GH #3108](https://github.com/modmail-dev/modmail/issues/3108), [PR #3124](https://github.com/modmail-dev/modmail/pull/3124)) -- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/modmail-dev/modmail/issues/3173), [PR #3174](https://github.com/modmail-dev/modmail/pull/3174)) - -### Improved - -- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/modmail-dev/modmail/issues/3048)) -- Use discord relative timedeltas. ([GH #3046](https://github.com/modmail-dev/modmail/issues/3046)) -- Use discord native buttons for all paginator sessions. -- `?help` and `?blocked` paginator sessions now have better multi-page UI. -- Autoupdate now automatically updates pipenv dependencies if possible. - -### Fixed - -- Several minor typos. ([PR #3095](https://github.com/modmail-dev/modmail/pull/3095), [PR #3116](https://github.com/modmail-dev/modmail/pull/3116)) -- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/modmail-dev/modmail/pull/3109)) -- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/modmail-dev/modmail/issues/3091), [PR #3092](https://github.com/modmail-dev/modmail/pull/3092)) -- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/modmail-dev/modmail/issues/3104), [PR #3105](https://github.com/modmail-dev/modmail/pull/3105)) -- Stickers now work in Modmail. -- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/modmail-dev/modmail/issues/3088)) -- Attachments now work on plain replies. ([GH #3102](https://github.com/modmail-dev/modmail/issues/3102)) -- Support LOTTIE stickers. ([GH #3119](https://github.com/modmail-dev/modmail/issues/3119)) -- Editing notes now work. ([GH #3094](https://github.com/modmail-dev/modmail/issues/3094)) -- Commands now work in threads. -- Audit log searching now properly works. -- Old data causing `?blocked` to fail. ([GH #3131](https://github.com/modmail-dev/modmail/issues/3131)) -- Delete channel auto close functionality now works. -- Improved error handling for autoupdate. ([PR #3161](https://github.com/modmail-dev/modmail/pull/3161)) -- Skip loading of already-loaded cog. ([PR #3172](https://github.com/modmail-dev/modmail/pull/3172)) -- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/modmail-dev/modmail/issues/3170), [PR #3178](https://github.com/modmail-dev/modmail/pull/3178)) -- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/modmail-dev/modmail/issues/3179)) - -### Internal - -- Improve regex parsing of channel topics. ([GH #3114](https://github.com/modmail-dev/modmail/issues/3114), [PR #3111](https://github.com/modmail-dev/modmail/pull/3111)) -- Add warning if deploying on a developmental version. -- Extensions are now loaded `on_connect`. -- MongoDB v5.0 clients are now supported. ([GH #3126](https://github.com/modmail-dev/modmail/issues/3126)) -- Bump python-dotenv to v0.20.0, support for python 3.10 -- Bump emoji to v1.7.0 -- Bump aiohttp to v3.8.1 -- Bump lottie to v0.6.11 -- Remove deprecated `core/decorators.py` from v3.3.0 - -# v3.10.5 - -### Internal - -- Locked plugin registry version impending v4 release. - -# v3.10.4 - -### Improved - -- Thread genesis message now shows other recipients. - -### Fixed - -- `?snippet add` now properly blocks command names. - -### Internal - -- Set `LOG_DISCORD` environment variable to the logger level and log discord events. - -# v3.10.3 -This is a hotfix for contact command. - -### Fixed - -- Fixed a bug where contacting with no category argument defaults to the top category. - -# v3.10.2 -This is a hotfix for react to contact. - -### Fixed - -- React to contact now works properly. - -# v3.10.1 - -This is a hotfix for the edit command. - -### Fixed - -- `?edit` now works properly. - -# v3.10.0 - -v3.10 adds group conversations while resolving other bugs and QOL changes. It is potentially breaking to some plugins that adds functionality to threads. - -### Breaking - -- `Thread.recipient` (`str`) is now `Thread.recipients` (`List[str]`). -- `Thread.reply` now returns `mod_message, user_message1, user_message2`... It is no longer limited at a size 2 tuple. - -### Added - -- Ability to have group conversations with up to 5 users. ([GH #143](https://github.com/modmail-dev/modmail/issues/143)) -- Snippets are invoked case insensitively. ([GH #3077](https://github.com/modmail-dev/modmail/issues/3077), [PR #3080](https://github.com/modmail-dev/modmail/pull/3080)) -- Default tags now use top hoisted role. ([GH #3014](https://github.com/modmail-dev/modmail/issues/3014)) -- New thread-related config - `thread_show_roles`, `thread_show_account_age`, `thread_show_join_age`, `thread_cancelled`, `thread_creation_contact_title`, `thread_creation_self_contact_response`, `thread_creation_contact_response`. ([GH #3072](https://github.com/modmail-dev/modmail/issues/3072)) -- `use_timestamp_channel_name` config to create thread channels by timestamp. - -### Improved - -- `?contact` now accepts a role or multiple users (creates a group conversation). ([GH #3082](https://github.com/modmail-dev/modmail/issues/3082)) -- Aliases are now supported in autotrigger. ([GH #3081](https://github.com/modmail-dev/modmail/pull/3081)) - -### Fixed - -- Certain situations where the internal thread cache breaks and spams new channels. ([GH #3022](https://github.com/modmail-dev/modmail/issues/3022), [PR #3028](https://github.com/modmail-dev/modmail/pull/3028)) -- Blocked users are now no longer allowed to use `?contact` and react to contact. ([COMMENT #819004157](https://github.com/modmail-dev/modmail/issues/2969#issuecomment-819004157), [PR #3027](https://github.com/modmail-dev/modmail/pull/3027)) -- UnicodeEncodeError will no longer be raised on Windows. ([PR #3043](https://github.com/modmail-dev/modmail/pull/3043)) -- Notifications are no longer duplicated when using both `?notify` and `subscribe`. ([PR #3015](https://github.com/modmail-dev/modmail/pull/3015)) -- `?contact` now works properly with both category and silent. ([GH #3076](https://github.com/modmail-dev/modmail/issues/3076)) -- `close_on_leave_reason` now works properly when `close_on_leave` is enabled. ([GH #3075](https://github.com/modmail-dev/modmail/issues/3075)) -- Invalid arguments are now properly catched and a proper error message is sent. -- Update database after resetting/purging all plugins. ([GH #3011](https://github.com/modmail-dev/modmail/pull/3011)) -- `thread_auto_close` timer now only resets on non-note and replies from mods. ([GH #3030](https://github.com/modmail-dev/modmail/issues/3030)) -- Deleted messages are now deleted on both ends. ([GH #3041](https://github.com/modmail-dev/modmail/issues/3041), [@JerrieAries](https://github.com/modmail-dev/modmail/commit/20b31f8e8b5497943513997fef788d72ae668438)) -- Persistent notes are now properly deleted from the database. ([GH #3013](https://github.com/modmail-dev/modmail/issues/3013)) -- Modmail Bot is now recognized to have `OWNER` permission level. This affects what can be run in autotriggers. - -### Internal - -- Fix return types, type hints and unresolved references ([PR #3009](https://github.com/modmail-dev/modmail/pull/3009)) -- Reload thread cache only when it's the first on_ready trigger. ([GH #3037](https://github.com/modmail-dev/modmail/issues/3037)) -- `format_channel_name` is now extendable to plugins. Modify `Bot.format_channel_name(bot, author, exclude_channel=None, force_null=False):`. ([GH #2982](https://github.com/modmail-dev/modmail/issues/2982)) - -# v3.9.5 - -### Internal - -- Bumped discord.py to v1.7.3, updated all other packages to latest. -- More debug log files are now kept. -- Resolve SSL errors by retrying without SSL - -# v3.9.4 - -### Fixed - -- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/modmail-dev/modmail/issues/3002), [PR #3003](https://github.com/modmail-dev/modmail/pull/3003)) -- There is now a proper message when trying to contact a bot. - -### Improved - -- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) -- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) - -### Internal - -- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/modmail-dev/modmail/pull/3001)) - -# v3.9.3 - -### Added - -- New config: `use_user_id_channel_name`, when set to TRUE, channel names would get created with the recipient's ID instead of their name and discriminator. - - This is now an option to better suit the needs of servers in Server Discovery - -### Internal - -- Signature of `format_channel_name` in core/util.py changed to: - - `format_channel_name(bot, author, exclude_channel=None, force_null=False)` - - -# v3.9.2 - -### Improved - -- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/modmail-dev/modmail/issues/2977), [PR #2988](https://github.com/modmail-dev/modmail/pull/2988)) - -### Fixed - -- `user_typing` default in the config help is now correct. - -# v3.9.1 - -### Internal - -- `bot.run` now more gracefully handles closing, similar to how discord.py handles it. - - No longer displays `PrivilegedIntentsRequired` exception when exiting when presence intent is disabled. - -# v3.9.0 - -### Breaking - -- `on_thread_initiate` and `on_thread_ready` events now have `thread, creator, category, initial_message` as additional arguments. - -### Fixed - -- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/modmail-dev/modmail/issues/2930), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) -- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/modmail-dev/modmail/issues/2969), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) -- Ghost errors are no longer raised when threads are created using non-organic methods. - -### Internal - -- `thread.reply` now returns (msg_to_user, msg_to_thread). Can be useful in plugins. - -# v3.8.6 - -### Added - -- Ability to install local plugins without relying on git / external sources - - Simply add your extension to plugins/@local, and use `?plugin add local/plugin-name` to load the plugin as normal -- Updated deps for requirements.min.txt and pyproject.toml - -# v3.8.5 - -### Added - -- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/modmail-dev/modmail/issues/2963), [PR #2964](https://github.com/modmail-dev/modmail/pull/2964)) -- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/modmail-dev/modmail/pull/2951)) - -### Fixed - -- Non-master/development branch deployments no longer cause errors to be raised. -- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/modmail-dev/modmail/issues/2961)) - -# v3.8.4 - -This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 where users were not properly cached. - -### Fixed - -- Corrupted data is no longer saved to thread cache. - -# v3.8.2 - -### Fixed - -- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/modmail-dev/modmail/issues/2934)) -- Fix update notifications. -- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/modmail-dev/modmail/issues/2935), [PR #2936](https://github.com/modmail-dev/modmail/pull/2936)) -- IDs in `` commands work now. - -# v3.8.1 - -### Fixed - -- Additional image uploads now render properly. ([PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) -- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/modmail-dev/modmail/issues/2931), [PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) -- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/modmail-dev/modmail/issues/2932)) - -# v3.8.0 - -### Added - -- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/modmail-dev/modmail/issues/2896)) -- `?fareply`, anonymously reply with variables. -- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/modmail-dev/modmail/issues/2905)) -- `disable_updates` config variable to control if the update command should be disabled or not. -- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/modmail-dev/modmail/issues/2907)) -- Support for only the "Server Members" intent. - -### Improved - -- Added command validation to `autotrigger add/edit`. -- `GITHUB_TOKEN` is now no longer required in Heroku setups. -- Clearer error messages on reply fails. - -### Fixed - -- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/modmail-dev/modmail/pull/2902)) -- Level permissions were not checked if command permissions were set. -- Regex autotriggers were not working if term was in the middle of strings. -- `?blocked` now no longers show blocks that have expired. -- Blocked roles will no longer trigger an error during unblock. -- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/modmail-dev/modmail/issues/2916)) -- Finding linked messages in replies work now. ([GH #2920](https://github.com/modmail-dev/modmail/issues/2920), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2920#issuecomment-751530495)) -- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/modmail-dev/modmail/issues/2926)) -- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/modmail-dev/modmail/issues/2910), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2910#issuecomment-753557313)) -- Display an error on [Lottie](https://airbnb.io/lottie/#/) stickers, instead of failing the send. -- `?perms get` now shows role/user names. ([PR #2927](https://github.com/modmail-dev/modmail/pull/2927)) - -### Internal - -- Make use of `git branch --show-current` to retrieve branch instead of using prerelease version check. -- Use discord.py 1.6.0 from PyPi instead of the development clone. - -# v3.7.13 - -### Fixed - -- Notes in config help are now optional. - -# v3.7.12 - -### Fixed - -- Bot was not responding to union errors. -- `?block [reason]` now works in threads. - -# v3.7.11 - -### Improved - -- Role block will now work better with seperate server setups. - -### Fixed - -- Bot not restarting after autoupdate on PM2. - -### Internal - -- Removed unnecessary loggings. - -# v3.7.10 - -### Added - -- Added `update_channel_id` to specify which channel autoupdate notifications were being sent to. -- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/modmail-dev/modmail/issues/2885)) - -# v3.7.9 - -### Fixed - -- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/modmail-dev/modmail/issues/2892), [PR #2893](https://github.com/modmail-dev/modmail/pull/2893)) - -### Improved - -- Clearer plugin debug messages when plugins are disabled - -# v3.7.8 - -### Added - -- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/modmail-dev/modmail/pull/2887)) - -### Fixed -- Permission levels were not respected. -- `perms remove` was not working. -- `logs` and `block` would not recognise users in a seperate server setup. -- Custom emojis were not working with `confirm_thread_creation`. - -### Internal -- Optimised `perms get`, bot should respond faster now. - -# v3.7.7 - -### Added - -- Added updating github fork if GITHUB_TOKEN was provided - -### Fixed - -- Skip blocked roles check if user is not in main guild. - -# v3.7.6 - -### Fixed - -- Autoupdate persists despite errors. -- Mention when normal thread created was not working. ([GH #2883](https://github.com/modmail-dev/modmail/issues/2883)) - -# v3.7.5 - -### Fixed - -- Close on emoji was not working. - -# v3.7.3 - -### Fixed - -- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/modmail-dev/modmail/issues/2881)) - -# v3.7.2 - -### Added - -- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/modmail-dev/modmail/issues/2880)) - -### Fixed - -- `?config set` would not respond if an invalid key was provided. - -# v3.7.1 - -### Fixed - -- Bot will now leave a reaction on the react to contact message. -- Added docstring to selfcontact - -# v3.7.0 - -### Added - -- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/modmail-dev/modmail/issues/2872)) -- Added `react_to_contact_message`, `react_to_contact_emoji` to allow users to create threads by reacting to a message. -- Added `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/modmail-dev/modmail/issues/215)) -- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/modmail-dev/modmail/issues/2763)) -- Added `close_on_leave`, `close_on_leave_reason` to automatically close threads upon recipient leaving the server. ([GH #2757](https://github.com/modmail-dev/modmail/issues/2757)) -- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/modmail-dev/modmail/issues/2833)) -- Added `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny` to allow users to confirm that they indeed want to create a new thread. ([GH #2773](https://github.com/modmail-dev/modmail/issues/2773)) -- Support Gyazo image links in message embeds. ([GH #282](https://github.com/modmail-dev/modmail/issues/282)) -- Added `silent` argument to `?contact` to restore old behaviour. -- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/modmail-dev/modmail/issues/2847)) -- Added a way to block roles. ([GH #2753](https://github.com/modmail-dev/modmail/issues/2753)) -- Added `cooldown_thread_title`, `cooldown_thread_response` to customise message sent when user is on a creating thread cooldown. ([GH #2865](https://github.com/modmail-dev/modmail/issues/2865)) -- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/modmail-dev/modmail/issues/2762)) -- Support stickers and reject non-messages. (i.e. pin_add) -- Added support for thread titles, `?title`. ([GH #2838](https://github.com/modmail-dev/modmail/issues/2838)) -- Added `data_collection` to specify if bot metadata should be collected by Modmail developers. -- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/modmail-dev/modmail/issues/130), [GH #649](https://github.com/modmail-dev/modmail/issues/649)) -- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/modmail-dev/modmail/issues/2842), [PR #2878](https://github.com/modmail-dev/modmail/pull/2878)) -- Autoupdates and `?update` which was removed in v3.0.0 - -### Fixed - -- `?contact` now sends members a DM. -- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/modmail-dev/modmail/issues/2856)) -- Command truncated after && in alias. ([GH #2870](https://github.com/modmail-dev/modmail/issues/2870)) -- `on_plugins_ready` event for plugins works now. - -### Improved - -- Plugins installations have clearer error messages. -- `?move` now does not require exact category names, accepts case-insensitive and startswith names. - -### Internal -- Use enums in config. ([GH #2821](https://github.com/modmail-dev/modmail/issues/2821)) -- `on_thread_close` event for plugins. -- `on_thread_reply` event for plugins. - -# v3.6.2 - -### Fixed - -- Plugins downloading requirements in virtual environments. - - -# v3.6.1 - -### Added - -- Proper error message if privileged intents not explicitly granted to bot. - - -# v3.6.0 - -### Added - -- Added `thread_move_title` to specify title of thread moved embed. -- Mark NSFW logs in log message. ([GH #2792](https://github.com/modmail-dev/modmail/issues/2792)) -- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/modmail-dev/modmail/issues/2828)) -- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/modmail-dev/modmail/issues/2796)) - -### Changed - -- `?move` now consumes rest in category name, which means `?move Long Category Name` works without quotes! -- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/modmail-dev/modmail/pull/2845)) - -### Fixed -- Unicode errors raised during windows selfhosting - -### Internal - -- Bump discord.py version to 1.5.1 -- Explicitly state intents used for connection -- Use `--diff` for black CI instead of `--check` ([GH #2816](https://github.com/modmail-dev/modmail/issues/2816)) - - -# v3.5.0 - -Fixed discord.py issue. - -### Added - -- A confirmation when you manually delete a thread message embed. -- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/modmail-dev/modmail/issues/2803)) -- Added `?plugins reset` command to completely reset everything related to plugins. This will fix some problems caused by broken plugins in the file system. -- Support private GitHub repos for plugins (thanks to @officialpiyush pr#2767) - -### Changed - -- Bump discord.py version to v1.3.3. -- Renamed `bot.owner_ids` to `bot.bot_owner_ids` as the attribute is now defined internally for team support. -- Deleting channel manually will now close the thread. -- Deleting messages will no longer cause the bot to produce warnings. -- Plugins will automatically be removed when it fails to load. -- Moved all database-related activities to clients.py under MongoDBClient, with possible future hook for additional database support. -- `bot.db` is deprecated in favour of `bot.api.db` and will be removed in the future. -- Deprecated `bot.plugin_db.get_partition` in favour of `bot.api.get_plugin_partition` (not final). -- Deprecated `MONGO_URI` config var (but will keep support in the future) in favour of `CONNECTION_URI` and `DATABASE_TYPE`. Right now there is one supported database - "mongodb", which is the default. - -### Fixed - -- Plugins not loading in Windows OS. Now uses proactor event loop for asyncio which should fix this. - - -# v3.4.1 - -### Fixed - -- Masked a bunch of noise errors when deleting messages. -- Added more checks for deleting messages. - -### Breaking - -- `thread_initiate` will be dispatched at the beginning of the setup process. -- `thread_create` is dispatched when the thread is registered as a thread by Modmail (i.e., when channel topic is edited). -- `thread_ready` is dispatched when a thread finishes its setup steps. - - -# v3.4.0 - -### Added - -- Thread cooldown! - - Set via the new config var `thread_cooldown`. - - Specify a time for the recipient to wait before allowed to create another thread. -- Fallback Category (thanks to DAzVise PR#636) - - Automatically created upon reaching the 50 channels limit. - - Manually set fallback category with the config var `fallback_category_id`. -- "enable" and "disable" support for yes or no config vars. -- Added "perhaps you meant" section to `?config help`. -- Multi-command alias is now more stable. With support for a single quote escape `\"`. -- New command `?freply`, which behaves exactly like `?reply` with the addition that you can substitute `{channel}`, `{recipient}`, and `{author}` to be their respective values. -- New command `?repair`, repair any broken Modmail thread (with help from @officialpiyush). -- Recipients get feedback when they edit their messages. -- Chained delete for DMs now comes with a message. -- poetry (in case someone needs it). - -### Changed - -- The look of alias and snippet when previewing. -- The database now saves the message ID of the thread embed, instead of the original message. -- Swapped the position of user and category for `?contact`. -- The log file will no longer grow infinitely large. -- A hard limit of a maximum of 25 steps for aliases. -- `?disable` is now `?disable new`. - -### Fixed - -- Setting config vars using human time wasn't working. -- Fixed some bugs with aliases. -- Fixed many issues with `?edit` and `?delete` and recipient message edit. -- Masked the error: "AttributeError: 'int' object has no attribute 'name'" - - Channel delete event will not be checked until discord.py fixes this issue. -- Chained reaction add/remove. -- Chained delete for thread channels. - -### Internal - -- Commit to black format line width max = 99, consistent with PyLint. -- No longer requires shlex for alias parsing. -- New checks with thread create / find. -- No more flake8 and Travis. - -# v3.3.2 - -### Fixed - -- An oversight with the permission system. - -# v3.3.1 - -### Emergency Patch - -- Fixed a recent issue with an animation KeyError due to Discord API update. - -# v3.3.0 - -### Important - -- Recommend all users to unblock and re-block all blocked users upon updating to this release. - -### Added - -- Three new config vars: - - `enable_plugins` (yes/no default yes) - - When set to no, Modmail will not load plugins. - - `error_color` (color format, defaults discord red) - - The color of error messages. - - `anon_reply_without_command` (yes/no default no) (Thanks to papiersnipper PR#288) - - When set, all non-command messages sent to thread channels are forwarded to the recipient anonymously without the need of `?anonreply`. - - This config takes precedence over `reply_without_command`. -- `?logs responded [user]` command. It will show all the logs that the user has sent a reply. (Thanks to papiersnipper PR#288) - - `user` when not provided, defaults to the user who ran the command. -- Open threads in limbo now auto-close if Modmail cannot find the channel. Modmail does this check every time the bot restarts. -- Ability to disable new threads from getting created. - - `?disable`. -- Ability to fully disable Modmail DM. - - `?disable all`. -- To re-enable DM: `?enable`, and to see the current status: `?isenable`. -- This disabled Modmail interface is customizable with the following config vars: - - `disabled_new_thread_title` - - `disabled_new_thread_response` - - `disabled_new_thread_footer` - - `disabled_current_thread_title` - - `disabled_current_thread_response` - - `disabled_current_thread_footer` -- Ability to delete notes when providing their ID. (Thanks to papiersnipper PR#402) -- Ability to delete log entries. (Thanks to papiersnipper PR#402) - -### Changed - -- `?contact` no longer send the "thread created" message to where the command was run, instead, it's now sent to the newly created thread channel. (Thanks to DAzVise) -- Automatically delete notes command `?note` when there're no attachments attached. -- Embed author links used to be inaccessible in many cases, now: - - `?anonreply`, `?reply`, and `?note` in the thread channel will link to the sender's profile. - - `?reply` and the recipient's DM will also link the sender's profile. - - `?anonreply` in DM channel will link to the first channel of the main guild. -- Plugins update (mostly internal). - - `git` is no longer used to install plugins; it now downloads through zip files. - - `?plugins enabled` renamed to `?plugins loaded` while `enabled` is still an alias to that command. - - Reorganized plugins folder structure. - - Logging / plugin-related messages change. - - Updating one plugin will not update other plugins; repositories no longer separate plugins, but the plugin name itself. -- The help command is in alphabetical order grouped by permissions. -- Notes are no longer always blurple; it's set to `MAIN_COLOR` now. -- Added `?plugins update` for updating all installed plugins. -- Reintroduce flake8 and use bandit for security issues detection. -- Add Travis checks for 3.6 in Linux and 3.7 for macOS and Windows. -- Debug logs not logs eval commands. -- Presence updates 30 minutes instead of 45 now. -- Fixed an assortment of problems to do with `?block`. -- Existing aliases can be used when creating new aliases. (Thanks to papiersnipper PR#402) - -### Internal - -- Reworked `config.get` and `config.set`, it feeds through the converters before setting/getting. - - To get/set the raw value, access through `config[]`. -- The prerelease naming scheme is now `x.x.x-devN`. -- `trigger_typing` has been moved to `core.utils.trigger_typing`, the original location is deprecated. -- Simpler status and activity logic. -- New logging logic. - -# v3.2.2 - -Security update! - -### Important - -- Supporter permission users used to be able to "hack" snippets to reveal all your config vars, including your token and MongoURI. -- Implemented some changes to address this bug: - - All customizable variables used in snippets, close messages, etc., using the `{}` syntax, now forbids chaining two or more attributes and attributes that start with `_`. -- We advise you to update to this version. -- If you felt your credentials had been leaked, consider changing your bot token / MongoURI. - -# v3.2.1 - -### Fixed - -- Can't set hex for main_color, recipient_color, etc. - -### Added - -- Discord colors by default when addressing them by names. - -# v3.2.0 - -### Added - -- Ability to change permission levels of individual commands. - - See `?permissions override` for more information. -- `thread_move_notify` and `thread_move_response` to notify recipients if a thread is moved. (Thanks to Flufster PR#360) -- IDs of messages sent to Modmail are now viewable. (Thanks to Flufster PR#360) - -### Fixed - -- `?help `, will return `Perhaps you meant: `, now it's fixed. - - For example, `?help add` used to return `Perhaps you meant: add`, now it wouldn't do this. -- Aliases and Permissions command names are always saved lowercase now. -- An improved Dockerfile. - -### Internal - -- Use regex to parse Changes, Added, Fixed, etc. and description. -- Adds `PermissionLevel.INVALID` when commands don't have a permission level. - -# v3.1.1 - -### Fixed - -- An issue when reading `config_help.json` for Windows users due to an encoding problem. - -# v3.1.0 - -### Breaking - -- `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. -- Truthy and falsy values for binary configuration variables are now interpreted respectfully. -- `LOG_URL_PREFIX` cannot be set to "NONE" to specify no additional path in the future, "/" is the new method. - -### Added - -- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. -- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when a thread is auto-closed. -- New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. -- Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. - - For users having trouble with pipenv or any other reason. -- Multi-step alias, see `?help alias add`. Public beta testing might be unstable. -- Misc commands without cogs are now displayed in `?help`. -- `?help` works for alias and snippets. -- `?config help ` shows a help embed for the configuration. -- Support setting permissions for subcommands. -- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` subcommands. - -### Changes - -- `thread_auto_close_response` has a configurable variable `{timeout}`. -- `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. -- `colorama` is no longer a necessity; this is due to some unsupported OS. -- Changelog command can now take a version argument to jump straight to the specified version. -- `?plugin enabled` results are now sorted alphabetically. -- `?plugin registry` results are now sorted alphabetically, helps users find plugins more easily. -- `?plugin registry page-number` plugin registry can specify a page number for quick access. -- A reworked interface for `?snippet` and `?alias`. - - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). - - Add an `?alias raw ` command for displaying the raw content of an alias (escaped markdown). -- The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. -- Removed unclear `rm` alias for some `remove` commands. -- Paginate `?config options`. -- All users configured with a permission level higher than REGULAR has access to the main Modmail category. - - Category overrides also changes when a level is removed or added to a user or role. -- `@everyone` is now accepted for `?perms add`. - -### Fixes - -- `?notify` no longer carries over to the next thread. -- `discord.NotFound` errors for `on_raw_reaction_add`. -- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when the user is blocked. -- Better `?block` usage message. -- Resolved errors when mods sent messages after a thread is closed somehow. -- Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. -- When creating snippets and aliases, it now checks if other snippets/aliases with the same name exist. -- Modmail looked for `config.json` in the wrong directory. - -### Internal - -- Removed supporting code for GitHub interaction. -- All default config values moved to `core/config.py`. -- `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. -- Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. -- Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. -- Bumped discord.py version to 1.2.3. -- Use discord tasks for metadata loop. -- More debug based logging. -- Reduce redundancies in `?perms` sub commands. -- paginator been split into `EmbedPaginatorSession` and `MessagePaginatorSession`, both subclassing `PaginatorSession`. - -# v3.0.3 - -### Added - -- New commands, `?alias edit ` and `?snippets edit `. - - They can be used to edit aliases and snippets, respectively. - -# v3.0.2 - -### Added - -- A new command, `?blocked whitelist `, this command prevents users from getting blocked by any means. - -### Changed - -- Removed some aliases from `?oauth`. - -# v3.0.1 - -### Fixed - -- Many bugs with `thread_auto_close`. - -# v3.0.0 - -### Added - -- `?sponsors` command will list sponsors. -- An alert will now be sent to the log channel if a thread channel fails to create. This could be due to a variety of problems such as insufficient permissions, or the category channel limit is met. -- Threads will close automatically after some time when `thread_auto_close` is set. -- Custom closing messages can be configured with `thread_auto_close_response`. - -### Breaking Changes - -- Removed auto-update functionality and the `?update` command in favor of the [Pull app](https://github.com/apps/pull). - -Read more about updating your bot [here](https://github.com/modmail-dev/modmail/wiki/updating) - -### Changed -- Channel names now can contain Unicode characters. -- Debug logs are now located in a different file for each bot. (Internal change) -- Default cogs always appear first in the help command now. - -### Fixed -- Editing notes now work, minor bug with edit command is fixed. -- Bug in the `?oauth` command where the response message fails to send when an ID is provided. -- Plugin requirement installation now works in virtual environments - - -# v2.24.1 - -### Fixed - -Fixed a bug with branches and `?plugin update`. - -# v2.24.0 - -### Added - -Branch support for `?plugin add` and in the registry. Typically for developers. - -# v2.23.0 - -### Added - -Added a "Mutual servers" field to the genesis embed if: -a) The user is not in the main guild. -b) The user shares more than one server with the bot. - -### Changed - -Notes with the `?note` command are now automatically pinned within the thread channel. - -# v2.22.0 - -### Added - -Added a 🛑 reaction to the paginators to delete the embed. - -### Fixed - -`?blocked` is now paginated using reactions. This fixes [#249](https://github.com/modmail-dev/modmail/issues/249) - -# v2.21.0 - -### Added - -New `?plugin registry compact` command which shows a more compact view of all plugins. - -# v2.20.2 - -### Plugin Registry - -Plugin developers can now make a PR to include their plugin in the `plugin registry` command. -Add your plugin in the `plugins/registry.json` file in the main repository. - -### Changed - -`?debug` command now shows the most recent logs first. (Starts at the last page) - -# v2.20.1 - -### What's new? - - - New error message when using thread-only commands outside of threads. - - `?unnotify`, ability to undo `?notify`. - - `?notify` and `?subscribe` now accepts other users. - -### Changes - -This update contains mostly internal changes. - - Implemented support for the new discord.py v1.1.1. - - Improved help text for most commands. - - Completely revamped help command, few users changes. - - Removed ABC (internal). - -# v2.20.0 - -### What's new? - -New `?oauth whitelist` command, which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app, check the logviewer [repo](https://github.com/modmail-dev/logviewer). - -# v2.19.1 - -### Changed - -- Ability to force an update despite having the same version number. Helpful to keep up-to-date with the latest GitHub commit. - - `?update force`. -- Plugin developers now have a new event called `on_plugin_ready`; this is a coroutine and is awaited when all plugins are loaded. Use `on_plugin_ready` instead of `on_ready` since `on_ready` will not get called in plugins. - -# v2.19.0 - -### What's new? - -- New config variable `guild_age`, similar to `account_age`, `guild_age` sets a limit as to how long a user has to wait after they joined the server to message Modmail. -- `guild_age` can be set the same way as `account_age`. - -# v2.18.5 - -Fix help command bug when using external plugins. - -# v2.18.4 - -Fix the teams permission bug. - -# v2.18.2 - -### Changed - -Commands now have better error messages. Instead of sending the help message for a command when an argument fails to be converted, the bot now says like "User 'bob' not found" instead. - -# v2.18.1 - -Un-deprecated the `OWNERS` config variable to support Discord developer team accounts. - -# v2.18.0 - -### New Permissions System - -- A brand new permission system! Replaced the old guild-based permissions (i.e., manage channels, manage messages), with the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. -- There are five permission levels: - - Owner [5] - - Administrator [4] - - Moderator [3] - - Supporter [2] - - Regular [1] - -### Usage - -You may add a role or user to a permission group through any of the following methods: -- `?permissions add level owner @role` -- `?permissions add level supporter member-name` -- `?permissions add level moderator everyone` -- `?permissions add level moderator @member#1234` -- `?permissions add level administrator 78912384930291853` - -The same applies to individual commands permissions: -- `?permissions add command command-name @member#1234` -- and the other methods listed above. - -To revoke permission, use `remove` instead of `add`. - -To view all roles and users with permission for a permission level or command do: -- `?permissions get command command-name` -- `?permissions get level owner` - -By default, all newly set up Modmail will have `OWNER` set to the owner of the bot, and `REGULAR` set to @everyone. - -### Breaking - -When updating to this version, all prior permission settings with guild-based permissions will be invalidated. You will need to convert to the above system. -`OWNERS` will also get removed; you will need to set owners through `?permissions add level owner 212931293123129` or any way listed above. - -### New Command - -- A `?delete` command, which is an alternative to manually deleting a message. This command is created to no longer require "manage messages" permission to recall thread messages. - -### Changed - -- The help message no longer conceals inaccessible commands due to check failures. - -# v2.17.2 - -### Changed - -- Logs search command will search through log keys as well now. -- For example, `?logs search e7499e82f8ff`. - -# v2.17.1 - -### What's new? - -Stricter fallback genesis embed search. - -### Changed - -How Modmail checks if a channel is a thread: - -1. The bot first checks if the channel topic is in the format `User ID: XXXX`, this means it is a thread. -2. If a channel topic is not found, the bot searches through the message history of a channel to find the thread creation embed. This step should never yield a thread for an average user. Still, in the case of another bot messing up the channel topic (happened to a user before), this extra step was added. - -# v2.17.0 - -### What's new? - -Added a config option `reply_without_command`, which, when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) - -To enable this functionality, do `?config set reply_without_command true` and to disable it, use `?config del reply_without_command`. - -### Changed - -The `move` command now only requires `manage_messages` perms instead of `manage_channels`. - -# v2.16.1 - -### Fixed - -An issue where a scheduled close would not execute over a long time if the recipient no shares any servers with the bot. - -# v2.16.0 - -### Changed - -All support for Modmail API (api.modmail.tk) has terminated. -If you're still using api.modmail.tk, you will need to migrate to the self-hosted database -option ASAP. Your bot will not work unless you switch to the self-hosted option. Refer to the installation tutorial for information regarding self-hosted Modmail. - -If a member leaves/joins (again) while they are a recipient of a thread, a message will be sent to notify you that this has occurred. - -# v2.15.1 - -### Fixed - -Emergency patch of a SyntaxError. - -# v2.15.0 - -### What's new? - -Added the ability to change the default close message via the introduction of two config variables. - -- `thread_close_response` - when someone closes the thread. -- `thread_self_close_response` - when the recipient closes their own thread. - -They will be provided by string variables that you can incorporate into them: - -- `closer` - the user object that closed the thread. -- `logkey` - the key for the thread logs, e.g. (`5219ccc82ad4`) -- `loglink` - the full link to the thread logs, e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) - -Example usage would be: ``?config set thread_close_message {closer.mention} closed the thread, here is the link to your logs: [**`{logkey}`**]({loglink})`` - -# v2.14.0 - -### What's new? - -Added the ability to enable the recipient to close their own threads. This takes place in the form of a reaction that the user can click to close their thread. This functionality is now enabled by default. - -To disable this, do `?config set disable_recipient_thread_close true` - -### More Customisability! - -More config variables have been added that you can edit. - -- `close_emoji` - the emoji that the user can click on to close a thread. Defaults to a lock (🔒) - -You now have complete control of the look of the thread creation and close embeds the users see. - -- `thread_creation_title` - the title of the embed. Defaults to 'Thread Created' -- `thread_creation_footer` - the footer text in the embed. Defaults to 'Your message has been sent...' -- `thread_close_title` - the title of the embed. Defaults to 'Thread Closed' -- `thread_close_footer` - the footer text in the embed. Defaults to 'Replying will create a new thread' - -# v2.13.13 - -### What's new? - -Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages Modmail. - -You can do this via `?config set sent_emoji disable`. - -### Fixed - -The bot now handles having too many roles to show in the thread created embed. - -# v2.13.12 - -### What's new? -Added image link in title in case discord fails to embed an image. - -# v2.13.11 - -### What's new? -- Introduced a new configuration variable `account_age` for setting a minimum account creation age. - - Users blocked by this reason will be stored in `blocked` along with other reasons for being blocked. - - `account_age` needs to be an ISO-8601 Duration Format (examples: `P12DT3H` 12 days and 3 hours, `P3Y5M` 3 years and 5 months `PT4H14M999S` 4 hours 14 minutes and 999 seconds). https://en.wikipedia.org/wiki/ISO_8601#Durations. - - You can set `account_age` using `config set account_age time` where "time" can be a simple human-readable time string or an ISO-8601 Duration Format string. - -### Changed -- `?block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. -- `?block`, like `?close`, now supports a block duration (temp blocking). - -# v2.13.10 - -### Fixed -- Fixed an issue where status and activity do not work if they were modified wrongly in the database. - - This was primarily an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. - -# v2.13.9 - -### Fixed -- Fixed a bug where an error was raised when a message with received during a scheduled closure. - -# v2.13.8 - -### Fixed -- A bug where a thread was blocked from sending messages when multiple images were uploaded, due to a typo. - -### Changed -- Uses https://hasteb.in instead of https://hastebin.com for `?debug hastebin`. - -# v2.13.7 - -### What's new? -- The ability to enable typing interactions. - - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". Use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. -- New `status` command, change the bot's status to `online`, `idle`, `dnd`, `invisible`, or `offline`. - - To remove the status (change it back to default), use `status clear`. - - This also introduces a new internal configuration variable: `status`. Possible values are `online`, `idle`, `dnd`, `invisible`, and `offline`. - -### Changed -- The internals for `activity` has drastically changed to accommodate the new `status` command. - -# v2.13.6 - -### Fixed -- Fixed a bug in the contact command where the response message did not send. - -# v2.13.5 - -### What's new? -- You will no longer need to view your bot debug logs from Heroku. `debug` will show you the recent logs within 24h through a series of embeds. - - If you don't mind your data (may or may not be limited to user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. - - `debug clear` will clear the locally cached logs. - - Local logs are automatically erased at least once every 27h for bots hosted on Heroku. - -### Fixed -- Will no longer show `Unclosed client session` and `Task was destroyed, but it is pending!` when the bot terminates. -- `thread.create` is now synchronous so that the first message sent can be queued to be sent as soon as a thread is created. - - This fixes a problem where if multiple messages are sent in quick succession, the first message sent (which triggers the thread creation) is not sent in order. -- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled, and the bot will send a message saying so. - -### Changed -- `print` is replaced by logging. - - New environment variable introduced: `LOG_LEVEL`. - - This influences the number of messages received in Heroku logs. - - Possible options, from least to most severe, are: `INFO`, `DEBUG`, `WARNING`, `ERROR`, `CRITICAL`. - - In most cases, you can ignore this change. -- `on_error` and `CommandNotFound` are now logged. - -# v2.13.4 - -### Changed -- `?contact` no longer raise a silent error in Heroku logs when the recipient is a bot. Now Modmail responds with an error message. - -# v2.13.3 - -### Fixed -- Fixed a typo in the config options. - -# v2.13.2 - -### Fixed -- Installing `requirements.txt` files in plugins. - -# v2.13.1 - -### Fixed -- Reading `requirements.txt` files in plugins. - -# v2.13.0 - -### What's new? -- Plugins: - - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit? Plugins are your go-to! - - [Creating Plugins Documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). - -# v2.12.5 - -### Fixed - -- `config del` command will now work correctly on self-hosted DB bots. - -# v2.12.4 - -### What's new? -- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/modmail-dev/modmail/blob/master/core/_color_data.py). - - Named colors can be set the same way as hex. But this can only be done through `config set`, which means database modifications will not work. - - For example: `config set main_color yellowish green`. -- New config var `main_color` allows you to customize the main Modmail color (as requested by many). Defaults to Discord `blurple`. - -# v2.12.3 - -### Fixed -- Patched a bug where `logs` sub-commands were accessible by anyone. -- Patched a bug where an error was raised when a thread is open where the recipient left the server. - -Huge thanks to Sasiko for reporting these issues. - -# v2.12.2 - -### Fixed -- Fixed a bug in self-hosted `?update` command. - -# v2.12.1 - -### Changed - -- `logs search` now also searches usernames present in thread logs. - -# v2.12.0 - -### Important -**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is because we are providing free service without getting anything in return. Thus we do not have the resources to scale to accommodate more users. -We recommend using your own database for logs. In the future you will soon get a `backup` command so you can download all your pre-existing data and migrate to your own database.** - -### Changed -- A lot of painful code cleanup, which is good for us (the developers), but shouldn't affect you. -- The appearance of the `?logs` command. It should be clearer with better info now. -- Bot owners get access to all commands regardless of server permissions. -- Blocked users no longer receive a message, only the blocked emoji will be sent. - -### What's new? -- **Note:** The following commands only work if you are self-hosting your logs. We recommend you to use your own database. -- Log search queries, in the form of two new commands. -- `logs search [query]` - this searches all log messages for a query string. -- `logs closed-by [user]` this returns all logs closed by a particular user - -### Fixed -- `activity listening to music` no longer results in two "to"s ("listening to to music"). - - This may require you to change your activity message to accommodate this fix. -- A problem where `main_category_id` and `log_channel_id` weren't updated when their corresponding channel or category get deleted. - -# v2.11.0 - -### What's new? -- `loglink` command, returns the log link for the current thread. - -# v2.10.2 - -### Changed -- Your logs now track and show edited messages. - -# v2.10.1 - -### Changed -- Use reply author's top role for the mod tag by default. - -# v2.10.0 - -### What's new? -- `anonreply` command to anonymously reply to the recipient. -The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message) — the avatar defaults to the guild icon URL. However, you can change both of these via the `anon_username`, `anon_avatar_url`, and `anon_tag` config variables. - -### Changed -- Your bot now logs all messages sent in a thread channel, including discussions that take place. You can now toggle to view them in the log viewer app. - -# v2.9.4 - -### Fixed -- Small bug due to a typo. - -# v2.9.3 - -### Changed -- Forgot to enable custom embed colors. - -### What's new? -- Ability to set a custom `mod_tag` (the text in the footer of the mod reply embed, which by default says "Moderator") - -# v2.9.2 - -### Changed -- Improve format of thread info embed. Slightly cleaner and simpler now. -- All commands are now blurple instead of green. - -### Fixed -- Bug where the close command wouldn't work if you didn't configure a log channel. - -### What's new? -- Ability to set your own custom `mod_color` and `recipient_color` for the thread message embeds. - -# v2.9.1 - -### Changed -- Changed order of arguments for `contact`. This is so that you can use aliases to their full potential. -- For example: - - `contact "Recruitment Category" @somedude` -- You can add an alias by doing: `alias add recruit contact "Recruitment Category"`. - - Now you can use the alias via: `recruit @somedude`. - -# v2.9.0 - -### What's new? -- New command `note` will add a system message to your thread logs. - - This is useful for noting the context of a conversation. - -# v2.8.1 - -### Fixed -- Fixed bug where thread logs were getting duplicated when using the `contact` command. -- Fixed bug where the wrong key was used for logs, which caused some `log` command log links to point to an HTTP 404 Not Found. - - A minor oversight from commit 1ba74d9. - -# v2.8.0 - -### Changed -- Major improvement in viewing thread logs. -- Log links are now rendered in HTML instead of plain text. - -# v2.7.2 - -### What's new? -- `config options` command to see a list of valid config variables that you can modify. - -### Security -Thread channels will now default to being private (`@everyone`'s read message perms set to `false`). - - If the thread creation category could not be resolved. - - This will save you from some trouble if, for whatever reason, your configuration gets messed up. - -# v2.7.1 - -### Changed - -- All reference to "modmail" / "Mod Mail" / "ModMail" are changed to "Modmail". -- `log_channel_id` is now part of the config upon `setup`. -- Added the ability to set where threads are created using the `main_category_id` configuration option. - -### Note - -- If your Modmail bot was set up a long time ago, you might experience an issue where messages were sent outside of the category. - - To fix this, set `main_category_id` to the ID of the Modmail category. - -# v2.7.0 - -### Changed - -- `move` command now syncs thread channel permissions with the destination category. -- `contact` command now supports an optional category argument (where the thread channel will be created). - -# v2.6.3 - -### Fixes -- Fixed small issue with finding threads. - -# v2.6.2 - -### Fixes -- Fixed log URLs for self-hosting users. - -# v2.6.1 - -### Fixed -- Replaced the testing `API_BASE_URL` with the actual URL. - -# v2.6.0 - -### What's new? -- `threads` is now a default alias to `logs`. - -### Changed -- Log URLs are moved to their own collection. -- Log URLs are now `https://logs.modmail.tk/LOGKEY`, no more numbers before the log key. -- We still support the numbers to not break everyone's URLs so quickly, but both work at the moment. -- This is a huge change to the backend logging, and there might be migration errors. If so, please contact us in our [Discord server](https://discord.gg/2fMbf2N). - -# v2.5.2 - -### Fixes -- Fixed a bug where requests sent when the API was not ready. - -# v2.5.1 - -### Fixes -- Emergency patch to save configs. - -# v2.5.0 - -### Background -- Bots hosted by Heroku restart at least once every 27 hours. -- During this period, local caches will be deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. -- [PR #135](https://github.com/modmail-dev/modmail/pull/135) - -### Changed -- Created a new internal config var: `closures`. -- Store closure details into `closures` when the scheduled time isn't "now". - - Loaded upon bot restart. - - Deleted when a thread is closed. -- Use `call_later()` instead of `sleep()` for scheduling. - -# v2.4.5 - -### Fixed -Fixed activity setting due to flawed logic in `config.get()` function. - -# v2.4.4 - -### Fixed -Fixed a bug in the `?activity` command where it would fail to set the activity on bot restart if the activity type was `playing`. - -# v2.4.3 - -### Changed - - Moved self-hosted log viewer to a separate repo. - -# v2.4.2 - -### What's new? -- Ability to set your own Twitch URL for `streaming` activity status. - -# v2.4.1 - -### Fixed -- Small bug in `?activity` command. - -# v2.4.0 - -### What's new? -- Added the `?activity` command for setting the activity -- [PR #131](https://github.com/modmail-dev/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). - -### Removed -- Removed the deprecated `status` command. -- This also means you will have to reset your bot status with the `?activity` command, as the `?status` command was removed. - -# v2.3.0 - -### What's new? -- Ability to self-host logs. - -### Changed -- Improved format for log channel embeds. -- Roles are now comma-separated in info embed. -- This only applies to separate server setups. - -### Fixed -- Bug in subscribe command. - - It will now unsubscribe after a thread is closed. - -# v2.2.0 - -### What's new? -- Notify command `notify [role]`. - - Notify a given role or yourself to the next thread message received. - - Once a thread message is received, you will be pinged once only. - -- Subscribe command `sub [role]` / `unsub [role]`. - - Subscribes yourself or a given role to be notified when thread messages are received. - - You will be pinged for every thread message received until you unsubscribe. - -### Changed -- Slightly improved log channel message format. - -# v2.1.1 - -### Fixed -- Small bug in `close` command. - -# v2.1.0 - -### What's new? -- Ability to set a custom thread-creation-response message. - - Via `config set thread_creation_response [message]`. - -### Changed -- Improve `?logs` command format. -- Improve thread log channel messages to have more relevant info. -- Improve close command. - - You can now close the thread after a delay and use a custom thread close message. - - You also now can close a thread silently. - -# v2.0.10 - -### Security -- Fix a bug where blocked users were still able to message Modmail. - -# v2.0.9 - -### What's new? -- Support for custom blocked and sent emoji. -- Use the `config set blocked_emoji [emoji]` or `sent_emoji` commands. - -### Fixes -- Support multiple images and file attachments in one message. -- This is only possible on mobile, so its good to handle it in code. - -# v2.0.8 - -### What's new? -- Added the ability to use your own log channel. - - You can do this via the `config set log_channel_id ` command. -- Added the ability to use your own main inbox category. - - You can do this via the `config set main_category_id ` command. - -### Changed -- You can now supply a reason when blocking a user. -- Blocked users are now stored in the database instead of in the channel topic. - - This means you can delete the top channel in the Modmail category now (after migrating the currently blocked users). - -# v2.0.7 - -### What's new? -- Added a `changelog` command to view the bot's changelog within discord. - -### Changed -- `update` command now shows the latest changes directly from CHANGELOG.md. -- Auto-update messages also show the latest changes from the GitHub repo. -- Removed the "latest changes" section from the `about` command. - -# v2.0.6 - -### Fixed -- Fix logs sending duplicated thread close logs. -- The bot will now tell you that a user is no longer in the server when you try to reply to a thread. - - Before this, it looked like you replied to the thread, but in reality, the message was not sent. - -# v2.0.5 - -### Changed -- `alias` command now checks if you are adding a valid alias-command combo. -- Manually deleting a channel will now correctly close the thread and post logs. - -# v2.0.4 - -### Fixed -- Fixed a one-off bug where the channel topic disappears, but Modmail operations should continue. -- Fixed `linked_message_id` issues. - -# v2.0.3 - -### Fixed -- The thread creation embed now shows the correct number of past logs. -- If using a separate server setup, roles in the info embed now are shown as names instead of mentions. - - This is because you can't mention roles across servers. - -# v2.0.2 - -### Security -- Made the `logs` command require "manage messages" permissions to execute. - - Before this patch, anyone could use the `logs` commands. - -# v2.0.1 - -### Changed -- Improved `block` / `unblock` commands. - - They now take a more comprehensive range of arguments: usernames, nicknames, mentions, and user IDs. - -### Fixed -- Setup command now configures permissions correctly so that the bot will always be able to see the main operations category. - -# v2.0.0 - -This release introduces the use of our centralized [API service](https://github.com/modmail-dev/webserver) to enable dynamic configuration, auto-updates, and thread logs. -To use this release, you must acquire an API token from https://modmail.tk. -Read the updated installation guide [here](https://github.com/modmail-dev/modmail/wiki/installation). - -### Changed -- Stability improvements through synchronization primitives. -- Refactor thread management and code. -- Update command now uses `api.modmail.tk`. -- `contact` command no longer tells the user you messaged them 👻 - -### Fixed -- `status` command now changes playing status indefinitely. - -### What's new? -- Dynamic `help` command (#84). -- Dynamic configuration through `api.modmail.tk`. -- Thread logs via `logs.modmail.tk` (#78). - - `log` command added. -- Automatic updates (#73). -- Dynamic command aliases and snippets (#86). -- Optional support for using a separate guild as the operations center (#81). -- NSFW Command to change channels to NSFW (#77). - -### Removed -- Removed `archive` command. - - Explanation: With thread logs (that lasts forever), there's no point in archiving. +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); +however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. + +# v4.1.2 + +### Fixed +- Members not caching correctly for large servers. ([PR #3365](https://github.com/modmail-dev/Modmail/pull/3365)) + +# v4.1.1 + +### Fixed +- `?msglink` now supports threads with multiple recipients. ([PR #3341](https://github.com/modmail-dev/Modmail/pull/3341)) +- Fixed persistent notes not working due to discord.py internal change. ([PR #3324](https://github.com/modmail-dev/Modmail/pull/3324)) + +### Added +- Support for custom activities with `?activity custom ` ([PR #3352](https://github.com/modmail-dev/Modmail/pull/3352)) + +# v4.1.0 + +Drops support for Python 3.9. Python 3.10 and Python 3.11 are now the only supported versions. + +### Fixed +- GIF stickers no longer cause the bot to crash. +- `?alias make/create` as aliases to `?alias add`. This improves continuity between the bot and its command structure. ([PR #3195](https://github.com/kyb3r/modmail/pull/3195)) +- Loading the blocked list with the `?blocked` command takes a long time when the list is large. ([PR #3242](https://github.com/kyb3r/modmail/pull/3242)) +- Reply not being forwarded from DM. ([PR #3239](https://github.com/modmail-dev/modmail/pull/3239)) +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) +- Fixed a syntactic error in the close message when a thread is closed after a certain duration. ([PR #3233](https://github.com/modmail-dev/Modmail/pull/3233)) +- Removed an extra space in the help command title when the command has no parameters. ([PR #3271](https://github.com/modmail-dev/Modmail/pull/3271)) +- Corrected some incorrect config help descriptions. ([PR #3277](https://github.com/modmail-dev/Modmail/pull/3277)) +- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) +- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) + +### Added +- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) +- `REGISTRY_PLUGINS_ONLY`, environment variable, when set, restricts to only allow adding registry plugins. ([PR #3247](https://github.com/modmail-dev/modmail/pull/3247)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `STREAM_LOG_FORMAT` and `FILE_LOG_FORMAT` environment variable to set the log format of the stream and file handlers respectively. Possible options are `json` and `plain` (default). ([PR #3305](https://github.com/modmail-dev/Modmail/pull/3305)) +- `LOG_EXPIRATION` environment variable to set the expiration time of logs. ([PR #3257](https://github.com/modmail-dev/Modmail/pull/3257)) +- New registry plugins: [`autoreact`](https://github.com/martinbndr/kyb3r-modmail-plugins/tree/master/autoreact) and [`rename`](https://github.com/Nicklaus-s/modmail-plugins/tree/main/rename). +- Improved join/leave message for multiple servers. + +### Changed +- Repo moved to https://github.com/modmail-dev/modmail. +- Channel name no longer shows `-0` if the user has migrated to the new username system. +- `?note` and `?reply` now allows you to send a sticker without any message. +- Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) +- Discord.py internal logging is now enabled by default. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- The confirm-thread-creation dialog now uses buttons instead of reactions. ([PR #3273](https://github.com/modmail-dev/Modmail/pull/3273)) +- `?disable all` no longer overrides `?disable new`. ([PR #3278](https://github.com/modmail-dev/Modmail/pull/3278)) +- Dropped root privileges for Modmail running under Docker. ([PR #3284](https://github.com/modmail-dev/Modmail/pull/3284)) + +### Internal +- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. +- Various dependencies have been updated to their latest versions. + +# v4.0.2 + +### Breaking + +- Presence intent is now by-default OFF. You can turn it on by setting `ENABLE_PRESENCE_INTENT=true` in the environment variables. + +### Fixed + +- Not having a guild icon no longer raises an exception. ([PR #3235](https://github.com/modmail-dev/modmail/pull/3235)) + - When no icon is set, use the default user icon. +- Resolved an issue where `?logs` doesn't work when the thread has no title. ([PR #3201](https://github.com/modmail-dev/modmail/pull/3201)) +- AttributeError raised when failing to forward a reaction. ([GH #3218](https://github.com/modmail-dev/modmail/issues/3218)) + +### Changed + +- Plain messages no longer forces `()` around the respondent text. ([PR #3234](https://github.com/modmail-dev/modmail/pull/3234)) +- Added workflow to automatically build Docker image ([PR #3232](https://github.com/modmail-dev/modmail/pull/3228)) +- Updated installation guide to reflect new preferred hosting methods + +# v4.0.1 + +This is a hotfix release. + +### Improved + +- Error Messages + +### Fixed + +- Thread cooldown + +# v4.0.0 + +### Breaking + +- Modmail now requires [`Message Content` privileged intent](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots). +- Upgraded to discord.py v2.0 ([internal changes](https://discordpy.readthedocs.io/en/latest/migrating.html), [GH #2990](https://github.com/modmail-dev/modmail/issues/2990)). +- Python 3.8 or higher is required. +- Asyncio changes ([gist](https://gist.github.com/Rapptz/6706e1c8f23ac27c98cee4dd985c8120)) +- Plugin registry is purged and all developers have to re-apply due to breaking changes. + +### Added + +- `use_hoisted_top_role` config to use change how default mod tags work, see `v3.10.0#Added` for details. ([PR #3093](https://github.com/modmail-dev/modmail/pull/3093)) +- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/modmail-dev/modmail/issues/3107)) +- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/modmail-dev/modmail/issues/3083)) +- `?fpareply` and `?fpreply` to reply to messages with variables plainly. +- `use_nickname_channel_name` config to use nicknames instead of usernames for channel names. ([GH #3112](https://github.com/modmail-dev/modmail/issues/3112)) +- `use_random_channel_name` config to use random nicknames vaguely tied to user ID. It is unable to be computed in reverse. ([GH #3143](https://github.com/modmail-dev/modmail/issues/3143)) +- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/modmail-dev/modmail/issues/3122)) +- Select menus for certain paginators. +- `Title` field in `?logs`. ([GH #3142](https://github.com/modmail-dev/modmail/issues/3142)) +- Snippets can be used in aliases. ([GH #3108](https://github.com/modmail-dev/modmail/issues/3108), [PR #3124](https://github.com/modmail-dev/modmail/pull/3124)) +- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/modmail-dev/modmail/issues/3173), [PR #3174](https://github.com/modmail-dev/modmail/pull/3174)) + +### Improved + +- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/modmail-dev/modmail/issues/3048)) +- Use discord relative timedeltas. ([GH #3046](https://github.com/modmail-dev/modmail/issues/3046)) +- Use discord native buttons for all paginator sessions. +- `?help` and `?blocked` paginator sessions now have better multi-page UI. +- Autoupdate now automatically updates pipenv dependencies if possible. + +### Fixed + +- Several minor typos. ([PR #3095](https://github.com/modmail-dev/modmail/pull/3095), [PR #3116](https://github.com/modmail-dev/modmail/pull/3116)) +- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/modmail-dev/modmail/pull/3109)) +- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/modmail-dev/modmail/issues/3091), [PR #3092](https://github.com/modmail-dev/modmail/pull/3092)) +- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/modmail-dev/modmail/issues/3104), [PR #3105](https://github.com/modmail-dev/modmail/pull/3105)) +- Stickers now work in Modmail. +- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/modmail-dev/modmail/issues/3088)) +- Attachments now work on plain replies. ([GH #3102](https://github.com/modmail-dev/modmail/issues/3102)) +- Support LOTTIE stickers. ([GH #3119](https://github.com/modmail-dev/modmail/issues/3119)) +- Editing notes now work. ([GH #3094](https://github.com/modmail-dev/modmail/issues/3094)) +- Commands now work in threads. +- Audit log searching now properly works. +- Old data causing `?blocked` to fail. ([GH #3131](https://github.com/modmail-dev/modmail/issues/3131)) +- Delete channel auto close functionality now works. +- Improved error handling for autoupdate. ([PR #3161](https://github.com/modmail-dev/modmail/pull/3161)) +- Skip loading of already-loaded cog. ([PR #3172](https://github.com/modmail-dev/modmail/pull/3172)) +- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/modmail-dev/modmail/issues/3170), [PR #3178](https://github.com/modmail-dev/modmail/pull/3178)) +- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/modmail-dev/modmail/issues/3179)) + +### Internal + +- Improve regex parsing of channel topics. ([GH #3114](https://github.com/modmail-dev/modmail/issues/3114), [PR #3111](https://github.com/modmail-dev/modmail/pull/3111)) +- Add warning if deploying on a developmental version. +- Extensions are now loaded `on_connect`. +- MongoDB v5.0 clients are now supported. ([GH #3126](https://github.com/modmail-dev/modmail/issues/3126)) +- Bump python-dotenv to v0.20.0, support for python 3.10 +- Bump emoji to v1.7.0 +- Bump aiohttp to v3.8.1 +- Bump lottie to v0.6.11 +- Remove deprecated `core/decorators.py` from v3.3.0 + +# v3.10.5 + +### Internal + +- Locked plugin registry version impending v4 release. + +# v3.10.4 + +### Improved + +- Thread genesis message now shows other recipients. + +### Fixed + +- `?snippet add` now properly blocks command names. + +### Internal + +- Set `LOG_DISCORD` environment variable to the logger level and log discord events. + +# v3.10.3 +This is a hotfix for contact command. + +### Fixed + +- Fixed a bug where contacting with no category argument defaults to the top category. + +# v3.10.2 +This is a hotfix for react to contact. + +### Fixed + +- React to contact now works properly. + +# v3.10.1 + +This is a hotfix for the edit command. + +### Fixed + +- `?edit` now works properly. + +# v3.10.0 + +v3.10 adds group conversations while resolving other bugs and QOL changes. It is potentially breaking to some plugins that adds functionality to threads. + +### Breaking + +- `Thread.recipient` (`str`) is now `Thread.recipients` (`List[str]`). +- `Thread.reply` now returns `mod_message, user_message1, user_message2`... It is no longer limited at a size 2 tuple. + +### Added + +- Ability to have group conversations with up to 5 users. ([GH #143](https://github.com/modmail-dev/modmail/issues/143)) +- Snippets are invoked case insensitively. ([GH #3077](https://github.com/modmail-dev/modmail/issues/3077), [PR #3080](https://github.com/modmail-dev/modmail/pull/3080)) +- Default tags now use top hoisted role. ([GH #3014](https://github.com/modmail-dev/modmail/issues/3014)) +- New thread-related config - `thread_show_roles`, `thread_show_account_age`, `thread_show_join_age`, `thread_cancelled`, `thread_creation_contact_title`, `thread_creation_self_contact_response`, `thread_creation_contact_response`. ([GH #3072](https://github.com/modmail-dev/modmail/issues/3072)) +- `use_timestamp_channel_name` config to create thread channels by timestamp. + +### Improved + +- `?contact` now accepts a role or multiple users (creates a group conversation). ([GH #3082](https://github.com/modmail-dev/modmail/issues/3082)) +- Aliases are now supported in autotrigger. ([GH #3081](https://github.com/modmail-dev/modmail/pull/3081)) + +### Fixed + +- Certain situations where the internal thread cache breaks and spams new channels. ([GH #3022](https://github.com/modmail-dev/modmail/issues/3022), [PR #3028](https://github.com/modmail-dev/modmail/pull/3028)) +- Blocked users are now no longer allowed to use `?contact` and react to contact. ([COMMENT #819004157](https://github.com/modmail-dev/modmail/issues/2969#issuecomment-819004157), [PR #3027](https://github.com/modmail-dev/modmail/pull/3027)) +- UnicodeEncodeError will no longer be raised on Windows. ([PR #3043](https://github.com/modmail-dev/modmail/pull/3043)) +- Notifications are no longer duplicated when using both `?notify` and `subscribe`. ([PR #3015](https://github.com/modmail-dev/modmail/pull/3015)) +- `?contact` now works properly with both category and silent. ([GH #3076](https://github.com/modmail-dev/modmail/issues/3076)) +- `close_on_leave_reason` now works properly when `close_on_leave` is enabled. ([GH #3075](https://github.com/modmail-dev/modmail/issues/3075)) +- Invalid arguments are now properly catched and a proper error message is sent. +- Update database after resetting/purging all plugins. ([GH #3011](https://github.com/modmail-dev/modmail/pull/3011)) +- `thread_auto_close` timer now only resets on non-note and replies from mods. ([GH #3030](https://github.com/modmail-dev/modmail/issues/3030)) +- Deleted messages are now deleted on both ends. ([GH #3041](https://github.com/modmail-dev/modmail/issues/3041), [@JerrieAries](https://github.com/modmail-dev/modmail/commit/20b31f8e8b5497943513997fef788d72ae668438)) +- Persistent notes are now properly deleted from the database. ([GH #3013](https://github.com/modmail-dev/modmail/issues/3013)) +- Modmail Bot is now recognized to have `OWNER` permission level. This affects what can be run in autotriggers. + +### Internal + +- Fix return types, type hints and unresolved references ([PR #3009](https://github.com/modmail-dev/modmail/pull/3009)) +- Reload thread cache only when it's the first on_ready trigger. ([GH #3037](https://github.com/modmail-dev/modmail/issues/3037)) +- `format_channel_name` is now extendable to plugins. Modify `Bot.format_channel_name(bot, author, exclude_channel=None, force_null=False):`. ([GH #2982](https://github.com/modmail-dev/modmail/issues/2982)) + +# v3.9.5 + +### Internal + +- Bumped discord.py to v1.7.3, updated all other packages to latest. +- More debug log files are now kept. +- Resolve SSL errors by retrying without SSL + +# v3.9.4 + +### Fixed + +- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/modmail-dev/modmail/issues/3002), [PR #3003](https://github.com/modmail-dev/modmail/pull/3003)) +- There is now a proper message when trying to contact a bot. + +### Improved + +- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) +- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) + +### Internal + +- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/modmail-dev/modmail/pull/3001)) + +# v3.9.3 + +### Added + +- New config: `use_user_id_channel_name`, when set to TRUE, channel names would get created with the recipient's ID instead of their name and discriminator. + - This is now an option to better suit the needs of servers in Server Discovery + +### Internal + +- Signature of `format_channel_name` in core/util.py changed to: + - `format_channel_name(bot, author, exclude_channel=None, force_null=False)` + + +# v3.9.2 + +### Improved + +- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/modmail-dev/modmail/issues/2977), [PR #2988](https://github.com/modmail-dev/modmail/pull/2988)) + +### Fixed + +- `user_typing` default in the config help is now correct. + +# v3.9.1 + +### Internal + +- `bot.run` now more gracefully handles closing, similar to how discord.py handles it. + - No longer displays `PrivilegedIntentsRequired` exception when exiting when presence intent is disabled. + +# v3.9.0 + +### Breaking + +- `on_thread_initiate` and `on_thread_ready` events now have `thread, creator, category, initial_message` as additional arguments. + +### Fixed + +- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/modmail-dev/modmail/issues/2930), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/modmail-dev/modmail/issues/2969), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- Ghost errors are no longer raised when threads are created using non-organic methods. + +### Internal + +- `thread.reply` now returns (msg_to_user, msg_to_thread). Can be useful in plugins. + +# v3.8.6 + +### Added + +- Ability to install local plugins without relying on git / external sources + - Simply add your extension to plugins/@local, and use `?plugin add local/plugin-name` to load the plugin as normal +- Updated deps for requirements.min.txt and pyproject.toml + +# v3.8.5 + +### Added + +- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/modmail-dev/modmail/issues/2963), [PR #2964](https://github.com/modmail-dev/modmail/pull/2964)) +- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/modmail-dev/modmail/pull/2951)) + +### Fixed + +- Non-master/development branch deployments no longer cause errors to be raised. +- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/modmail-dev/modmail/issues/2961)) + +# v3.8.4 + +This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 where users were not properly cached. + +### Fixed + +- Corrupted data is no longer saved to thread cache. + +# v3.8.2 + +### Fixed + +- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/modmail-dev/modmail/issues/2934)) +- Fix update notifications. +- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/modmail-dev/modmail/issues/2935), [PR #2936](https://github.com/modmail-dev/modmail/pull/2936)) +- IDs in `` commands work now. + +# v3.8.1 + +### Fixed + +- Additional image uploads now render properly. ([PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/modmail-dev/modmail/issues/2931), [PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/modmail-dev/modmail/issues/2932)) + +# v3.8.0 + +### Added + +- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/modmail-dev/modmail/issues/2896)) +- `?fareply`, anonymously reply with variables. +- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/modmail-dev/modmail/issues/2905)) +- `disable_updates` config variable to control if the update command should be disabled or not. +- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/modmail-dev/modmail/issues/2907)) +- Support for only the "Server Members" intent. + +### Improved + +- Added command validation to `autotrigger add/edit`. +- `GITHUB_TOKEN` is now no longer required in Heroku setups. +- Clearer error messages on reply fails. + +### Fixed + +- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/modmail-dev/modmail/pull/2902)) +- Level permissions were not checked if command permissions were set. +- Regex autotriggers were not working if term was in the middle of strings. +- `?blocked` now no longers show blocks that have expired. +- Blocked roles will no longer trigger an error during unblock. +- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/modmail-dev/modmail/issues/2916)) +- Finding linked messages in replies work now. ([GH #2920](https://github.com/modmail-dev/modmail/issues/2920), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2920#issuecomment-751530495)) +- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/modmail-dev/modmail/issues/2926)) +- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/modmail-dev/modmail/issues/2910), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2910#issuecomment-753557313)) +- Display an error on [Lottie](https://airbnb.io/lottie/#/) stickers, instead of failing the send. +- `?perms get` now shows role/user names. ([PR #2927](https://github.com/modmail-dev/modmail/pull/2927)) + +### Internal + +- Make use of `git branch --show-current` to retrieve branch instead of using prerelease version check. +- Use discord.py 1.6.0 from PyPi instead of the development clone. + +# v3.7.13 + +### Fixed + +- Notes in config help are now optional. + +# v3.7.12 + +### Fixed + +- Bot was not responding to union errors. +- `?block [reason]` now works in threads. + +# v3.7.11 + +### Improved + +- Role block will now work better with seperate server setups. + +### Fixed + +- Bot not restarting after autoupdate on PM2. + +### Internal + +- Removed unnecessary loggings. + +# v3.7.10 + +### Added + +- Added `update_channel_id` to specify which channel autoupdate notifications were being sent to. +- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/modmail-dev/modmail/issues/2885)) + +# v3.7.9 + +### Fixed + +- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/modmail-dev/modmail/issues/2892), [PR #2893](https://github.com/modmail-dev/modmail/pull/2893)) + +### Improved + +- Clearer plugin debug messages when plugins are disabled + +# v3.7.8 + +### Added + +- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/modmail-dev/modmail/pull/2887)) + +### Fixed +- Permission levels were not respected. +- `perms remove` was not working. +- `logs` and `block` would not recognise users in a seperate server setup. +- Custom emojis were not working with `confirm_thread_creation`. + +### Internal +- Optimised `perms get`, bot should respond faster now. + +# v3.7.7 + +### Added + +- Added updating github fork if GITHUB_TOKEN was provided + +### Fixed + +- Skip blocked roles check if user is not in main guild. + +# v3.7.6 + +### Fixed + +- Autoupdate persists despite errors. +- Mention when normal thread created was not working. ([GH #2883](https://github.com/modmail-dev/modmail/issues/2883)) + +# v3.7.5 + +### Fixed + +- Close on emoji was not working. + +# v3.7.3 + +### Fixed + +- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/modmail-dev/modmail/issues/2881)) + +# v3.7.2 + +### Added + +- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/modmail-dev/modmail/issues/2880)) + +### Fixed + +- `?config set` would not respond if an invalid key was provided. + +# v3.7.1 + +### Fixed + +- Bot will now leave a reaction on the react to contact message. +- Added docstring to selfcontact + +# v3.7.0 + +### Added + +- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/modmail-dev/modmail/issues/2872)) +- Added `react_to_contact_message`, `react_to_contact_emoji` to allow users to create threads by reacting to a message. +- Added `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/modmail-dev/modmail/issues/215)) +- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/modmail-dev/modmail/issues/2763)) +- Added `close_on_leave`, `close_on_leave_reason` to automatically close threads upon recipient leaving the server. ([GH #2757](https://github.com/modmail-dev/modmail/issues/2757)) +- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/modmail-dev/modmail/issues/2833)) +- Added `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny` to allow users to confirm that they indeed want to create a new thread. ([GH #2773](https://github.com/modmail-dev/modmail/issues/2773)) +- Support Gyazo image links in message embeds. ([GH #282](https://github.com/modmail-dev/modmail/issues/282)) +- Added `silent` argument to `?contact` to restore old behaviour. +- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/modmail-dev/modmail/issues/2847)) +- Added a way to block roles. ([GH #2753](https://github.com/modmail-dev/modmail/issues/2753)) +- Added `cooldown_thread_title`, `cooldown_thread_response` to customise message sent when user is on a creating thread cooldown. ([GH #2865](https://github.com/modmail-dev/modmail/issues/2865)) +- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/modmail-dev/modmail/issues/2762)) +- Support stickers and reject non-messages. (i.e. pin_add) +- Added support for thread titles, `?title`. ([GH #2838](https://github.com/modmail-dev/modmail/issues/2838)) +- Added `data_collection` to specify if bot metadata should be collected by Modmail developers. +- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/modmail-dev/modmail/issues/130), [GH #649](https://github.com/modmail-dev/modmail/issues/649)) +- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/modmail-dev/modmail/issues/2842), [PR #2878](https://github.com/modmail-dev/modmail/pull/2878)) +- Autoupdates and `?update` which was removed in v3.0.0 + +### Fixed + +- `?contact` now sends members a DM. +- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/modmail-dev/modmail/issues/2856)) +- Command truncated after && in alias. ([GH #2870](https://github.com/modmail-dev/modmail/issues/2870)) +- `on_plugins_ready` event for plugins works now. + +### Improved + +- Plugins installations have clearer error messages. +- `?move` now does not require exact category names, accepts case-insensitive and startswith names. + +### Internal +- Use enums in config. ([GH #2821](https://github.com/modmail-dev/modmail/issues/2821)) +- `on_thread_close` event for plugins. +- `on_thread_reply` event for plugins. + +# v3.6.2 + +### Fixed + +- Plugins downloading requirements in virtual environments. + + +# v3.6.1 + +### Added + +- Proper error message if privileged intents not explicitly granted to bot. + + +# v3.6.0 + +### Added + +- Added `thread_move_title` to specify title of thread moved embed. +- Mark NSFW logs in log message. ([GH #2792](https://github.com/modmail-dev/modmail/issues/2792)) +- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/modmail-dev/modmail/issues/2828)) +- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/modmail-dev/modmail/issues/2796)) + +### Changed + +- `?move` now consumes rest in category name, which means `?move Long Category Name` works without quotes! +- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/modmail-dev/modmail/pull/2845)) + +### Fixed +- Unicode errors raised during windows selfhosting + +### Internal + +- Bump discord.py version to 1.5.1 +- Explicitly state intents used for connection +- Use `--diff` for black CI instead of `--check` ([GH #2816](https://github.com/modmail-dev/modmail/issues/2816)) + + +# v3.5.0 + +Fixed discord.py issue. + +### Added + +- A confirmation when you manually delete a thread message embed. +- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/modmail-dev/modmail/issues/2803)) +- Added `?plugins reset` command to completely reset everything related to plugins. This will fix some problems caused by broken plugins in the file system. +- Support private GitHub repos for plugins (thanks to @officialpiyush pr#2767) + +### Changed + +- Bump discord.py version to v1.3.3. +- Renamed `bot.owner_ids` to `bot.bot_owner_ids` as the attribute is now defined internally for team support. +- Deleting channel manually will now close the thread. +- Deleting messages will no longer cause the bot to produce warnings. +- Plugins will automatically be removed when it fails to load. +- Moved all database-related activities to clients.py under MongoDBClient, with possible future hook for additional database support. +- `bot.db` is deprecated in favour of `bot.api.db` and will be removed in the future. +- Deprecated `bot.plugin_db.get_partition` in favour of `bot.api.get_plugin_partition` (not final). +- Deprecated `MONGO_URI` config var (but will keep support in the future) in favour of `CONNECTION_URI` and `DATABASE_TYPE`. Right now there is one supported database - "mongodb", which is the default. + +### Fixed + +- Plugins not loading in Windows OS. Now uses proactor event loop for asyncio which should fix this. + + +# v3.4.1 + +### Fixed + +- Masked a bunch of noise errors when deleting messages. +- Added more checks for deleting messages. + +### Breaking + +- `thread_initiate` will be dispatched at the beginning of the setup process. +- `thread_create` is dispatched when the thread is registered as a thread by Modmail (i.e., when channel topic is edited). +- `thread_ready` is dispatched when a thread finishes its setup steps. + + +# v3.4.0 + +### Added + +- Thread cooldown! + - Set via the new config var `thread_cooldown`. + - Specify a time for the recipient to wait before allowed to create another thread. +- Fallback Category (thanks to DAzVise PR#636) + - Automatically created upon reaching the 50 channels limit. + - Manually set fallback category with the config var `fallback_category_id`. +- "enable" and "disable" support for yes or no config vars. +- Added "perhaps you meant" section to `?config help`. +- Multi-command alias is now more stable. With support for a single quote escape `\"`. +- New command `?freply`, which behaves exactly like `?reply` with the addition that you can substitute `{channel}`, `{recipient}`, and `{author}` to be their respective values. +- New command `?repair`, repair any broken Modmail thread (with help from @officialpiyush). +- Recipients get feedback when they edit their messages. +- Chained delete for DMs now comes with a message. +- poetry (in case someone needs it). + +### Changed + +- The look of alias and snippet when previewing. +- The database now saves the message ID of the thread embed, instead of the original message. +- Swapped the position of user and category for `?contact`. +- The log file will no longer grow infinitely large. +- A hard limit of a maximum of 25 steps for aliases. +- `?disable` is now `?disable new`. + +### Fixed + +- Setting config vars using human time wasn't working. +- Fixed some bugs with aliases. +- Fixed many issues with `?edit` and `?delete` and recipient message edit. +- Masked the error: "AttributeError: 'int' object has no attribute 'name'" + - Channel delete event will not be checked until discord.py fixes this issue. +- Chained reaction add/remove. +- Chained delete for thread channels. + +### Internal + +- Commit to black format line width max = 99, consistent with PyLint. +- No longer requires shlex for alias parsing. +- New checks with thread create / find. +- No more flake8 and Travis. + +# v3.3.2 + +### Fixed + +- An oversight with the permission system. + +# v3.3.1 + +### Emergency Patch + +- Fixed a recent issue with an animation KeyError due to Discord API update. + +# v3.3.0 + +### Important + +- Recommend all users to unblock and re-block all blocked users upon updating to this release. + +### Added + +- Three new config vars: + - `enable_plugins` (yes/no default yes) + - When set to no, Modmail will not load plugins. + - `error_color` (color format, defaults discord red) + - The color of error messages. + - `anon_reply_without_command` (yes/no default no) (Thanks to papiersnipper PR#288) + - When set, all non-command messages sent to thread channels are forwarded to the recipient anonymously without the need of `?anonreply`. + - This config takes precedence over `reply_without_command`. +- `?logs responded [user]` command. It will show all the logs that the user has sent a reply. (Thanks to papiersnipper PR#288) + - `user` when not provided, defaults to the user who ran the command. +- Open threads in limbo now auto-close if Modmail cannot find the channel. Modmail does this check every time the bot restarts. +- Ability to disable new threads from getting created. + - `?disable`. +- Ability to fully disable Modmail DM. + - `?disable all`. +- To re-enable DM: `?enable`, and to see the current status: `?isenable`. +- This disabled Modmail interface is customizable with the following config vars: + - `disabled_new_thread_title` + - `disabled_new_thread_response` + - `disabled_new_thread_footer` + - `disabled_current_thread_title` + - `disabled_current_thread_response` + - `disabled_current_thread_footer` +- Ability to delete notes when providing their ID. (Thanks to papiersnipper PR#402) +- Ability to delete log entries. (Thanks to papiersnipper PR#402) + +### Changed + +- `?contact` no longer send the "thread created" message to where the command was run, instead, it's now sent to the newly created thread channel. (Thanks to DAzVise) +- Automatically delete notes command `?note` when there're no attachments attached. +- Embed author links used to be inaccessible in many cases, now: + - `?anonreply`, `?reply`, and `?note` in the thread channel will link to the sender's profile. + - `?reply` and the recipient's DM will also link the sender's profile. + - `?anonreply` in DM channel will link to the first channel of the main guild. +- Plugins update (mostly internal). + - `git` is no longer used to install plugins; it now downloads through zip files. + - `?plugins enabled` renamed to `?plugins loaded` while `enabled` is still an alias to that command. + - Reorganized plugins folder structure. + - Logging / plugin-related messages change. + - Updating one plugin will not update other plugins; repositories no longer separate plugins, but the plugin name itself. +- The help command is in alphabetical order grouped by permissions. +- Notes are no longer always blurple; it's set to `MAIN_COLOR` now. +- Added `?plugins update` for updating all installed plugins. +- Reintroduce flake8 and use bandit for security issues detection. +- Add Travis checks for 3.6 in Linux and 3.7 for macOS and Windows. +- Debug logs not logs eval commands. +- Presence updates 30 minutes instead of 45 now. +- Fixed an assortment of problems to do with `?block`. +- Existing aliases can be used when creating new aliases. (Thanks to papiersnipper PR#402) + +### Internal + +- Reworked `config.get` and `config.set`, it feeds through the converters before setting/getting. + - To get/set the raw value, access through `config[]`. +- The prerelease naming scheme is now `x.x.x-devN`. +- `trigger_typing` has been moved to `core.utils.trigger_typing`, the original location is deprecated. +- Simpler status and activity logic. +- New logging logic. + +# v3.2.2 + +Security update! + +### Important + +- Supporter permission users used to be able to "hack" snippets to reveal all your config vars, including your token and MongoURI. +- Implemented some changes to address this bug: + - All customizable variables used in snippets, close messages, etc., using the `{}` syntax, now forbids chaining two or more attributes and attributes that start with `_`. +- We advise you to update to this version. +- If you felt your credentials had been leaked, consider changing your bot token / MongoURI. + +# v3.2.1 + +### Fixed + +- Can't set hex for main_color, recipient_color, etc. + +### Added + +- Discord colors by default when addressing them by names. + +# v3.2.0 + +### Added + +- Ability to change permission levels of individual commands. + - See `?permissions override` for more information. +- `thread_move_notify` and `thread_move_response` to notify recipients if a thread is moved. (Thanks to Flufster PR#360) +- IDs of messages sent to Modmail are now viewable. (Thanks to Flufster PR#360) + +### Fixed + +- `?help `, will return `Perhaps you meant: `, now it's fixed. + - For example, `?help add` used to return `Perhaps you meant: add`, now it wouldn't do this. +- Aliases and Permissions command names are always saved lowercase now. +- An improved Dockerfile. + +### Internal + +- Use regex to parse Changes, Added, Fixed, etc. and description. +- Adds `PermissionLevel.INVALID` when commands don't have a permission level. + +# v3.1.1 + +### Fixed + +- An issue when reading `config_help.json` for Windows users due to an encoding problem. + +# v3.1.0 + +### Breaking + +- `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. +- Truthy and falsy values for binary configuration variables are now interpreted respectfully. +- `LOG_URL_PREFIX` cannot be set to "NONE" to specify no additional path in the future, "/" is the new method. + +### Added + +- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. +- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when a thread is auto-closed. +- New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. +- Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. + - For users having trouble with pipenv or any other reason. +- Multi-step alias, see `?help alias add`. Public beta testing might be unstable. +- Misc commands without cogs are now displayed in `?help`. +- `?help` works for alias and snippets. +- `?config help ` shows a help embed for the configuration. +- Support setting permissions for subcommands. +- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` subcommands. + +### Changes + +- `thread_auto_close_response` has a configurable variable `{timeout}`. +- `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. +- `colorama` is no longer a necessity; this is due to some unsupported OS. +- Changelog command can now take a version argument to jump straight to the specified version. +- `?plugin enabled` results are now sorted alphabetically. +- `?plugin registry` results are now sorted alphabetically, helps users find plugins more easily. +- `?plugin registry page-number` plugin registry can specify a page number for quick access. +- A reworked interface for `?snippet` and `?alias`. + - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). + - Add an `?alias raw ` command for displaying the raw content of an alias (escaped markdown). +- The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. +- Removed unclear `rm` alias for some `remove` commands. +- Paginate `?config options`. +- All users configured with a permission level higher than REGULAR has access to the main Modmail category. + - Category overrides also changes when a level is removed or added to a user or role. +- `@everyone` is now accepted for `?perms add`. + +### Fixes + +- `?notify` no longer carries over to the next thread. +- `discord.NotFound` errors for `on_raw_reaction_add`. +- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when the user is blocked. +- Better `?block` usage message. +- Resolved errors when mods sent messages after a thread is closed somehow. +- Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. +- When creating snippets and aliases, it now checks if other snippets/aliases with the same name exist. +- Modmail looked for `config.json` in the wrong directory. + +### Internal + +- Removed supporting code for GitHub interaction. +- All default config values moved to `core/config.py`. +- `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. +- Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. +- Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. +- Bumped discord.py version to 1.2.3. +- Use discord tasks for metadata loop. +- More debug based logging. +- Reduce redundancies in `?perms` sub commands. +- paginator been split into `EmbedPaginatorSession` and `MessagePaginatorSession`, both subclassing `PaginatorSession`. + +# v3.0.3 + +### Added + +- New commands, `?alias edit ` and `?snippets edit `. + - They can be used to edit aliases and snippets, respectively. + +# v3.0.2 + +### Added + +- A new command, `?blocked whitelist `, this command prevents users from getting blocked by any means. + +### Changed + +- Removed some aliases from `?oauth`. + +# v3.0.1 + +### Fixed + +- Many bugs with `thread_auto_close`. + +# v3.0.0 + +### Added + +- `?sponsors` command will list sponsors. +- An alert will now be sent to the log channel if a thread channel fails to create. This could be due to a variety of problems such as insufficient permissions, or the category channel limit is met. +- Threads will close automatically after some time when `thread_auto_close` is set. +- Custom closing messages can be configured with `thread_auto_close_response`. + +### Breaking Changes + +- Removed auto-update functionality and the `?update` command in favor of the [Pull app](https://github.com/apps/pull). + +Read more about updating your bot [here](https://github.com/modmail-dev/modmail/wiki/updating) + +### Changed +- Channel names now can contain Unicode characters. +- Debug logs are now located in a different file for each bot. (Internal change) +- Default cogs always appear first in the help command now. + +### Fixed +- Editing notes now work, minor bug with edit command is fixed. +- Bug in the `?oauth` command where the response message fails to send when an ID is provided. +- Plugin requirement installation now works in virtual environments + + +# v2.24.1 + +### Fixed + +Fixed a bug with branches and `?plugin update`. + +# v2.24.0 + +### Added + +Branch support for `?plugin add` and in the registry. Typically for developers. + +# v2.23.0 + +### Added + +Added a "Mutual servers" field to the genesis embed if: +a) The user is not in the main guild. +b) The user shares more than one server with the bot. + +### Changed + +Notes with the `?note` command are now automatically pinned within the thread channel. + +# v2.22.0 + +### Added + +Added a 🛑 reaction to the paginators to delete the embed. + +### Fixed + +`?blocked` is now paginated using reactions. This fixes [#249](https://github.com/modmail-dev/modmail/issues/249) + +# v2.21.0 + +### Added + +New `?plugin registry compact` command which shows a more compact view of all plugins. + +# v2.20.2 + +### Plugin Registry + +Plugin developers can now make a PR to include their plugin in the `plugin registry` command. +Add your plugin in the `plugins/registry.json` file in the main repository. + +### Changed + +`?debug` command now shows the most recent logs first. (Starts at the last page) + +# v2.20.1 + +### What's new? + + - New error message when using thread-only commands outside of threads. + - `?unnotify`, ability to undo `?notify`. + - `?notify` and `?subscribe` now accepts other users. + +### Changes + +This update contains mostly internal changes. + - Implemented support for the new discord.py v1.1.1. + - Improved help text for most commands. + - Completely revamped help command, few users changes. + - Removed ABC (internal). + +# v2.20.0 + +### What's new? + +New `?oauth whitelist` command, which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app, check the logviewer [repo](https://github.com/modmail-dev/logviewer). + +# v2.19.1 + +### Changed + +- Ability to force an update despite having the same version number. Helpful to keep up-to-date with the latest GitHub commit. + - `?update force`. +- Plugin developers now have a new event called `on_plugin_ready`; this is a coroutine and is awaited when all plugins are loaded. Use `on_plugin_ready` instead of `on_ready` since `on_ready` will not get called in plugins. + +# v2.19.0 + +### What's new? + +- New config variable `guild_age`, similar to `account_age`, `guild_age` sets a limit as to how long a user has to wait after they joined the server to message Modmail. +- `guild_age` can be set the same way as `account_age`. + +# v2.18.5 + +Fix help command bug when using external plugins. + +# v2.18.4 + +Fix the teams permission bug. + +# v2.18.2 + +### Changed + +Commands now have better error messages. Instead of sending the help message for a command when an argument fails to be converted, the bot now says like "User 'bob' not found" instead. + +# v2.18.1 + +Un-deprecated the `OWNERS` config variable to support Discord developer team accounts. + +# v2.18.0 + +### New Permissions System + +- A brand new permission system! Replaced the old guild-based permissions (i.e., manage channels, manage messages), with the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. +- There are five permission levels: + - Owner [5] + - Administrator [4] + - Moderator [3] + - Supporter [2] + - Regular [1] + +### Usage + +You may add a role or user to a permission group through any of the following methods: +- `?permissions add level owner @role` +- `?permissions add level supporter member-name` +- `?permissions add level moderator everyone` +- `?permissions add level moderator @member#1234` +- `?permissions add level administrator 78912384930291853` + +The same applies to individual commands permissions: +- `?permissions add command command-name @member#1234` +- and the other methods listed above. + +To revoke permission, use `remove` instead of `add`. + +To view all roles and users with permission for a permission level or command do: +- `?permissions get command command-name` +- `?permissions get level owner` + +By default, all newly set up Modmail will have `OWNER` set to the owner of the bot, and `REGULAR` set to @everyone. + +### Breaking + +When updating to this version, all prior permission settings with guild-based permissions will be invalidated. You will need to convert to the above system. +`OWNERS` will also get removed; you will need to set owners through `?permissions add level owner 212931293123129` or any way listed above. + +### New Command + +- A `?delete` command, which is an alternative to manually deleting a message. This command is created to no longer require "manage messages" permission to recall thread messages. + +### Changed + +- The help message no longer conceals inaccessible commands due to check failures. + +# v2.17.2 + +### Changed + +- Logs search command will search through log keys as well now. +- For example, `?logs search e7499e82f8ff`. + +# v2.17.1 + +### What's new? + +Stricter fallback genesis embed search. + +### Changed + +How Modmail checks if a channel is a thread: + +1. The bot first checks if the channel topic is in the format `User ID: XXXX`, this means it is a thread. +2. If a channel topic is not found, the bot searches through the message history of a channel to find the thread creation embed. This step should never yield a thread for an average user. Still, in the case of another bot messing up the channel topic (happened to a user before), this extra step was added. + +# v2.17.0 + +### What's new? + +Added a config option `reply_without_command`, which, when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) + +To enable this functionality, do `?config set reply_without_command true` and to disable it, use `?config del reply_without_command`. + +### Changed + +The `move` command now only requires `manage_messages` perms instead of `manage_channels`. + +# v2.16.1 + +### Fixed + +An issue where a scheduled close would not execute over a long time if the recipient no shares any servers with the bot. + +# v2.16.0 + +### Changed + +All support for Modmail API (api.modmail.tk) has terminated. +If you're still using api.modmail.tk, you will need to migrate to the self-hosted database +option ASAP. Your bot will not work unless you switch to the self-hosted option. Refer to the installation tutorial for information regarding self-hosted Modmail. + +If a member leaves/joins (again) while they are a recipient of a thread, a message will be sent to notify you that this has occurred. + +# v2.15.1 + +### Fixed + +Emergency patch of a SyntaxError. + +# v2.15.0 + +### What's new? + +Added the ability to change the default close message via the introduction of two config variables. + +- `thread_close_response` - when someone closes the thread. +- `thread_self_close_response` - when the recipient closes their own thread. + +They will be provided by string variables that you can incorporate into them: + +- `closer` - the user object that closed the thread. +- `logkey` - the key for the thread logs, e.g. (`5219ccc82ad4`) +- `loglink` - the full link to the thread logs, e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) + +Example usage would be: ``?config set thread_close_message {closer.mention} closed the thread, here is the link to your logs: [**`{logkey}`**]({loglink})`` + +# v2.14.0 + +### What's new? + +Added the ability to enable the recipient to close their own threads. This takes place in the form of a reaction that the user can click to close their thread. This functionality is now enabled by default. + +To disable this, do `?config set disable_recipient_thread_close true` + +### More Customisability! + +More config variables have been added that you can edit. + +- `close_emoji` - the emoji that the user can click on to close a thread. Defaults to a lock (🔒) + +You now have complete control of the look of the thread creation and close embeds the users see. + +- `thread_creation_title` - the title of the embed. Defaults to 'Thread Created' +- `thread_creation_footer` - the footer text in the embed. Defaults to 'Your message has been sent...' +- `thread_close_title` - the title of the embed. Defaults to 'Thread Closed' +- `thread_close_footer` - the footer text in the embed. Defaults to 'Replying will create a new thread' + +# v2.13.13 + +### What's new? + +Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages Modmail. + +You can do this via `?config set sent_emoji disable`. + +### Fixed + +The bot now handles having too many roles to show in the thread created embed. + +# v2.13.12 + +### What's new? +Added image link in title in case discord fails to embed an image. + +# v2.13.11 + +### What's new? +- Introduced a new configuration variable `account_age` for setting a minimum account creation age. + - Users blocked by this reason will be stored in `blocked` along with other reasons for being blocked. + - `account_age` needs to be an ISO-8601 Duration Format (examples: `P12DT3H` 12 days and 3 hours, `P3Y5M` 3 years and 5 months `PT4H14M999S` 4 hours 14 minutes and 999 seconds). https://en.wikipedia.org/wiki/ISO_8601#Durations. + - You can set `account_age` using `config set account_age time` where "time" can be a simple human-readable time string or an ISO-8601 Duration Format string. + +### Changed +- `?block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. +- `?block`, like `?close`, now supports a block duration (temp blocking). + +# v2.13.10 + +### Fixed +- Fixed an issue where status and activity do not work if they were modified wrongly in the database. + - This was primarily an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. + +# v2.13.9 + +### Fixed +- Fixed a bug where an error was raised when a message with received during a scheduled closure. + +# v2.13.8 + +### Fixed +- A bug where a thread was blocked from sending messages when multiple images were uploaded, due to a typo. + +### Changed +- Uses https://hasteb.in instead of https://hastebin.com for `?debug hastebin`. + +# v2.13.7 + +### What's new? +- The ability to enable typing interactions. + - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". Use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. +- New `status` command, change the bot's status to `online`, `idle`, `dnd`, `invisible`, or `offline`. + - To remove the status (change it back to default), use `status clear`. + - This also introduces a new internal configuration variable: `status`. Possible values are `online`, `idle`, `dnd`, `invisible`, and `offline`. + +### Changed +- The internals for `activity` has drastically changed to accommodate the new `status` command. + +# v2.13.6 + +### Fixed +- Fixed a bug in the contact command where the response message did not send. + +# v2.13.5 + +### What's new? +- You will no longer need to view your bot debug logs from Heroku. `debug` will show you the recent logs within 24h through a series of embeds. + - If you don't mind your data (may or may not be limited to user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. + - `debug clear` will clear the locally cached logs. + - Local logs are automatically erased at least once every 27h for bots hosted on Heroku. + +### Fixed +- Will no longer show `Unclosed client session` and `Task was destroyed, but it is pending!` when the bot terminates. +- `thread.create` is now synchronous so that the first message sent can be queued to be sent as soon as a thread is created. + - This fixes a problem where if multiple messages are sent in quick succession, the first message sent (which triggers the thread creation) is not sent in order. +- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled, and the bot will send a message saying so. + +### Changed +- `print` is replaced by logging. + - New environment variable introduced: `LOG_LEVEL`. + - This influences the number of messages received in Heroku logs. + - Possible options, from least to most severe, are: `INFO`, `DEBUG`, `WARNING`, `ERROR`, `CRITICAL`. + - In most cases, you can ignore this change. +- `on_error` and `CommandNotFound` are now logged. + +# v2.13.4 + +### Changed +- `?contact` no longer raise a silent error in Heroku logs when the recipient is a bot. Now Modmail responds with an error message. + +# v2.13.3 + +### Fixed +- Fixed a typo in the config options. + +# v2.13.2 + +### Fixed +- Installing `requirements.txt` files in plugins. + +# v2.13.1 + +### Fixed +- Reading `requirements.txt` files in plugins. + +# v2.13.0 + +### What's new? +- Plugins: + - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit? Plugins are your go-to! + - [Creating Plugins Documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). + +# v2.12.5 + +### Fixed + +- `config del` command will now work correctly on self-hosted DB bots. + +# v2.12.4 + +### What's new? +- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/modmail-dev/modmail/blob/master/core/_color_data.py). + - Named colors can be set the same way as hex. But this can only be done through `config set`, which means database modifications will not work. + - For example: `config set main_color yellowish green`. +- New config var `main_color` allows you to customize the main Modmail color (as requested by many). Defaults to Discord `blurple`. + +# v2.12.3 + +### Fixed +- Patched a bug where `logs` sub-commands were accessible by anyone. +- Patched a bug where an error was raised when a thread is open where the recipient left the server. + +Huge thanks to Sasiko for reporting these issues. + +# v2.12.2 + +### Fixed +- Fixed a bug in self-hosted `?update` command. + +# v2.12.1 + +### Changed + +- `logs search` now also searches usernames present in thread logs. + +# v2.12.0 + +### Important +**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is because we are providing free service without getting anything in return. Thus we do not have the resources to scale to accommodate more users. +We recommend using your own database for logs. In the future you will soon get a `backup` command so you can download all your pre-existing data and migrate to your own database.** + +### Changed +- A lot of painful code cleanup, which is good for us (the developers), but shouldn't affect you. +- The appearance of the `?logs` command. It should be clearer with better info now. +- Bot owners get access to all commands regardless of server permissions. +- Blocked users no longer receive a message, only the blocked emoji will be sent. + +### What's new? +- **Note:** The following commands only work if you are self-hosting your logs. We recommend you to use your own database. +- Log search queries, in the form of two new commands. +- `logs search [query]` - this searches all log messages for a query string. +- `logs closed-by [user]` this returns all logs closed by a particular user + +### Fixed +- `activity listening to music` no longer results in two "to"s ("listening to to music"). + - This may require you to change your activity message to accommodate this fix. +- A problem where `main_category_id` and `log_channel_id` weren't updated when their corresponding channel or category get deleted. + +# v2.11.0 + +### What's new? +- `loglink` command, returns the log link for the current thread. + +# v2.10.2 + +### Changed +- Your logs now track and show edited messages. + +# v2.10.1 + +### Changed +- Use reply author's top role for the mod tag by default. + +# v2.10.0 + +### What's new? +- `anonreply` command to anonymously reply to the recipient. +The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message) — the avatar defaults to the guild icon URL. However, you can change both of these via the `anon_username`, `anon_avatar_url`, and `anon_tag` config variables. + +### Changed +- Your bot now logs all messages sent in a thread channel, including discussions that take place. You can now toggle to view them in the log viewer app. + +# v2.9.4 + +### Fixed +- Small bug due to a typo. + +# v2.9.3 + +### Changed +- Forgot to enable custom embed colors. + +### What's new? +- Ability to set a custom `mod_tag` (the text in the footer of the mod reply embed, which by default says "Moderator") + +# v2.9.2 + +### Changed +- Improve format of thread info embed. Slightly cleaner and simpler now. +- All commands are now blurple instead of green. + +### Fixed +- Bug where the close command wouldn't work if you didn't configure a log channel. + +### What's new? +- Ability to set your own custom `mod_color` and `recipient_color` for the thread message embeds. + +# v2.9.1 + +### Changed +- Changed order of arguments for `contact`. This is so that you can use aliases to their full potential. +- For example: + - `contact "Recruitment Category" @somedude` +- You can add an alias by doing: `alias add recruit contact "Recruitment Category"`. + - Now you can use the alias via: `recruit @somedude`. + +# v2.9.0 + +### What's new? +- New command `note` will add a system message to your thread logs. - - This is useful for noting the context of a conversation. + +# v2.8.1 + +### Fixed +- Fixed bug where thread logs were getting duplicated when using the `contact` command. +- Fixed bug where the wrong key was used for logs, which caused some `log` command log links to point to an HTTP 404 Not Found. + - A minor oversight from commit 1ba74d9. + +# v2.8.0 + +### Changed +- Major improvement in viewing thread logs. +- Log links are now rendered in HTML instead of plain text. + +# v2.7.2 + +### What's new? +- `config options` command to see a list of valid config variables that you can modify. + +### Security +Thread channels will now default to being private (`@everyone`'s read message perms set to `false`). + - If the thread creation category could not be resolved. + - This will save you from some trouble if, for whatever reason, your configuration gets messed up. + +# v2.7.1 + +### Changed + +- All reference to "modmail" / "Mod Mail" / "ModMail" are changed to "Modmail". +- `log_channel_id` is now part of the config upon `setup`. +- Added the ability to set where threads are created using the `main_category_id` configuration option. + +### Note + +- If your Modmail bot was set up a long time ago, you might experience an issue where messages were sent outside of the category. + - To fix this, set `main_category_id` to the ID of the Modmail category. + +# v2.7.0 + +### Changed + +- `move` command now syncs thread channel permissions with the destination category. +- `contact` command now supports an optional category argument (where the thread channel will be created). + +# v2.6.3 + +### Fixes +- Fixed small issue with finding threads. + +# v2.6.2 + +### Fixes +- Fixed log URLs for self-hosting users. + +# v2.6.1 + +### Fixed +- Replaced the testing `API_BASE_URL` with the actual URL. + +# v2.6.0 + +### What's new? +- `threads` is now a default alias to `logs`. + +### Changed +- Log URLs are moved to their own collection. +- Log URLs are now `https://logs.modmail.tk/LOGKEY`, no more numbers before the log key. +- We still support the numbers to not break everyone's URLs so quickly, but both work at the moment. +- This is a huge change to the backend logging, and there might be migration errors. If so, please contact us in our [Discord server](https://discord.gg/2fMbf2N). + +# v2.5.2 + +### Fixes +- Fixed a bug where requests sent when the API was not ready. + +# v2.5.1 + +### Fixes +- Emergency patch to save configs. + +# v2.5.0 + +### Background +- Bots hosted by Heroku restart at least once every 27 hours. +- During this period, local caches will be deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. +- [PR #135](https://github.com/modmail-dev/modmail/pull/135) + +### Changed +- Created a new internal config var: `closures`. +- Store closure details into `closures` when the scheduled time isn't "now". + - Loaded upon bot restart. + - Deleted when a thread is closed. +- Use `call_later()` instead of `sleep()` for scheduling. + +# v2.4.5 + +### Fixed +Fixed activity setting due to flawed logic in `config.get()` function. + +# v2.4.4 + +### Fixed +Fixed a bug in the `?activity` command where it would fail to set the activity on bot restart if the activity type was `playing`. + +# v2.4.3 + +### Changed + - Moved self-hosted log viewer to a separate repo. + +# v2.4.2 + +### What's new? +- Ability to set your own Twitch URL for `streaming` activity status. + +# v2.4.1 + +### Fixed +- Small bug in `?activity` command. + +# v2.4.0 + +### What's new? +- Added the `?activity` command for setting the activity +- [PR #131](https://github.com/modmail-dev/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). + +### Removed +- Removed the deprecated `status` command. +- This also means you will have to reset your bot status with the `?activity` command, as the `?status` command was removed. + +# v2.3.0 + +### What's new? +- Ability to self-host logs. + +### Changed +- Improved format for log channel embeds. +- Roles are now comma-separated in info embed. +- This only applies to separate server setups. + +### Fixed +- Bug in subscribe command. + - It will now unsubscribe after a thread is closed. + +# v2.2.0 + +### What's new? +- Notify command `notify [role]`. + - Notify a given role or yourself to the next thread message received. + - Once a thread message is received, you will be pinged once only. + +- Subscribe command `sub [role]` / `unsub [role]`. + - Subscribes yourself or a given role to be notified when thread messages are received. + - You will be pinged for every thread message received until you unsubscribe. + +### Changed +- Slightly improved log channel message format. + +# v2.1.1 + +### Fixed +- Small bug in `close` command. + +# v2.1.0 + +### What's new? +- Ability to set a custom thread-creation-response message. + - Via `config set thread_creation_response [message]`. + +### Changed +- Improve `?logs` command format. +- Improve thread log channel messages to have more relevant info. +- Improve close command. + - You can now close the thread after a delay and use a custom thread close message. + - You also now can close a thread silently. + +# v2.0.10 + +### Security +- Fix a bug where blocked users were still able to message Modmail. + +# v2.0.9 + +### What's new? +- Support for custom blocked and sent emoji. +- Use the `config set blocked_emoji [emoji]` or `sent_emoji` commands. + +### Fixes +- Support multiple images and file attachments in one message. +- This is only possible on mobile, so its good to handle it in code. + +# v2.0.8 + +### What's new? +- Added the ability to use your own log channel. + - You can do this via the `config set log_channel_id ` command. +- Added the ability to use your own main inbox category. + - You can do this via the `config set main_category_id ` command. + +### Changed +- You can now supply a reason when blocking a user. +- Blocked users are now stored in the database instead of in the channel topic. + - This means you can delete the top channel in the Modmail category now (after migrating the currently blocked users). + +# v2.0.7 + +### What's new? +- Added a `changelog` command to view the bot's changelog within discord. + +### Changed +- `update` command now shows the latest changes directly from CHANGELOG.md. +- Auto-update messages also show the latest changes from the GitHub repo. +- Removed the "latest changes" section from the `about` command. + +# v2.0.6 + +### Fixed +- Fix logs sending duplicated thread close logs. +- The bot will now tell you that a user is no longer in the server when you try to reply to a thread. + - Before this, it looked like you replied to the thread, but in reality, the message was not sent. + +# v2.0.5 + +### Changed +- `alias` command now checks if you are adding a valid alias-command combo. +- Manually deleting a channel will now correctly close the thread and post logs. + +# v2.0.4 + +### Fixed +- Fixed a one-off bug where the channel topic disappears, but Modmail operations should continue. +- Fixed `linked_message_id` issues. + +# v2.0.3 + +### Fixed +- The thread creation embed now shows the correct number of past logs. +- If using a separate server setup, roles in the info embed now are shown as names instead of mentions. + - This is because you can't mention roles across servers. + +# v2.0.2 + +### Security +- Made the `logs` command require "manage messages" permissions to execute. + - Before this patch, anyone could use the `logs` commands. + +# v2.0.1 + +### Changed +- Improved `block` / `unblock` commands. + - They now take a more comprehensive range of arguments: usernames, nicknames, mentions, and user IDs. + +### Fixed +- Setup command now configures permissions correctly so that the bot will always be able to see the main operations category. + +# v2.0.0 + +This release introduces the use of our centralized [API service](https://github.com/modmail-dev/webserver) to enable dynamic configuration, auto-updates, and thread logs. +To use this release, you must acquire an API token from https://modmail.tk. +Read the updated installation guide [here](https://github.com/modmail-dev/modmail/wiki/installation). + +### Changed +- Stability improvements through synchronization primitives. +- Refactor thread management and code. +- Update command now uses `api.modmail.tk`. +- `contact` command no longer tells the user you messaged them 👻 + +### Fixed +- `status` command now changes playing status indefinitely. + +### What's new? +- Dynamic `help` command (#84). +- Dynamic configuration through `api.modmail.tk`. +- Thread logs via `logs.modmail.tk` (#78). + - `log` command added. +- Automatic updates (#73). +- Dynamic command aliases and snippets (#86). +- Optional support for using a separate guild as the operations center (#81). +- NSFW Command to change channels to NSFW (#77). + +### Removed +- Removed `archive` command. + - Explanation: With thread logs (that lasts forever), there's no point in archiving. diff --git a/Dockerfile b/Dockerfile index 246d3cf7a6..f0c4e13ab0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,38 +1,38 @@ -FROM python:3.11-slim-bookworm as base - -RUN apt-get update && \ - apt-get install --no-install-recommends -y \ - # Install CairoSVG dependencies. - libcairo2 && \ - # Cleanup APT. - apt-get clean && \ - rm -rf /var/lib/apt/lists/* && \ - # Create a non-root user. - useradd --shell /usr/sbin/nologin --create-home -d /opt/modmail modmail - -FROM base as builder - -COPY requirements.txt . - -RUN pip install --root-user-action=ignore --no-cache-dir --upgrade pip wheel && \ - python -m venv /opt/modmail/.venv && \ - . /opt/modmail/.venv/bin/activate && \ - pip install --no-cache-dir --upgrade -r requirements.txt - -FROM base - -# Copy the entire venv. -COPY --from=builder --chown=modmail:modmail /opt/modmail/.venv /opt/modmail/.venv - -# Copy repository files. -WORKDIR /opt/modmail -USER modmail:modmail -COPY --chown=modmail:modmail . . - -# This sets some Python runtime variables and disables the internal auto-update. -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PATH=/opt/modmail/.venv/bin:$PATH \ - USING_DOCKER=yes - -CMD ["python", "bot.py"] +FROM python:3.11-slim-bookworm as base + +RUN apt-get update && \ + apt-get install --no-install-recommends -y \ + # Install CairoSVG dependencies. + libcairo2 && \ + # Cleanup APT. + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + # Create a non-root user. + useradd --shell /usr/sbin/nologin --create-home -d /opt/modmail modmail + +FROM base as builder + +COPY requirements.txt . + +RUN pip install --root-user-action=ignore --no-cache-dir --upgrade pip wheel && \ + python -m venv /opt/modmail/.venv && \ + . /opt/modmail/.venv/bin/activate && \ + pip install --no-cache-dir --upgrade -r requirements.txt + +FROM base + +# Copy the entire venv. +COPY --from=builder --chown=modmail:modmail /opt/modmail/.venv /opt/modmail/.venv + +# Copy repository files. +WORKDIR /opt/modmail +USER modmail:modmail +COPY --chown=modmail:modmail . . + +# This sets some Python runtime variables and disables the internal auto-update. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH=/opt/modmail/.venv/bin:$PATH \ + USING_DOCKER=yes + +CMD ["python", "bot.py"] diff --git a/LICENSE b/LICENSE index 7579c25e0a..7a7a5cf442 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,661 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - Modmail for Discord. - Copyright (C) 2018-2019 kyb3r - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Modmail for Discord. + Copyright (C) 2018-2019 kyb3r + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PRIVACY.md b/PRIVACY.md index b4283e3660..f432346543 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,122 +1,122 @@ -# Privacy Statement - -Hey, we are the lead developers of Modmail bot. This is a look into the data we collect, the data you collect, the data other parties collect, and what can be done about any of this data. -> **Disclaimer**: None of us are lawyers. We are just trying to be more transparent - -### TL;DR - -Yes, we collect some data to keep us happy. You collect some data to keep the bot functioning. External services also collect some data that is out of our control. - -## Interpretation - -- Modmail: This application that has been made open-source. -- Modmail Team: Lead developers, namely kyb3r, fourjr and taku. -- Bot: Your instance of the Modmail bot. -- Bot owner: The person managing the bot. -- Guild: A [server](https://discord.com/developers/docs/resources/guild#guild-resource), an isolated collection of users and channels, within Discord -- User: The end user, or server members, that interface with the bot. -- Database: A location where data is stored, hosted by the bot owner. The following types of database are currently supported: [MongoDB](#MongoDB). -- Logviewer: A webserver hosted by the bot owner. - -## The Data We Collect - -No data is being collected unless someone decides to host the bot and the bot is kept online. - -The Modmail Team collect some metadata to keep us updated on the number of instances that are making use of the bot and know what features we should focus on. The following is a list of data that we collect: -- Bot ID -- Bot username and discriminator -- Bot avatar URL -- Main guild ID -- Main guild name -- Main guild member count -- Bot uptime -- Bot latency -- Bot version -- Whether the bot is selfhosted - -No tokens/passwords/private data is ever being collected or sent to our servers. - -This metadata is sent to our centralised servers every hour that the bot is up and can be viewed in the bot logs when the `log_level` is set to `DEBUG`. - -As our bot is completely open-source, the part that details this behaviour is located in `bot.py > ModmailBot > post_metadata`. - -We assure you that the data is not being sold to anybody. - -### Opting out - -The bot owner can opt out of this data collection by setting `data_collection` to `off` within the configuration variables or the `.env` file. - -### Data deletion - -Data can be deleted with a request in a DM to our [support server](https://discord.gg/etJNHCQ)'s Modmail bot. - -## The Data You Collect - -When using the bot, the bot can collect various bits of user data to ensure that the bot can run smoothly. -This data is stored in a database instance that is hosted by the bot owner (more details below). - -When a thread is created, the bot saves the following data: -- Timestamp -- Log Key -- Channel ID -- Guild ID -- Bot ID -- Recipient ID -- Recipient Username and Discriminator -- Recipient Avatar URL -- Whether the recipient is a moderator - -When a message is sent in a thread, the bot saves the following data: -- Timestamp -- Message ID -- Message author ID -- Message author username and discriminator -- Message author avatar URL -- Whether the message author is a moderator -- Message content -- All attachment urls in the message - -This data is essential to have live logs for the web logviewer to function. -The Modmail team does not track any data by users. - -### Opting out - -There is no way for users or moderators to opt out from this data collection. - -### Data deletion - -Logs can be deleted using the `?logs delete ` command. This will remove all data from that specific log entry from the database permenantly. - -## The Data Other Parties Collect - -Plugins form a large part of the Modmail experience. Although we do not have any control over the data plugins collect, including plugins within our registry, all plugins are open-sourced by design. Some plugin devs may collect data beyond our control, and it is the bot owner's responsibility to check with the various plugin developers involved. - -We recommend 4 external services to be used when setting up the Modmail bot. -We have no control over the data external parties collect and it is up to the bot owner's choice as to which external service they choose to employ when using Modmail. -If you wish to opt out of any of this data collection, please view their own privacy policies and data collection information. We will not provide support for such a procedure. - -### Discord - -- [Discord Privacy Policy](https://discord.com/privacy) - -### Heroku - -- [Heroku Security](https://www.heroku.com/policy/security) -- [Salesforce Privacy Policy](https://www.salesforce.com/company/privacy/). - -### MongoDB - -- [MongoDB Privacy Policy](https://www.mongodb.com/legal/privacy-policy). - -### Github - -- [Github Privacy Statement](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-privacy-statement) - -## Maximum Privacy Setup - -For a maximum privacy setup, we recommend the following hosting procedure. We have included links to various help articles for each relevant step. We will not provide support for such a procedure. -- [Creating a local mongodb instance](https://zellwk.com/blog/local-mongodb/) -- [Hosting Modmail on your personal computer](https://taaku18.github.io/modmail/local-hosting/) -- Ensuring `data_collection` is set to `no` in the `.env` file. -- [Opt out of discord data collection](https://support.discord.com/hc/en-us/articles/360004109911-Data-Privacy-Controls) -- Do not use any plugins, setting `enable_plugins` to `no`. +# Privacy Statement + +Hey, we are the lead developers of Modmail bot. This is a look into the data we collect, the data you collect, the data other parties collect, and what can be done about any of this data. +> **Disclaimer**: None of us are lawyers. We are just trying to be more transparent + +### TL;DR + +Yes, we collect some data to keep us happy. You collect some data to keep the bot functioning. External services also collect some data that is out of our control. + +## Interpretation + +- Modmail: This application that has been made open-source. +- Modmail Team: Lead developers, namely kyb3r, fourjr and taku. +- Bot: Your instance of the Modmail bot. +- Bot owner: The person managing the bot. +- Guild: A [server](https://discord.com/developers/docs/resources/guild#guild-resource), an isolated collection of users and channels, within Discord +- User: The end user, or server members, that interface with the bot. +- Database: A location where data is stored, hosted by the bot owner. The following types of database are currently supported: [MongoDB](#MongoDB). +- Logviewer: A webserver hosted by the bot owner. + +## The Data We Collect + +No data is being collected unless someone decides to host the bot and the bot is kept online. + +The Modmail Team collect some metadata to keep us updated on the number of instances that are making use of the bot and know what features we should focus on. The following is a list of data that we collect: +- Bot ID +- Bot username and discriminator +- Bot avatar URL +- Main guild ID +- Main guild name +- Main guild member count +- Bot uptime +- Bot latency +- Bot version +- Whether the bot is selfhosted + +No tokens/passwords/private data is ever being collected or sent to our servers. + +This metadata is sent to our centralised servers every hour that the bot is up and can be viewed in the bot logs when the `log_level` is set to `DEBUG`. + +As our bot is completely open-source, the part that details this behaviour is located in `bot.py > ModmailBot > post_metadata`. + +We assure you that the data is not being sold to anybody. + +### Opting out + +The bot owner can opt out of this data collection by setting `data_collection` to `off` within the configuration variables or the `.env` file. + +### Data deletion + +Data can be deleted with a request in a DM to our [support server](https://discord.gg/etJNHCQ)'s Modmail bot. + +## The Data You Collect + +When using the bot, the bot can collect various bits of user data to ensure that the bot can run smoothly. +This data is stored in a database instance that is hosted by the bot owner (more details below). + +When a thread is created, the bot saves the following data: +- Timestamp +- Log Key +- Channel ID +- Guild ID +- Bot ID +- Recipient ID +- Recipient Username and Discriminator +- Recipient Avatar URL +- Whether the recipient is a moderator + +When a message is sent in a thread, the bot saves the following data: +- Timestamp +- Message ID +- Message author ID +- Message author username and discriminator +- Message author avatar URL +- Whether the message author is a moderator +- Message content +- All attachment urls in the message + +This data is essential to have live logs for the web logviewer to function. +The Modmail team does not track any data by users. + +### Opting out + +There is no way for users or moderators to opt out from this data collection. + +### Data deletion + +Logs can be deleted using the `?logs delete ` command. This will remove all data from that specific log entry from the database permenantly. + +## The Data Other Parties Collect + +Plugins form a large part of the Modmail experience. Although we do not have any control over the data plugins collect, including plugins within our registry, all plugins are open-sourced by design. Some plugin devs may collect data beyond our control, and it is the bot owner's responsibility to check with the various plugin developers involved. + +We recommend 4 external services to be used when setting up the Modmail bot. +We have no control over the data external parties collect and it is up to the bot owner's choice as to which external service they choose to employ when using Modmail. +If you wish to opt out of any of this data collection, please view their own privacy policies and data collection information. We will not provide support for such a procedure. + +### Discord + +- [Discord Privacy Policy](https://discord.com/privacy) + +### Heroku + +- [Heroku Security](https://www.heroku.com/policy/security) +- [Salesforce Privacy Policy](https://www.salesforce.com/company/privacy/). + +### MongoDB + +- [MongoDB Privacy Policy](https://www.mongodb.com/legal/privacy-policy). + +### Github + +- [Github Privacy Statement](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-privacy-statement) + +## Maximum Privacy Setup + +For a maximum privacy setup, we recommend the following hosting procedure. We have included links to various help articles for each relevant step. We will not provide support for such a procedure. +- [Creating a local mongodb instance](https://zellwk.com/blog/local-mongodb/) +- [Hosting Modmail on your personal computer](https://taaku18.github.io/modmail/local-hosting/) +- Ensuring `data_collection` is set to `no` in the `.env` file. +- [Opt out of discord data collection](https://support.discord.com/hc/en-us/articles/360004109911-Data-Privacy-Controls) +- Do not use any plugins, setting `enable_plugins` to `no`. diff --git a/Pipfile b/Pipfile index 21205b36f5..89b3694ba2 100644 --- a/Pipfile +++ b/Pipfile @@ -1,30 +1,30 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -bandit = ">=1.7.5" -black = "==23.11.0" -pylint = "==3.0.2" -typing-extensions = "==4.8.0" - -[packages] -aiohttp = "==3.9.0" -colorama = "==0.4.6" -"discord.py" = {version = "==2.3.2", extras = ["speed"]} -emoji = "==2.8.0" -isodate = "==0.6.1" -motor = "==3.3.2" -natural = "==0.2.0" # Why is this needed? -packaging = "==23.2" -parsedatetime = "==2.6" -pymongo = {extras = ["srv"], version = "*"} # Required by motor -python-dateutil = "==2.8.2" -python-dotenv = "==1.0.0" -uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} -lottie = {version = "==0.7.0", extras = ["pdf"]} -requests = "==2.31.0" - -[scripts] -bot = "python bot.py" +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +bandit = ">=1.7.5" +black = "==23.11.0" +pylint = "==3.0.2" +typing-extensions = "==4.8.0" + +[packages] +aiohttp = "==3.9.0" +colorama = "==0.4.6" +"discord.py" = {version = "==2.5.2", extras = ["speed"]} +emoji = "==2.8.0" +isodate = "==0.6.1" +motor = "==3.3.2" +natural = "==0.2.0" # Why is this needed? +packaging = "==23.2" +parsedatetime = "==2.6" +pymongo = {extras = ["srv"], version = "*"} # Required by motor +python-dateutil = "==2.8.2" +python-dotenv = "==1.0.0" +uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} +lottie = {version = "==0.7.0", extras = ["pdf"]} +requests = "==2.31.0" + +[scripts] +bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index 5f07c7b131..2a2241f668 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,1388 +1,1388 @@ -{ - "_meta": { - "hash": { - "sha256": "7fee393ea9ea4c0b923033f0da0fdc590ba3f75c6072812062cdc458b84bf9ae" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiodns": { - "hashes": [ - "sha256:1073eac48185f7a4150cad7f96a5192d6911f12b4fb894de80a088508c9b3a99", - "sha256:a387b63da4ced6aad35b1dda2d09620ad608a1c7c0fb71efa07ebb4cd511928d" - ], - "version": "==3.1.1" - }, - "aiohttp": { - "hashes": [ - "sha256:05857848da443c8c12110d99285d499b4e84d59918a21132e45c3f0804876994", - "sha256:05a183f1978802588711aed0dea31e697d760ce9055292db9dc1604daa9a8ded", - "sha256:09f23292d29135025e19e8ff4f0a68df078fe4ee013bca0105b2e803989de92d", - "sha256:11ca808f9a6b63485059f5f6e164ef7ec826483c1212a44f268b3653c91237d8", - "sha256:1736d87dad8ef46a8ec9cddd349fa9f7bd3a064c47dd6469c0d6763d3d49a4fc", - "sha256:1df43596b826022b14998f0460926ce261544fedefe0d2f653e1b20f49e96454", - "sha256:23170247ef89ffa842a02bbfdc425028574d9e010611659abeb24d890bc53bb8", - "sha256:2779f5e7c70f7b421915fd47db332c81de365678180a9f3ab404088f87ba5ff9", - "sha256:28185e36a78d247c55e9fbea2332d16aefa14c5276a582ce7a896231c6b1c208", - "sha256:2cbc14a13fb6b42d344e4f27746a4b03a2cb0c1c3c5b932b0d6ad8881aa390e3", - "sha256:2d71abc15ff7047412ef26bf812dfc8d0d1020d664617f4913df2df469f26b76", - "sha256:2d820162c8c2bdbe97d328cd4f417c955ca370027dce593345e437b2e9ffdc4d", - "sha256:317719d7f824eba55857fe0729363af58e27c066c731bc62cd97bc9c3d9c7ea4", - "sha256:35a68cd63ca6aaef5707888f17a70c36efe62b099a4e853d33dc2e9872125be8", - "sha256:3607375053df58ed6f23903aa10cf3112b1240e8c799d243bbad0f7be0666986", - "sha256:366bc870d7ac61726f32a489fbe3d1d8876e87506870be66b01aeb84389e967e", - "sha256:3abf0551874fecf95f93b58f25ef4fc9a250669a2257753f38f8f592db85ddea", - "sha256:3d7f6235c7475658acfc1769d968e07ab585c79f6ca438ddfecaa9a08006aee2", - "sha256:3dd8119752dd30dd7bca7d4bc2a92a59be6a003e4e5c2cf7e248b89751b8f4b7", - "sha256:42fe4fd9f0dfcc7be4248c162d8056f1d51a04c60e53366b0098d1267c4c9da8", - "sha256:45820ddbb276113ead8d4907a7802adb77548087ff5465d5c554f9aa3928ae7d", - "sha256:4790e44f46a4aa07b64504089def5744d3b6780468c4ec3a1a36eb7f2cae9814", - "sha256:4afa8f71dba3a5a2e1e1282a51cba7341ae76585345c43d8f0e624882b622218", - "sha256:4b777c9286b6c6a94f50ddb3a6e730deec327e9e2256cb08b5530db0f7d40fd8", - "sha256:4ee1b4152bc3190cc40ddd6a14715e3004944263ea208229ab4c297712aa3075", - "sha256:51a4cd44788ea0b5e6bb8fa704597af3a30be75503a7ed1098bc5b8ffdf6c982", - "sha256:536b01513d67d10baf6f71c72decdf492fb7433c5f2f133e9a9087379d4b6f31", - "sha256:571760ad7736b34d05597a1fd38cbc7d47f7b65deb722cb8e86fd827404d1f6b", - "sha256:5a2eb5311a37fe105aa35f62f75a078537e1a9e4e1d78c86ec9893a3c97d7a30", - "sha256:5ab16c254e2312efeb799bc3c06897f65a133b38b69682bf75d1f1ee1a9c43a9", - "sha256:65b0a70a25456d329a5e1426702dde67be0fb7a4ead718005ba2ca582d023a94", - "sha256:673343fbc0c1ac44d0d2640addc56e97a052504beacd7ade0dc5e76d3a4c16e8", - "sha256:6777a390e41e78e7c45dab43a4a0196c55c3b8c30eebe017b152939372a83253", - "sha256:6896b8416be9ada4d22cd359d7cb98955576ce863eadad5596b7cdfbf3e17c6c", - "sha256:694df243f394629bcae2d8ed94c589a181e8ba8604159e6e45e7b22e58291113", - "sha256:70e851f596c00f40a2f00a46126c95c2e04e146015af05a9da3e4867cfc55911", - "sha256:7276fe0017664414fdc3618fca411630405f1aaf0cc3be69def650eb50441787", - "sha256:76a86a9989ebf82ee61e06e2bab408aec4ea367dc6da35145c3352b60a112d11", - "sha256:7a94bde005a8f926d0fa38b88092a03dea4b4875a61fbcd9ac6f4351df1b57cd", - "sha256:7ae5f99a32c53731c93ac3075abd3e1e5cfbe72fc3eaac4c27c9dd64ba3b19fe", - "sha256:7e8a3b79b6d186a9c99761fd4a5e8dd575a48d96021f220ac5b5fa856e5dd029", - "sha256:816f4db40555026e4cdda604a1088577c1fb957d02f3f1292e0221353403f192", - "sha256:8303531e2c17b1a494ffaeba48f2da655fe932c4e9a2626c8718403c83e5dd2b", - "sha256:8488519aa05e636c5997719fe543c8daf19f538f4fa044f3ce94bee608817cff", - "sha256:87c8b0a6487e8109427ccf638580865b54e2e3db4a6e0e11c02639231b41fc0f", - "sha256:8c9e5f4d7208cda1a2bb600e29069eecf857e6980d0ccc922ccf9d1372c16f4b", - "sha256:94697c7293199c2a2551e3e3e18438b4cba293e79c6bc2319f5fd652fccb7456", - "sha256:9623cfd9e85b76b83ef88519d98326d4731f8d71869867e47a0b979ffec61c73", - "sha256:98d21092bf2637c5fa724a428a69e8f5955f2182bff61f8036827cf6ce1157bf", - "sha256:99ae01fb13a618b9942376df77a1f50c20a281390dad3c56a6ec2942e266220d", - "sha256:9c196b30f1b1aa3363a69dd69079ae9bec96c2965c4707eaa6914ba099fb7d4f", - "sha256:a00ce44c21612d185c5275c5cba4bab8d7c1590f248638b667ed8a782fa8cd6f", - "sha256:a1b66dbb8a7d5f50e9e2ea3804b01e766308331d0cac76eb30c563ac89c95985", - "sha256:a1d7edf74a36de0e5ca50787e83a77cf352f5504eb0ffa3f07000a911ba353fb", - "sha256:a1e3b3c107ccb0e537f309f719994a55621acd2c8fdf6d5ce5152aed788fb940", - "sha256:a486ddf57ab98b6d19ad36458b9f09e6022de0381674fe00228ca7b741aacb2f", - "sha256:ac9669990e2016d644ba8ae4758688534aabde8dbbc81f9af129c3f5f01ca9cd", - "sha256:b1a2ea8252cacc7fd51df5a56d7a2bb1986ed39be9397b51a08015727dfb69bd", - "sha256:c5b7bf8fe4d39886adc34311a233a2e01bc10eb4e842220235ed1de57541a896", - "sha256:c67a51ea415192c2e53e4e048c78bab82d21955b4281d297f517707dc836bf3d", - "sha256:ca4fddf84ac7d8a7d0866664936f93318ff01ee33e32381a115b19fb5a4d1202", - "sha256:d5b9345ab92ebe6003ae11d8092ce822a0242146e6fa270889b9ba965457ca40", - "sha256:d97c3e286d0ac9af6223bc132dc4bad6540b37c8d6c0a15fe1e70fb34f9ec411", - "sha256:db04d1de548f7a62d1dd7e7cdf7c22893ee168e22701895067a28a8ed51b3735", - "sha256:dcf71c55ec853826cd70eadb2b6ac62ec577416442ca1e0a97ad875a1b3a0305", - "sha256:de3cc86f4ea8b4c34a6e43a7306c40c1275e52bfa9748d869c6b7d54aa6dad80", - "sha256:deac0a32aec29608eb25d730f4bc5a261a65b6c48ded1ed861d2a1852577c932", - "sha256:e18d92c3e9e22553a73e33784fcb0ed484c9874e9a3e96c16a8d6a1e74a0217b", - "sha256:eb6dfd52063186ac97b4caa25764cdbcdb4b10d97f5c5f66b0fa95052e744eb7", - "sha256:f09960b5bb1017d16c0f9e9f7fc42160a5a49fa1e87a175fd4a2b1a1833ea0af", - "sha256:f1e4f254e9c35d8965d377e065c4a8a55d396fe87c8e7e8429bcfdeeb229bfb3", - "sha256:f32c86dc967ab8c719fd229ce71917caad13cc1e8356ee997bf02c5b368799bf", - "sha256:f50b4663c3e0262c3a361faf440761fbef60ccdde5fe8545689a4b3a3c149fb4", - "sha256:f8e05f5163528962ce1d1806fce763ab893b1c5b7ace0a3538cd81a90622f844", - "sha256:f929f4c9b9a00f3e6cc0587abb95ab9c05681f8b14e0fe1daecfa83ea90f8318", - "sha256:f9e09a1c83521d770d170b3801eea19b89f41ccaa61d53026ed111cb6f088887" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==3.9.0" - }, - "aiosignal": { - "hashes": [ - "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", - "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.1" - }, - "async-timeout": { - "hashes": [ - "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", - "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" - ], - "markers": "python_version < '3.11'", - "version": "==4.0.3" - }, - "attrs": { - "hashes": [ - "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", - "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" - ], - "markers": "python_version >= '3.7'", - "version": "==23.1.0" - }, - "brotli": { - "hashes": [ - "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208", - "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48", - "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354", - "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a", - "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", - "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c", - "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088", - "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", - "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a", - "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", - "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438", - "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578", - "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b", - "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b", - "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68", - "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d", - "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", - "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", - "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", - "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", - "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0", - "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", - "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", - "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112", - "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", - "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", - "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", - "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95", - "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", - "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914", - "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", - "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a", - "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7", - "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", - "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", - "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f", - "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", - "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", - "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", - "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", - "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", - "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97", - "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d", - "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf", - "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac", - "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", - "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74", - "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60", - "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c", - "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1", - "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", - "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", - "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", - "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", - "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460", - "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751", - "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9", - "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", - "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474", - "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", - "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", - "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", - "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", - "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467", - "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619", - "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", - "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", - "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579", - "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84", - "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b", - "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59", - "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", - "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", - "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", - "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2", - "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3", - "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64", - "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643", - "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", - "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985", - "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596", - "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2", - "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064" - ], - "version": "==1.1.0" - }, - "cairocffi": { - "hashes": [ - "sha256:78e6bbe47357640c453d0be929fa49cd05cce2e1286f3d2a1ca9cbda7efdb8b7", - "sha256:aa78ee52b9069d7475eeac457389b6275aa92111895d78fbaa2202a52dac112e" - ], - "markers": "python_version >= '3.7'", - "version": "==1.6.1" - }, - "cairosvg": { - "hashes": [ - "sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0", - "sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b" - ], - "markers": "python_version >= '3.5'", - "version": "==2.7.1" - }, - "certifi": { - "hashes": [ - "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", - "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" - ], - "markers": "python_version >= '3.6'", - "version": "==2023.11.17" - }, - "cffi": { - "hashes": [ - "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", - "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", - "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", - "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", - "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", - "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", - "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", - "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", - "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", - "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", - "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", - "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", - "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", - "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", - "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", - "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", - "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", - "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", - "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", - "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", - "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", - "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", - "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", - "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", - "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", - "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", - "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", - "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", - "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", - "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", - "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", - "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", - "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", - "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", - "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", - "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", - "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", - "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", - "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", - "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", - "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", - "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", - "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", - "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", - "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", - "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", - "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", - "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", - "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", - "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", - "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", - "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" - ], - "markers": "python_version >= '3.8'", - "version": "==1.16.0" - }, - "charset-normalizer": { - "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" - }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", - "version": "==0.4.6" - }, - "cssselect2": { - "hashes": [ - "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a", - "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" - ], - "markers": "python_version >= '3.7'", - "version": "==0.7.0" - }, - "defusedxml": { - "hashes": [ - "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", - "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.7.1" - }, - "discord.py": { - "extras": [ - "speed" - ], - "hashes": [ - "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c", - "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==2.3.2" - }, - "dnspython": { - "hashes": [ - "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8", - "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984" - ], - "markers": "python_version >= '3.8' and python_version < '4.0'", - "version": "==2.4.2" - }, - "emoji": { - "hashes": [ - "sha256:8d8b5dec3c507444b58890e598fc895fcec022b3f5acb49497c6ccc5208b8b00", - "sha256:a8468fd836b7ecb6d1eac054c9a591701ce0ccd6c6f7779ad71b66f76664df90" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.0" - }, - "frozenlist": { - "hashes": [ - "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6", - "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01", - "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251", - "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9", - "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b", - "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87", - "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf", - "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f", - "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0", - "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2", - "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b", - "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc", - "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c", - "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467", - "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9", - "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1", - "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a", - "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79", - "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167", - "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300", - "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf", - "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea", - "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2", - "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab", - "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3", - "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb", - "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087", - "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc", - "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8", - "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62", - "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f", - "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326", - "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c", - "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431", - "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963", - "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7", - "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef", - "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3", - "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956", - "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781", - "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472", - "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc", - "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839", - "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672", - "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3", - "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503", - "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d", - "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8", - "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b", - "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc", - "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f", - "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559", - "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b", - "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95", - "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb", - "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963", - "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919", - "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f", - "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3", - "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1", - "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e" - ], - "markers": "python_version >= '3.8'", - "version": "==1.4.0" - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "isodate": { - "hashes": [ - "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", - "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9" - ], - "index": "pypi", - "version": "==0.6.1" - }, - "lottie": { - "extras": [ - "pdf" - ], - "hashes": [ - "sha256:a3242f8ba37051fbdd7503ecd168203a08e4af26f17be2ecca08a64af1e7d3c1" - ], - "markers": "python_version >= '3'", - "version": "==0.7.0" - }, - "motor": { - "hashes": [ - "sha256:6fe7e6f0c4f430b9e030b9d22549b732f7c2226af3ab71ecc309e4a1b7d19953", - "sha256:d2fc38de15f1c8058f389c1a44a4d4105c0405c48c061cd492a654496f7bc26a" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==3.3.2" - }, - "multidict": { - "hashes": [ - "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", - "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", - "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", - "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", - "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", - "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", - "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", - "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", - "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", - "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", - "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", - "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", - "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", - "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", - "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", - "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", - "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", - "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", - "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", - "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", - "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", - "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", - "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", - "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", - "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", - "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", - "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", - "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", - "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", - "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", - "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", - "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", - "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", - "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", - "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", - "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", - "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", - "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", - "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", - "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", - "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", - "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", - "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", - "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", - "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", - "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", - "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", - "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", - "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", - "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", - "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", - "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", - "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", - "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", - "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", - "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", - "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", - "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", - "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", - "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", - "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", - "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", - "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", - "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", - "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", - "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", - "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", - "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", - "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", - "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", - "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", - "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", - "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", - "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" - ], - "markers": "python_version >= '3.7'", - "version": "==6.0.4" - }, - "natural": { - "hashes": [ - "sha256:18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2" - ], - "index": "pypi", - "version": "==0.2.0" - }, - "orjson": { - "hashes": [ - "sha256:06ad5543217e0e46fd7ab7ea45d506c76f878b87b1b4e369006bdb01acc05a83", - "sha256:0a73160e823151f33cdc05fe2cea557c5ef12fdf276ce29bb4f1c571c8368a60", - "sha256:1234dc92d011d3554d929b6cf058ac4a24d188d97be5e04355f1b9223e98bbe9", - "sha256:1d0dc4310da8b5f6415949bd5ef937e60aeb0eb6b16f95041b5e43e6200821fb", - "sha256:2a11b4b1a8415f105d989876a19b173f6cdc89ca13855ccc67c18efbd7cbd1f8", - "sha256:2e2ecd1d349e62e3960695214f40939bbfdcaeaaa62ccc638f8e651cf0970e5f", - "sha256:3a2ce5ea4f71681623f04e2b7dadede3c7435dfb5e5e2d1d0ec25b35530e277b", - "sha256:3e892621434392199efb54e69edfff9f699f6cc36dd9553c5bf796058b14b20d", - "sha256:3fb205ab52a2e30354640780ce4587157a9563a68c9beaf52153e1cea9aa0921", - "sha256:4689270c35d4bb3102e103ac43c3f0b76b169760aff8bcf2d401a3e0e58cdb7f", - "sha256:49f8ad582da6e8d2cf663c4ba5bf9f83cc052570a3a767487fec6af839b0e777", - "sha256:4bd176f528a8151a6efc5359b853ba3cc0e82d4cd1fab9c1300c5d957dc8f48c", - "sha256:4cf7837c3b11a2dfb589f8530b3cff2bd0307ace4c301e8997e95c7468c1378e", - "sha256:4fd72fab7bddce46c6826994ce1e7de145ae1e9e106ebb8eb9ce1393ca01444d", - "sha256:5148bab4d71f58948c7c39d12b14a9005b6ab35a0bdf317a8ade9a9e4d9d0bd5", - "sha256:5869e8e130e99687d9e4be835116c4ebd83ca92e52e55810962446d841aba8de", - "sha256:602a8001bdf60e1a7d544be29c82560a7b49319a0b31d62586548835bbe2c862", - "sha256:61804231099214e2f84998316f3238c4c2c4aaec302df12b21a64d72e2a135c7", - "sha256:666c6fdcaac1f13eb982b649e1c311c08d7097cbda24f32612dae43648d8db8d", - "sha256:674eb520f02422546c40401f4efaf8207b5e29e420c17051cddf6c02783ff5ca", - "sha256:7ec960b1b942ee3c69323b8721df2a3ce28ff40e7ca47873ae35bfafeb4555ca", - "sha256:7f433be3b3f4c66016d5a20e5b4444ef833a1f802ced13a2d852c637f69729c1", - "sha256:7f8fb7f5ecf4f6355683ac6881fd64b5bb2b8a60e3ccde6ff799e48791d8f864", - "sha256:81a3a3a72c9811b56adf8bcc829b010163bb2fc308877e50e9910c9357e78521", - "sha256:858379cbb08d84fe7583231077d9a36a1a20eb72f8c9076a45df8b083724ad1d", - "sha256:8b9ba0ccd5a7f4219e67fbbe25e6b4a46ceef783c42af7dbc1da548eb28b6531", - "sha256:92af0d00091e744587221e79f68d617b432425a7e59328ca4c496f774a356071", - "sha256:9ebbdbd6a046c304b1845e96fbcc5559cd296b4dfd3ad2509e33c4d9ce07d6a1", - "sha256:9edd2856611e5050004f4722922b7b1cd6268da34102667bd49d2a2b18bafb81", - "sha256:a353bf1f565ed27ba71a419b2cd3db9d6151da426b61b289b6ba1422a702e643", - "sha256:b5b7d4a44cc0e6ff98da5d56cde794385bdd212a86563ac321ca64d7f80c80d1", - "sha256:b90f340cb6397ec7a854157fac03f0c82b744abdd1c0941a024c3c29d1340aff", - "sha256:c18a4da2f50050a03d1da5317388ef84a16013302a5281d6f64e4a3f406aabc4", - "sha256:c338ed69ad0b8f8f8920c13f529889fe0771abbb46550013e3c3d01e5174deef", - "sha256:c5a02360e73e7208a872bf65a7554c9f15df5fe063dc047f79738998b0506a14", - "sha256:c62b6fa2961a1dcc51ebe88771be5319a93fd89bd247c9ddf732bc250507bc2b", - "sha256:c812312847867b6335cfb264772f2a7e85b3b502d3a6b0586aa35e1858528ab1", - "sha256:c943b35ecdf7123b2d81d225397efddf0bce2e81db2f3ae633ead38e85cd5ade", - "sha256:ce0a29c28dfb8eccd0f16219360530bc3cfdf6bf70ca384dacd36e6c650ef8e8", - "sha256:cf80b550092cc480a0cbd0750e8189247ff45457e5a023305f7ef1bcec811616", - "sha256:cff7570d492bcf4b64cc862a6e2fb77edd5e5748ad715f487628f102815165e9", - "sha256:d2c1e559d96a7f94a4f581e2a32d6d610df5840881a8cba8f25e446f4d792df3", - "sha256:deeb3922a7a804755bbe6b5be9b312e746137a03600f488290318936c1a2d4dc", - "sha256:e28a50b5be854e18d54f75ef1bb13e1abf4bc650ab9d635e4258c58e71eb6ad5", - "sha256:e99c625b8c95d7741fe057585176b1b8783d46ed4b8932cf98ee145c4facf499", - "sha256:ec6f18f96b47299c11203edfbdc34e1b69085070d9a3d1f302810cc23ad36bf3", - "sha256:ed8bc367f725dfc5cabeed1ae079d00369900231fbb5a5280cf0736c30e2adf7", - "sha256:ee5926746232f627a3be1cc175b2cfad24d0170d520361f4ce3fa2fd83f09e1d", - "sha256:f295efcd47b6124b01255d1491f9e46f17ef40d3d7eabf7364099e463fb45f0f", - "sha256:fb0b361d73f6b8eeceba47cd37070b5e6c9de5beaeaa63a1cb35c7e1a73ef088" - ], - "version": "==3.9.10" - }, - "packaging": { - "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==23.2" - }, - "parsedatetime": { - "hashes": [ - "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", - "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" - ], - "index": "pypi", - "version": "==2.6" - }, - "pillow": { - "hashes": [ - "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d", - "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de", - "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616", - "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839", - "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099", - "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a", - "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219", - "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106", - "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b", - "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412", - "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b", - "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7", - "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2", - "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7", - "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14", - "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f", - "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27", - "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57", - "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262", - "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28", - "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610", - "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172", - "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273", - "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e", - "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d", - "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818", - "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f", - "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9", - "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01", - "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7", - "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651", - "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312", - "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80", - "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666", - "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061", - "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b", - "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992", - "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593", - "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4", - "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db", - "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba", - "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd", - "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e", - "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212", - "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb", - "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2", - "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34", - "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256", - "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f", - "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2", - "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38", - "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996", - "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a", - "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793" - ], - "markers": "python_version >= '3.8'", - "version": "==10.1.0" - }, - "pycares": { - "hashes": [ - "sha256:112a4979c695b1c86f6782163d7dec58d57a3b9510536dcf4826550f9053dd9a", - "sha256:1168a48a834813aa80f412be2df4abaf630528a58d15c704857448b20b1675c0", - "sha256:21a5a0468861ec7df7befa69050f952da13db5427ae41ffe4713bc96291d1d95", - "sha256:229a1675eb33bc9afb1fc463e73ee334950ccc485bc83a43f6ae5839fb4d5fa3", - "sha256:22c00bf659a9fa44d7b405cf1cd69b68b9d37537899898d8cbe5dffa4016b273", - "sha256:23aa3993a352491a47fcf17867f61472f32f874df4adcbb486294bd9fbe8abee", - "sha256:24da119850841d16996713d9c3374ca28a21deee056d609fbbed29065d17e1f6", - "sha256:2eeec144bcf6a7b6f2d74d6e70cbba7886a84dd373c886f06cb137a07de4954c", - "sha256:34736a2ffaa9c08ca9c707011a2d7b69074bbf82d645d8138bba771479b2362f", - "sha256:3aebc73e5ad70464f998f77f2da2063aa617cbd8d3e8174dd7c5b4518f967153", - "sha256:3eaa6681c0a3e3f3868c77aca14b7760fed35fdfda2fe587e15c701950e7bc69", - "sha256:4afc2644423f4eef97857a9fd61be9758ce5e336b4b0bd3d591238bb4b8b03e0", - "sha256:52084961262232ec04bd75f5043aed7e5d8d9695e542ff691dfef0110209f2d4", - "sha256:56cf3349fa3a2e67ed387a7974c11d233734636fe19facfcda261b411af14d80", - "sha256:5ed4e04af4012f875b78219d34434a6d08a67175150ac1b79eb70ab585d4ba8c", - "sha256:64965dc19c578a683ea73487a215a8897276224e004d50eeb21f0bc7a0b63c88", - "sha256:6ef64649eba56448f65e26546d85c860709844d2fc22ef14d324fe0b27f761a9", - "sha256:77cf5a2fd5583c670de41a7f4a7b46e5cbabe7180d8029f728571f4d2e864084", - "sha256:7bddc6adba8f699728f7fc1c9ce8cef359817ad78e2ed52b9502cb5f8dc7f741", - "sha256:813d661cbe2e37d87da2d16b7110a6860e93ddb11735c6919c8a3545c7b9c8d8", - "sha256:82bba2ab77eb5addbf9758d514d9bdef3c1bfe7d1649a47bd9a0d55a23ef478b", - "sha256:8bf2eaa83a5987e48fa63302f0fe7ce3275cfda87b34d40fef9ce703fb3ac002", - "sha256:8d186dafccdaa3409194c0f94db93c1a5d191145a275f19da6591f9499b8e7b8", - "sha256:8f64cb58729689d4d0e78f0bfb4c25ce2f851d0274c0273ac751795c04b8798a", - "sha256:902461a92b6a80fd5041a2ec5235680c7cc35e43615639ec2a40e63fca2dfb51", - "sha256:917f08f0b5d9324e9a34211e68d27447c552b50ab967044776bbab7e42a553a2", - "sha256:94d6962db81541eb0396d2f0dfcbb18cdb8c8b251d165efc2d974ae652c547d4", - "sha256:97892cced5794d721fb4ff8765764aa4ea48fe8b2c3820677505b96b83d4ef47", - "sha256:9a0303428d013ccf5c51de59c83f9127aba6200adb7fd4be57eddb432a1edd2a", - "sha256:9dc04c54c6ea615210c1b9e803d0e2d2255f87a3d5d119b6482c8f0dfa15b26b", - "sha256:a0c5368206057884cde18602580083aeaad9b860e2eac14fd253543158ce1e93", - "sha256:ad58e284a658a8a6a84af2e0b62f2f961f303cedfe551854d7bd40c3cbb61912", - "sha256:afb91792f1556f97be7f7acb57dc7756d89c5a87bd8b90363a77dbf9ea653817", - "sha256:b61579cecf1f4d616e5ea31a6e423a16680ab0d3a24a2ffe7bb1d4ee162477ff", - "sha256:b7af06968cbf6851566e806bf3e72825b0e6671832a2cbe840be1d2d65350710", - "sha256:bce8db2fc6f3174bd39b81405210b9b88d7b607d33e56a970c34a0c190da0490", - "sha256:bfb89ca9e3d0a9b5332deeb666b2ede9d3469107742158f4aeda5ce032d003f4", - "sha256:c680fef1b502ee680f8f0b95a41af4ec2c234e50e16c0af5bbda31999d3584bd", - "sha256:c6a8bde63106f162fca736e842a916853cad3c8d9d137e11c9ffa37efa818b02", - "sha256:cb49d5805cd347c404f928c5ae7c35e86ba0c58ffa701dbe905365e77ce7d641", - "sha256:ceb12974367b0a68a05d52f4162b29f575d241bd53de155efe632bf2c943c7f6", - "sha256:d33e2a1120887e89075f7f814ec144f66a6ce06a54f5722ccefc62fbeda83cff", - "sha256:db24c4e7fea4a052c6e869cbf387dd85d53b9736cfe1ef5d8d568d1ca925e977", - "sha256:e3a6f7cfdfd11eb5493d6d632e582408c8f3b429f295f8799c584c108b28db6f", - "sha256:eb66c30eb11e877976b7ead13632082a8621df648c408b8e15cdb91a452dd502", - "sha256:ed2a38e34bec6f2586435f6ff0bc5fe11d14bebd7ed492cf739a424e81681540", - "sha256:f36bdc1562142e3695555d2f4ac0cb69af165eddcefa98efc1c79495b533481f", - "sha256:f47579d508f2f56eddd16ce72045782ad3b1b3b678098699e2b6a1b30733e1c2", - "sha256:f5f646eec041db6ffdbcaf3e0756fb92018f7af3266138c756bb09d2b5baadec", - "sha256:fd644505a8cfd7f6584d33a9066d4e3d47700f050ef1490230c962de5dfb28c6", - "sha256:fff16b09042ba077f7b8aa5868d1d22456f0002574d0ba43462b10a009331677" - ], - "markers": "python_version >= '3.8'", - "version": "==4.4.0" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pymongo": { - "extras": [ - "srv" - ], - "hashes": [ - "sha256:014e7049dd019a6663747ca7dae328943e14f7261f7c1381045dfc26a04fa330", - "sha256:055f5c266e2767a88bb585d01137d9c7f778b0195d3dbf4a487ef0638be9b651", - "sha256:05c30fd35cc97f14f354916b45feea535d59060ef867446b5c3c7f9b609dd5dc", - "sha256:0634994b026336195778e5693583c060418d4ab453eff21530422690a97e1ee8", - "sha256:09c7de516b08c57647176b9fc21d929d628e35bcebc7422220c89ae40b62126a", - "sha256:107a234dc55affc5802acb3b6d83cbb8c87355b38a9457fcd8806bdeb8bce161", - "sha256:10a379fb60f1b2406ae57b8899bacfe20567918c8e9d2d545e1b93628fcf2050", - "sha256:128b1485753106c54af481789cdfea12b90a228afca0b11fb3828309a907e10e", - "sha256:1394c4737b325166a65ae7c145af1ebdb9fb153ebedd37cf91d676313e4a67b8", - "sha256:1c63e3a2e8fb815c4b1f738c284a4579897e37c3cfd95fdb199229a1ccfb638a", - "sha256:1e4ed21029d80c4f62605ab16398fe1ce093fff4b5f22d114055e7d9fbc4adb0", - "sha256:1ec71ac633b126c0775ed4604ca8f56c3540f5c21a1220639f299e7a544b55f9", - "sha256:21812453354b151200034750cd30b0140e82ec2a01fd4357390f67714a1bfbde", - "sha256:256c503a75bd71cf7fb9ebf889e7e222d49c6036a48aad5a619f98a0adf0e0d7", - "sha256:2703a9f8f5767986b4f51c259ff452cc837c5a83c8ed5f5361f6e49933743b2f", - "sha256:288c21ab9531b037f7efa4e467b33176bc73a0c27223c141b822ab4a0e66ff2a", - "sha256:2972dd1f1285866aba027eff2f4a2bbf8aa98563c2ced14cb34ee5602b36afdf", - "sha256:2973f113e079fb98515722cd728e1820282721ec9fd52830e4b73cabdbf1eb28", - "sha256:2ca0ba501898b2ec31e6c3acf90c31910944f01d454ad8e489213a156ccf1bda", - "sha256:2d2be5c9c3488fa8a70f83ed925940f488eac2837a996708d98a0e54a861f212", - "sha256:2f8c04277d879146eacda920476e93d520eff8bec6c022ac108cfa6280d84348", - "sha256:325701ae7b56daa5b0692305b7cb505ca50f80a1288abb32ff420a8a209b01ca", - "sha256:3729b8db02063da50eeb3db88a27670d85953afb9a7f14c213ac9e3dca93034b", - "sha256:3919708594b86d0f5cdc713eb6fccd3f9b9532af09ea7a5d843c933825ef56c4", - "sha256:39a1cd5d383b37285641d5a7a86be85274466ae336a61b51117155936529f9b3", - "sha256:3ec6c20385c5a58e16b1ea60c5e4993ea060540671d7d12664f385f2fb32fe79", - "sha256:47aa128be2e66abd9d1a9b0437c62499d812d291f17b55185cb4aa33a5f710a4", - "sha256:49f2af6cf82509b15093ce3569229e0d53c90ad8ae2eef940652d4cf1f81e045", - "sha256:4a0269811661ba93c472c8a60ea82640e838c2eb148d252720a09b5123f2c2fe", - "sha256:518c90bdd6e842c446d01a766b9136fec5ec6cc94f3b8c3f8b4a332786ee6b64", - "sha256:5717a308a703dda2886a5796a07489c698b442f5e409cf7dc2ac93de8d61d764", - "sha256:5802acc012bbb4bce4dff92973dff76482f30ef35dd4cb8ab5b0e06aa8f08c80", - "sha256:5e63146dbdb1eac207464f6e0cfcdb640c9c5ff0f57b754fa96fe252314a1dc6", - "sha256:6695d7136a435c1305b261a9ddb9b3ecec9863e05aab3935b96038145fd3a977", - "sha256:680fa0fc719e1a3dcb81130858368f51d83667d431924d0bcf249644bce8f303", - "sha256:6b18276f14b4b6d92e707ab6db19b938e112bd2f1dc3f9f1a628df58e4fd3f0d", - "sha256:6bafea6061d63059d8bc2ffc545e2f049221c8a4457d236c5cd6a66678673eab", - "sha256:6d6a1b1361f118e7fefa17ae3114e77f10ee1b228b20d50c47c9f351346180c8", - "sha256:747c84f4e690fbe6999c90ac97246c95d31460d890510e4a3fa61b7d2b87aa34", - "sha256:79f41576b3022c2fe9780ae3e44202b2438128a25284a8ddfa038f0785d87019", - "sha256:7b0e6361754ac596cd16bfc6ed49f69ffcd9b60b7bc4bcd3ea65c6a83475e4ff", - "sha256:7e3b0127b260d4abae7b62203c4c7ef0874c901b55155692353db19de4b18bc4", - "sha256:7fc2bb8a74dcfcdd32f89528e38dcbf70a3a6594963d60dc9595e3b35b66e414", - "sha256:806e094e9e85d8badc978af8c95b69c556077f11844655cb8cd2d1758769e521", - "sha256:81dd1308bd5630d2bb5980f00aa163b986b133f1e9ed66c66ce2a5bc3572e891", - "sha256:82e620842e12e8cb4050d2643a81c8149361cd82c0a920fa5a15dc4ca8a4000f", - "sha256:85f2cdc400ee87f5952ebf2a117488f2525a3fb2e23863a8efe3e4ee9e54e4d1", - "sha256:8ab6bcc8e424e07c1d4ba6df96f7fb963bcb48f590b9456de9ebd03b88084fe8", - "sha256:8adf014f2779992eba3b513e060d06f075f0ab2fb3ad956f413a102312f65cdf", - "sha256:9b0f98481ad5dc4cb430a60bbb8869f05505283b9ae1c62bdb65eb5e020ee8e3", - "sha256:9bea9138b0fc6e2218147e9c6ce1ff76ff8e29dc00bb1b64842bd1ca107aee9f", - "sha256:a09bfb51953930e7e838972ddf646c5d5f984992a66d79da6ba7f6a8d8a890cd", - "sha256:a0be99b599da95b7a90a918dd927b20c434bea5e1c9b3efc6a3c6cd67c23f813", - "sha256:a49aca4d961823b2846b739380c847e8964ff7ae0f0a683992b9d926054f0d6d", - "sha256:a4dc1319d0c162919ee7f4ee6face076becae2abbd351cc14f1fe70af5fb20d9", - "sha256:a8273e1abbcff1d7d29cbbb1ea7e57d38be72f1af3c597c854168508b91516c2", - "sha256:a8f7f9feecae53fa18d6a3ea7c75f9e9a1d4d20e5c3f9ce3fba83f07bcc4eee2", - "sha256:ad4f66fbb893b55f96f03020e67dcab49ffde0177c6565ccf9dec4fdf974eb61", - "sha256:af425f323fce1b07755edd783581e7283557296946212f5b1a934441718e7528", - "sha256:b14dd73f595199f4275bed4fb509277470d9b9059310537e3b3daba12b30c157", - "sha256:b4ad70d7cac4ca0c7b31444a0148bd3af01a2662fa12b1ad6f57cd4a04e21766", - "sha256:b80a4ee19b3442c57c38afa978adca546521a8822d663310b63ae2a7d7b13f3a", - "sha256:ba51129fcc510824b6ca6e2ce1c27e3e4d048b6e35d3ae6f7e517bed1b8b25ce", - "sha256:c011bd5ad03cc096f99ffcfdd18a1817354132c1331bed7a837a25226659845f", - "sha256:cc94f9fea17a5af8cf1a343597711a26b0117c0b812550d99934acb89d526ed2", - "sha256:ccd785fafa1c931deff6a7116e9a0d402d59fabe51644b0d0c268295ff847b25", - "sha256:d16a534da0e39785687b7295e2fcf9a339f4a20689024983d11afaa4657f8507", - "sha256:d3077a31633beef77d057c6523f5de7271ddef7bde5e019285b00c0cc9cac1e3", - "sha256:d603edea1ff7408638b2504905c032193b7dcee7af269802dbb35bc8c3310ed5", - "sha256:db082f728160369d9a6ed2e722438291558fc15ce06d0a7d696a8dad735c236b", - "sha256:ddef295aaf80cefb0c1606f1995899efcb17edc6b327eb6589e234e614b87756", - "sha256:e16ade71c93f6814d095d25cd6d28a90d63511ea396bd96e9ffcb886b278baaa", - "sha256:e3db7d833a7c38c317dc95b54e27f1d27012e031b45a7c24e360b53197d5f6e7", - "sha256:e5e193f89f4f8c1fe273f9a6e6df915092c9f2af6db2d1afb8bd53855025c11f", - "sha256:eb438a8bf6b695bf50d57e6a059ff09652a07968b2041178b3744ea785fcef9b", - "sha256:ebf02c32afa6b67e5861a27183dd98ed88419a94a2ab843cc145fb0bafcc5b28", - "sha256:ecd9e1fa97aa11bf67472220285775fa15e896da108f425e55d23d7540a712ce", - "sha256:ef67fedd863ffffd4adfd46d9d992b0f929c7f61a8307366d664d93517f2c78e", - "sha256:f28ae33dc5a0b9cee06e95fd420e42155d83271ab75964baf747ce959cac5f52", - "sha256:fb1c56d891f9e34303c451998ef62ba52659648bb0d75b03c5e4ac223a3342c2", - "sha256:fe03bf25fae4b95d8afe40004a321df644400fdcba4c8e5e1a19c1085b740888" - ], - "markers": "python_version >= '3.7'", - "version": "==4.6.0" - }, - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" - }, - "python-dotenv": { - "hashes": [ - "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", - "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.0.0" - }, - "requests": { - "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "tinycss2": { - "hashes": [ - "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847", - "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627" - ], - "markers": "python_version >= '3.7'", - "version": "==1.2.1" - }, - "urllib3": { - "hashes": [ - "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", - "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" - ], - "markers": "python_version >= '3.8'", - "version": "==2.1.0" - }, - "uvloop": { - "hashes": [ - "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd", - "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec", - "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b", - "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc", - "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797", - "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5", - "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2", - "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d", - "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be", - "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd", - "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12", - "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17", - "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef", - "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24", - "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428", - "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1", - "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849", - "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593", - "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd", - "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67", - "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6", - "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3", - "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd", - "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8", - "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7", - "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533", - "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957", - "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650", - "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e", - "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7", - "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256" - ], - "markers": "sys_platform != 'win32'", - "version": "==0.19.0" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "yarl": { - "hashes": [ - "sha256:09c19e5f4404574fcfb736efecf75844ffe8610606f3fccc35a1515b8b6712c4", - "sha256:0ab5baaea8450f4a3e241ef17e3d129b2143e38a685036b075976b9c415ea3eb", - "sha256:0d155a092bf0ebf4a9f6f3b7a650dc5d9a5bbb585ef83a52ed36ba46f55cc39d", - "sha256:126638ab961633f0940a06e1c9d59919003ef212a15869708dcb7305f91a6732", - "sha256:1a0a4f3aaa18580038cfa52a7183c8ffbbe7d727fe581300817efc1e96d1b0e9", - "sha256:1d93461e2cf76c4796355494f15ffcb50a3c198cc2d601ad8d6a96219a10c363", - "sha256:26a1a8443091c7fbc17b84a0d9f38de34b8423b459fb853e6c8cdfab0eacf613", - "sha256:271d63396460b6607b588555ea27a1a02b717ca2e3f2cf53bdde4013d7790929", - "sha256:28a108cb92ce6cf867690a962372996ca332d8cda0210c5ad487fe996e76b8bb", - "sha256:29beac86f33d6c7ab1d79bd0213aa7aed2d2f555386856bb3056d5fdd9dab279", - "sha256:2c757f64afe53a422e45e3e399e1e3cf82b7a2f244796ce80d8ca53e16a49b9f", - "sha256:2dad8166d41ebd1f76ce107cf6a31e39801aee3844a54a90af23278b072f1ccf", - "sha256:2dc72e891672343b99db6d497024bf8b985537ad6c393359dc5227ef653b2f17", - "sha256:2f3c8822bc8fb4a347a192dd6a28a25d7f0ea3262e826d7d4ef9cc99cd06d07e", - "sha256:32435d134414e01d937cd9d6cc56e8413a8d4741dea36af5840c7750f04d16ab", - "sha256:3cfa4dbe17b2e6fca1414e9c3bcc216f6930cb18ea7646e7d0d52792ac196808", - "sha256:3d5434b34100b504aabae75f0622ebb85defffe7b64ad8f52b8b30ec6ef6e4b9", - "sha256:4003f380dac50328c85e85416aca6985536812c082387255c35292cb4b41707e", - "sha256:44e91a669c43f03964f672c5a234ae0d7a4d49c9b85d1baa93dec28afa28ffbd", - "sha256:4a14907b597ec55740f63e52d7fee0e9ee09d5b9d57a4f399a7423268e457b57", - "sha256:4ce77d289f8d40905c054b63f29851ecbfd026ef4ba5c371a158cfe6f623663e", - "sha256:4d6d74a97e898c1c2df80339aa423234ad9ea2052f66366cef1e80448798c13d", - "sha256:51382c72dd5377861b573bd55dcf680df54cea84147c8648b15ac507fbef984d", - "sha256:525cd69eff44833b01f8ef39aa33a9cc53a99ff7f9d76a6ef6a9fb758f54d0ff", - "sha256:53ec65f7eee8655bebb1f6f1607760d123c3c115a324b443df4f916383482a67", - "sha256:5f74b015c99a5eac5ae589de27a1201418a5d9d460e89ccb3366015c6153e60a", - "sha256:6280353940f7e5e2efaaabd686193e61351e966cc02f401761c4d87f48c89ea4", - "sha256:632c7aeb99df718765adf58eacb9acb9cbc555e075da849c1378ef4d18bf536a", - "sha256:6465d36381af057d0fab4e0f24ef0e80ba61f03fe43e6eeccbe0056e74aadc70", - "sha256:66a6dbf6ca7d2db03cc61cafe1ee6be838ce0fbc97781881a22a58a7c5efef42", - "sha256:6d350388ba1129bc867c6af1cd17da2b197dff0d2801036d2d7d83c2d771a682", - "sha256:7217234b10c64b52cc39a8d82550342ae2e45be34f5bff02b890b8c452eb48d7", - "sha256:721ee3fc292f0d069a04016ef2c3a25595d48c5b8ddc6029be46f6158d129c92", - "sha256:72a57b41a0920b9a220125081c1e191b88a4cdec13bf9d0649e382a822705c65", - "sha256:73cc83f918b69110813a7d95024266072d987b903a623ecae673d1e71579d566", - "sha256:778df71c8d0c8c9f1b378624b26431ca80041660d7be7c3f724b2c7a6e65d0d6", - "sha256:79e1df60f7c2b148722fb6cafebffe1acd95fd8b5fd77795f56247edaf326752", - "sha256:7c86d0d0919952d05df880a1889a4f0aeb6868e98961c090e335671dea5c0361", - "sha256:7eaf13af79950142ab2bbb8362f8d8d935be9aaf8df1df89c86c3231e4ff238a", - "sha256:828235a2a169160ee73a2fcfb8a000709edf09d7511fccf203465c3d5acc59e4", - "sha256:8535e111a064f3bdd94c0ed443105934d6f005adad68dd13ce50a488a0ad1bf3", - "sha256:88d2c3cc4b2f46d1ba73d81c51ec0e486f59cc51165ea4f789677f91a303a9a7", - "sha256:8a2538806be846ea25e90c28786136932ec385c7ff3bc1148e45125984783dc6", - "sha256:8dab30b21bd6fb17c3f4684868c7e6a9e8468078db00f599fb1c14e324b10fca", - "sha256:8f18a7832ff85dfcd77871fe677b169b1bc60c021978c90c3bb14f727596e0ae", - "sha256:946db4511b2d815979d733ac6a961f47e20a29c297be0d55b6d4b77ee4b298f6", - "sha256:96758e56dceb8a70f8a5cff1e452daaeff07d1cc9f11e9b0c951330f0a2396a7", - "sha256:9a172c3d5447b7da1680a1a2d6ecdf6f87a319d21d52729f45ec938a7006d5d8", - "sha256:9a5211de242754b5e612557bca701f39f8b1a9408dff73c6db623f22d20f470e", - "sha256:9df9a0d4c5624790a0dea2e02e3b1b3c69aed14bcb8650e19606d9df3719e87d", - "sha256:aa4643635f26052401750bd54db911b6342eb1a9ac3e74f0f8b58a25d61dfe41", - "sha256:aed37db837ecb5962469fad448aaae0f0ee94ffce2062cf2eb9aed13328b5196", - "sha256:af52725c7c39b0ee655befbbab5b9a1b209e01bb39128dce0db226a10014aacc", - "sha256:b0b8c06afcf2bac5a50b37f64efbde978b7f9dc88842ce9729c020dc71fae4ce", - "sha256:b61e64b06c3640feab73fa4ff9cb64bd8182de52e5dc13038e01cfe674ebc321", - "sha256:b7831566595fe88ba17ea80e4b61c0eb599f84c85acaa14bf04dd90319a45b90", - "sha256:b8bc5b87a65a4e64bc83385c05145ea901b613d0d3a434d434b55511b6ab0067", - "sha256:b8d51817cf4b8d545963ec65ff06c1b92e5765aa98831678d0e2240b6e9fd281", - "sha256:b9f9cafaf031c34d95c1528c16b2fa07b710e6056b3c4e2e34e9317072da5d1a", - "sha256:bb72d2a94481e7dc7a0c522673db288f31849800d6ce2435317376a345728225", - "sha256:c25ec06e4241e162f5d1f57c370f4078797ade95c9208bd0c60f484834f09c96", - "sha256:c405d482c320a88ab53dcbd98d6d6f32ada074f2d965d6e9bf2d823158fa97de", - "sha256:c4472fe53ebf541113e533971bd8c32728debc4c6d8cc177f2bff31d011ec17e", - "sha256:c4b1efb11a8acd13246ffb0bee888dd0e8eb057f8bf30112e3e21e421eb82d4a", - "sha256:c5f3faeb8100a43adf3e7925d556801d14b5816a0ac9e75e22948e787feec642", - "sha256:c6f034386e5550b5dc8ded90b5e2ff7db21f0f5c7de37b6efc5dac046eb19c10", - "sha256:c99ddaddb2fbe04953b84d1651149a0d85214780e4d0ee824e610ab549d98d92", - "sha256:ca6b66f69e30f6e180d52f14d91ac854b8119553b524e0e28d5291a724f0f423", - "sha256:cccdc02e46d2bd7cb5f38f8cc3d9db0d24951abd082b2f242c9e9f59c0ab2af3", - "sha256:cd49a908cb6d387fc26acee8b7d9fcc9bbf8e1aca890c0b2fdfd706057546080", - "sha256:cf7a4e8de7f1092829caef66fd90eaf3710bc5efd322a816d5677b7664893c93", - "sha256:cfd77e8e5cafba3fb584e0f4b935a59216f352b73d4987be3af51f43a862c403", - "sha256:d34c4f80956227f2686ddea5b3585e109c2733e2d4ef12eb1b8b4e84f09a2ab6", - "sha256:d61a0ca95503867d4d627517bcfdc28a8468c3f1b0b06c626f30dd759d3999fd", - "sha256:d81657b23e0edb84b37167e98aefb04ae16cbc5352770057893bd222cdc6e45f", - "sha256:d92d897cb4b4bf915fbeb5e604c7911021a8456f0964f3b8ebbe7f9188b9eabb", - "sha256:dd318e6b75ca80bff0b22b302f83a8ee41c62b8ac662ddb49f67ec97e799885d", - "sha256:dd952b9c64f3b21aedd09b8fe958e4931864dba69926d8a90c90d36ac4e28c9a", - "sha256:e0e7e83f31e23c5d00ff618045ddc5e916f9e613d33c5a5823bc0b0a0feb522f", - "sha256:e0f17d1df951336a02afc8270c03c0c6e60d1f9996fcbd43a4ce6be81de0bd9d", - "sha256:e2a16ef5fa2382af83bef4a18c1b3bcb4284c4732906aa69422cf09df9c59f1f", - "sha256:e36021db54b8a0475805acc1d6c4bca5d9f52c3825ad29ae2d398a9d530ddb88", - "sha256:e73db54c967eb75037c178a54445c5a4e7461b5203b27c45ef656a81787c0c1b", - "sha256:e741bd48e6a417bdfbae02e088f60018286d6c141639359fb8df017a3b69415a", - "sha256:f7271d6bd8838c49ba8ae647fc06469137e1c161a7ef97d778b72904d9b68696", - "sha256:fc391e3941045fd0987c77484b2799adffd08e4b6735c4ee5f054366a2e1551d", - "sha256:fc94441bcf9cb8c59f51f23193316afefbf3ff858460cb47b5758bf66a14d130", - "sha256:fe34befb8c765b8ce562f0200afda3578f8abb159c76de3ab354c80b72244c41", - "sha256:fe8080b4f25dfc44a86bedd14bc4f9d469dfc6456e6f3c5d9077e81a5fedfba7", - "sha256:ff34cb09a332832d1cf38acd0f604c068665192c6107a439a92abfd8acf90fe2" - ], - "markers": "python_version >= '3.7'", - "version": "==1.9.3" - } - }, - "develop": { - "astroid": { - "hashes": [ - "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca", - "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.0.1" - }, - "bandit": { - "hashes": [ - "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549", - "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.7.5" - }, - "black": { - "hashes": [ - "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", - "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", - "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", - "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", - "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", - "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", - "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", - "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", - "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", - "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", - "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", - "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", - "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", - "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", - "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", - "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", - "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", - "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==23.11.0" - }, - "click": { - "hashes": [ - "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" - ], - "markers": "python_version >= '3.7'", - "version": "==8.1.7" - }, - "dill": { - "hashes": [ - "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e", - "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03" - ], - "markers": "python_version < '3.11'", - "version": "==0.3.7" - }, - "gitdb": { - "hashes": [ - "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", - "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" - ], - "markers": "python_version >= '3.7'", - "version": "==4.0.11" - }, - "gitpython": { - "hashes": [ - "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4", - "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a" - ], - "markers": "python_version >= '3.7'", - "version": "==3.1.40" - }, - "isort": { - "hashes": [ - "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", - "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==5.12.0" - }, - "markdown-it-py": { - "hashes": [ - "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", - "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" - ], - "markers": "python_version >= '3.8'", - "version": "==3.0.0" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, - "mdurl": { - "hashes": [ - "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.1.2" - }, - "mypy-extensions": { - "hashes": [ - "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", - "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.0" - }, - "packaging": { - "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==23.2" - }, - "pathspec": { - "hashes": [ - "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", - "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" - ], - "markers": "python_version >= '3.7'", - "version": "==0.11.2" - }, - "pbr": { - "hashes": [ - "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda", - "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9" - ], - "markers": "python_version >= '2.6'", - "version": "==6.0.0" - }, - "platformdirs": { - "hashes": [ - "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", - "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731" - ], - "markers": "python_version >= '3.7'", - "version": "==4.0.0" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "pylint": { - "hashes": [ - "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", - "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" - ], - "index": "pypi", - "markers": "python_full_version >= '3.8.0'", - "version": "==3.0.2" - }, - "pyyaml": { - "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" - ], - "markers": "python_version >= '3.6'", - "version": "==6.0.1" - }, - "rich": { - "hashes": [ - "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", - "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==13.7.0" - }, - "smmap": { - "hashes": [ - "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", - "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" - ], - "markers": "python_version >= '3.7'", - "version": "==5.0.1" - }, - "stevedore": { - "hashes": [ - "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d", - "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c" - ], - "markers": "python_version >= '3.8'", - "version": "==5.1.0" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "tomlkit": { - "hashes": [ - "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", - "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.12.3" - }, - "typing-extensions": { - "hashes": [ - "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", - "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.8.0" - } - } -} +{ + "_meta": { + "hash": { + "sha256": "7fee393ea9ea4c0b923033f0da0fdc590ba3f75c6072812062cdc458b84bf9ae" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "aiodns": { + "hashes": [ + "sha256:1073eac48185f7a4150cad7f96a5192d6911f12b4fb894de80a088508c9b3a99", + "sha256:a387b63da4ced6aad35b1dda2d09620ad608a1c7c0fb71efa07ebb4cd511928d" + ], + "version": "==3.1.1" + }, + "aiohttp": { + "hashes": [ + "sha256:05857848da443c8c12110d99285d499b4e84d59918a21132e45c3f0804876994", + "sha256:05a183f1978802588711aed0dea31e697d760ce9055292db9dc1604daa9a8ded", + "sha256:09f23292d29135025e19e8ff4f0a68df078fe4ee013bca0105b2e803989de92d", + "sha256:11ca808f9a6b63485059f5f6e164ef7ec826483c1212a44f268b3653c91237d8", + "sha256:1736d87dad8ef46a8ec9cddd349fa9f7bd3a064c47dd6469c0d6763d3d49a4fc", + "sha256:1df43596b826022b14998f0460926ce261544fedefe0d2f653e1b20f49e96454", + "sha256:23170247ef89ffa842a02bbfdc425028574d9e010611659abeb24d890bc53bb8", + "sha256:2779f5e7c70f7b421915fd47db332c81de365678180a9f3ab404088f87ba5ff9", + "sha256:28185e36a78d247c55e9fbea2332d16aefa14c5276a582ce7a896231c6b1c208", + "sha256:2cbc14a13fb6b42d344e4f27746a4b03a2cb0c1c3c5b932b0d6ad8881aa390e3", + "sha256:2d71abc15ff7047412ef26bf812dfc8d0d1020d664617f4913df2df469f26b76", + "sha256:2d820162c8c2bdbe97d328cd4f417c955ca370027dce593345e437b2e9ffdc4d", + "sha256:317719d7f824eba55857fe0729363af58e27c066c731bc62cd97bc9c3d9c7ea4", + "sha256:35a68cd63ca6aaef5707888f17a70c36efe62b099a4e853d33dc2e9872125be8", + "sha256:3607375053df58ed6f23903aa10cf3112b1240e8c799d243bbad0f7be0666986", + "sha256:366bc870d7ac61726f32a489fbe3d1d8876e87506870be66b01aeb84389e967e", + "sha256:3abf0551874fecf95f93b58f25ef4fc9a250669a2257753f38f8f592db85ddea", + "sha256:3d7f6235c7475658acfc1769d968e07ab585c79f6ca438ddfecaa9a08006aee2", + "sha256:3dd8119752dd30dd7bca7d4bc2a92a59be6a003e4e5c2cf7e248b89751b8f4b7", + "sha256:42fe4fd9f0dfcc7be4248c162d8056f1d51a04c60e53366b0098d1267c4c9da8", + "sha256:45820ddbb276113ead8d4907a7802adb77548087ff5465d5c554f9aa3928ae7d", + "sha256:4790e44f46a4aa07b64504089def5744d3b6780468c4ec3a1a36eb7f2cae9814", + "sha256:4afa8f71dba3a5a2e1e1282a51cba7341ae76585345c43d8f0e624882b622218", + "sha256:4b777c9286b6c6a94f50ddb3a6e730deec327e9e2256cb08b5530db0f7d40fd8", + "sha256:4ee1b4152bc3190cc40ddd6a14715e3004944263ea208229ab4c297712aa3075", + "sha256:51a4cd44788ea0b5e6bb8fa704597af3a30be75503a7ed1098bc5b8ffdf6c982", + "sha256:536b01513d67d10baf6f71c72decdf492fb7433c5f2f133e9a9087379d4b6f31", + "sha256:571760ad7736b34d05597a1fd38cbc7d47f7b65deb722cb8e86fd827404d1f6b", + "sha256:5a2eb5311a37fe105aa35f62f75a078537e1a9e4e1d78c86ec9893a3c97d7a30", + "sha256:5ab16c254e2312efeb799bc3c06897f65a133b38b69682bf75d1f1ee1a9c43a9", + "sha256:65b0a70a25456d329a5e1426702dde67be0fb7a4ead718005ba2ca582d023a94", + "sha256:673343fbc0c1ac44d0d2640addc56e97a052504beacd7ade0dc5e76d3a4c16e8", + "sha256:6777a390e41e78e7c45dab43a4a0196c55c3b8c30eebe017b152939372a83253", + "sha256:6896b8416be9ada4d22cd359d7cb98955576ce863eadad5596b7cdfbf3e17c6c", + "sha256:694df243f394629bcae2d8ed94c589a181e8ba8604159e6e45e7b22e58291113", + "sha256:70e851f596c00f40a2f00a46126c95c2e04e146015af05a9da3e4867cfc55911", + "sha256:7276fe0017664414fdc3618fca411630405f1aaf0cc3be69def650eb50441787", + "sha256:76a86a9989ebf82ee61e06e2bab408aec4ea367dc6da35145c3352b60a112d11", + "sha256:7a94bde005a8f926d0fa38b88092a03dea4b4875a61fbcd9ac6f4351df1b57cd", + "sha256:7ae5f99a32c53731c93ac3075abd3e1e5cfbe72fc3eaac4c27c9dd64ba3b19fe", + "sha256:7e8a3b79b6d186a9c99761fd4a5e8dd575a48d96021f220ac5b5fa856e5dd029", + "sha256:816f4db40555026e4cdda604a1088577c1fb957d02f3f1292e0221353403f192", + "sha256:8303531e2c17b1a494ffaeba48f2da655fe932c4e9a2626c8718403c83e5dd2b", + "sha256:8488519aa05e636c5997719fe543c8daf19f538f4fa044f3ce94bee608817cff", + "sha256:87c8b0a6487e8109427ccf638580865b54e2e3db4a6e0e11c02639231b41fc0f", + "sha256:8c9e5f4d7208cda1a2bb600e29069eecf857e6980d0ccc922ccf9d1372c16f4b", + "sha256:94697c7293199c2a2551e3e3e18438b4cba293e79c6bc2319f5fd652fccb7456", + "sha256:9623cfd9e85b76b83ef88519d98326d4731f8d71869867e47a0b979ffec61c73", + "sha256:98d21092bf2637c5fa724a428a69e8f5955f2182bff61f8036827cf6ce1157bf", + "sha256:99ae01fb13a618b9942376df77a1f50c20a281390dad3c56a6ec2942e266220d", + "sha256:9c196b30f1b1aa3363a69dd69079ae9bec96c2965c4707eaa6914ba099fb7d4f", + "sha256:a00ce44c21612d185c5275c5cba4bab8d7c1590f248638b667ed8a782fa8cd6f", + "sha256:a1b66dbb8a7d5f50e9e2ea3804b01e766308331d0cac76eb30c563ac89c95985", + "sha256:a1d7edf74a36de0e5ca50787e83a77cf352f5504eb0ffa3f07000a911ba353fb", + "sha256:a1e3b3c107ccb0e537f309f719994a55621acd2c8fdf6d5ce5152aed788fb940", + "sha256:a486ddf57ab98b6d19ad36458b9f09e6022de0381674fe00228ca7b741aacb2f", + "sha256:ac9669990e2016d644ba8ae4758688534aabde8dbbc81f9af129c3f5f01ca9cd", + "sha256:b1a2ea8252cacc7fd51df5a56d7a2bb1986ed39be9397b51a08015727dfb69bd", + "sha256:c5b7bf8fe4d39886adc34311a233a2e01bc10eb4e842220235ed1de57541a896", + "sha256:c67a51ea415192c2e53e4e048c78bab82d21955b4281d297f517707dc836bf3d", + "sha256:ca4fddf84ac7d8a7d0866664936f93318ff01ee33e32381a115b19fb5a4d1202", + "sha256:d5b9345ab92ebe6003ae11d8092ce822a0242146e6fa270889b9ba965457ca40", + "sha256:d97c3e286d0ac9af6223bc132dc4bad6540b37c8d6c0a15fe1e70fb34f9ec411", + "sha256:db04d1de548f7a62d1dd7e7cdf7c22893ee168e22701895067a28a8ed51b3735", + "sha256:dcf71c55ec853826cd70eadb2b6ac62ec577416442ca1e0a97ad875a1b3a0305", + "sha256:de3cc86f4ea8b4c34a6e43a7306c40c1275e52bfa9748d869c6b7d54aa6dad80", + "sha256:deac0a32aec29608eb25d730f4bc5a261a65b6c48ded1ed861d2a1852577c932", + "sha256:e18d92c3e9e22553a73e33784fcb0ed484c9874e9a3e96c16a8d6a1e74a0217b", + "sha256:eb6dfd52063186ac97b4caa25764cdbcdb4b10d97f5c5f66b0fa95052e744eb7", + "sha256:f09960b5bb1017d16c0f9e9f7fc42160a5a49fa1e87a175fd4a2b1a1833ea0af", + "sha256:f1e4f254e9c35d8965d377e065c4a8a55d396fe87c8e7e8429bcfdeeb229bfb3", + "sha256:f32c86dc967ab8c719fd229ce71917caad13cc1e8356ee997bf02c5b368799bf", + "sha256:f50b4663c3e0262c3a361faf440761fbef60ccdde5fe8545689a4b3a3c149fb4", + "sha256:f8e05f5163528962ce1d1806fce763ab893b1c5b7ace0a3538cd81a90622f844", + "sha256:f929f4c9b9a00f3e6cc0587abb95ab9c05681f8b14e0fe1daecfa83ea90f8318", + "sha256:f9e09a1c83521d770d170b3801eea19b89f41ccaa61d53026ed111cb6f088887" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==3.9.0" + }, + "aiosignal": { + "hashes": [ + "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", + "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" + }, + "async-timeout": { + "hashes": [ + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" + ], + "markers": "python_version < '3.11'", + "version": "==4.0.3" + }, + "attrs": { + "hashes": [ + "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", + "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1.0" + }, + "brotli": { + "hashes": [ + "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208", + "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48", + "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354", + "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a", + "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", + "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c", + "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088", + "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", + "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a", + "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", + "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438", + "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578", + "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b", + "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b", + "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68", + "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d", + "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", + "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", + "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", + "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", + "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0", + "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", + "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", + "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112", + "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", + "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", + "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", + "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95", + "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", + "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914", + "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", + "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a", + "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7", + "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", + "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", + "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f", + "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", + "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", + "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", + "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", + "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", + "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97", + "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d", + "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf", + "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac", + "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", + "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74", + "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60", + "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c", + "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1", + "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", + "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", + "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", + "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", + "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460", + "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751", + "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9", + "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", + "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474", + "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", + "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", + "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", + "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", + "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467", + "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619", + "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", + "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", + "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579", + "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84", + "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b", + "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59", + "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", + "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", + "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", + "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2", + "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3", + "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64", + "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643", + "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", + "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985", + "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596", + "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2", + "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064" + ], + "version": "==1.1.0" + }, + "cairocffi": { + "hashes": [ + "sha256:78e6bbe47357640c453d0be929fa49cd05cce2e1286f3d2a1ca9cbda7efdb8b7", + "sha256:aa78ee52b9069d7475eeac457389b6275aa92111895d78fbaa2202a52dac112e" + ], + "markers": "python_version >= '3.7'", + "version": "==1.6.1" + }, + "cairosvg": { + "hashes": [ + "sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0", + "sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b" + ], + "markers": "python_version >= '3.5'", + "version": "==2.7.1" + }, + "certifi": { + "hashes": [ + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.11.17" + }, + "cffi": { + "hashes": [ + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + ], + "markers": "python_version >= '3.8'", + "version": "==1.16.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.2" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "cssselect2": { + "hashes": [ + "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a", + "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" + ], + "markers": "python_version >= '3.7'", + "version": "==0.7.0" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" + }, + "discord.py": { + "extras": [ + "speed" + ], + "hashes": [ + "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c", + "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==2.3.2" + }, + "dnspython": { + "hashes": [ + "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8", + "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984" + ], + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==2.4.2" + }, + "emoji": { + "hashes": [ + "sha256:8d8b5dec3c507444b58890e598fc895fcec022b3f5acb49497c6ccc5208b8b00", + "sha256:a8468fd836b7ecb6d1eac054c9a591701ce0ccd6c6f7779ad71b66f76664df90" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.0" + }, + "frozenlist": { + "hashes": [ + "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6", + "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01", + "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251", + "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9", + "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b", + "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87", + "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf", + "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f", + "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0", + "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2", + "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b", + "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc", + "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c", + "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467", + "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9", + "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1", + "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a", + "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79", + "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167", + "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300", + "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf", + "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea", + "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2", + "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab", + "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3", + "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb", + "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087", + "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc", + "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8", + "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62", + "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f", + "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326", + "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c", + "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431", + "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963", + "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7", + "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef", + "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3", + "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956", + "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781", + "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472", + "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc", + "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839", + "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672", + "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3", + "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503", + "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d", + "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8", + "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b", + "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc", + "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f", + "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559", + "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b", + "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95", + "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb", + "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963", + "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919", + "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f", + "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3", + "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1", + "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "isodate": { + "hashes": [ + "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", + "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9" + ], + "index": "pypi", + "version": "==0.6.1" + }, + "lottie": { + "extras": [ + "pdf" + ], + "hashes": [ + "sha256:a3242f8ba37051fbdd7503ecd168203a08e4af26f17be2ecca08a64af1e7d3c1" + ], + "markers": "python_version >= '3'", + "version": "==0.7.0" + }, + "motor": { + "hashes": [ + "sha256:6fe7e6f0c4f430b9e030b9d22549b732f7c2226af3ab71ecc309e4a1b7d19953", + "sha256:d2fc38de15f1c8058f389c1a44a4d4105c0405c48c061cd492a654496f7bc26a" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==3.3.2" + }, + "multidict": { + "hashes": [ + "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", + "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", + "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", + "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", + "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", + "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", + "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", + "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", + "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", + "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", + "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", + "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", + "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", + "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", + "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", + "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", + "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", + "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", + "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", + "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", + "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", + "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", + "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", + "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", + "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", + "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", + "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", + "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", + "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", + "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", + "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", + "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", + "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", + "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", + "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", + "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", + "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", + "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", + "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", + "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", + "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", + "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", + "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", + "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", + "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", + "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", + "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", + "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", + "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", + "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", + "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", + "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", + "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", + "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", + "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", + "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", + "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", + "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", + "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", + "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", + "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", + "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", + "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", + "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", + "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", + "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", + "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", + "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", + "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", + "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", + "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", + "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", + "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", + "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.4" + }, + "natural": { + "hashes": [ + "sha256:18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2" + ], + "index": "pypi", + "version": "==0.2.0" + }, + "orjson": { + "hashes": [ + "sha256:06ad5543217e0e46fd7ab7ea45d506c76f878b87b1b4e369006bdb01acc05a83", + "sha256:0a73160e823151f33cdc05fe2cea557c5ef12fdf276ce29bb4f1c571c8368a60", + "sha256:1234dc92d011d3554d929b6cf058ac4a24d188d97be5e04355f1b9223e98bbe9", + "sha256:1d0dc4310da8b5f6415949bd5ef937e60aeb0eb6b16f95041b5e43e6200821fb", + "sha256:2a11b4b1a8415f105d989876a19b173f6cdc89ca13855ccc67c18efbd7cbd1f8", + "sha256:2e2ecd1d349e62e3960695214f40939bbfdcaeaaa62ccc638f8e651cf0970e5f", + "sha256:3a2ce5ea4f71681623f04e2b7dadede3c7435dfb5e5e2d1d0ec25b35530e277b", + "sha256:3e892621434392199efb54e69edfff9f699f6cc36dd9553c5bf796058b14b20d", + "sha256:3fb205ab52a2e30354640780ce4587157a9563a68c9beaf52153e1cea9aa0921", + "sha256:4689270c35d4bb3102e103ac43c3f0b76b169760aff8bcf2d401a3e0e58cdb7f", + "sha256:49f8ad582da6e8d2cf663c4ba5bf9f83cc052570a3a767487fec6af839b0e777", + "sha256:4bd176f528a8151a6efc5359b853ba3cc0e82d4cd1fab9c1300c5d957dc8f48c", + "sha256:4cf7837c3b11a2dfb589f8530b3cff2bd0307ace4c301e8997e95c7468c1378e", + "sha256:4fd72fab7bddce46c6826994ce1e7de145ae1e9e106ebb8eb9ce1393ca01444d", + "sha256:5148bab4d71f58948c7c39d12b14a9005b6ab35a0bdf317a8ade9a9e4d9d0bd5", + "sha256:5869e8e130e99687d9e4be835116c4ebd83ca92e52e55810962446d841aba8de", + "sha256:602a8001bdf60e1a7d544be29c82560a7b49319a0b31d62586548835bbe2c862", + "sha256:61804231099214e2f84998316f3238c4c2c4aaec302df12b21a64d72e2a135c7", + "sha256:666c6fdcaac1f13eb982b649e1c311c08d7097cbda24f32612dae43648d8db8d", + "sha256:674eb520f02422546c40401f4efaf8207b5e29e420c17051cddf6c02783ff5ca", + "sha256:7ec960b1b942ee3c69323b8721df2a3ce28ff40e7ca47873ae35bfafeb4555ca", + "sha256:7f433be3b3f4c66016d5a20e5b4444ef833a1f802ced13a2d852c637f69729c1", + "sha256:7f8fb7f5ecf4f6355683ac6881fd64b5bb2b8a60e3ccde6ff799e48791d8f864", + "sha256:81a3a3a72c9811b56adf8bcc829b010163bb2fc308877e50e9910c9357e78521", + "sha256:858379cbb08d84fe7583231077d9a36a1a20eb72f8c9076a45df8b083724ad1d", + "sha256:8b9ba0ccd5a7f4219e67fbbe25e6b4a46ceef783c42af7dbc1da548eb28b6531", + "sha256:92af0d00091e744587221e79f68d617b432425a7e59328ca4c496f774a356071", + "sha256:9ebbdbd6a046c304b1845e96fbcc5559cd296b4dfd3ad2509e33c4d9ce07d6a1", + "sha256:9edd2856611e5050004f4722922b7b1cd6268da34102667bd49d2a2b18bafb81", + "sha256:a353bf1f565ed27ba71a419b2cd3db9d6151da426b61b289b6ba1422a702e643", + "sha256:b5b7d4a44cc0e6ff98da5d56cde794385bdd212a86563ac321ca64d7f80c80d1", + "sha256:b90f340cb6397ec7a854157fac03f0c82b744abdd1c0941a024c3c29d1340aff", + "sha256:c18a4da2f50050a03d1da5317388ef84a16013302a5281d6f64e4a3f406aabc4", + "sha256:c338ed69ad0b8f8f8920c13f529889fe0771abbb46550013e3c3d01e5174deef", + "sha256:c5a02360e73e7208a872bf65a7554c9f15df5fe063dc047f79738998b0506a14", + "sha256:c62b6fa2961a1dcc51ebe88771be5319a93fd89bd247c9ddf732bc250507bc2b", + "sha256:c812312847867b6335cfb264772f2a7e85b3b502d3a6b0586aa35e1858528ab1", + "sha256:c943b35ecdf7123b2d81d225397efddf0bce2e81db2f3ae633ead38e85cd5ade", + "sha256:ce0a29c28dfb8eccd0f16219360530bc3cfdf6bf70ca384dacd36e6c650ef8e8", + "sha256:cf80b550092cc480a0cbd0750e8189247ff45457e5a023305f7ef1bcec811616", + "sha256:cff7570d492bcf4b64cc862a6e2fb77edd5e5748ad715f487628f102815165e9", + "sha256:d2c1e559d96a7f94a4f581e2a32d6d610df5840881a8cba8f25e446f4d792df3", + "sha256:deeb3922a7a804755bbe6b5be9b312e746137a03600f488290318936c1a2d4dc", + "sha256:e28a50b5be854e18d54f75ef1bb13e1abf4bc650ab9d635e4258c58e71eb6ad5", + "sha256:e99c625b8c95d7741fe057585176b1b8783d46ed4b8932cf98ee145c4facf499", + "sha256:ec6f18f96b47299c11203edfbdc34e1b69085070d9a3d1f302810cc23ad36bf3", + "sha256:ed8bc367f725dfc5cabeed1ae079d00369900231fbb5a5280cf0736c30e2adf7", + "sha256:ee5926746232f627a3be1cc175b2cfad24d0170d520361f4ce3fa2fd83f09e1d", + "sha256:f295efcd47b6124b01255d1491f9e46f17ef40d3d7eabf7364099e463fb45f0f", + "sha256:fb0b361d73f6b8eeceba47cd37070b5e6c9de5beaeaa63a1cb35c7e1a73ef088" + ], + "version": "==3.9.10" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "parsedatetime": { + "hashes": [ + "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", + "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" + ], + "index": "pypi", + "version": "==2.6" + }, + "pillow": { + "hashes": [ + "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d", + "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de", + "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616", + "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839", + "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099", + "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a", + "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219", + "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106", + "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b", + "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412", + "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b", + "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7", + "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2", + "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7", + "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14", + "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f", + "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27", + "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57", + "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262", + "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28", + "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610", + "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172", + "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273", + "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e", + "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d", + "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818", + "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f", + "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9", + "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01", + "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7", + "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651", + "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312", + "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80", + "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666", + "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061", + "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b", + "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992", + "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593", + "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4", + "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db", + "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba", + "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd", + "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e", + "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212", + "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb", + "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2", + "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34", + "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256", + "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f", + "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2", + "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38", + "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996", + "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a", + "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793" + ], + "markers": "python_version >= '3.8'", + "version": "==10.1.0" + }, + "pycares": { + "hashes": [ + "sha256:112a4979c695b1c86f6782163d7dec58d57a3b9510536dcf4826550f9053dd9a", + "sha256:1168a48a834813aa80f412be2df4abaf630528a58d15c704857448b20b1675c0", + "sha256:21a5a0468861ec7df7befa69050f952da13db5427ae41ffe4713bc96291d1d95", + "sha256:229a1675eb33bc9afb1fc463e73ee334950ccc485bc83a43f6ae5839fb4d5fa3", + "sha256:22c00bf659a9fa44d7b405cf1cd69b68b9d37537899898d8cbe5dffa4016b273", + "sha256:23aa3993a352491a47fcf17867f61472f32f874df4adcbb486294bd9fbe8abee", + "sha256:24da119850841d16996713d9c3374ca28a21deee056d609fbbed29065d17e1f6", + "sha256:2eeec144bcf6a7b6f2d74d6e70cbba7886a84dd373c886f06cb137a07de4954c", + "sha256:34736a2ffaa9c08ca9c707011a2d7b69074bbf82d645d8138bba771479b2362f", + "sha256:3aebc73e5ad70464f998f77f2da2063aa617cbd8d3e8174dd7c5b4518f967153", + "sha256:3eaa6681c0a3e3f3868c77aca14b7760fed35fdfda2fe587e15c701950e7bc69", + "sha256:4afc2644423f4eef97857a9fd61be9758ce5e336b4b0bd3d591238bb4b8b03e0", + "sha256:52084961262232ec04bd75f5043aed7e5d8d9695e542ff691dfef0110209f2d4", + "sha256:56cf3349fa3a2e67ed387a7974c11d233734636fe19facfcda261b411af14d80", + "sha256:5ed4e04af4012f875b78219d34434a6d08a67175150ac1b79eb70ab585d4ba8c", + "sha256:64965dc19c578a683ea73487a215a8897276224e004d50eeb21f0bc7a0b63c88", + "sha256:6ef64649eba56448f65e26546d85c860709844d2fc22ef14d324fe0b27f761a9", + "sha256:77cf5a2fd5583c670de41a7f4a7b46e5cbabe7180d8029f728571f4d2e864084", + "sha256:7bddc6adba8f699728f7fc1c9ce8cef359817ad78e2ed52b9502cb5f8dc7f741", + "sha256:813d661cbe2e37d87da2d16b7110a6860e93ddb11735c6919c8a3545c7b9c8d8", + "sha256:82bba2ab77eb5addbf9758d514d9bdef3c1bfe7d1649a47bd9a0d55a23ef478b", + "sha256:8bf2eaa83a5987e48fa63302f0fe7ce3275cfda87b34d40fef9ce703fb3ac002", + "sha256:8d186dafccdaa3409194c0f94db93c1a5d191145a275f19da6591f9499b8e7b8", + "sha256:8f64cb58729689d4d0e78f0bfb4c25ce2f851d0274c0273ac751795c04b8798a", + "sha256:902461a92b6a80fd5041a2ec5235680c7cc35e43615639ec2a40e63fca2dfb51", + "sha256:917f08f0b5d9324e9a34211e68d27447c552b50ab967044776bbab7e42a553a2", + "sha256:94d6962db81541eb0396d2f0dfcbb18cdb8c8b251d165efc2d974ae652c547d4", + "sha256:97892cced5794d721fb4ff8765764aa4ea48fe8b2c3820677505b96b83d4ef47", + "sha256:9a0303428d013ccf5c51de59c83f9127aba6200adb7fd4be57eddb432a1edd2a", + "sha256:9dc04c54c6ea615210c1b9e803d0e2d2255f87a3d5d119b6482c8f0dfa15b26b", + "sha256:a0c5368206057884cde18602580083aeaad9b860e2eac14fd253543158ce1e93", + "sha256:ad58e284a658a8a6a84af2e0b62f2f961f303cedfe551854d7bd40c3cbb61912", + "sha256:afb91792f1556f97be7f7acb57dc7756d89c5a87bd8b90363a77dbf9ea653817", + "sha256:b61579cecf1f4d616e5ea31a6e423a16680ab0d3a24a2ffe7bb1d4ee162477ff", + "sha256:b7af06968cbf6851566e806bf3e72825b0e6671832a2cbe840be1d2d65350710", + "sha256:bce8db2fc6f3174bd39b81405210b9b88d7b607d33e56a970c34a0c190da0490", + "sha256:bfb89ca9e3d0a9b5332deeb666b2ede9d3469107742158f4aeda5ce032d003f4", + "sha256:c680fef1b502ee680f8f0b95a41af4ec2c234e50e16c0af5bbda31999d3584bd", + "sha256:c6a8bde63106f162fca736e842a916853cad3c8d9d137e11c9ffa37efa818b02", + "sha256:cb49d5805cd347c404f928c5ae7c35e86ba0c58ffa701dbe905365e77ce7d641", + "sha256:ceb12974367b0a68a05d52f4162b29f575d241bd53de155efe632bf2c943c7f6", + "sha256:d33e2a1120887e89075f7f814ec144f66a6ce06a54f5722ccefc62fbeda83cff", + "sha256:db24c4e7fea4a052c6e869cbf387dd85d53b9736cfe1ef5d8d568d1ca925e977", + "sha256:e3a6f7cfdfd11eb5493d6d632e582408c8f3b429f295f8799c584c108b28db6f", + "sha256:eb66c30eb11e877976b7ead13632082a8621df648c408b8e15cdb91a452dd502", + "sha256:ed2a38e34bec6f2586435f6ff0bc5fe11d14bebd7ed492cf739a424e81681540", + "sha256:f36bdc1562142e3695555d2f4ac0cb69af165eddcefa98efc1c79495b533481f", + "sha256:f47579d508f2f56eddd16ce72045782ad3b1b3b678098699e2b6a1b30733e1c2", + "sha256:f5f646eec041db6ffdbcaf3e0756fb92018f7af3266138c756bb09d2b5baadec", + "sha256:fd644505a8cfd7f6584d33a9066d4e3d47700f050ef1490230c962de5dfb28c6", + "sha256:fff16b09042ba077f7b8aa5868d1d22456f0002574d0ba43462b10a009331677" + ], + "markers": "python_version >= '3.8'", + "version": "==4.4.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, + "pymongo": { + "extras": [ + "srv" + ], + "hashes": [ + "sha256:014e7049dd019a6663747ca7dae328943e14f7261f7c1381045dfc26a04fa330", + "sha256:055f5c266e2767a88bb585d01137d9c7f778b0195d3dbf4a487ef0638be9b651", + "sha256:05c30fd35cc97f14f354916b45feea535d59060ef867446b5c3c7f9b609dd5dc", + "sha256:0634994b026336195778e5693583c060418d4ab453eff21530422690a97e1ee8", + "sha256:09c7de516b08c57647176b9fc21d929d628e35bcebc7422220c89ae40b62126a", + "sha256:107a234dc55affc5802acb3b6d83cbb8c87355b38a9457fcd8806bdeb8bce161", + "sha256:10a379fb60f1b2406ae57b8899bacfe20567918c8e9d2d545e1b93628fcf2050", + "sha256:128b1485753106c54af481789cdfea12b90a228afca0b11fb3828309a907e10e", + "sha256:1394c4737b325166a65ae7c145af1ebdb9fb153ebedd37cf91d676313e4a67b8", + "sha256:1c63e3a2e8fb815c4b1f738c284a4579897e37c3cfd95fdb199229a1ccfb638a", + "sha256:1e4ed21029d80c4f62605ab16398fe1ce093fff4b5f22d114055e7d9fbc4adb0", + "sha256:1ec71ac633b126c0775ed4604ca8f56c3540f5c21a1220639f299e7a544b55f9", + "sha256:21812453354b151200034750cd30b0140e82ec2a01fd4357390f67714a1bfbde", + "sha256:256c503a75bd71cf7fb9ebf889e7e222d49c6036a48aad5a619f98a0adf0e0d7", + "sha256:2703a9f8f5767986b4f51c259ff452cc837c5a83c8ed5f5361f6e49933743b2f", + "sha256:288c21ab9531b037f7efa4e467b33176bc73a0c27223c141b822ab4a0e66ff2a", + "sha256:2972dd1f1285866aba027eff2f4a2bbf8aa98563c2ced14cb34ee5602b36afdf", + "sha256:2973f113e079fb98515722cd728e1820282721ec9fd52830e4b73cabdbf1eb28", + "sha256:2ca0ba501898b2ec31e6c3acf90c31910944f01d454ad8e489213a156ccf1bda", + "sha256:2d2be5c9c3488fa8a70f83ed925940f488eac2837a996708d98a0e54a861f212", + "sha256:2f8c04277d879146eacda920476e93d520eff8bec6c022ac108cfa6280d84348", + "sha256:325701ae7b56daa5b0692305b7cb505ca50f80a1288abb32ff420a8a209b01ca", + "sha256:3729b8db02063da50eeb3db88a27670d85953afb9a7f14c213ac9e3dca93034b", + "sha256:3919708594b86d0f5cdc713eb6fccd3f9b9532af09ea7a5d843c933825ef56c4", + "sha256:39a1cd5d383b37285641d5a7a86be85274466ae336a61b51117155936529f9b3", + "sha256:3ec6c20385c5a58e16b1ea60c5e4993ea060540671d7d12664f385f2fb32fe79", + "sha256:47aa128be2e66abd9d1a9b0437c62499d812d291f17b55185cb4aa33a5f710a4", + "sha256:49f2af6cf82509b15093ce3569229e0d53c90ad8ae2eef940652d4cf1f81e045", + "sha256:4a0269811661ba93c472c8a60ea82640e838c2eb148d252720a09b5123f2c2fe", + "sha256:518c90bdd6e842c446d01a766b9136fec5ec6cc94f3b8c3f8b4a332786ee6b64", + "sha256:5717a308a703dda2886a5796a07489c698b442f5e409cf7dc2ac93de8d61d764", + "sha256:5802acc012bbb4bce4dff92973dff76482f30ef35dd4cb8ab5b0e06aa8f08c80", + "sha256:5e63146dbdb1eac207464f6e0cfcdb640c9c5ff0f57b754fa96fe252314a1dc6", + "sha256:6695d7136a435c1305b261a9ddb9b3ecec9863e05aab3935b96038145fd3a977", + "sha256:680fa0fc719e1a3dcb81130858368f51d83667d431924d0bcf249644bce8f303", + "sha256:6b18276f14b4b6d92e707ab6db19b938e112bd2f1dc3f9f1a628df58e4fd3f0d", + "sha256:6bafea6061d63059d8bc2ffc545e2f049221c8a4457d236c5cd6a66678673eab", + "sha256:6d6a1b1361f118e7fefa17ae3114e77f10ee1b228b20d50c47c9f351346180c8", + "sha256:747c84f4e690fbe6999c90ac97246c95d31460d890510e4a3fa61b7d2b87aa34", + "sha256:79f41576b3022c2fe9780ae3e44202b2438128a25284a8ddfa038f0785d87019", + "sha256:7b0e6361754ac596cd16bfc6ed49f69ffcd9b60b7bc4bcd3ea65c6a83475e4ff", + "sha256:7e3b0127b260d4abae7b62203c4c7ef0874c901b55155692353db19de4b18bc4", + "sha256:7fc2bb8a74dcfcdd32f89528e38dcbf70a3a6594963d60dc9595e3b35b66e414", + "sha256:806e094e9e85d8badc978af8c95b69c556077f11844655cb8cd2d1758769e521", + "sha256:81dd1308bd5630d2bb5980f00aa163b986b133f1e9ed66c66ce2a5bc3572e891", + "sha256:82e620842e12e8cb4050d2643a81c8149361cd82c0a920fa5a15dc4ca8a4000f", + "sha256:85f2cdc400ee87f5952ebf2a117488f2525a3fb2e23863a8efe3e4ee9e54e4d1", + "sha256:8ab6bcc8e424e07c1d4ba6df96f7fb963bcb48f590b9456de9ebd03b88084fe8", + "sha256:8adf014f2779992eba3b513e060d06f075f0ab2fb3ad956f413a102312f65cdf", + "sha256:9b0f98481ad5dc4cb430a60bbb8869f05505283b9ae1c62bdb65eb5e020ee8e3", + "sha256:9bea9138b0fc6e2218147e9c6ce1ff76ff8e29dc00bb1b64842bd1ca107aee9f", + "sha256:a09bfb51953930e7e838972ddf646c5d5f984992a66d79da6ba7f6a8d8a890cd", + "sha256:a0be99b599da95b7a90a918dd927b20c434bea5e1c9b3efc6a3c6cd67c23f813", + "sha256:a49aca4d961823b2846b739380c847e8964ff7ae0f0a683992b9d926054f0d6d", + "sha256:a4dc1319d0c162919ee7f4ee6face076becae2abbd351cc14f1fe70af5fb20d9", + "sha256:a8273e1abbcff1d7d29cbbb1ea7e57d38be72f1af3c597c854168508b91516c2", + "sha256:a8f7f9feecae53fa18d6a3ea7c75f9e9a1d4d20e5c3f9ce3fba83f07bcc4eee2", + "sha256:ad4f66fbb893b55f96f03020e67dcab49ffde0177c6565ccf9dec4fdf974eb61", + "sha256:af425f323fce1b07755edd783581e7283557296946212f5b1a934441718e7528", + "sha256:b14dd73f595199f4275bed4fb509277470d9b9059310537e3b3daba12b30c157", + "sha256:b4ad70d7cac4ca0c7b31444a0148bd3af01a2662fa12b1ad6f57cd4a04e21766", + "sha256:b80a4ee19b3442c57c38afa978adca546521a8822d663310b63ae2a7d7b13f3a", + "sha256:ba51129fcc510824b6ca6e2ce1c27e3e4d048b6e35d3ae6f7e517bed1b8b25ce", + "sha256:c011bd5ad03cc096f99ffcfdd18a1817354132c1331bed7a837a25226659845f", + "sha256:cc94f9fea17a5af8cf1a343597711a26b0117c0b812550d99934acb89d526ed2", + "sha256:ccd785fafa1c931deff6a7116e9a0d402d59fabe51644b0d0c268295ff847b25", + "sha256:d16a534da0e39785687b7295e2fcf9a339f4a20689024983d11afaa4657f8507", + "sha256:d3077a31633beef77d057c6523f5de7271ddef7bde5e019285b00c0cc9cac1e3", + "sha256:d603edea1ff7408638b2504905c032193b7dcee7af269802dbb35bc8c3310ed5", + "sha256:db082f728160369d9a6ed2e722438291558fc15ce06d0a7d696a8dad735c236b", + "sha256:ddef295aaf80cefb0c1606f1995899efcb17edc6b327eb6589e234e614b87756", + "sha256:e16ade71c93f6814d095d25cd6d28a90d63511ea396bd96e9ffcb886b278baaa", + "sha256:e3db7d833a7c38c317dc95b54e27f1d27012e031b45a7c24e360b53197d5f6e7", + "sha256:e5e193f89f4f8c1fe273f9a6e6df915092c9f2af6db2d1afb8bd53855025c11f", + "sha256:eb438a8bf6b695bf50d57e6a059ff09652a07968b2041178b3744ea785fcef9b", + "sha256:ebf02c32afa6b67e5861a27183dd98ed88419a94a2ab843cc145fb0bafcc5b28", + "sha256:ecd9e1fa97aa11bf67472220285775fa15e896da108f425e55d23d7540a712ce", + "sha256:ef67fedd863ffffd4adfd46d9d992b0f929c7f61a8307366d664d93517f2c78e", + "sha256:f28ae33dc5a0b9cee06e95fd420e42155d83271ab75964baf747ce959cac5f52", + "sha256:fb1c56d891f9e34303c451998ef62ba52659648bb0d75b03c5e4ac223a3342c2", + "sha256:fe03bf25fae4b95d8afe40004a321df644400fdcba4c8e5e1a19c1085b740888" + ], + "markers": "python_version >= '3.7'", + "version": "==4.6.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "python-dotenv": { + "hashes": [ + "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", + "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.0.0" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "tinycss2": { + "hashes": [ + "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847", + "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.1" + }, + "urllib3": { + "hashes": [ + "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", + "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" + }, + "uvloop": { + "hashes": [ + "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd", + "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec", + "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b", + "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc", + "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797", + "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5", + "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2", + "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d", + "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be", + "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd", + "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12", + "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17", + "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef", + "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24", + "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428", + "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1", + "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849", + "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593", + "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd", + "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67", + "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6", + "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3", + "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd", + "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8", + "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7", + "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533", + "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957", + "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650", + "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e", + "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7", + "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256" + ], + "markers": "sys_platform != 'win32'", + "version": "==0.19.0" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "yarl": { + "hashes": [ + "sha256:09c19e5f4404574fcfb736efecf75844ffe8610606f3fccc35a1515b8b6712c4", + "sha256:0ab5baaea8450f4a3e241ef17e3d129b2143e38a685036b075976b9c415ea3eb", + "sha256:0d155a092bf0ebf4a9f6f3b7a650dc5d9a5bbb585ef83a52ed36ba46f55cc39d", + "sha256:126638ab961633f0940a06e1c9d59919003ef212a15869708dcb7305f91a6732", + "sha256:1a0a4f3aaa18580038cfa52a7183c8ffbbe7d727fe581300817efc1e96d1b0e9", + "sha256:1d93461e2cf76c4796355494f15ffcb50a3c198cc2d601ad8d6a96219a10c363", + "sha256:26a1a8443091c7fbc17b84a0d9f38de34b8423b459fb853e6c8cdfab0eacf613", + "sha256:271d63396460b6607b588555ea27a1a02b717ca2e3f2cf53bdde4013d7790929", + "sha256:28a108cb92ce6cf867690a962372996ca332d8cda0210c5ad487fe996e76b8bb", + "sha256:29beac86f33d6c7ab1d79bd0213aa7aed2d2f555386856bb3056d5fdd9dab279", + "sha256:2c757f64afe53a422e45e3e399e1e3cf82b7a2f244796ce80d8ca53e16a49b9f", + "sha256:2dad8166d41ebd1f76ce107cf6a31e39801aee3844a54a90af23278b072f1ccf", + "sha256:2dc72e891672343b99db6d497024bf8b985537ad6c393359dc5227ef653b2f17", + "sha256:2f3c8822bc8fb4a347a192dd6a28a25d7f0ea3262e826d7d4ef9cc99cd06d07e", + "sha256:32435d134414e01d937cd9d6cc56e8413a8d4741dea36af5840c7750f04d16ab", + "sha256:3cfa4dbe17b2e6fca1414e9c3bcc216f6930cb18ea7646e7d0d52792ac196808", + "sha256:3d5434b34100b504aabae75f0622ebb85defffe7b64ad8f52b8b30ec6ef6e4b9", + "sha256:4003f380dac50328c85e85416aca6985536812c082387255c35292cb4b41707e", + "sha256:44e91a669c43f03964f672c5a234ae0d7a4d49c9b85d1baa93dec28afa28ffbd", + "sha256:4a14907b597ec55740f63e52d7fee0e9ee09d5b9d57a4f399a7423268e457b57", + "sha256:4ce77d289f8d40905c054b63f29851ecbfd026ef4ba5c371a158cfe6f623663e", + "sha256:4d6d74a97e898c1c2df80339aa423234ad9ea2052f66366cef1e80448798c13d", + "sha256:51382c72dd5377861b573bd55dcf680df54cea84147c8648b15ac507fbef984d", + "sha256:525cd69eff44833b01f8ef39aa33a9cc53a99ff7f9d76a6ef6a9fb758f54d0ff", + "sha256:53ec65f7eee8655bebb1f6f1607760d123c3c115a324b443df4f916383482a67", + "sha256:5f74b015c99a5eac5ae589de27a1201418a5d9d460e89ccb3366015c6153e60a", + "sha256:6280353940f7e5e2efaaabd686193e61351e966cc02f401761c4d87f48c89ea4", + "sha256:632c7aeb99df718765adf58eacb9acb9cbc555e075da849c1378ef4d18bf536a", + "sha256:6465d36381af057d0fab4e0f24ef0e80ba61f03fe43e6eeccbe0056e74aadc70", + "sha256:66a6dbf6ca7d2db03cc61cafe1ee6be838ce0fbc97781881a22a58a7c5efef42", + "sha256:6d350388ba1129bc867c6af1cd17da2b197dff0d2801036d2d7d83c2d771a682", + "sha256:7217234b10c64b52cc39a8d82550342ae2e45be34f5bff02b890b8c452eb48d7", + "sha256:721ee3fc292f0d069a04016ef2c3a25595d48c5b8ddc6029be46f6158d129c92", + "sha256:72a57b41a0920b9a220125081c1e191b88a4cdec13bf9d0649e382a822705c65", + "sha256:73cc83f918b69110813a7d95024266072d987b903a623ecae673d1e71579d566", + "sha256:778df71c8d0c8c9f1b378624b26431ca80041660d7be7c3f724b2c7a6e65d0d6", + "sha256:79e1df60f7c2b148722fb6cafebffe1acd95fd8b5fd77795f56247edaf326752", + "sha256:7c86d0d0919952d05df880a1889a4f0aeb6868e98961c090e335671dea5c0361", + "sha256:7eaf13af79950142ab2bbb8362f8d8d935be9aaf8df1df89c86c3231e4ff238a", + "sha256:828235a2a169160ee73a2fcfb8a000709edf09d7511fccf203465c3d5acc59e4", + "sha256:8535e111a064f3bdd94c0ed443105934d6f005adad68dd13ce50a488a0ad1bf3", + "sha256:88d2c3cc4b2f46d1ba73d81c51ec0e486f59cc51165ea4f789677f91a303a9a7", + "sha256:8a2538806be846ea25e90c28786136932ec385c7ff3bc1148e45125984783dc6", + "sha256:8dab30b21bd6fb17c3f4684868c7e6a9e8468078db00f599fb1c14e324b10fca", + "sha256:8f18a7832ff85dfcd77871fe677b169b1bc60c021978c90c3bb14f727596e0ae", + "sha256:946db4511b2d815979d733ac6a961f47e20a29c297be0d55b6d4b77ee4b298f6", + "sha256:96758e56dceb8a70f8a5cff1e452daaeff07d1cc9f11e9b0c951330f0a2396a7", + "sha256:9a172c3d5447b7da1680a1a2d6ecdf6f87a319d21d52729f45ec938a7006d5d8", + "sha256:9a5211de242754b5e612557bca701f39f8b1a9408dff73c6db623f22d20f470e", + "sha256:9df9a0d4c5624790a0dea2e02e3b1b3c69aed14bcb8650e19606d9df3719e87d", + "sha256:aa4643635f26052401750bd54db911b6342eb1a9ac3e74f0f8b58a25d61dfe41", + "sha256:aed37db837ecb5962469fad448aaae0f0ee94ffce2062cf2eb9aed13328b5196", + "sha256:af52725c7c39b0ee655befbbab5b9a1b209e01bb39128dce0db226a10014aacc", + "sha256:b0b8c06afcf2bac5a50b37f64efbde978b7f9dc88842ce9729c020dc71fae4ce", + "sha256:b61e64b06c3640feab73fa4ff9cb64bd8182de52e5dc13038e01cfe674ebc321", + "sha256:b7831566595fe88ba17ea80e4b61c0eb599f84c85acaa14bf04dd90319a45b90", + "sha256:b8bc5b87a65a4e64bc83385c05145ea901b613d0d3a434d434b55511b6ab0067", + "sha256:b8d51817cf4b8d545963ec65ff06c1b92e5765aa98831678d0e2240b6e9fd281", + "sha256:b9f9cafaf031c34d95c1528c16b2fa07b710e6056b3c4e2e34e9317072da5d1a", + "sha256:bb72d2a94481e7dc7a0c522673db288f31849800d6ce2435317376a345728225", + "sha256:c25ec06e4241e162f5d1f57c370f4078797ade95c9208bd0c60f484834f09c96", + "sha256:c405d482c320a88ab53dcbd98d6d6f32ada074f2d965d6e9bf2d823158fa97de", + "sha256:c4472fe53ebf541113e533971bd8c32728debc4c6d8cc177f2bff31d011ec17e", + "sha256:c4b1efb11a8acd13246ffb0bee888dd0e8eb057f8bf30112e3e21e421eb82d4a", + "sha256:c5f3faeb8100a43adf3e7925d556801d14b5816a0ac9e75e22948e787feec642", + "sha256:c6f034386e5550b5dc8ded90b5e2ff7db21f0f5c7de37b6efc5dac046eb19c10", + "sha256:c99ddaddb2fbe04953b84d1651149a0d85214780e4d0ee824e610ab549d98d92", + "sha256:ca6b66f69e30f6e180d52f14d91ac854b8119553b524e0e28d5291a724f0f423", + "sha256:cccdc02e46d2bd7cb5f38f8cc3d9db0d24951abd082b2f242c9e9f59c0ab2af3", + "sha256:cd49a908cb6d387fc26acee8b7d9fcc9bbf8e1aca890c0b2fdfd706057546080", + "sha256:cf7a4e8de7f1092829caef66fd90eaf3710bc5efd322a816d5677b7664893c93", + "sha256:cfd77e8e5cafba3fb584e0f4b935a59216f352b73d4987be3af51f43a862c403", + "sha256:d34c4f80956227f2686ddea5b3585e109c2733e2d4ef12eb1b8b4e84f09a2ab6", + "sha256:d61a0ca95503867d4d627517bcfdc28a8468c3f1b0b06c626f30dd759d3999fd", + "sha256:d81657b23e0edb84b37167e98aefb04ae16cbc5352770057893bd222cdc6e45f", + "sha256:d92d897cb4b4bf915fbeb5e604c7911021a8456f0964f3b8ebbe7f9188b9eabb", + "sha256:dd318e6b75ca80bff0b22b302f83a8ee41c62b8ac662ddb49f67ec97e799885d", + "sha256:dd952b9c64f3b21aedd09b8fe958e4931864dba69926d8a90c90d36ac4e28c9a", + "sha256:e0e7e83f31e23c5d00ff618045ddc5e916f9e613d33c5a5823bc0b0a0feb522f", + "sha256:e0f17d1df951336a02afc8270c03c0c6e60d1f9996fcbd43a4ce6be81de0bd9d", + "sha256:e2a16ef5fa2382af83bef4a18c1b3bcb4284c4732906aa69422cf09df9c59f1f", + "sha256:e36021db54b8a0475805acc1d6c4bca5d9f52c3825ad29ae2d398a9d530ddb88", + "sha256:e73db54c967eb75037c178a54445c5a4e7461b5203b27c45ef656a81787c0c1b", + "sha256:e741bd48e6a417bdfbae02e088f60018286d6c141639359fb8df017a3b69415a", + "sha256:f7271d6bd8838c49ba8ae647fc06469137e1c161a7ef97d778b72904d9b68696", + "sha256:fc391e3941045fd0987c77484b2799adffd08e4b6735c4ee5f054366a2e1551d", + "sha256:fc94441bcf9cb8c59f51f23193316afefbf3ff858460cb47b5758bf66a14d130", + "sha256:fe34befb8c765b8ce562f0200afda3578f8abb159c76de3ab354c80b72244c41", + "sha256:fe8080b4f25dfc44a86bedd14bc4f9d469dfc6456e6f3c5d9077e81a5fedfba7", + "sha256:ff34cb09a332832d1cf38acd0f604c068665192c6107a439a92abfd8acf90fe2" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.3" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca", + "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.1" + }, + "bandit": { + "hashes": [ + "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549", + "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.7.5" + }, + "black": { + "hashes": [ + "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", + "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", + "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", + "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", + "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", + "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", + "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", + "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", + "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", + "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", + "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", + "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", + "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", + "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", + "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", + "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", + "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", + "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==23.11.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "dill": { + "hashes": [ + "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e", + "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03" + ], + "markers": "python_version < '3.11'", + "version": "==0.3.7" + }, + "gitdb": { + "hashes": [ + "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", + "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.11" + }, + "gitpython": { + "hashes": [ + "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4", + "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.40" + }, + "isort": { + "hashes": [ + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.12.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pathspec": { + "hashes": [ + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.2" + }, + "pbr": { + "hashes": [ + "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda", + "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9" + ], + "markers": "python_version >= '2.6'", + "version": "==6.0.0" + }, + "platformdirs": { + "hashes": [ + "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", + "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.0" + }, + "pygments": { + "hashes": [ + "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", + "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" + ], + "markers": "python_version >= '3.7'", + "version": "==2.17.2" + }, + "pylint": { + "hashes": [ + "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", + "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.2" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0.1" + }, + "rich": { + "hashes": [ + "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", + "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==13.7.0" + }, + "smmap": { + "hashes": [ + "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", + "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" + ], + "markers": "python_version >= '3.7'", + "version": "==5.0.1" + }, + "stevedore": { + "hashes": [ + "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d", + "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c" + ], + "markers": "python_version >= '3.8'", + "version": "==5.1.0" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "tomlkit": { + "hashes": [ + "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", + "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.12.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", + "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.8.0" + } + } +} diff --git a/Procfile b/Procfile index 29cff6d9d1..f3d43bbc92 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -worker: python bot.py +worker: python bot.py diff --git a/README.md b/README.md index 2e3d32e344..aa6fb12e88 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,183 @@ -
- -
- A feature-rich Modmail bot for Discord. -
-
- - - - - -
- - - - - - - Bot instances - - - - Support - - - - Patreon - - - - Made with Python 3.10 - - - - - - - - MIT License - - -
- -
- - -## What is Modmail? - -Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. - -This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! - -For up-to-date setup instructions, please visit our [**documentation**](https://docs.modmail.dev/installation) page. - -## How does it work? - -When a member sends a direct message to the bot, Modmail will create a channel or "thread" into a designated category. All further DM messages will automatically relay to that channel; any available staff can respond within the channel. - -Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). - -## Features - -* **Highly Customisable:** - * Bot activity, prefix, category, log channel, etc. - * Command permission system. - * Interface elements (color, responses, reactions, etc.). - * Snippets and *command aliases*. - * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). - * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). - -* **Advanced Logging Functionality:** - * When you close a thread, Modmail will generate a log link and post it to your log channel. - * Native Discord dark-mode feel. - * Markdown/formatting support. - * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). - * See past logs of a user with `?logs`. - * Searchable by text queries using `?logs search`. - -* **Robust implementation:** - * Schedule tasks in human time, e.g. `?close in 2 hours silently`. - * Editing and deleting messages are synced. - * Support for the diverse range of message contents (multiple images, files). - * Paginated commands interfaces via reactions. - -This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. - -## Installation - -There are a number of options for hosting your very own dedicated Modmail bot. - -Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. - -### Patreon Hosting - -If you don't want the trouble of renting and configuring your server to host Modmail, we got a solution for you! We offer hosting and maintenance of your own, private Modmail bot (including a Logviewer) through [**Patreon**](https://patreon.com/kyber). - -## FAQ - -**Q: Where can I find the Modmail bot invite link?** - -**A:** Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following our [**documentation**](https://docs.modmail.dev/installation) steps or subscribe to [**Patreon**](https://patreon.com/kyber). - -**Q: Where can I find out more info about Modmail?** - -**A:** You can find more info about Modmail on our [**documentation**](https://docs.modmail.dev) page. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/cnUpwrnpYb) for help and support. - -## Plugins - -Modmail supports the use of third-party plugins to extend or add functionalities to the bot. -Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. - -You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. - -To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). - -Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/cnUpwrnpYb). - -## Sponsors - -Special thanks to our sponsors for supporting the project. - -SirReddit: -
- - - -
-
-Prime Servers Inc: -
- - - -
-
-Real Madrid: -
- - - -
-
-Advertise Your Server: -
- - - -
-
-Help Us • Help Other's: -
- - - -
-
-Discord Advice Center: -
- - - -
-
-Blacklight Promotions: -
- - - - - -Become a sponsor on [Patreon](https://patreon.com/kyber). - -## Contributing - -Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/modmail-dev/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. - -If you like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! - -## Beta Testing - -Our [development](https://github.com/modmail-dev/modmail/tree/development) branch is where most of our features are tested before public release. Be warned that there could be bugs in various commands so keep it away from any large servers you manage. - -If you wish to test the new features and play around with them, feel free to join our [Public Test Server](https://discord.gg/v5hTjKC). Bugs can be raised within that server or in our Github issues (state that you are using the development branch though). +
+ +
+ A feature-rich Modmail bot for Discord. +
+
+ + + + + +
+ + + + + + + Bot instances + + + + Support + + + + Patreon + + + + Made with Python 3.10 + + + + + + + + MIT License + + +
+ +
+ + +## What is Modmail? + +Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. + +This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! + +For up-to-date setup instructions, please visit our [**documentation**](https://docs.modmail.dev/installation) page. + +## How does it work? + +When a member sends a direct message to the bot, Modmail will create a channel or "thread" into a designated category. All further DM messages will automatically relay to that channel; any available staff can respond within the channel. + +Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). + +## Features + +* **Highly Customisable:** + * Bot activity, prefix, category, log channel, etc. + * Command permission system. + * Interface elements (color, responses, reactions, etc.). + * Snippets and *command aliases*. + * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). + * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). + +* **Advanced Logging Functionality:** + * When you close a thread, Modmail will generate a log link and post it to your log channel. + * Native Discord dark-mode feel. + * Markdown/formatting support. + * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). + * See past logs of a user with `?logs`. + * Searchable by text queries using `?logs search`. + +* **Robust implementation:** + * Schedule tasks in human time, e.g. `?close in 2 hours silently`. + * Editing and deleting messages are synced. + * Support for the diverse range of message contents (multiple images, files). + * Paginated commands interfaces via reactions. + +This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. + +## Installation + +There are a number of options for hosting your very own dedicated Modmail bot. + +Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. + +### Patreon Hosting + +If you don't want the trouble of renting and configuring your server to host Modmail, we got a solution for you! We offer hosting and maintenance of your own, private Modmail bot (including a Logviewer) through [**Patreon**](https://patreon.com/kyber). + +## FAQ + +**Q: Where can I find the Modmail bot invite link?** + +**A:** Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following our [**documentation**](https://docs.modmail.dev/installation) steps or subscribe to [**Patreon**](https://patreon.com/kyber). + +**Q: Where can I find out more info about Modmail?** + +**A:** You can find more info about Modmail on our [**documentation**](https://docs.modmail.dev) page. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/cnUpwrnpYb) for help and support. + +## Plugins + +Modmail supports the use of third-party plugins to extend or add functionalities to the bot. +Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. + +You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. + +To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). + +Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/cnUpwrnpYb). + +## Sponsors + +Special thanks to our sponsors for supporting the project. + +SirReddit: +
+ + + +
+
+Prime Servers Inc: +
+ + + +
+
+Real Madrid: +
+ + + +
+
+Advertise Your Server: +
+ + + +
+
+Help Us • Help Other's: +
+ + + +
+
+Discord Advice Center: +
+ + + +
+
+Blacklight Promotions: +
+ + + + + +Become a sponsor on [Patreon](https://patreon.com/kyber). + +## Contributing + +Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/modmail-dev/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. + +If you like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! + +## Beta Testing + +Our [development](https://github.com/modmail-dev/modmail/tree/development) branch is where most of our features are tested before public release. Be warned that there could be bugs in various commands so keep it away from any large servers you manage. + +If you wish to test the new features and play around with them, feel free to join our [Public Test Server](https://discord.gg/v5hTjKC). Bugs can be raised within that server or in our Github issues (state that you are using the development branch though). diff --git a/SPONSORS.json b/SPONSORS.json index b6212b8ed7..30ebeff8db 100644 --- a/SPONSORS.json +++ b/SPONSORS.json @@ -1,162 +1,162 @@ -[ - { - "embed": { - "description": "This is a youtube channel that provides high quality reddit content. Videos are uploaded regularly so you are never out of funny, interesting or even creepy content!", - "color": 15114050, - "footer": { - "icon_url": "https://i.imgur.com/fvNKUks.png", - "text": "I am a robot, son of Daniel (UK)" - }, - "thumbnail": { - "url": "https://i.imgur.com/WyzaPKY.png" - }, - "author": { - "name": "Sir Reddit", - "url": "https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1", - "icon_url": "https://i.imgur.com/WyzaPKY.png" - }, - "fields": [ - { - "name": "Subscribe!", - "value": "[**Click Here**](https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1)" - }, - { - "name": "Discord Server", - "value": "[**Click Here**](https://discord.gg/V8ErqHb)" - } - ] - } - }, - { - "embed": { - "title": "Berkand Karadere", - "description": "Berkand Karadere is an German Community Manager who integrated new systems into the game industry. He also is hosting and developing web servers and game servers. He also plays American Football for the Dortmund Giants and his journey has just begun.", - "color": 2968248, - "thumbnail": { - "url": "https://i.imgur.com/cs2QEcp.png" - }, - "fields": [ - { - "name": "Discord Server", - "value": "[**Click here**](https://discord.gg/BanCwptMJV)" - } - ] - } - }, - { - "embed": { - "description": "Quality Hosting at Prices You Deserve!", - "color": 3137203, - "footer": { - "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", - "text": "Prime Servers, Inc." - }, - "thumbnail": { - "url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" - }, - "author": { - "name": "Prime Servers, Inc.", - "url": "https://primeserversinc.com", - "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" - }, - "fields": [ - { - "name": "Twitter", - "value": "[**Click Here**](https://twitter.com/PrimeServersInc)" - }, - { - "name": "Discord Server", - "value": "[**Click Here**](https://discord.gg/cYM6Urn)" - } - ] - } - }, - { - "embed": { - "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", - "color": 53380, - "author": { - "name": "Discord Advice Center", - "url": "https://discord.gg/nkMDQfuK", - "icon_url": "https://i.imgur.com/cjVtRw5.jpg" - }, - "image": { - "url": "https://i.imgur.com/1hrjcHd.png" - }, - "fields": [ - { - "name": "Discord Server", - "value": "[**Click Here**](https://discord.gg/zmwZy5fd9v)" - } - ] - } - }, - { - "embed": { - "footer": { - "text": "Join noch heute!" - }, - "thumbnail": { - "url": "https://i.imgur.com/bp0xfyK.png" - }, - "fields": [ - { - "inline": false, - "name": "Viele Verschiedene Talks", - "value": "Gro\u00dfe Community\nGewinnspiele" - } - ], - "color": 61532, - "description": "Die etwas andere Community", - "url": "https://discord.gg/uncommon", - "title": "uncommon community" - } - }, - { - "embed": { - "author": { - "name": "Help us • Help Others" - }, - "title": "Join Today", - "url": "https://discord.gg/5yQCFzY6HU", - "description": "At Help Us • Help Others, we accept as true with inside the transformative electricity of cooperation and kindness. Each one people has the capability to make a meaningful impact by means of helping and caring for others. Whether you want assistance or want to offer it, this is the right region for you!", - "fields": [ - { - "name": "What we offer:", - "value": "`🎬` - Active community\n`👮` - Active staff around the globe! \n`🛜` - 40+ Advertising channels to grow your socials!\n`💎` - Boosting Perks\n`🎉` - Event's monthly especially bank holiday roles!!\n`🔢` - Unique levelling systems\n`📞` - Multiple voice channels including gaming!\n`🎁` - Exclusive giveaways!" - }, - { - "name": "We Are Hiring", - "value": "`🔵` - Moderators\n`🔵` - Human Resources\n`🔵` - Community Team\n`🔵` - Partnership Manager\n`🔵` - Growth Manager\n`🚀` Much more to come!\n\n\nJoin Today!" - } - ], - "image": { - "url": "https://cdn.discordapp.com/attachments/1218338794416246874/1243635366326567002/AD_animated.gif" - }, - "color": 45300, - "footer": { - "text": "Help Us • Help Others" - } - } - }, - { - "embed": { - "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", - "author": { - "name": "New York Roleplay", - "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" - }, - "color": 431075, - "thumbnail": { - "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" - } - } - }, - { - "embed": { - "title": "CityStore PLC", - "description": "*Your Retail Journey*\n*\"Better choice and better value in food, fashion & homewares.\"*\n\n\n**------------------------------------------**\n*__About us__*\nSupermarket, CityStore PLC! Attend a training to become staff!\n\nThis game is currently in V3\n\nWe have a training Centre and applications center!\n\n**------------------------------------------**\n\n> *❤️ Don't hesitate! Dive into the excitement today by joining our vibrant community on Discord. Experience our unique perspective and become an integral part of our group. Your **journey** with us promises to be unforgettable no regrets, only great memories await! ❤️*\n\n*We hope to see you. *\n\n*Signed,*\n**CityStore PLC**\n> Discord: https://discord.gg/yjFQb5mrSk\n> Roblox Group: https://www.roblox.com/groups/32819373/CityStore-PLC#!/about\n\nJoin us now and become apart of Citystore PLC community! 🎉", - "color": 15523550 - } - } -] +[ + { + "embed": { + "description": "This is a youtube channel that provides high quality reddit content. Videos are uploaded regularly so you are never out of funny, interesting or even creepy content!", + "color": 15114050, + "footer": { + "icon_url": "https://i.imgur.com/fvNKUks.png", + "text": "I am a robot, son of Daniel (UK)" + }, + "thumbnail": { + "url": "https://i.imgur.com/WyzaPKY.png" + }, + "author": { + "name": "Sir Reddit", + "url": "https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1", + "icon_url": "https://i.imgur.com/WyzaPKY.png" + }, + "fields": [ + { + "name": "Subscribe!", + "value": "[**Click Here**](https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1)" + }, + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/V8ErqHb)" + } + ] + } + }, + { + "embed": { + "title": "Berkand Karadere", + "description": "Berkand Karadere is an German Community Manager who integrated new systems into the game industry. He also is hosting and developing web servers and game servers. He also plays American Football for the Dortmund Giants and his journey has just begun.", + "color": 2968248, + "thumbnail": { + "url": "https://i.imgur.com/cs2QEcp.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click here**](https://discord.gg/BanCwptMJV)" + } + ] + } + }, + { + "embed": { + "description": "Quality Hosting at Prices You Deserve!", + "color": 3137203, + "footer": { + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", + "text": "Prime Servers, Inc." + }, + "thumbnail": { + "url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" + }, + "author": { + "name": "Prime Servers, Inc.", + "url": "https://primeserversinc.com", + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" + }, + "fields": [ + { + "name": "Twitter", + "value": "[**Click Here**](https://twitter.com/PrimeServersInc)" + }, + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/cYM6Urn)" + } + ] + } + }, + { + "embed": { + "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", + "color": 53380, + "author": { + "name": "Discord Advice Center", + "url": "https://discord.gg/nkMDQfuK", + "icon_url": "https://i.imgur.com/cjVtRw5.jpg" + }, + "image": { + "url": "https://i.imgur.com/1hrjcHd.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/zmwZy5fd9v)" + } + ] + } + }, + { + "embed": { + "footer": { + "text": "Join noch heute!" + }, + "thumbnail": { + "url": "https://i.imgur.com/bp0xfyK.png" + }, + "fields": [ + { + "inline": false, + "name": "Viele Verschiedene Talks", + "value": "Gro\u00dfe Community\nGewinnspiele" + } + ], + "color": 61532, + "description": "Die etwas andere Community", + "url": "https://discord.gg/uncommon", + "title": "uncommon community" + } + }, + { + "embed": { + "author": { + "name": "Help us • Help Others" + }, + "title": "Join Today", + "url": "https://discord.gg/5yQCFzY6HU", + "description": "At Help Us • Help Others, we accept as true with inside the transformative electricity of cooperation and kindness. Each one people has the capability to make a meaningful impact by means of helping and caring for others. Whether you want assistance or want to offer it, this is the right region for you!", + "fields": [ + { + "name": "What we offer:", + "value": "`🎬` - Active community\n`👮` - Active staff around the globe! \n`🛜` - 40+ Advertising channels to grow your socials!\n`💎` - Boosting Perks\n`🎉` - Event's monthly especially bank holiday roles!!\n`🔢` - Unique levelling systems\n`📞` - Multiple voice channels including gaming!\n`🎁` - Exclusive giveaways!" + }, + { + "name": "We Are Hiring", + "value": "`🔵` - Moderators\n`🔵` - Human Resources\n`🔵` - Community Team\n`🔵` - Partnership Manager\n`🔵` - Growth Manager\n`🚀` Much more to come!\n\n\nJoin Today!" + } + ], + "image": { + "url": "https://cdn.discordapp.com/attachments/1218338794416246874/1243635366326567002/AD_animated.gif" + }, + "color": 45300, + "footer": { + "text": "Help Us • Help Others" + } + } + }, + { + "embed": { + "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", + "author": { + "name": "New York Roleplay", + "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + }, + "color": 431075, + "thumbnail": { + "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + } + } + }, + { + "embed": { + "title": "CityStore PLC", + "description": "*Your Retail Journey*\n*\"Better choice and better value in food, fashion & homewares.\"*\n\n\n**------------------------------------------**\n*__About us__*\nSupermarket, CityStore PLC! Attend a training to become staff!\n\nThis game is currently in V3\n\nWe have a training Centre and applications center!\n\n**------------------------------------------**\n\n> *❤️ Don't hesitate! Dive into the excitement today by joining our vibrant community on Discord. Experience our unique perspective and become an integral part of our group. Your **journey** with us promises to be unforgettable no regrets, only great memories await! ❤️*\n\n*We hope to see you. *\n\n*Signed,*\n**CityStore PLC**\n> Discord: https://discord.gg/yjFQb5mrSk\n> Roblox Group: https://www.roblox.com/groups/32819373/CityStore-PLC#!/about\n\nJoin us now and become apart of Citystore PLC community! 🎉", + "color": 15523550 + } + } +] diff --git a/app.json b/app.json index decd58695c..e1919b2118 100644 --- a/app.json +++ b/app.json @@ -1,39 +1,39 @@ -{ - "name": "Modmail", - "description": "An easy to install Modmail bot for Discord - DM to contact mods!", - "repository": "https://github.com/modmail-dev/modmail", - "env": { - "TOKEN": { - "description": "Your discord bot's token.", - "required": true - }, - "GUILD_ID": { - "description": "The id for the server you are hosting this bot for.", - "required": true - }, - "OWNERS": { - "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", - "required": true - }, - "CONNECTION_URI": { - "description": "The connection URI for your database.", - "required": true - }, - "DATABASE_TYPE": { - "description": "The type of your database. There is only one supported database at the moment - MongoDB (default).", - "required": false - }, - "LOG_URL": { - "description": "The url of the log viewer app for viewing self-hosted logs.", - "required": true - }, - "GITHUB_TOKEN": { - "description": "A github personal access token with the repo scope.", - "required": false - }, - "REGISTRY_PLUGINS_ONLY": { - "description": "If set to true, only plugins that are in the registry can be loaded.", - "required": false - } - } -} +{ + "name": "Modmail", + "description": "An easy to install Modmail bot for Discord - DM to contact mods!", + "repository": "https://github.com/modmail-dev/modmail", + "env": { + "TOKEN": { + "description": "Your discord bot's token.", + "required": true + }, + "GUILD_ID": { + "description": "The id for the server you are hosting this bot for.", + "required": true + }, + "OWNERS": { + "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", + "required": true + }, + "CONNECTION_URI": { + "description": "The connection URI for your database.", + "required": true + }, + "DATABASE_TYPE": { + "description": "The type of your database. There is only one supported database at the moment - MongoDB (default).", + "required": false + }, + "LOG_URL": { + "description": "The url of the log viewer app for viewing self-hosted logs.", + "required": true + }, + "GITHUB_TOKEN": { + "description": "A github personal access token with the repo scope.", + "required": false + }, + "REGISTRY_PLUGINS_ONLY": { + "description": "If set to true, only plugins that are in the registry can be loaded.", + "required": false + } + } +} diff --git a/bot.py b/bot.py index 3f13ef7ced..d9502d6097 100644 --- a/bot.py +++ b/bot.py @@ -1,1837 +1,2009 @@ -__version__ = "4.1.2" - - -import asyncio -import copy -import hashlib -import logging -import os -import re -import string -import struct -import sys -import platform -import typing -from datetime import datetime, timezone, timedelta -from subprocess import PIPE -from types import SimpleNamespace - -import discord -import isodate -from aiohttp import ClientSession, ClientResponseError -from discord.ext import commands, tasks -from discord.ext.commands.view import StringView -from emoji import is_emoji -from packaging.version import Version - - -try: - # noinspection PyUnresolvedReferences - from colorama import init - - init() -except ImportError: - pass - -from core import checks -from core.changelog import Changelog -from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient -from core.config import ConfigManager -from core.models import ( - DMDisabled, - HostingMethod, - InvalidConfigError, - PermissionLevel, - SafeFormatter, - configure_logging, - getLogger, -) -from core.thread import ThreadManager -from core.time import human_timedelta -from core.utils import extract_block_timestamp, normalize_alias, parse_alias, truncate, tryint, human_join - -logger = getLogger(__name__) - -temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") -if not os.path.exists(temp_dir): - os.mkdir(temp_dir) - -if sys.platform == "win32": - try: - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - except AttributeError: - logger.error("Failed to use WindowsProactorEventLoopPolicy.", exc_info=True) - - -class ModmailBot(commands.Bot): - def __init__(self): - self.config = ConfigManager(self) - self.config.populate_cache() - - intents = discord.Intents.all() - if not self.config["enable_presence_intent"]: - intents.presences = False - - super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` - self.session = None - self._api = None - self.formatter = SafeFormatter() - self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] - self._connected = None - self.start_time = discord.utils.utcnow() - self._started = False - - self.threads = ThreadManager(self) - - log_dir = os.path.join(temp_dir, "logs") - if not os.path.exists(log_dir): - os.mkdir(log_dir) - self.log_file_path = os.path.join(log_dir, "modmail.log") - configure_logging(self) - - self.plugin_db = PluginDatabaseClient(self) # Deprecated - self.startup() - - def get_guild_icon( - self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None - ) -> str: - if guild is None: - guild = self.guild - if guild.icon is None: - return "https://cdn.discordapp.com/embed/avatars/0.png" - if size is None: - return guild.icon.url - return guild.icon.with_size(size).url - - def _resolve_snippet(self, name: str) -> typing.Optional[str]: - """ - Get actual snippet names from direct aliases to snippets. - - If the provided name is a snippet, it's returned unchanged. - If there is an alias by this name, it is parsed to see if it - refers only to a snippet, in which case that snippet name is - returned. - - If no snippets were found, None is returned. - """ - if name in self.snippets: - return name - - try: - (command,) = parse_alias(self.aliases[name]) - except (KeyError, ValueError): - # There is either no alias by this name present or the - # alias has multiple steps. - pass - else: - if command in self.snippets: - return command - - @property - def uptime(self) -> str: - now = discord.utils.utcnow() - delta = now - self.start_time - hours, remainder = divmod(int(delta.total_seconds()), 3600) - minutes, seconds = divmod(remainder, 60) - days, hours = divmod(hours, 24) - - fmt = "{h}h {m}m {s}s" - if days: - fmt = "{d}d " + fmt - - return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) - - @property - def hosting_method(self) -> HostingMethod: - # use enums - if ".heroku" in os.environ.get("PYTHONHOME", ""): - return HostingMethod.HEROKU - - if os.environ.get("pm_id"): - return HostingMethod.PM2 - - if os.environ.get("INVOCATION_ID"): - return HostingMethod.SYSTEMD - - if os.environ.get("USING_DOCKER"): - return HostingMethod.DOCKER - - if os.environ.get("TERM"): - return HostingMethod.SCREEN - - return HostingMethod.OTHER - - def startup(self): - logger.line() - logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") - logger.info("││││ │ │││││├─┤││") - logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") - logger.info("v%s", __version__) - logger.info("Authors: kyb3r, fourjr, Taaku18") - logger.line() - logger.info("discord.py: v%s", discord.__version__) - logger.line() - - async def load_extensions(self): - for cog in self.loaded_cogs: - if cog in self.extensions: - continue - logger.debug("Loading %s.", cog) - try: - await self.load_extension(cog) - logger.debug("Successfully loaded %s.", cog) - except Exception: - logger.exception("Failed to load %s.", cog) - logger.line("debug") - - @property - def version(self): - return Version(__version__) - - @property - def api(self) -> ApiClient: - if self._api is None: - if self.config["database_type"].lower() == "mongodb": - self._api = MongoDBClient(self) - else: - logger.critical("Invalid database type.") - raise RuntimeError - return self._api - - @property - def db(self): - # deprecated - return self.api.db - - async def get_prefix(self, message=None): - return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] - - def run(self): - async def runner(): - async with self: - self._connected = asyncio.Event() - self.session = ClientSession(loop=self.loop) - - if self.config["enable_presence_intent"]: - logger.info("Starting bot with presence intent.") - else: - logger.info("Starting bot without presence intent.") - - try: - await self.start(self.token) - except discord.PrivilegedIntentsRequired: - logger.critical( - "Privileged intents are not explicitly granted in the discord developers dashboard." - ) - except discord.LoginFailure: - logger.critical("Invalid token") - except Exception: - logger.critical("Fatal exception", exc_info=True) - finally: - if self.session: - await self.session.close() - if not self.is_closed(): - await self.close() - - async def _cancel_tasks(): - async with self: - task_retriever = asyncio.all_tasks - loop = self.loop - tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} - - if not tasks: - return - - logger.info("Cleaning up after %d tasks.", len(tasks)) - for task in tasks: - task.cancel() - - await asyncio.gather(*tasks, return_exceptions=True) - logger.info("All tasks finished cancelling.") - - for task in tasks: - try: - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "Unhandled exception during Client.run shutdown.", - "exception": task.exception(), - "task": task, - } - ) - except (asyncio.InvalidStateError, asyncio.CancelledError): - pass - - try: - asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) - except (KeyboardInterrupt, SystemExit): - logger.info("Received signal to terminate bot and event loop.") - finally: - logger.info("Cleaning up tasks.") - - try: - cancel_tasks_coro = _cancel_tasks() - asyncio.run(cancel_tasks_coro) - finally: - logger.info("Closing the event loop.") - - @property - def bot_owner_ids(self): - owner_ids = self.config["owners"] - if owner_ids is not None: - owner_ids = set(map(int, str(owner_ids).split(","))) - if self.owner_id is not None: - owner_ids.add(self.owner_id) - permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) - for perm in permissions: - owner_ids.add(int(perm)) - return owner_ids - - async def is_owner(self, user: discord.User) -> bool: - if user.id in self.bot_owner_ids: - return True - return await super().is_owner(user) - - @property - def log_channel(self) -> typing.Optional[discord.TextChannel]: - channel_id = self.config["log_channel_id"] - if channel_id is not None: - try: - channel = self.get_channel(int(channel_id)) - if channel is not None: - return channel - except ValueError: - pass - logger.debug("LOG_CHANNEL_ID was invalid, removed.") - self.config.remove("log_channel_id") - if self.main_category is not None: - try: - channel = self.main_category.channels[0] - self.config["log_channel_id"] = channel.id - logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) - return channel - except IndexError: - pass - logger.warning( - "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id `.", - self.prefix, - self.prefix, - ) - return None - - @property - def mention_channel(self): - channel_id = self.config["mention_channel_id"] - if channel_id is not None: - try: - channel = self.get_channel(int(channel_id)) - if channel is not None: - return channel - except ValueError: - pass - logger.debug("MENTION_CHANNEL_ID was invalid, removed.") - self.config.remove("mention_channel_id") - - return self.log_channel - - @property - def update_channel(self): - channel_id = self.config["update_channel_id"] - if channel_id is not None: - try: - channel = self.get_channel(int(channel_id)) - if channel is not None: - return channel - except ValueError: - pass - logger.debug("UPDATE_CHANNEL_ID was invalid, removed.") - self.config.remove("update_channel_id") - - return self.log_channel - - async def wait_for_connected(self) -> None: - await self.wait_until_ready() - await self._connected.wait() - await self.config.wait_until_ready() - - @property - def snippets(self) -> typing.Dict[str, str]: - return self.config["snippets"] - - @property - def aliases(self) -> typing.Dict[str, str]: - return self.config["aliases"] - - @property - def auto_triggers(self) -> typing.Dict[str, str]: - return self.config["auto_triggers"] - - @property - def token(self) -> str: - token = self.config["token"] - if token is None: - logger.critical("TOKEN must be set, set this as bot token found on the Discord Developer Portal.") - sys.exit(0) - return token - - @property - def guild_id(self) -> typing.Optional[int]: - guild_id = self.config["guild_id"] - if guild_id is not None: - try: - return int(str(guild_id)) - except ValueError: - self.config.remove("guild_id") - logger.critical("Invalid GUILD_ID set.") - else: - logger.debug("No GUILD_ID set.") - return None - - @property - def guild(self) -> typing.Optional[discord.Guild]: - """ - The guild that the bot is serving - (the server where users message it from) - """ - return discord.utils.get(self.guilds, id=self.guild_id) - - @property - def modmail_guild(self) -> typing.Optional[discord.Guild]: - """ - The guild that the bot is operating in - (where the bot is creating threads) - """ - modmail_guild_id = self.config["modmail_guild_id"] - if modmail_guild_id is None: - return self.guild - try: - guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) - if guild is not None: - return guild - except ValueError: - pass - self.config.remove("modmail_guild_id") - logger.critical("Invalid MODMAIL_GUILD_ID set.") - return self.guild - - @property - def using_multiple_server_setup(self) -> bool: - return self.modmail_guild != self.guild - - @property - def main_category(self) -> typing.Optional[discord.CategoryChannel]: - if self.modmail_guild is not None: - category_id = self.config["main_category_id"] - if category_id is not None: - try: - cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) - if cat is not None: - return cat - except ValueError: - pass - self.config.remove("main_category_id") - logger.debug("MAIN_CATEGORY_ID was invalid, removed.") - cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") - if cat is not None: - self.config["main_category_id"] = cat.id - logger.debug( - 'No main category set explicitly, setting category "Modmail" as the main category.' - ) - return cat - return None - - @property - def blocked_users(self) -> typing.Dict[str, str]: - return self.config["blocked"] - - @property - def blocked_roles(self) -> typing.Dict[str, str]: - return self.config["blocked_roles"] - - @property - def blocked_whitelisted_users(self) -> typing.List[str]: - return self.config["blocked_whitelist"] - - @property - def prefix(self) -> str: - return str(self.config["prefix"]) - - @property - def mod_color(self) -> int: - return self.config.get("mod_color") - - @property - def recipient_color(self) -> int: - return self.config.get("recipient_color") - - @property - def main_color(self) -> int: - return self.config.get("main_color") - - @property - def error_color(self) -> int: - return self.config.get("error_color") - - def command_perm(self, command_name: str) -> PermissionLevel: - level = self.config["override_command_level"].get(command_name) - if level is not None: - try: - return PermissionLevel[level.upper()] - except KeyError: - logger.warning("Invalid override_command_level for command %s.", command_name) - self.config["override_command_level"].pop(command_name) - - command = self.get_command(command_name) - if command is None: - logger.debug("Command %s not found.", command_name) - return PermissionLevel.INVALID - level = next( - (check.permission_level for check in command.checks if hasattr(check, "permission_level")), - None, - ) - if level is None: - logger.debug("Command %s does not have a permission level.", command_name) - return PermissionLevel.INVALID - return level - - async def on_connect(self): - try: - await self.api.validate_database_connection() - except Exception: - logger.debug("Logging out due to failed database connection.") - return await self.close() - - logger.debug("Connected to gateway.") - await self.config.refresh() - await self.api.setup_indexes() - await self.load_extensions() - self._connected.set() - - async def on_ready(self): - """Bot startup, sets uptime.""" - - # Wait until config cache is populated with stuff from db and on_connect ran - await self.wait_for_connected() - - if self.guild is None: - logger.error("Logging out due to invalid GUILD_ID.") - return await self.close() - - if self._started: - # Bot has started before - logger.line() - logger.warning("Bot restarted due to internal discord reloading.") - logger.line() - return - - logger.line() - logger.debug("Client ready.") - logger.info("Logged in as: %s", self.user) - logger.info("Bot ID: %s", self.user.id) - owners = ", ".join( - getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids - ) - logger.info("Owners: %s", owners) - logger.info("Prefix: %s", self.prefix) - logger.info("Guild Name: %s", self.guild.name) - logger.info("Guild ID: %s", self.guild.id) - if self.using_multiple_server_setup: - logger.info("Receiving guild ID: %s", self.modmail_guild.id) - logger.line() - - if "dev" in __version__: - logger.warning( - "You are running a developmental version. This should not be used in production. (v%s)", - __version__, - ) - logger.line() - - await self.threads.populate_cache() - - # closures - closures = self.config["closures"] - logger.info("There are %d thread(s) pending to be closed.", len(closures)) - logger.line() - - for recipient_id, items in tuple(closures.items()): - after = ( - datetime.fromisoformat(items["time"]).astimezone(timezone.utc) - discord.utils.utcnow() - ).total_seconds() - if after <= 0: - logger.debug("Closing thread for recipient %s.", recipient_id) - after = 0 - else: - logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after) - - thread = await self.threads.find(recipient_id=int(recipient_id)) - - if not thread: - # If the channel is deleted - logger.debug("Failed to close thread for recipient %s.", recipient_id) - self.config["closures"].pop(recipient_id) - await self.config.update() - continue - - await thread.close( - closer=await self.get_or_fetch_user(items["closer_id"]), - after=after, - silent=items["silent"], - delete_channel=items["delete_channel"], - message=items["message"], - auto_close=items.get("auto_close", False), - ) - - for log in await self.api.get_open_logs(): - if self.get_channel(int(log["channel_id"])) is None: - logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) - log_data = await self.api.post_log( - log["channel_id"], - { - "open": False, - "title": None, - "closed_at": str(discord.utils.utcnow()), - "close_message": "Channel has been deleted, no closer found.", - "closer": { - "id": str(self.user.id), - "name": self.user.name, - "discriminator": self.user.discriminator, - "avatar_url": self.user.display_avatar.url, - "mod": True, - }, - }, - ) - if log_data: - logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) - else: - logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) - - other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] - if any(other_guilds): - logger.warning( - "The bot is in more servers other than the main and staff server. " - "This may cause data compromise (%s).", - ", ".join(str(guild.name) for guild in other_guilds), - ) - logger.warning("If the external servers are valid, you may ignore this message.") - - self.post_metadata.start() - self.autoupdate.start() - self.log_expiry.start() - self._started = True - - async def convert_emoji(self, name: str) -> str: - ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) - converter = commands.EmojiConverter() - - if not is_emoji(name): - try: - name = await converter.convert(ctx, name.strip(":")) - except commands.BadArgument as e: - logger.warning("%s is not a valid emoji: %s", name, e) - raise - return name - - async def get_or_fetch_user(self, id: int) -> discord.User: - """ - Retrieve a User based on their ID. - - This tries getting the user from the cache and falls back to making - an API call if they're not found in the cache. - """ - return self.get_user(id) or await self.fetch_user(id) - - @staticmethod - async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: - """ - Attempt to get a member from cache; on failure fetch from the API. - - Returns: - The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. - """ - return guild.get_member(member_id) or await guild.fetch_member(member_id) - - async def retrieve_emoji(self) -> typing.Tuple[str, str]: - sent_emoji = self.config["sent_emoji"] - blocked_emoji = self.config["blocked_emoji"] - - if sent_emoji != "disable": - try: - sent_emoji = await self.convert_emoji(sent_emoji) - except commands.BadArgument: - logger.warning("Removed sent emoji (%s).", sent_emoji) - sent_emoji = self.config.remove("sent_emoji") - await self.config.update() - - if blocked_emoji != "disable": - try: - blocked_emoji = await self.convert_emoji(blocked_emoji) - except commands.BadArgument: - logger.warning("Removed blocked emoji (%s).", blocked_emoji) - blocked_emoji = self.config.remove("blocked_emoji") - await self.config.update() - - return sent_emoji, blocked_emoji - - def check_account_age(self, author: discord.Member) -> bool: - account_age = self.config.get("account_age") - now = discord.utils.utcnow() - - try: - min_account_age = author.created_at + account_age - except ValueError: - logger.warning("Error with 'account_age'.", exc_info=True) - min_account_age = author.created_at + self.config.remove("account_age") - - if min_account_age > now: - # User account has not reached the required time - delta = human_timedelta(min_account_age) - logger.debug("Blocked due to account age, user %s.", author.name) - - if str(author.id) not in self.blocked_users: - new_reason = f"System Message: New Account. User can try again {delta}." - self.blocked_users[str(author.id)] = new_reason - - return False - return True - - def check_guild_age(self, author: discord.Member) -> bool: - guild_age = self.config.get("guild_age") - now = discord.utils.utcnow() - - if not hasattr(author, "joined_at"): - logger.warning("Not in guild, cannot verify guild_age, %s.", author.name) - return True - - try: - min_guild_age = author.joined_at + guild_age - except ValueError: - logger.warning("Error with 'guild_age'.", exc_info=True) - min_guild_age = author.joined_at + self.config.remove("guild_age") - - if min_guild_age > now: - # User has not stayed in the guild for long enough - delta = human_timedelta(min_guild_age) - logger.debug("Blocked due to guild age, user %s.", author.name) - - if str(author.id) not in self.blocked_users: - new_reason = f"System Message: Recently Joined. User can try again {delta}." - self.blocked_users[str(author.id)] = new_reason - - return False - return True - - def check_manual_blocked_roles(self, author: discord.Member) -> bool: - if isinstance(author, discord.Member): - for r in author.roles: - if str(r.id) in self.blocked_roles: - blocked_reason = self.blocked_roles.get(str(r.id)) or "" - - try: - end_time, after = extract_block_timestamp(blocked_reason, author.id) - except ValueError: - return False - - if end_time is not None: - if after <= 0: - # No longer blocked - self.blocked_roles.pop(str(r.id)) - logger.debug("No longer blocked, role %s.", r.name) - return True - logger.debug("User blocked, role %s.", r.name) - return False - - return True - - def check_manual_blocked(self, author: discord.Member) -> bool: - if str(author.id) not in self.blocked_users: - return True - - blocked_reason = self.blocked_users.get(str(author.id)) or "" - - if blocked_reason.startswith("System Message:"): - # Met the limits already, otherwise it would've been caught by the previous checks - logger.debug("No longer internally blocked, user %s.", author.name) - self.blocked_users.pop(str(author.id)) - return True - - try: - end_time, after = extract_block_timestamp(blocked_reason, author.id) - except ValueError: - return False - - if end_time is not None: - if after <= 0: - # No longer blocked - self.blocked_users.pop(str(author.id)) - logger.debug("No longer blocked, user %s.", author.name) - return True - logger.debug("User blocked, user %s.", author.name) - return False - - async def _process_blocked(self, message): - _, blocked_emoji = await self.retrieve_emoji() - if await self.is_blocked(message.author, channel=message.channel, send_message=True): - await self.add_reaction(message, blocked_emoji) - return True - return False - - async def is_blocked( - self, - author: discord.User, - *, - channel: discord.TextChannel = None, - send_message: bool = False, - ) -> bool: - member = self.guild.get_member(author.id) - if member is None: - # try to find in other guilds - for g in self.guilds: - member = g.get_member(author.id) - if member: - break - - if member is None: - logger.debug("User not in guild, %s.", author.id) - - if member is not None: - author = member - - if str(author.id) in self.blocked_whitelisted_users: - if str(author.id) in self.blocked_users: - self.blocked_users.pop(str(author.id)) - await self.config.update() - return False - - blocked_reason = self.blocked_users.get(str(author.id)) or "" - - if not self.check_account_age(author) or not self.check_guild_age(author): - new_reason = self.blocked_users.get(str(author.id)) - if new_reason != blocked_reason: - if send_message: - await channel.send( - embed=discord.Embed( - title="Message not sent!", - description=new_reason, - color=self.error_color, - ) - ) - return True - - if not self.check_manual_blocked(author): - return True - - if not self.check_manual_blocked_roles(author): - return True - - await self.config.update() - return False - - async def get_thread_cooldown(self, author: discord.Member): - thread_cooldown = self.config.get("thread_cooldown") - now = discord.utils.utcnow() - - if thread_cooldown == isodate.Duration(): - return - - last_log = await self.api.get_latest_user_logs(author.id) - - if last_log is None: - logger.debug("Last thread wasn't found, %s.", author.name) - return - - last_log_closed_at = last_log.get("closed_at") - - if not last_log_closed_at: - logger.debug("Last thread was not closed, %s.", author.name) - return - - try: - cooldown = datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + thread_cooldown - except ValueError: - logger.warning("Error with 'thread_cooldown'.", exc_info=True) - cooldown = datetime.fromisoformat(last_log_closed_at).astimezone( - timezone.utc - ) + self.config.remove("thread_cooldown") - - if cooldown > now: - # User messaged before thread cooldown ended - delta = human_timedelta(cooldown) - logger.debug("Blocked due to thread cooldown, user %s.", author.name) - return delta - return - - @staticmethod - async def add_reaction( - msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str] - ) -> bool: - if reaction != "disable": - try: - await msg.add_reaction(reaction) - except (discord.HTTPException, TypeError) as e: - logger.warning("Failed to add reaction %s: %s.", reaction, e) - return False - return True - - async def process_dm_modmail(self, message: discord.Message) -> None: - """Processes messages sent to the bot.""" - blocked = await self._process_blocked(message) - if blocked: - return - sent_emoji, blocked_emoji = await self.retrieve_emoji() - - if message.type not in [discord.MessageType.default, discord.MessageType.reply]: - return - - thread = await self.threads.find(recipient=message.author) - if thread is None: - delta = await self.get_thread_cooldown(message.author) - if delta: - await message.channel.send( - embed=discord.Embed( - title=self.config["cooldown_thread_title"], - description=self.config["cooldown_thread_response"].format(delta=delta), - color=self.error_color, - ) - ) - return - - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): - embed = discord.Embed( - title=self.config["disabled_new_thread_title"], - color=self.error_color, - description=self.config["disabled_new_thread_response"], - ) - embed.set_footer( - text=self.config["disabled_new_thread_footer"], - icon_url=self.get_guild_icon(guild=message.guild, size=128), - ) - logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author) - await self.add_reaction(message, blocked_emoji) - return await message.channel.send(embed=embed) - - thread = await self.threads.create(message.author, message=message) - else: - if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: - embed = discord.Embed( - title=self.config["disabled_current_thread_title"], - color=self.error_color, - description=self.config["disabled_current_thread_response"], - ) - embed.set_footer( - text=self.config["disabled_current_thread_footer"], - icon_url=self.get_guild_icon(guild=message.guild, size=128), - ) - logger.info("A message was blocked from %s due to disabled Modmail.", message.author) - await self.add_reaction(message, blocked_emoji) - return await message.channel.send(embed=embed) - - if not thread.cancelled: - try: - await thread.send(message) - except Exception: - logger.error("Failed to send message:", exc_info=True) - await self.add_reaction(message, blocked_emoji) - else: - for user in thread.recipients: - # send to all other recipients - if user != message.author: - try: - await thread.send(message, user) - except Exception: - # silently ignore - logger.error("Failed to send message:", exc_info=True) - - await self.add_reaction(message, sent_emoji) - self.dispatch("thread_reply", thread, False, message, False, False) - - def _get_snippet_command(self) -> commands.Command: - """Get the correct reply command based on the snippet config""" - modifiers = "f" - if self.config["plain_snippets"]: - modifiers += "p" - if self.config["anonymous_snippets"]: - modifiers += "a" - - return self.get_command(f"{modifiers}reply") - - async def get_contexts(self, message, *, cls=commands.Context): - """ - Returns all invocation contexts from the message. - Supports getting the prefix from database as well as command aliases. - """ - - view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) - thread = await self.threads.find(channel=ctx.channel) - - if message.author.id == self.user.id: # type: ignore - return [ctx] - - prefixes = await self.get_prefix() - - invoked_prefix = discord.utils.find(view.skip_string, prefixes) - if invoked_prefix is None: - return [ctx] - - invoker = view.get_word().lower() - - # Check if a snippet is being called. - # This needs to be done before checking for aliases since - # snippets can have multiple words. - try: - # Use removeprefix once PY3.9+ - snippet_text = self.snippets[message.content[len(invoked_prefix) :]] - except KeyError: - snippet_text = None - - # Check if there is any aliases being called. - alias = self.aliases.get(invoker) - if alias is not None and snippet_text is None: - ctxs = [] - aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) - if not aliases: - logger.warning("Alias %s is invalid, removing.", invoker) - self.aliases.pop(invoker) - - for alias in aliases: - command = None - try: - snippet_text = self.snippets[alias] - except KeyError: - command_invocation_text = alias - else: - command = self._get_snippet_command() - command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" - view = StringView(invoked_prefix + command_invocation_text) - ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) - ctx_.thread = thread - discord.utils.find(view.skip_string, prefixes) - ctx_.invoked_with = view.get_word().lower() - ctx_.command = command or self.all_commands.get(ctx_.invoked_with) - ctxs += [ctx_] - return ctxs - - ctx.thread = thread - - if snippet_text is not None: - # Process snippets - ctx.command = self._get_snippet_command() - reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") - discord.utils.find(reply_view.skip_string, prefixes) - ctx.invoked_with = reply_view.get_word().lower() - ctx.view = reply_view - else: - ctx.command = self.all_commands.get(invoker) - ctx.invoked_with = invoker - - return [ctx] - - async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): - message.author = self.modmail_guild.me - message.channel = channel - message.guild = channel.guild - - view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) - thread = await self.threads.find(channel=ctx.channel) - - invoked_prefix = self.prefix - invoker = None - - if self.config.get("use_regex_autotrigger"): - trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) - if trigger: - invoker = re.search(trigger, message.content).group(0) - else: - trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys())) - if trigger: - invoker = trigger.lower() - - alias = self.auto_triggers[trigger] - - ctxs = [] - - if alias is not None: - ctxs = [] - aliases = normalize_alias(alias) - if not aliases: - logger.warning("Alias %s is invalid as called in autotrigger.", invoker) - - message.author = thread.recipient # Allow for get_contexts to work - - for alias in aliases: - message.content = invoked_prefix + alias - ctxs += await self.get_contexts(message) - - message.author = self.modmail_guild.me # Fix message so commands execute properly - - for ctx in ctxs: - if ctx.command: - old_checks = copy.copy(ctx.command.checks) - ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)] - - await self.invoke(ctx) - - ctx.command.checks = old_checks - continue - - async def get_context(self, message, *, cls=commands.Context): - """ - Returns the invocation context from the message. - Supports getting the prefix from database. - """ - - view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) - - if message.author.id == self.user.id: - return ctx - - ctx.thread = await self.threads.find(channel=ctx.channel) - - prefixes = await self.get_prefix() - - invoked_prefix = discord.utils.find(view.skip_string, prefixes) - if invoked_prefix is None: - return ctx - - invoker = view.get_word().lower() - - ctx.invoked_with = invoker - ctx.command = self.all_commands.get(invoker) - - return ctx - - async def update_perms( - self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True - ) -> None: - if value != -1: - value = str(value) - if isinstance(name, PermissionLevel): - level = True - permissions = self.config["level_permissions"] - name = name.name - else: - level = False - permissions = self.config["command_permissions"] - if name not in permissions: - if add: - permissions[name] = [value] - else: - if add: - if value not in permissions[name]: - permissions[name].append(value) - else: - if value in permissions[name]: - permissions[name].remove(value) - - if level: - self.config["level_permissions"] = permissions - else: - self.config["command_permissions"] = permissions - logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) - await self.config.update() - - async def on_message(self, message): - await self.wait_for_connected() - if message.type == discord.MessageType.pins_add and message.author == self.user: - await message.delete() - - if ( - (f"<@{self.user.id}" in message.content or f"<@!{self.user.id}" in message.content) - and self.config["alert_on_mention"] - and not message.author.bot - ): - em = discord.Embed( - title="Bot mention", - description=f"[Jump URL]({message.jump_url})\n{truncate(message.content, 50)}", - color=self.main_color, - ) - if self.config["show_timestamp"]: - em.timestamp = discord.utils.utcnow() - - if not self.config["silent_alert_on_mention"]: - content = self.config["mention"] - else: - content = "" - await self.mention_channel.send(content=content, embed=em) - - await self.process_commands(message) - - async def process_commands(self, message): - if message.author.bot: - return - - if isinstance(message.channel, discord.DMChannel): - return await self.process_dm_modmail(message) - - ctxs = await self.get_contexts(message) - for ctx in ctxs: - if ctx.command: - if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): - logger.debug( - "Command %s has no permissions check, adding invalid level.", - ctx.command.qualified_name, - ) - checks.has_permissions(PermissionLevel.INVALID)(ctx.command) - - await self.invoke(ctx) - continue - - thread = await self.threads.find(channel=ctx.channel) - if thread is not None: - anonymous = False - plain = False - if self.config.get("anon_reply_without_command"): - anonymous = True - if self.config.get("plain_reply_without_command"): - plain = True - - if ( - self.config.get("reply_without_command") - or self.config.get("anon_reply_without_command") - or self.config.get("plain_reply_without_command") - ): - await thread.reply(message, anonymous=anonymous, plain=plain) - else: - await self.api.append_log(message, type_="internal") - elif ctx.invoked_with: - exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) - self.dispatch("command_error", ctx, exc) - - async def on_typing(self, channel, user, _): - await self.wait_for_connected() - - if user.bot: - return - - if isinstance(channel, discord.DMChannel): - if not self.config.get("user_typing"): - return - - thread = await self.threads.find(recipient=user) - - if thread: - await thread.channel.typing() - else: - if not self.config.get("mod_typing"): - return - - thread = await self.threads.find(channel=channel) - if thread is not None and thread.recipient: - for user in thread.recipients: - if await self.is_blocked(user): - continue - await user.typing() - - async def handle_reaction_events(self, payload): - user = self.get_user(payload.user_id) - if user is None or user.bot: - return - - channel = self.get_channel(payload.channel_id) - thread = None - # dm channel not in internal cache - if not channel: - thread = await self.threads.find(recipient=user) - if not thread: - return - channel = await thread.recipient.create_dm() - if channel.id != payload.channel_id: - return - - from_dm = isinstance(channel, discord.DMChannel) - from_txt = isinstance(channel, discord.TextChannel) - if not from_dm and not from_txt: - return - - if not thread: - params = {"recipient": user} if from_dm else {"channel": channel} - thread = await self.threads.find(**params) - if not thread: - return - - # thread must exist before doing this API call - try: - message = await channel.fetch_message(payload.message_id) - except (discord.NotFound, discord.Forbidden): - return - - reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config["close_emoji"]) - if from_dm: - if ( - payload.event_type == "REACTION_ADD" - and message.embeds - and str(reaction) == str(close_emoji) - and self.config.get("recipient_thread_close") - ): - ts = message.embeds[0].timestamp - if ts == thread.channel.created_at: - # the reacted message is the corresponding thread creation embed - # closing thread - return await thread.close(closer=user) - if ( - message.author == self.user - and message.embeds - and self.config.get("confirm_thread_creation") - and message.embeds[0].title == self.config["confirm_thread_creation_title"] - and message.embeds[0].description == self.config["confirm_thread_response"] - ): - return - if not thread.recipient.dm_channel: - await thread.recipient.create_dm() - try: - linked_messages = await thread.find_linked_message_from_dm(message, either_direction=True) - except ValueError as e: - logger.warning("Failed to find linked message for reactions: %s", e) - return - else: - try: - _, *linked_messages = await thread.find_linked_messages( - message1=message, either_direction=True - ) - except ValueError as e: - logger.warning("Failed to find linked message for reactions: %s", e) - return - - if self.config["transfer_reactions"] and linked_messages is not [None]: - if payload.event_type == "REACTION_ADD": - for msg in linked_messages: - await self.add_reaction(msg, reaction) - await self.add_reaction(message, reaction) - else: - try: - for msg in linked_messages: - await msg.remove_reaction(reaction, self.user) - await message.remove_reaction(reaction, self.user) - except (discord.HTTPException, TypeError) as e: - logger.warning("Failed to remove reaction: %s", e) - - async def handle_react_to_contact(self, payload): - react_message_id = tryint(self.config.get("react_to_contact_message")) - react_message_emoji = self.config.get("react_to_contact_emoji") - if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: - return - if payload.emoji.is_unicode_emoji(): - emoji_fmt = payload.emoji.name - else: - emoji_fmt = f"<:{payload.emoji.name}:{payload.emoji.id}>" - - if emoji_fmt != react_message_emoji: - return - channel = self.get_channel(payload.channel_id) - member = channel.guild.get_member(payload.user_id) - if member.bot: - return - message = await channel.fetch_message(payload.message_id) - await message.remove_reaction(payload.emoji, member) - await message.add_reaction(emoji_fmt) # bot adds as well - - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): - embed = discord.Embed( - title=self.config["disabled_new_thread_title"], - color=self.error_color, - description=self.config["disabled_new_thread_response"], - ) - embed.set_footer( - text=self.config["disabled_new_thread_footer"], - icon_url=self.get_guild_icon(guild=channel.guild, size=128), - ) - logger.info( - "A new thread using react to contact was blocked from %s due to disabled Modmail.", - member, - ) - return await member.send(embed=embed) - - ctx = await self.get_context(message) - await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) - - async def on_raw_reaction_add(self, payload): - await asyncio.gather( - self.handle_reaction_events(payload), - self.handle_react_to_contact(payload), - ) - - async def on_raw_reaction_remove(self, payload): - if self.config["transfer_reactions"]: - await self.handle_reaction_events(payload) - - async def on_guild_channel_delete(self, channel): - if channel.guild != self.modmail_guild: - return - - if isinstance(channel, discord.CategoryChannel): - if self.main_category == channel: - logger.debug("Main category was deleted.") - self.config.remove("main_category_id") - await self.config.update() - return - - if not isinstance(channel, discord.TextChannel): - return - - if self.log_channel is None or self.log_channel == channel: - logger.info("Log channel deleted.") - self.config.remove("log_channel_id") - await self.config.update() - return - - audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) - found_entry = False - async for entry in audit_logs: - if int(entry.target.id) == channel.id: - found_entry = True - break - - if not found_entry: - logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) - return - - mod = entry.user - if mod == self.user: - return - - thread = await self.threads.find(channel=channel) - if thread and thread.channel == channel: - logger.debug("Manually closed channel %s.", channel.name) - await thread.close(closer=mod, silent=True, delete_channel=False) - - async def on_member_remove(self, member): - thread = await self.threads.find(recipient=member) - if thread: - if member.guild == self.guild and self.config["close_on_leave"]: - await thread.close( - closer=member.guild.me, - message=self.config["close_on_leave_reason"], - silent=True, - ) - else: - if len(self.guilds) > 1: - guild_left = member.guild - remaining_guilds = member.mutual_guilds - - if remaining_guilds: - remaining_guild_names = [guild.name for guild in remaining_guilds] - leave_message = ( - f"The recipient has left {guild_left}. " - f"They are still in {human_join(remaining_guild_names, final='and')}." - ) - else: - leave_message = ( - f"The recipient has left {guild_left}. We no longer share any mutual servers." - ) - else: - leave_message = "The recipient has left the server." - - embed = discord.Embed(description=leave_message, color=self.error_color) - await thread.channel.send(embed=embed) - - async def on_member_join(self, member): - thread = await self.threads.find(recipient=member) - if thread: - if len(self.guilds) > 1: - guild_joined = member.guild - join_message = f"The recipient has joined {guild_joined}." - else: - join_message = "The recipient has joined the server." - embed = discord.Embed(description=join_message, color=self.mod_color) - await thread.channel.send(embed=embed) - - async def on_message_delete(self, message): - """Support for deleting linked messages""" - - if message.is_system(): - return - - if isinstance(message.channel, discord.DMChannel): - if message.author == self.user: - return - thread = await self.threads.find(recipient=message.author) - if not thread: - return - try: - message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) - except ValueError as e: - if str(e) != "Thread channel message not found.": - logger.debug("Failed to find linked message to delete: %s", e) - return - message = message[0] - embed = message.embeds[0] - - if embed.footer.icon: - icon_url = embed.footer.icon.url - else: - icon_url = None - - embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=icon_url) - await message.edit(embed=embed) - return - - if message.author != self.user: - return - - thread = await self.threads.find(channel=message.channel) - if not thread: - return - - try: - await thread.delete_message(message, note=False) - embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) - except ValueError as e: - if str(e) not in {"DM message not found.", "Malformed thread message."}: - logger.debug("Failed to find linked message to delete: %s", e) - embed = discord.Embed(description="Failed to delete message.", color=self.error_color) - else: - return - except discord.NotFound: - return - embed.set_footer(text=f"Message ID: {message.id} from {message.author}.") - return await message.channel.send(embed=embed) - - async def on_bulk_message_delete(self, messages): - await discord.utils.async_all(self.on_message_delete(msg) for msg in messages) - - async def on_message_edit(self, before, after): - if after.author.bot: - return - if before.content == after.content: - return - - if isinstance(after.channel, discord.DMChannel): - thread = await self.threads.find(recipient=before.author) - if not thread: - return - - try: - await thread.edit_dm_message(after, after.content) - except ValueError: - _, blocked_emoji = await self.retrieve_emoji() - await self.add_reaction(after, blocked_emoji) - else: - embed = discord.Embed(description="Successfully Edited Message", color=self.main_color) - embed.set_footer(text=f"Message ID: {after.id}") - await after.channel.send(embed=embed) - - async def on_error(self, event_method, *args, **kwargs): - logger.error("Ignoring exception in %s.", event_method) - logger.error("Unexpected exception:", exc_info=sys.exc_info()) - - async def on_command_error( - self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False - ) -> None: - if not unhandled_by_cog: - command = context.command - if command and command.has_error_handler(): - return - cog = context.cog - if cog and cog.has_error_handler(): - return - - if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): - await context.typing() - await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) - elif isinstance(exception, commands.CommandNotFound): - logger.warning("CommandNotFound: %s", exception) - elif isinstance(exception, commands.MissingRequiredArgument): - await context.send_help(context.command) - elif isinstance(exception, commands.CommandOnCooldown): - await context.send( - embed=discord.Embed( - title="Command on cooldown", - description=f"Try again in {exception.retry_after:.2f} seconds", - color=self.error_color, - ) - ) - elif isinstance(exception, commands.CheckFailure): - for check in context.command.checks: - if not await check(context): - if hasattr(check, "fail_msg"): - await context.send( - embed=discord.Embed(color=self.error_color, description=check.fail_msg) - ) - if hasattr(check, "permission_level"): - corrected_permission_level = self.command_perm(context.command.qualified_name) - logger.warning( - "User %s does not have permission to use this command: `%s` (%s).", - context.author.name, - context.command.qualified_name, - corrected_permission_level.name, - ) - logger.warning("CheckFailure: %s", exception) - elif isinstance(exception, commands.DisabledCommand): - logger.info("DisabledCommand: %s is trying to run eval but it's disabled", context.author.name) - else: - logger.error("Unexpected exception:", exc_info=exception) - - @tasks.loop(hours=1) - async def post_metadata(self): - info = await self.application_info() - - delta = discord.utils.utcnow() - self.start_time - data = { - "bot_id": self.user.id, - "bot_name": str(self.user), - "avatar_url": self.user.display_avatar.url, - "guild_id": self.guild_id, - "guild_name": self.guild.name, - "member_count": len(self.guild.members), - "uptime": delta.total_seconds(), - "latency": f"{self.ws.latency * 1000:.4f}", - "version": str(self.version), - "selfhosted": True, - "last_updated": str(discord.utils.utcnow()), - } - - if info.team is not None: - data.update( - { - "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", - "owner_id": info.team.owner_id, - "team": True, - } - ) - else: - data.update({"owner_name": info.owner.name, "owner_id": info.owner.id, "team": False}) - - async with self.session.post("https://api.modmail.dev/metadata", json=data): - logger.debug("Uploading metadata to Modmail server.") - - @post_metadata.before_loop - async def before_post_metadata(self): - await self.wait_for_connected() - if not self.config.get("data_collection") or not self.guild: - self.post_metadata.cancel() - return - - logger.debug("Starting metadata loop.") - logger.line("debug") - - @tasks.loop(hours=1) - async def autoupdate(self): - changelog = await Changelog.from_url(self) - latest = changelog.latest_version - - if self.version < Version(latest.version): - error = None - data = {} - try: - # update fork if gh_token exists - data = await self.api.update_repository() - except InvalidConfigError: - pass - except ClientResponseError as exc: - error = exc - if self.hosting_method == HostingMethod.HEROKU: - if error is not None: - logger.error(f"Autoupdate failed! Status: {error.status}.") - logger.error(f"Error message: {error.message}") - self.autoupdate.cancel() - return - - commit_data = data.get("data") - if not commit_data: - return - - logger.info("Bot has been updated.") - - if not self.config["update_notifications"]: - return - - embed = discord.Embed(color=self.main_color) - message = commit_data["commit"]["message"] - html_url = commit_data["html_url"] - short_sha = commit_data["sha"][:6] - user = data["user"] - embed.add_field( - name="Merge Commit", - value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", - ) - embed.set_author( - name=user["username"] + " - Updating Bot", - icon_url=user["avatar_url"], - url=user["url"], - ) - - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") - - embed.description = latest.description - for name, value in latest.fields.items(): - embed.add_field(name=name, value=value) - - channel = self.update_channel - await channel.send(embed=embed) - else: - command = "git pull" - proc = await asyncio.create_subprocess_shell( - command, - stderr=PIPE, - stdout=PIPE, - ) - err = await proc.stderr.read() - err = err.decode("utf-8").rstrip() - res = await proc.stdout.read() - res = res.decode("utf-8").rstrip() - - if err and not res: - logger.warning(f"Autoupdate failed: {err}") - self.autoupdate.cancel() - return - - elif res != "Already up to date.": - if os.getenv("PIPENV_ACTIVE"): - # Update pipenv if possible - await asyncio.create_subprocess_shell( - "pipenv sync", - stderr=PIPE, - stdout=PIPE, - ) - message = "" - else: - message = "\n\nDo manually update dependencies if your bot has crashed." - - logger.info("Bot has been updated.") - channel = self.update_channel - if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): - embed = discord.Embed(title="Bot has been updated", color=self.main_color) - embed.set_footer( - text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" - ) - if self.config["update_notifications"]: - await channel.send(embed=embed) - else: - embed = discord.Embed( - title="Bot has been updated and is logging out.", - description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", - color=self.main_color, - ) - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") - if self.config["update_notifications"]: - await channel.send(embed=embed) - return await self.close() - - @autoupdate.before_loop - async def before_autoupdate(self): - await self.wait_for_connected() - logger.debug("Starting autoupdate loop") - - if self.config.get("disable_autoupdates"): - logger.warning("Autoupdates disabled.") - self.autoupdate.cancel() - return - - if self.hosting_method == HostingMethod.DOCKER: - logger.warning("Autoupdates disabled as using Docker.") - self.autoupdate.cancel() - return - - if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: - logger.warning("GitHub access token not found.") - logger.warning("Autoupdates disabled.") - self.autoupdate.cancel() - return - - @tasks.loop(hours=1, reconnect=False) - async def log_expiry(self): - log_expire_after = self.config.get("log_expiration") - if log_expire_after == isodate.Duration(): - return self.log_expiry.stop() - - now = discord.utils.utcnow() - expiration_datetime = now - log_expire_after - # WARNING: comparison is done lexicographically, not by date. - # This is fine as long as the date is in zero-padded ISO format, which it should be. - expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) - - logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") - - def format_channel_name(self, author, exclude_channel=None, force_null=False): - """Sanitises a username for use with text channel names - - Placed in main bot class to be extendable to plugins""" - guild = self.modmail_guild - - if force_null: - name = new_name = "null" - else: - if self.config["use_random_channel_name"]: - to_hash = self.token.split(".")[-1] + str(author.id) - digest = hashlib.md5(to_hash.encode("utf8"), usedforsecurity=False) - name = new_name = digest.hexdigest()[-8:] - elif self.config["use_user_id_channel_name"]: - name = new_name = str(author.id) - elif self.config["use_timestamp_channel_name"]: - name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") - else: - if self.config["use_nickname_channel_name"]: - author_member = self.guild.get_member(author.id) - name = author_member.display_name.lower() - else: - name = author.name.lower() - - if force_null: - name = "null" - - name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" - if author.discriminator != "0": - name += f"-{author.discriminator}" - new_name = name - - counter = 1 - existed = set(c.name for c in guild.text_channels if c != exclude_channel) - while new_name in existed: - new_name = f"{name}_{counter}" # multiple channels with same name - counter += 1 - - return new_name - - -def main(): - try: - # noinspection PyUnresolvedReferences - import uvloop # type: ignore - - logger.debug("Setting up with uvloop.") - uvloop.install() - except ImportError: - pass - - try: - import cairosvg # noqa: F401 - except OSError: - if os.name == "nt": - if struct.calcsize("P") * 8 != 64: - logger.error( - "Unable to import cairosvg, ensure your Python is a 64-bit version: https://www.python.org/downloads/" - ) - else: - logger.error( - "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" - ) - else: - if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): - logger.error( - "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" - ) - else: - logger.error( - "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" - ) - sys.exit(0) - - # check discord version - discord_version = "2.3.2" - if discord.__version__ != discord_version: - logger.error( - "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", - discord_version, - discord.__version__, - ) - sys.exit(0) - - bot = ModmailBot() - bot.run() - - -if __name__ == "__main__": - main() +__version__ = "4.1.2" + + +import asyncio +import copy +import hashlib +import logging +import os +import re +import string +import struct +import sys +import platform +import typing +from datetime import datetime, timezone, timedelta +from subprocess import PIPE +from types import SimpleNamespace + +import discord +import isodate +from aiohttp import ClientSession, ClientResponseError +from discord.ext import commands, tasks +from discord.ext.commands.view import StringView +from emoji import is_emoji +from packaging.version import Version + + +try: + # noinspection PyUnresolvedReferences + from colorama import init + + init() +except ImportError: + pass + +from core import checks +from core.changelog import Changelog +from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient +from core.config import ConfigManager +from core.models import ( + DMDisabled, + HostingMethod, + InvalidConfigError, + PermissionLevel, + SafeFormatter, + configure_logging, + getLogger, +) +from core.thread import ThreadManager +from core.time import human_timedelta +from core.utils import extract_block_timestamp, normalize_alias, parse_alias, truncate, tryint, human_join + +logger = getLogger(__name__) + +temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") +if not os.path.exists(temp_dir): + os.mkdir(temp_dir) + +if sys.platform == "win32": + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except AttributeError: + logger.error("Failed to use WindowsProactorEventLoopPolicy.", exc_info=True) + + +class ModmailBot(commands.Bot): + def __init__(self): + self.config = ConfigManager(self) + self.config.populate_cache() + + intents = discord.Intents.all() + if not self.config["enable_presence_intent"]: + intents.presences = False + + super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` + self.session = None + self._api = None + self.formatter = SafeFormatter() + self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] + self._connected = None + self.start_time = discord.utils.utcnow() + self._started = False + + self.threads = ThreadManager(self) + + log_dir = os.path.join(temp_dir, "logs") + if not os.path.exists(log_dir): + os.mkdir(log_dir) + self.log_file_path = os.path.join(log_dir, "modmail.log") + configure_logging(self) + + self.plugin_db = PluginDatabaseClient(self) # Deprecated + self.startup() + + def get_guild_icon( + self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None + ) -> str: + if guild is None: + guild = self.guild + if guild.icon is None: + return "https://cdn.discordapp.com/embed/avatars/0.png" + if size is None: + return guild.icon.url + return guild.icon.with_size(size).url + + def _resolve_snippet(self, name: str) -> typing.Optional[str]: + """ + Get actual snippet names from direct aliases to snippets. + + If the provided name is a snippet, it's returned unchanged. + If there is an alias by this name, it is parsed to see if it + refers only to a snippet, in which case that snippet name is + returned. + + If no snippets were found, None is returned. + """ + if name in self.snippets: + return name + + try: + (command,) = parse_alias(self.aliases[name]) + except (KeyError, ValueError): + # There is either no alias by this name present or the + # alias has multiple steps. + pass + else: + if command in self.snippets: + return command + + @property + def uptime(self) -> str: + now = discord.utils.utcnow() + delta = now - self.start_time + hours, remainder = divmod(int(delta.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + days, hours = divmod(hours, 24) + + fmt = "{h}h {m}m {s}s" + if days: + fmt = "{d}d " + fmt + + return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) + + @property + def hosting_method(self) -> HostingMethod: + # use enums + if ".heroku" in os.environ.get("PYTHONHOME", ""): + return HostingMethod.HEROKU + + if os.environ.get("pm_id"): + return HostingMethod.PM2 + + if os.environ.get("INVOCATION_ID"): + return HostingMethod.SYSTEMD + + if os.environ.get("USING_DOCKER"): + return HostingMethod.DOCKER + + if os.environ.get("TERM"): + return HostingMethod.SCREEN + + return HostingMethod.OTHER + + def startup(self): + logger.line() + logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") + logger.info("││││ │ │││││├─┤││") + logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") + logger.info("v%s", __version__) + logger.info("Authors: kyb3r, fourjr, Taaku18") + logger.line() + logger.info("discord.py: v2.5.2") + logger.line() + + async def load_extensions(self): + for cog in self.loaded_cogs: + if cog in self.extensions: + continue + logger.debug("Loading %s.", cog) + try: + await self.load_extension(cog) + logger.debug("Successfully loaded %s.", cog) + except Exception: + logger.exception("Failed to load %s.", cog) + logger.line("debug") + + @property + def version(self): + return Version(__version__) + + @property + def api(self) -> ApiClient: + if self._api is None: + if self.config["database_type"].lower() == "mongodb": + self._api = MongoDBClient(self) + else: + logger.critical("Invalid database type.") + raise RuntimeError + return self._api + + @property + def db(self): + # deprecated + return self.api.db + + async def get_prefix(self, message=None): + return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] + + def run(self): + async def runner(): + async with self: + self._connected = asyncio.Event() + self.session = ClientSession(loop=self.loop) + + if self.config["enable_presence_intent"]: + logger.info("Starting bot with presence intent.") + else: + logger.info("Starting bot without presence intent.") + + try: + await self.start(self.token) + except discord.PrivilegedIntentsRequired: + logger.critical( + "Privileged intents are not explicitly granted in the discord developers dashboard." + ) + except discord.LoginFailure: + logger.critical("Invalid token") + except Exception: + logger.critical("Fatal exception", exc_info=True) + finally: + if self.session: + await self.session.close() + if not self.is_closed(): + await self.close() + + async def _cancel_tasks(): + async with self: + task_retriever = asyncio.all_tasks + loop = self.loop + tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} + + if not tasks: + return + + logger.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("All tasks finished cancelling.") + + for task in tasks: + try: + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + except (asyncio.InvalidStateError, asyncio.CancelledError): + pass + + try: + asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) + except (KeyboardInterrupt, SystemExit): + logger.info("Received signal to terminate bot and event loop.") + finally: + logger.info("Cleaning up tasks.") + + try: + cancel_tasks_coro = _cancel_tasks() + asyncio.run(cancel_tasks_coro) + finally: + logger.info("Closing the event loop.") + + @property + def bot_owner_ids(self): + owner_ids = self.config["owners"] + if owner_ids is not None: + owner_ids = set(map(int, str(owner_ids).split(","))) + if self.owner_id is not None: + owner_ids.add(self.owner_id) + permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) + for perm in permissions: + owner_ids.add(int(perm)) + return owner_ids + + async def is_owner(self, user: discord.User) -> bool: + if user.id in self.bot_owner_ids: + return True + return await super().is_owner(user) + + @property + def log_channel(self) -> typing.Optional[discord.TextChannel]: + channel_id = self.config["log_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("LOG_CHANNEL_ID was invalid, removed.") + self.config.remove("log_channel_id") + if self.main_category is not None: + try: + channel = self.main_category.channels[0] + self.config["log_channel_id"] = channel.id + logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) + return channel + except IndexError: + pass + logger.warning( + "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id `.", + self.prefix, + self.prefix, + ) + return None + + @property + def mention_channel(self): + channel_id = self.config["mention_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("MENTION_CHANNEL_ID was invalid, removed.") + self.config.remove("mention_channel_id") + + return self.log_channel + + @property + def update_channel(self): + channel_id = self.config["update_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("UPDATE_CHANNEL_ID was invalid, removed.") + self.config.remove("update_channel_id") + + return self.log_channel + + async def wait_for_connected(self) -> None: + await self.wait_until_ready() + await self._connected.wait() + await self.config.wait_until_ready() + + @property + def snippets(self) -> typing.Dict[str, str]: + return self.config["snippets"] + + @property + def aliases(self) -> typing.Dict[str, str]: + return self.config["aliases"] + + @property + def auto_triggers(self) -> typing.Dict[str, str]: + return self.config["auto_triggers"] + + @property + def token(self) -> str: + token = self.config["token"] + if token is None: + logger.critical("TOKEN must be set, set this as bot token found on the Discord Developer Portal.") + sys.exit(0) + return token + + @property + def guild_id(self) -> typing.Optional[int]: + guild_id = self.config["guild_id"] + if guild_id is not None: + try: + return int(str(guild_id)) + except ValueError: + self.config.remove("guild_id") + logger.critical("Invalid GUILD_ID set.") + else: + logger.debug("No GUILD_ID set.") + return None + + @property + def guild(self) -> typing.Optional[discord.Guild]: + """ + The guild that the bot is serving + (the server where users message it from) + """ + return discord.utils.get(self.guilds, id=self.guild_id) + + @property + def modmail_guild(self) -> typing.Optional[discord.Guild]: + """ + The guild that the bot is operating in + (where the bot is creating threads) + """ + modmail_guild_id = self.config["modmail_guild_id"] + if modmail_guild_id is None: + return self.guild + try: + guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) + if guild is not None: + return guild + except ValueError: + pass + self.config.remove("modmail_guild_id") + logger.critical("Invalid MODMAIL_GUILD_ID set.") + return self.guild + + @property + def using_multiple_server_setup(self) -> bool: + return self.modmail_guild != self.guild + + @property + def main_category(self) -> typing.Optional[discord.CategoryChannel]: + if self.modmail_guild is not None: + category_id = self.config["main_category_id"] + if category_id is not None: + try: + cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) + if cat is not None: + return cat + except ValueError: + pass + self.config.remove("main_category_id") + logger.debug("MAIN_CATEGORY_ID was invalid, removed.") + cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") + if cat is not None: + self.config["main_category_id"] = cat.id + logger.debug( + 'No main category set explicitly, setting category "Modmail" as the main category.' + ) + return cat + return None + + @property + def blocked_users(self) -> typing.Dict[str, str]: + return self.config["blocked"] + + @property + def blocked_roles(self) -> typing.Dict[str, str]: + return self.config["blocked_roles"] + + @property + def blocked_whitelisted_users(self) -> typing.List[str]: + return self.config["blocked_whitelist"] + + @property + def prefix(self) -> str: + return str(self.config["prefix"]) + + @property + def mod_color(self) -> int: + return self.config.get("mod_color") + + @property + def recipient_color(self) -> int: + return self.config.get("recipient_color") + + @property + def main_color(self) -> int: + return self.config.get("main_color") + + @property + def error_color(self) -> int: + return self.config.get("error_color") + + def command_perm(self, command_name: str) -> PermissionLevel: + level = self.config["override_command_level"].get(command_name) + if level is not None: + try: + return PermissionLevel[level.upper()] + except KeyError: + logger.warning("Invalid override_command_level for command %s.", command_name) + self.config["override_command_level"].pop(command_name) + + command = self.get_command(command_name) + if command is None: + logger.debug("Command %s not found.", command_name) + return PermissionLevel.INVALID + level = next( + (check.permission_level for check in command.checks if hasattr(check, "permission_level")), + None, + ) + if level is None: + logger.debug("Command %s does not have a permission level.", command_name) + return PermissionLevel.INVALID + return level + + async def on_connect(self): + try: + await self.api.validate_database_connection() + except Exception: + logger.debug("Logging out due to failed database connection.") + return await self.close() + + logger.debug("Connected to gateway.") + await self.config.refresh() + await self.api.setup_indexes() + await self.load_extensions() + self._connected.set() + + async def on_ready(self): + """Bot startup, sets uptime.""" + + # Wait until config cache is populated with stuff from db and on_connect ran + await self.wait_for_connected() + + if self.guild is None: + logger.error("Logging out due to invalid GUILD_ID.") + return await self.close() + + if self._started: + # Bot has started before + logger.line() + logger.warning("Bot restarted due to internal discord reloading.") + logger.line() + return + + logger.line() + logger.debug("Client ready.") + logger.info("Logged in as: %s", self.user) + logger.info("Bot ID: %s", self.user.id) + owners = ", ".join( + getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids + ) + logger.info("Owners: %s", owners) + logger.info("Prefix: %s", self.prefix) + logger.info("Guild Name: %s", self.guild.name) + logger.info("Guild ID: %s", self.guild.id) + if self.using_multiple_server_setup: + logger.info("Receiving guild ID: %s", self.modmail_guild.id) + logger.line() + + if "dev" in __version__: + logger.warning( + "You are running a developmental version. This should not be used in production. (v%s)", + __version__, + ) + logger.line() + + await self.threads.populate_cache() + + # closures + closures = self.config["closures"] + logger.info("There are %d thread(s) pending to be closed.", len(closures)) + logger.line() + + for recipient_id, items in tuple(closures.items()): + after = ( + datetime.fromisoformat(items["time"]).astimezone(timezone.utc) - discord.utils.utcnow() + ).total_seconds() + if after <= 0: + logger.debug("Closing thread for recipient %s.", recipient_id) + after = 0 + else: + logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after) + + thread = await self.threads.find(recipient_id=int(recipient_id)) + + if not thread: + # If the channel is deleted + logger.debug("Failed to close thread for recipient %s.", recipient_id) + self.config["closures"].pop(recipient_id) + await self.config.update() + continue + + await thread.close( + closer=await self.get_or_fetch_user(items["closer_id"]), + after=after, + silent=items["silent"], + delete_channel=items["delete_channel"], + message=items["message"], + auto_close=items.get("auto_close", False), + ) + + for log in await self.api.get_open_logs(): + if self.get_channel(int(log["channel_id"])) is None: + logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) + log_data = await self.api.post_log( + log["channel_id"], + { + "open": False, + "title": None, + "closed_at": str(discord.utils.utcnow()), + "close_message": "Channel has been deleted, no closer found.", + "closer": { + "id": str(self.user.id), + "name": self.user.name, + "discriminator": self.user.discriminator, + "avatar_url": self.user.display_avatar.url, + "mod": True, + }, + }, + ) + if log_data: + logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) + else: + logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) + + other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] + if any(other_guilds): + logger.warning( + "The bot is in more servers other than the main and staff server. " + "This may cause data compromise (%s).", + ", ".join(str(guild.name) for guild in other_guilds), + ) + logger.warning("If the external servers are valid, you may ignore this message.") + + self.post_metadata.start() + self.autoupdate.start() + self.log_expiry.start() + self._started = True + + async def convert_emoji(self, name: str) -> str: + ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) + converter = commands.EmojiConverter() + + if not is_emoji(name): + try: + name = await converter.convert(ctx, name.strip(":")) + except commands.BadArgument as e: + logger.warning("%s is not a valid emoji: %s", name, e) + raise + return name + + async def get_or_fetch_user(self, id: int) -> discord.User: + """ + Retrieve a User based on their ID. + + This tries getting the user from the cache and falls back to making + an API call if they're not found in the cache. + """ + return self.get_user(id) or await self.fetch_user(id) + + @staticmethod + async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Returns: + The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. + """ + return guild.get_member(member_id) or await guild.fetch_member(member_id) + + async def retrieve_emoji(self) -> typing.Tuple[str, str]: + sent_emoji = self.config["sent_emoji"] + blocked_emoji = self.config["blocked_emoji"] + + if sent_emoji != "disable": + try: + sent_emoji = await self.convert_emoji(sent_emoji) + except commands.BadArgument: + logger.warning("Removed sent emoji (%s).", sent_emoji) + sent_emoji = self.config.remove("sent_emoji") + await self.config.update() + + if blocked_emoji != "disable": + try: + blocked_emoji = await self.convert_emoji(blocked_emoji) + except commands.BadArgument: + logger.warning("Removed blocked emoji (%s).", blocked_emoji) + blocked_emoji = self.config.remove("blocked_emoji") + await self.config.update() + + return sent_emoji, blocked_emoji + + def check_account_age(self, author: discord.Member) -> bool: + account_age = self.config.get("account_age") + now = discord.utils.utcnow() + + try: + min_account_age = author.created_at + account_age + except ValueError: + logger.warning("Error with 'account_age'.", exc_info=True) + min_account_age = author.created_at + self.config.remove("account_age") + + if min_account_age > now: + # User account has not reached the required time + delta = human_timedelta(min_account_age) + logger.debug("Blocked due to account age, user %s.", author.name) + + if str(author.id) not in self.blocked_users: + new_reason = f"System Message: New Account. User can try again {delta}." + self.blocked_users[str(author.id)] = new_reason + + return False + return True + + def check_guild_age(self, author: discord.Member) -> bool: + guild_age = self.config.get("guild_age") + now = discord.utils.utcnow() + + if not hasattr(author, "joined_at"): + logger.warning("Not in guild, cannot verify guild_age, %s.", author.name) + return True + + try: + min_guild_age = author.joined_at + guild_age + except ValueError: + logger.warning("Error with 'guild_age'.", exc_info=True) + min_guild_age = author.joined_at + self.config.remove("guild_age") + + if min_guild_age > now: + # User has not stayed in the guild for long enough + delta = human_timedelta(min_guild_age) + logger.debug("Blocked due to guild age, user %s.", author.name) + + if str(author.id) not in self.blocked_users: + new_reason = f"System Message: Recently Joined. User can try again {delta}." + self.blocked_users[str(author.id)] = new_reason + + return False + return True + + def check_manual_blocked_roles(self, author: discord.Member) -> bool: + if isinstance(author, discord.Member): + for r in author.roles: + if str(r.id) in self.blocked_roles: + blocked_reason = self.blocked_roles.get(str(r.id)) or "" + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False + + if end_time is not None: + if after <= 0: + # No longer blocked + self.blocked_roles.pop(str(r.id)) + logger.debug("No longer blocked, role %s.", r.name) + return True + logger.debug("User blocked, role %s.", r.name) + return False + + return True + + def check_manual_blocked(self, author: discord.Member) -> bool: + if str(author.id) not in self.blocked_users: + return True + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + + if blocked_reason.startswith("System Message:"): + # Met the limits already, otherwise it would've been caught by the previous checks + logger.debug("No longer internally blocked, user %s.", author.name) + self.blocked_users.pop(str(author.id)) + return True + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False + + if end_time is not None: + if after <= 0: + # No longer blocked + self.blocked_users.pop(str(author.id)) + logger.debug("No longer blocked, user %s.", author.name) + return True + logger.debug("User blocked, user %s.", author.name) + return False + + async def _process_blocked(self, message): + _, blocked_emoji = await self.retrieve_emoji() + if await self.is_blocked(message.author, channel=message.channel, send_message=True): + await self.add_reaction(message, blocked_emoji) + return True + return False + + async def is_blocked( + self, + author: discord.User, + *, + channel: discord.TextChannel = None, + send_message: bool = False, + ) -> bool: + member = self.guild.get_member(author.id) + if member is None: + # try to find in other guilds + for g in self.guilds: + member = g.get_member(author.id) + if member: + break + + if member is None: + logger.debug("User not in guild, %s.", author.id) + + if member is not None: + author = member + + if str(author.id) in self.blocked_whitelisted_users: + if str(author.id) in self.blocked_users: + self.blocked_users.pop(str(author.id)) + await self.config.update() + return False + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + + if not self.check_account_age(author) or not self.check_guild_age(author): + new_reason = self.blocked_users.get(str(author.id)) + if new_reason != blocked_reason: + if send_message: + await channel.send( + embed=discord.Embed( + title="Message not sent!", + description=new_reason, + color=self.error_color, + ) + ) + return True + + if not self.check_manual_blocked(author): + return True + + if not self.check_manual_blocked_roles(author): + return True + + await self.config.update() + return False + + async def get_thread_cooldown(self, author: discord.Member): + thread_cooldown = self.config.get("thread_cooldown") + now = discord.utils.utcnow() + + if thread_cooldown == isodate.Duration(): + return + + last_log = await self.api.get_latest_user_logs(author.id) + + if last_log is None: + logger.debug("Last thread wasn't found, %s.", author.name) + return + + last_log_closed_at = last_log.get("closed_at") + + if not last_log_closed_at: + logger.debug("Last thread was not closed, %s.", author.name) + return + + try: + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + thread_cooldown + except ValueError: + logger.warning("Error with 'thread_cooldown'.", exc_info=True) + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone( + timezone.utc + ) + self.config.remove("thread_cooldown") + + if cooldown > now: + # User messaged before thread cooldown ended + delta = human_timedelta(cooldown) + logger.debug("Blocked due to thread cooldown, user %s.", author.name) + return delta + return + + @staticmethod + async def add_reaction( + msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str] + ) -> bool: + if reaction != "disable": + try: + await msg.add_reaction(reaction) + except (discord.HTTPException, TypeError) as e: + logger.warning("Failed to add reaction %s: %s.", reaction, e) + return False + return True + + async def process_dm_modmail(self, message: discord.Message) -> None: + """Processes messages sent to the bot.""" + blocked = await self._process_blocked(message) + if blocked: + return + sent_emoji, blocked_emoji = await self.retrieve_emoji() + + # Handle forwarded messages (Discord forwards) + # See: https://discord.com/developers/docs/resources/message#message-reference-content-attribution-forwards + # 1. Multi-forward (message_snapshots) + if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): + if hasattr(message, "message_snapshots") and message.message_snapshots: + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + for snap in message.message_snapshots: + author = getattr(snap, "author", None) + author_name = getattr(author, "name", "Unknown") if author else "Unknown" + author_avatar = getattr(author, "display_avatar", None) + author_avatar_url = author_avatar.url if author_avatar else None + # fix: Only use '[This is a forwarded message.]' if snap.content is actually empty + content = snap.content if snap.content else "[This is a forwarded message.]" + if snap.embeds: + content += "\n" + "\n".join( + [e.to_dict().get("description", "[embed]") for e in snap.embeds] + ) + if snap.attachments: + content += "\n" + "\n".join([a.url for a in snap.attachments]) + + class DummySnap: + def __init__(self, snap, author, content): + self.author = author + self.content = content + self.attachments = getattr(snap, "attachments", []) + self.stickers = getattr(snap, "stickers", []) + self.created_at = getattr(snap, "created_at", message.created_at) + self.embeds = getattr(snap, "embeds", []) + self.id = getattr(snap, "id", 0) + + dummy_msg = DummySnap(snap, author, content) + await thread.send(dummy_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + # 2. Single-message forward (MessageType.forward) + elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + # Check for message.reference and its type + ref = getattr(message, "reference", None) + if ref and getattr(ref, "type", None) == getattr(discord, "MessageReferenceType", None).forward: + # Try to fetch the referenced message + ref_msg = None + try: + if ref.resolved: + ref_msg = ref.resolved + elif ref.message_id and ref.channel_id: + channel = self.get_channel(ref.channel_id) or ( + await self.fetch_channel(ref.channel_id) + ) + ref_msg = await channel.fetch_message(ref.message_id) + except Exception: + ref_msg = None + if ref_msg: + # Forward the referenced message as if it was sent + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A message was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + await thread.send(ref_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + + if message.type not in [discord.MessageType.default, discord.MessageType.reply]: + return + + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + self.threads.cache[thread.id] = thread + # Update the DB with the new channel_id after restoration + if thread.channel: + await self.api.logs.update_one( + {"recipient.id": str(thread.id)}, {"$set": {"channel_id": str(thread.channel.id)}} + ) + # Re-fetch the thread object to ensure channel is valid + thread = await self.threads.find(recipient=message.author) + + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + if not thread.cancelled: + try: + await thread.send(message) + except Exception: + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, blocked_emoji) + else: + for user in thread.recipients: + # send to all other recipients + if user != message.author: + try: + await thread.send(message, user) + except Exception: + # silently ignore + logger.error("Failed to send message:", exc_info=True) + + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + + def _get_snippet_command(self) -> commands.Command: + """Get the correct reply command based on the snippet config""" + modifiers = "f" + if self.config["plain_snippets"]: + modifiers += "p" + if self.config["anonymous_snippets"]: + modifiers += "a" + + return self.get_command(f"{modifiers}reply") + + async def get_contexts(self, message, *, cls=commands.Context): + """ + Returns all invocation contexts from the message. + Supports getting the prefix from database as well as command aliases. + """ + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + thread = await self.threads.find(channel=ctx.channel) + + if message.author.id == self.user.id: # type: ignore + return [ctx] + + prefixes = await self.get_prefix() + + invoked_prefix = discord.utils.find(view.skip_string, prefixes) + if invoked_prefix is None: + return [ctx] + + invoker = view.get_word().lower() + + # Check if a snippet is being called. + # This needs to be done before checking for aliases since + # snippets can have multiple words. + try: + # Use removeprefix once PY3.9+ + snippet_text = self.snippets[message.content[len(invoked_prefix) :]] + except KeyError: + snippet_text = None + + # Check if there is any aliases being called. + alias = self.aliases.get(invoker) + if alias is not None and snippet_text is None: + ctxs = [] + aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) + if not aliases: + logger.warning("Alias %s is invalid, removing.", invoker) + self.aliases.pop(invoker) + + for alias in aliases: + command = None + try: + snippet_text = self.snippets[alias] + except KeyError: + command_invocation_text = alias + else: + command = self._get_snippet_command() + command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" + view = StringView(invoked_prefix + command_invocation_text) + ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx_.thread = thread + discord.utils.find(view.skip_string, prefixes) + ctx_.invoked_with = view.get_word().lower() + ctx_.command = command or self.all_commands.get(ctx_.invoked_with) + ctxs += [ctx_] + return ctxs + + ctx.thread = thread + + if snippet_text is not None: + # Process snippets + ctx.command = self._get_snippet_command() + reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") + discord.utils.find(reply_view.skip_string, prefixes) + ctx.invoked_with = reply_view.get_word().lower() + ctx.view = reply_view + else: + ctx.command = self.all_commands.get(invoker) + ctx.invoked_with = invoker + + return [ctx] + + async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): + message.author = self.modmail_guild.me + message.channel = channel + message.guild = channel.guild + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + thread = await self.threads.find(channel=ctx.channel) + + invoked_prefix = self.prefix + invoker = None + + if self.config.get("use_regex_autotrigger"): + trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) + if trigger: + invoker = re.search(trigger, message.content).group(0) + else: + trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys())) + if trigger: + invoker = trigger.lower() + + alias = self.auto_triggers[trigger] + + ctxs = [] + + if alias is not None: + ctxs = [] + aliases = normalize_alias(alias) + if not aliases: + logger.warning("Alias %s is invalid as called in autotrigger.", invoker) + + message.author = thread.recipient # Allow for get_contexts to work + + for alias in aliases: + message.content = invoked_prefix + alias + ctxs += await self.get_contexts(message) + + message.author = self.modmail_guild.me # Fix message so commands execute properly + + for ctx in ctxs: + if ctx.command: + old_checks = copy.copy(ctx.command.checks) + ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)] + + await self.invoke(ctx) + + ctx.command.checks = old_checks + continue + + async def get_context(self, message, *, cls=commands.Context): + """ + Returns the invocation context from the message. + Supports getting the prefix from database. + """ + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + + if message.author.id == self.user.id: + return ctx + + ctx.thread = await self.threads.find(channel=ctx.channel) + + prefixes = await self.get_prefix() + + invoked_prefix = discord.utils.find(view.skip_string, prefixes) + if invoked_prefix is None: + return ctx + + invoker = view.get_word().lower() + + ctx.invoked_with = invoker + ctx.command = self.all_commands.get(invoker) + + return ctx + + async def update_perms( + self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True + ) -> None: + if value != -1: + value = str(value) + if isinstance(name, PermissionLevel): + level = True + permissions = self.config["level_permissions"] + name = name.name + else: + level = False + permissions = self.config["command_permissions"] + if name not in permissions: + if add: + permissions[name] = [value] + else: + if add: + if value not in permissions[name]: + permissions[name].append(value) + else: + if value in permissions[name]: + permissions[name].remove(value) + + if level: + self.config["level_permissions"] = permissions + else: + self.config["command_permissions"] = permissions + logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) + await self.config.update() + + async def on_message(self, message): + await self.wait_for_connected() + if message.type == discord.MessageType.pins_add and message.author == self.user: + await message.delete() + + if ( + (f"<@{self.user.id}" in message.content or f"<@!{self.user.id}" in message.content) + and self.config["alert_on_mention"] + and not message.author.bot + ): + em = discord.Embed( + title="Bot mention", + description=f"[Jump URL]({message.jump_url})\n{truncate(message.content, 50)}", + color=self.main_color, + ) + if self.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + if not self.config["silent_alert_on_mention"]: + content = self.config["mention"] + else: + content = "" + await self.mention_channel.send(content=content, embed=em) + + # --- MODERATOR-ONLY MESSAGE LOGGING --- + # If a moderator sends a message directly in a thread channel (not via modmail command), log it + if not message.author.bot and not isinstance(message.channel, discord.DMChannel): + thread = await self.threads.find(channel=message.channel) + if thread is not None: + ctxs = await self.get_contexts(message) + is_command = any(ctx.command for ctx in ctxs) + if not is_command: + # Only log if not a command + perms = message.channel.permissions_for(message.author) + if perms.manage_messages or perms.administrator: + await self.api.append_log(message, type_="mod_only") + + await self.process_commands(message) + + async def process_commands(self, message): + if message.author.bot: + return + + if isinstance(message.channel, discord.DMChannel): + return await self.process_dm_modmail(message) + + ctxs = await self.get_contexts(message) + for ctx in ctxs: + if ctx.command: + if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): + logger.debug( + "Command %s has no permissions check, adding invalid level.", + ctx.command.qualified_name, + ) + checks.has_permissions(PermissionLevel.INVALID)(ctx.command) + + await self.invoke(ctx) + continue + + thread = await self.threads.find(channel=ctx.channel) + if thread is not None: + anonymous = False + plain = False + if self.config.get("anon_reply_without_command"): + anonymous = True + if self.config.get("plain_reply_without_command"): + plain = True + + if ( + self.config.get("reply_without_command") + or self.config.get("anon_reply_without_command") + or self.config.get("plain_reply_without_command") + ): + await thread.reply(message, anonymous=anonymous, plain=plain) + else: + await self.api.append_log(message, type_="internal") + elif ctx.invoked_with: + exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) + self.dispatch("command_error", ctx, exc) + + async def on_typing(self, channel, user, _): + await self.wait_for_connected() + + if user.bot: + return + + if isinstance(channel, discord.DMChannel): + if not self.config.get("user_typing"): + return + + thread = await self.threads.find(recipient=user) + + if thread: + await thread.channel.typing() + else: + if not self.config.get("mod_typing"): + return + + thread = await self.threads.find(channel=channel) + if thread is not None and thread.recipient: + for user in thread.recipients: + if await self.is_blocked(user): + continue + await user.typing() + + async def handle_reaction_events(self, payload): + user = self.get_user(payload.user_id) + if user is None or user.bot: + return + + channel = self.get_channel(payload.channel_id) + thread = None + # dm channel not in internal cache + if not channel: + thread = await self.threads.find(recipient=user) + if not thread: + return + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return + + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: + return + + # thread must exist before doing this API call + try: + message = await channel.fetch_message(payload.message_id) + except (discord.NotFound, discord.Forbidden): + return + + reaction = payload.emoji + close_emoji = await self.convert_emoji(self.config["close_emoji"]) + if from_dm: + if ( + payload.event_type == "REACTION_ADD" + and message.embeds + and str(reaction) == str(close_emoji) + and self.config.get("recipient_thread_close") + ): + ts = message.embeds[0].timestamp + if ts == thread.channel.created_at: + # the reacted message is the corresponding thread creation embed + # closing thread + return await thread.close(closer=user) + if ( + message.author == self.user + and message.embeds + and self.config.get("confirm_thread_creation") + and message.embeds[0].title == self.config["confirm_thread_creation_title"] + and message.embeds[0].description == self.config["confirm_thread_response"] + ): + return + if not thread.recipient.dm_channel: + await thread.recipient.create_dm() + try: + linked_messages = await thread.find_linked_message_from_dm(message, either_direction=True) + except ValueError as e: + logger.warning("Failed to find linked message for reactions: %s", e) + return + else: + try: + _, *linked_messages = await thread.find_linked_messages( + message1=message, either_direction=True + ) + except ValueError as e: + logger.warning("Failed to find linked message for reactions: %s", e) + return + + if self.config["transfer_reactions"] and linked_messages is not [None]: + if payload.event_type == "REACTION_ADD": + for msg in linked_messages: + await self.add_reaction(msg, reaction) + await self.add_reaction(message, reaction) + else: + try: + for msg in linked_messages: + await msg.remove_reaction(reaction, self.user) + await message.remove_reaction(reaction, self.user) + except (discord.HTTPException, TypeError) as e: + logger.warning("Failed to remove reaction: %s", e) + + async def handle_react_to_contact(self, payload): + react_message_id = tryint(self.config.get("react_to_contact_message")) + react_message_emoji = self.config.get("react_to_contact_emoji") + if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: + return + if payload.emoji.is_unicode_emoji(): + emoji_fmt = payload.emoji.name + else: + emoji_fmt = f"<:{payload.emoji.name}:{payload.emoji.id}>" + + if emoji_fmt != react_message_emoji: + return + channel = self.get_channel(payload.channel_id) + member = channel.guild.get_member(payload.user_id) + if member.bot: + return + message = await channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, member) + await message.add_reaction(emoji_fmt) # bot adds as well + + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=channel.guild, size=128), + ) + logger.info( + "A new thread using react to contact was blocked from %s due to disabled Modmail.", + member, + ) + return await member.send(embed=embed) + + ctx = await self.get_context(message) + await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) + + async def on_raw_reaction_add(self, payload): + await asyncio.gather( + self.handle_reaction_events(payload), + self.handle_react_to_contact(payload), + ) + + async def on_raw_reaction_remove(self, payload): + if self.config["transfer_reactions"]: + await self.handle_reaction_events(payload) + + async def on_guild_channel_delete(self, channel): + if channel.guild != self.modmail_guild: + return + + if isinstance(channel, discord.CategoryChannel): + if self.main_category == channel: + logger.debug("Main category was deleted.") + self.config.remove("main_category_id") + await self.config.update() + return + + if not isinstance(channel, discord.TextChannel): + return + + if self.log_channel is None or self.log_channel == channel: + logger.info("Log channel deleted.") + self.config.remove("log_channel_id") + await self.config.update() + return + + audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) + found_entry = False + async for entry in audit_logs: + if int(entry.target.id) == channel.id: + found_entry = True + break + + if not found_entry: + logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) + return + + mod = entry.user + if mod == self.user: + return + + thread = await self.threads.find(channel=channel) + if thread and thread.channel == channel: + logger.debug("Manually closed channel %s.", channel.name) + await thread.close(closer=mod, silent=True, delete_channel=False) + + async def on_member_remove(self, member): + thread = await self.threads.find(recipient=member) + if thread: + if member.guild == self.guild and self.config["close_on_leave"]: + await thread.close( + closer=member.guild.me, + message=self.config["close_on_leave_reason"], + silent=True, + ) + else: + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) + await thread.channel.send(embed=embed) + + async def on_member_join(self, member): + thread = await self.threads.find(recipient=member) + if thread: + if len(self.guilds) > 1: + guild_joined = member.guild + join_message = f"The recipient has joined {guild_joined}." + else: + join_message = "The recipient has joined the server." + embed = discord.Embed(description=join_message, color=self.mod_color) + await thread.channel.send(embed=embed) + + async def on_message_delete(self, message): + """Support for deleting linked messages""" + + if message.is_system(): + return + + if isinstance(message.channel, discord.DMChannel): + if message.author == self.user: + return + thread = await self.threads.find(recipient=message.author) + if not thread: + return + try: + message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) + except ValueError as e: + if str(e) != "Thread channel message not found.": + logger.debug("Failed to find linked message to delete: %s", e) + return + message = message[0] + embed = message.embeds[0] + + if embed.footer.icon: + icon_url = embed.footer.icon.url + else: + icon_url = None + + embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=icon_url) + await message.edit(embed=embed) + return + + if message.author != self.user: + return + + thread = await self.threads.find(channel=message.channel) + if not thread: + return + + try: + await thread.delete_message(message, note=False) + embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) + except ValueError as e: + if str(e) not in {"DM message not found.", "Malformed thread message."}: + logger.debug("Failed to find linked message to delete: %s", e) + embed = discord.Embed(description="Failed to delete message.", color=self.error_color) + else: + return + except discord.NotFound: + return + embed.set_footer(text=f"Message ID: {message.id} from {message.author}.") + return await message.channel.send(embed=embed) + + async def on_bulk_message_delete(self, messages): + await discord.utils.async_all(self.on_message_delete(msg) for msg in messages) + + async def on_message_edit(self, before, after): + if after.author.bot: + return + if before.content == after.content: + return + + if isinstance(after.channel, discord.DMChannel): + thread = await self.threads.find(recipient=before.author) + if not thread: + return + + try: + await thread.edit_dm_message(after, after.content) + except ValueError: + _, blocked_emoji = await self.retrieve_emoji() + await self.add_reaction(after, blocked_emoji) + else: + embed = discord.Embed(description="Successfully Edited Message", color=self.main_color) + embed.set_footer(text=f"Message ID: {after.id}") + await after.channel.send(embed=embed) + + async def on_error(self, event_method, *args, **kwargs): + logger.error("Ignoring exception in %s.", event_method) + logger.error("Unexpected exception:", exc_info=sys.exc_info()) + + async def on_command_error( + self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False + ) -> None: + if not unhandled_by_cog: + command = context.command + if command and command.has_error_handler(): + return + cog = context.cog + if cog and cog.has_error_handler(): + return + + if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): + await context.typing() + await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) + elif isinstance(exception, commands.CommandNotFound): + logger.warning("CommandNotFound: %s", exception) + elif isinstance(exception, commands.MissingRequiredArgument): + await context.send_help(context.command) + elif isinstance(exception, commands.CommandOnCooldown): + await context.send( + embed=discord.Embed( + title="Command on cooldown", + description=f"Try again in {exception.retry_after:.2f} seconds", + color=self.error_color, + ) + ) + elif isinstance(exception, commands.CheckFailure): + for check in context.command.checks: + if not await check(context): + if hasattr(check, "fail_msg"): + await context.send( + embed=discord.Embed(color=self.error_color, description=check.fail_msg) + ) + if hasattr(check, "permission_level"): + corrected_permission_level = self.command_perm(context.command.qualified_name) + logger.warning( + "User %s does not have permission to use this command: `%s` (%s).", + context.author.name, + context.command.qualified_name, + corrected_permission_level.name, + ) + logger.warning("CheckFailure: %s", exception) + elif isinstance(exception, commands.DisabledCommand): + logger.info("DisabledCommand: %s is trying to run eval but it's disabled", context.author.name) + else: + logger.error("Unexpected exception:", exc_info=exception) + + @tasks.loop(hours=1) + async def post_metadata(self): + info = await self.application_info() + + delta = discord.utils.utcnow() - self.start_time + data = { + "bot_id": self.user.id, + "bot_name": str(self.user), + "avatar_url": self.user.display_avatar.url, + "guild_id": self.guild_id, + "guild_name": self.guild.name, + "member_count": len(self.guild.members), + "uptime": delta.total_seconds(), + "latency": f"{self.ws.latency * 1000:.4f}", + "version": str(self.version), + "selfhosted": True, + "last_updated": str(discord.utils.utcnow()), + } + + if info.team is not None: + data.update( + { + "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", + "owner_id": info.team.owner_id, + "team": True, + } + ) + else: + data.update({"owner_name": info.owner.name, "owner_id": info.owner.id, "team": False}) + + async with self.session.post("https://api.modmail.dev/metadata", json=data): + logger.debug("Uploading metadata to Modmail server.") + + @post_metadata.before_loop + async def before_post_metadata(self): + await self.wait_for_connected() + if not self.config.get("data_collection") or not self.guild: + self.post_metadata.cancel() + return + + logger.debug("Starting metadata loop.") + logger.line("debug") + + @tasks.loop(hours=1) + async def autoupdate(self): + changelog = await Changelog.from_url(self) + latest = changelog.latest_version + + if self.version < Version(latest.version): + error = None + data = {} + try: + # update fork if gh_token exists + data = await self.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + if self.hosting_method == HostingMethod.HEROKU: + if error is not None: + logger.error(f"Autoupdate failed! Status: {error.status}.") + logger.error(f"Error message: {error.message}") + self.autoupdate.cancel() + return + + commit_data = data.get("data") + if not commit_data: + return + + logger.info("Bot has been updated.") + + if not self.config["update_notifications"]: + return + + embed = discord.Embed(color=self.main_color) + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] + user = data["user"] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", + ) + embed.set_author( + name=user["username"] + " - Updating Bot", + icon_url=user["avatar_url"], + url=user["url"], + ) + + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + + embed.description = latest.description + for name, value in latest.fields.items(): + embed.add_field(name=name, value=value) + + channel = self.update_channel + await channel.send(embed=embed) + else: + command = "git pull" + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) + err = await proc.stderr.read() + err = err.decode("utf-8").rstrip() + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if err and not res: + logger.warning(f"Autoupdate failed: {err}") + self.autoupdate.cancel() + return + + elif res != "Already up to date.": + if os.getenv("PIPENV_ACTIVE"): + # Update pipenv if possible + await asyncio.create_subprocess_shell( + "pipenv sync", + stderr=PIPE, + stdout=PIPE, + ) + message = "" + else: + message = "\n\nDo manually update dependencies if your bot has crashed." + + logger.info("Bot has been updated.") + channel = self.update_channel + if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): + embed = discord.Embed(title="Bot has been updated", color=self.main_color) + embed.set_footer( + text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" + ) + if self.config["update_notifications"]: + await channel.send(embed=embed) + else: + embed = discord.Embed( + title="Bot has been updated and is logging out.", + description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", + color=self.main_color, + ) + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + if self.config["update_notifications"]: + await channel.send(embed=embed) + return await self.close() + + @autoupdate.before_loop + async def before_autoupdate(self): + await self.wait_for_connected() + logger.debug("Starting autoupdate loop") + + if self.config.get("disable_autoupdates"): + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + if self.hosting_method == HostingMethod.DOCKER: + logger.warning("Autoupdates disabled as using Docker.") + self.autoupdate.cancel() + return + + if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: + logger.warning("GitHub access token not found.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + @tasks.loop(hours=1, reconnect=False) + async def log_expiry(self): + log_expire_after = self.config.get("log_expiration") + if log_expire_after == isodate.Duration(): + return self.log_expiry.stop() + + now = discord.utils.utcnow() + expiration_datetime = now - log_expire_after + # WARNING: comparison is done lexicographically, not by date. + # This is fine as long as the date is in zero-padded ISO format, which it should be. + expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) + + logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") + + def format_channel_name(self, author, exclude_channel=None, force_null=False): + """Sanitises a username for use with text channel names + + Placed in main bot class to be extendable to plugins""" + guild = self.modmail_guild + + if force_null: + name = new_name = "null" + else: + if self.config["use_random_channel_name"]: + to_hash = self.token.split(".")[-1] + str(author.id) + digest = hashlib.md5(to_hash.encode("utf8"), usedforsecurity=False) + name = new_name = digest.hexdigest()[-8:] + elif self.config["use_user_id_channel_name"]: + name = new_name = str(author.id) + elif self.config["use_timestamp_channel_name"]: + name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") + else: + if self.config["use_nickname_channel_name"]: + author_member = self.guild.get_member(author.id) + name = author_member.display_name.lower() + else: + name = author.name.lower() + + if force_null: + name = "null" + + name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" + if author.discriminator != "0": + name += f"-{author.discriminator}" + new_name = name + + counter = 1 + existed = set(c.name for c in guild.text_channels if c != exclude_channel) + while new_name in existed: + new_name = f"{name}_{counter}" # multiple channels with same name + counter += 1 + + return new_name + + +def main(): + try: + # noinspection PyUnresolvedReferences + import uvloop # type: ignore + + logger.debug("Setting up with uvloop.") + uvloop.install() + except ImportError: + pass + + try: + import cairosvg # noqa: F401 + except OSError: + if os.name == "nt": + if struct.calcsize("P") * 8 != 64: + logger.error( + "Unable to import cairosvg, ensure your Python is a 64-bit version: https://www.python.org/downloads/" + ) + else: + logger.error( + "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" + ) + else: + if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): + logger.error( + "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + else: + logger.error( + "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + sys.exit(0) + + # check discord version + discord_version = "2.5.2" + if discord.__version__ != discord_version: + logger.error( + "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", + discord_version, + discord.__version__, + ) + sys.exit(0) + + bot = ModmailBot() + bot.run() + + +if __name__ == "__main__": + main() diff --git a/cogs/modmail.py b/cogs/modmail.py index f91ef8d36a..6b34ebc151 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,8 +1,9 @@ import asyncio import re -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from itertools import zip_longest from typing import Optional, Union, List, Tuple, Literal +import logging import discord from discord.ext import commands @@ -29,6 +30,19 @@ class Modmail(commands.Cog): def __init__(self, bot): self.bot = bot + def _resolve_user(self, user_str): + """Helper to resolve a user from mention, ID, or username.""" + import re + + if not user_str: + return None + if user_str.isdigit(): + return int(user_str) + match = re.match(r"<@!?(\d+)>", user_str) + if match: + return int(match.group(1)) + return None + @commands.command() @trigger_typing @checks.has_permissions(PermissionLevel.OWNER) @@ -865,7 +879,10 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) + em.set_footer( + text=str(ctx.author), + icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + ) for u in users: to_exec.append(u.send(embed=em)) @@ -881,7 +898,9 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) for i in ctx.thread.recipients: if i not in users: @@ -958,7 +977,10 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) + em.set_footer( + text=str(ctx.author), + icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + ) for u in users: to_exec.append(u.send(embed=em)) @@ -974,7 +996,9 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) for i in ctx.thread.recipients: if i not in users: @@ -1052,7 +1076,7 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) - em.set_footer(text=name, icon_url=avatar_url) + em.set_footer(text=name, icon_url=avatar_url if avatar_url else None) for u in users: to_exec.append(u.send(embed=em)) @@ -1068,7 +1092,9 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) for i in ctx.thread.recipients: if i not in users: @@ -1141,7 +1167,7 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) - em.set_footer(text=name, icon_url=avatar_url) + em.set_footer(text=name, icon_url=avatar_url if avatar_url else None) for u in users: to_exec.append(u.send(embed=em)) @@ -1157,7 +1183,9 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) for i in ctx.thread.recipients: if i not in users: @@ -1172,6 +1200,7 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() async def logs(self, ctx, *, user: User = None): """ Get previous Modmail thread logs of a member. @@ -1661,7 +1690,9 @@ async def contact( ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{creator}", icon_url=creator.display_avatar.url) + em.set_footer( + text=f"{creator}", icon_url=creator.display_avatar.url if creator.display_avatar else None + ) for u in users: await u.send(embed=em) @@ -2230,6 +2261,227 @@ async def isenable(self, ctx): return await ctx.send(embed=embed) + @commands.command(usage="[after]") + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def snooze(self, ctx, *, after: UserFriendlyTime = None): + """ + Snooze this thread: deletes the channel, keeps the ticket open in DM, and restores it when the user replies or a moderator unsnoozes it. + Optionally specify a duration, e.g. 'snooze 2d' for 2 days. + Uses config: max_snooze_time, snooze_title, snooze_text + """ + thread = ctx.thread + if thread.snoozed: + await ctx.send("This thread is already snoozed.") + logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.") + return + max_snooze = self.bot.config.get("max_snooze_time") + if max_snooze is None: + max_snooze = 604800 + max_snooze = int(max_snooze) + if after: + snooze_for = int((after.dt - after.now).total_seconds()) + if snooze_for > max_snooze: + snooze_for = max_snooze + else: + snooze_for = max_snooze + now = datetime.utcnow() + await self.bot.api.logs.update_one( + {"recipient.id": str(thread.id)}, + {"$set": {"snooze_start": now.isoformat(), "snooze_for": snooze_for}}, + ) + embed = discord.Embed( + title=self.bot.config.get("snooze_title") or "Thread Snoozed", + description=self.bot.config.get("snooze_text") or "This thread has been snoozed.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + ok = await thread.snooze(moderator=ctx.author) + if ok: + logging.info( + f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} snoozed for {snooze_for}s." + ) + self.bot.threads.cache[thread.id] = thread + else: + await ctx.send("Failed to snooze this thread.") + logging.error(f"[SNOOZE] Failed to snooze thread for {getattr(thread.recipient, 'id', None)}.") + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def unsnooze(self, ctx, *, user: str = None): + """ + Unsnooze a thread: restores the channel and replays messages. + You can specify a user by mention or ID, or run in a thread channel to unsnooze that thread. + Uses config: unsnooze_text + """ + import discord + + thread = None + user_obj = None + if user is not None: + user_id = self._resolve_user(user) + if user_id: + try: + user_obj = await self.bot.get_or_fetch_user(user_id) + except Exception: + user_obj = discord.Object(user_id) + if user_obj: + thread = await self.bot.threads.find(recipient=user_obj) + if not thread: + await ctx.send(f"[DEBUG] No thread found for user {user} (obj: {user_obj}).") + logging.warning(f"[UNSNOOZE] No thread found for user {user} (obj: {user_obj})") + return + elif hasattr(ctx, "thread"): + thread = ctx.thread + else: + await ctx.send("This is not a Modmail thread.") + logging.warning("[UNSNOOZE] Not a Modmail thread context.") + return + if not thread.snoozed: + await ctx.send("This thread is not snoozed.") + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} is not snoozed.") + return + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + await ctx.send( + self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." + ) + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} unsnoozed.") + else: + await ctx.send("Failed to unsnooze this thread.") + logging.error( + f"[UNSNOOZE] Failed to unsnooze thread for {getattr(thread.recipient, 'id', None)}." + ) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snoozed(self, ctx): + """ + List all currently snoozed threads/users. + """ + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + if not snoozed: + await ctx.send("No threads are currently snoozed.") + return + lines = [] + now = datetime.utcnow() + for entry in snoozed: + user = entry.get("recipient", {}).get("name", "Unknown") + user_id = entry.get("recipient", {}).get("id", "?") + since = entry.get("snooze_start") + duration = entry.get("snooze_for") + if since: + try: + since_dt = datetime.fromisoformat(since) + since_str = f"" # Discord relative timestamp + except Exception as e: + since_str = "?" + logging.warning(f"[SNOOZED] Invalid snooze_start for {user_id}: {since} ({e})") + else: + since_str = "?" + logging.warning(f"[SNOOZED] Missing snooze_start for {user_id}") + if duration and since_str != "?": + try: + until_dt = datetime.fromisoformat(since) + timedelta(seconds=int(duration)) + until_str = f"" + except Exception as e: + until_str = "?" + logging.warning(f"[SNOOZED] Invalid until time for {user_id}: {since} + {duration} ({e})") + else: + until_str = "?" + lines.append(f"- {user} (`{user_id}`) since {since_str}, until {until_str}") + await ctx.send("Snoozed threads:\n" + "\n".join(lines)) + + async def cog_load(self): + self.bot.loop.create_task(self.snooze_auto_unsnooze_task()) + + async def snooze_auto_unsnooze_task(self): + await self.bot.wait_until_ready() + while True: + now = datetime.utcnow() + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + for entry in snoozed: + start = entry.get("snooze_start") + snooze_for = entry.get("snooze_for") + if not start: + continue + start_dt = datetime.fromisoformat(start) + if snooze_for is not None: + duration = int(snooze_for) + else: + max_snooze = self.bot.config.get("max_snooze_time") + if max_snooze is None: + max_snooze = 604800 + duration = int(max_snooze) + if (now - start_dt).total_seconds() > duration: + # Auto-unsnooze + thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"])) + if thread and thread.snoozed: + await thread.restore_from_snooze() + await asyncio.sleep(60) + + async def process_dm_modmail(self, message: discord.Message) -> None: + # ... existing code ... + # Before processing, check if thread is snoozed and auto-unsnooze + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + # Ensure the thread object in the cache is updated with the new channel + self.threads.cache[thread.id] = thread + # ... rest of the method unchanged ... + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + async def clearsnoozed(self, ctx): + """ + List all snoozed threads and ask for confirmation before clearing (unsnoozing) all of them. + Only proceed if the user confirms. + """ + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + if not snoozed: + await ctx.send("No threads are currently snoozed.") + return + lines = [] + for entry in snoozed: + user = entry.get("recipient", {}).get("name", "Unknown") + user_id = entry.get("recipient", {}).get("id", "?") + lines.append(f"- {user} (`{user_id}`)") + msg = await ctx.send( + "The following threads are currently snoozed and will be unsnoozed if you confirm:\n" + + "\n".join(lines) + + "\n\nType `yes` to confirm, or anything else to cancel." + ) + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel + + try: + reply = await self.bot.wait_for("message", check=check, timeout=30) + except asyncio.TimeoutError: + await ctx.send("Timed out. No threads were unsnoozed.") + return + if reply.content.strip().lower() != "yes": + await ctx.send("Cancelled. No threads were unsnoozed.") + return + count = 0 + for entry in snoozed: + user_id = entry.get("recipient", {}).get("id") + if not user_id: + continue + user_obj = None + try: + user_obj = await self.bot.get_or_fetch_user(int(user_id)) + except Exception: + user_obj = discord.Object(int(user_id)) + thread = await self.bot.threads.find(recipient=user_obj) + if thread and thread.snoozed: + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + count += 1 + await ctx.send(f"Unsnoozed {count} threads.") + async def setup(bot): await bot.add_cog(Modmail(bot)) diff --git a/cogs/plugins.py b/cogs/plugins.py index 78bc0aa544..2ae7904b8c 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -1,770 +1,773 @@ -import asyncio -import io -import json -import os -import shutil -import sys -import typing -import zipfile -from difflib import get_close_matches -from importlib import invalidate_caches -from pathlib import Path, PurePath -from re import match -from site import USER_SITE -from subprocess import PIPE - -import discord -from discord.ext import commands -from packaging.version import Version - -from core import checks -from core.models import PermissionLevel, getLogger -from core.paginator import EmbedPaginatorSession -from core.utils import trigger_typing, truncate - -logger = getLogger(__name__) - - -class InvalidPluginError(commands.BadArgument): - pass - - -class Plugin: - def __init__(self, user, repo=None, name=None, branch=None): - if repo is None: - self.user = "@local" - self.repo = "@local" - self.name = user - self.local = True - self.branch = "@local" - self.url = f"@local/{user}" - self.link = f"@local/{user}" - else: - self.user = user - self.repo = repo - self.name = name - self.local = False - self.branch = branch if branch is not None else "master" - self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" - self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" - - @property - def path(self): - if self.local: - return PurePath("plugins") / "@local" / self.name - return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" - - @property - def abs_path(self): - return Path(__file__).absolute().parent.parent / self.path - - @property - def cache_path(self): - if self.local: - raise ValueError("No cache path for local plugins!") - return ( - Path(__file__).absolute().parent.parent - / "temp" - / "plugins-cache" - / f"{self.user}-{self.repo}-{self.branch}.zip" - ) - - @property - def ext_string(self): - if self.local: - return f"plugins.@local.{self.name}.{self.name}" - return f"plugins.{self.user}.{self.repo}.{self.name}-{self.branch}.{self.name}" - - def __str__(self): - if self.local: - return f"@local/{self.name}" - return f"{self.user}/{self.repo}/{self.name}@{self.branch}" - - def __lt__(self, other): - return self.name.lower() < other.name.lower() - - @classmethod - def from_string(cls, s, strict=False): - m = match(r"^@?local/(.+)$", s) - if m is None: - if not strict: - m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) - else: - m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) - - if m is not None: - return Plugin(*m.groups()) - raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple - - def __hash__(self): - return hash((self.user, self.repo, self.name, self.branch)) - - def __repr__(self): - return f"" - - def __eq__(self, other): - return isinstance(other, Plugin) and self.__str__() == other.__str__() - - -class Plugins(commands.Cog): - """ - Plugins expand Modmail functionality by allowing third-party addons. - - These addons could have a range of features from moderation to simply - making your life as a moderator easier! - Learn how to create a plugin yourself here: - https://github.com/modmail-dev/modmail/wiki/Plugins - """ - - def __init__(self, bot): - self.bot = bot - self.registry = {} - self.loaded_plugins = set() - self._ready_event = asyncio.Event() - - async def cog_load(self): - await self.populate_registry() - if self.bot.config.get("enable_plugins"): - await self.initial_load_plugins() - else: - logger.info("Plugins not loaded since ENABLE_PLUGINS=false.") - - async def populate_registry(self): - url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" - try: - async with self.bot.session.get(url) as resp: - self.registry = json.loads(await resp.text()) - except asyncio.TimeoutError: - logger.warning("Failed to fetch registry. Loading with empty registry") - - async def initial_load_plugins(self): - for plugin_name in list(self.bot.config["plugins"]): - try: - plugin = Plugin.from_string(plugin_name, strict=True) - except InvalidPluginError: - self.bot.config["plugins"].remove(plugin_name) - try: - # For backwards compat - plugin = Plugin.from_string(plugin_name) - except InvalidPluginError: - logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) - continue - - logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) - self.bot.config["plugins"].append(str(plugin)) - - try: - await self.download_plugin(plugin) - await self.load_plugin(plugin) - except Exception: - self.bot.config["plugins"].remove(plugin_name) - logger.error( - "Error when loading plugin %s. Plugin removed from config.", - plugin, - exc_info=True, - ) - continue - - logger.debug("Finished loading all plugins.") - - self.bot.dispatch("plugins_ready") - - self._ready_event.set() - await self.bot.config.update() - - async def download_plugin(self, plugin, force=False): - if plugin.abs_path.exists() and (not force or plugin.local): - return - - if plugin.local: - raise InvalidPluginError(f"Local plugin {plugin} not found!") - - plugin.abs_path.mkdir(parents=True, exist_ok=True) - - if plugin.cache_path.exists() and not force: - plugin_io = plugin.cache_path.open("rb") - logger.debug("Loading cached %s.", plugin.cache_path) - else: - headers = {} - github_token = self.bot.config["github_token"] - if github_token is not None: - headers["Authorization"] = f"token {github_token}" - - async with self.bot.session.get(plugin.url, headers=headers) as resp: - logger.debug("Downloading %s.", plugin.url) - raw = await resp.read() - - try: - raw = await resp.text() - except UnicodeDecodeError: - pass - else: - if raw == "Not Found": - raise InvalidPluginError("Plugin not found") - else: - raise InvalidPluginError("Invalid download received, non-bytes object") - - plugin_io = io.BytesIO(raw) - if not plugin.cache_path.parent.exists(): - plugin.cache_path.parent.mkdir(parents=True) - - with plugin.cache_path.open("wb") as f: - f.write(raw) - - with zipfile.ZipFile(plugin_io) as zipf: - for info in zipf.infolist(): - path = PurePath(info.filename) - if len(path.parts) >= 3 and path.parts[1] == plugin.name: - plugin_path = plugin.abs_path / Path(*path.parts[2:]) - if info.is_dir(): - plugin_path.mkdir(parents=True, exist_ok=True) - else: - plugin_path.parent.mkdir(parents=True, exist_ok=True) - with zipf.open(info) as src, plugin_path.open("wb") as dst: - shutil.copyfileobj(src, dst) - - plugin_io.close() - - async def load_plugin(self, plugin): - if not (plugin.abs_path / f"{plugin.name}.py").exists(): - raise InvalidPluginError(f"{plugin.name}.py not found.") - - req_txt = plugin.abs_path / "requirements.txt" - - if req_txt.exists(): - # Install PIP requirements - - venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env - user_install = " --user" if not venv else "" - proc = await asyncio.create_subprocess_shell( - f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', - stderr=PIPE, - stdout=PIPE, - ) - - logger.debug("Downloading requirements for %s.", plugin.ext_string) - - stdout, stderr = await proc.communicate() - - if stdout: - logger.debug("[stdout]\n%s.", stdout.decode()) - - if stderr: - logger.debug("[stderr]\n%s.", stderr.decode()) - logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) - raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") - - if os.path.exists(USER_SITE): - sys.path.insert(0, USER_SITE) - - try: - await self.bot.load_extension(plugin.ext_string) - logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) - self.loaded_plugins.add(plugin) - - except commands.ExtensionError as exc: - logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) - raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc - - async def unload_plugin(self, plugin: Plugin) -> None: - try: - await self.bot.unload_extension(plugin.ext_string) - except commands.ExtensionError as exc: - raise exc - - ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) - for module in list(sys.modules.keys()): - if module == ext_parent or module.startswith(ext_parent + "."): - del sys.modules[module] - - async def parse_user_input(self, ctx, plugin_name, check_version=False): - if not self.bot.config["enable_plugins"]: - embed = discord.Embed( - description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", - color=self.bot.main_color, - ) - await ctx.send(embed=embed) - return - - if not self._ready_event.is_set(): - embed = discord.Embed( - description="Plugins are still loading, please try again later.", - color=self.bot.main_color, - ) - await ctx.send(embed=embed) - return - - if plugin_name in self.registry: - details = self.registry[plugin_name] - user, repo = details["repository"].split("/", maxsplit=1) - branch = details.get("branch") - - if check_version: - required_version = details.get("bot_version", False) - - if required_version and self.bot.version < Version(required_version): - embed = discord.Embed( - description="Your bot's version is too low. " - f"This plugin requires version `{required_version}`.", - color=self.bot.error_color, - ) - await ctx.send(embed=embed) - return - - plugin = Plugin(user, repo, plugin_name, branch) - - else: - if self.bot.config.get("registry_plugins_only"): - embed = discord.Embed( - description="This plugin is not in the registry. To install this plugin, " - "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", - color=self.bot.error_color, - ) - await ctx.send(embed=embed) - return - try: - plugin = Plugin.from_string(plugin_name) - except InvalidPluginError: - embed = discord.Embed( - description="Invalid plugin name, double check the plugin name " - "or use one of the following formats: " - "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", - color=self.bot.error_color, - ) - await ctx.send(embed=embed) - return - return plugin - - @commands.group(aliases=["plugin"], invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins(self, ctx): - """ - Manage plugins for Modmail. - """ - - await ctx.send_help(ctx.command) - - @plugins.command(name="add", aliases=["install", "load"]) - @checks.has_permissions(PermissionLevel.OWNER) - @trigger_typing - async def plugins_add(self, ctx, *, plugin_name: str): - """ - Install a new plugin for the bot. - - `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, - or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) - or `local/name` for local plugins. - """ - - plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) - if plugin is None: - return - - if str(plugin) in self.bot.config["plugins"]: - embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) - return await ctx.send(embed=embed) - - if plugin.name in self.bot.cogs: - # another class with the same name - embed = discord.Embed( - description="Cannot install this plugin (dupe cog name).", - color=self.bot.error_color, - ) - return await ctx.send(embed=embed) - - if plugin.local: - embed = discord.Embed( - description=f"Starting to load local plugin from {plugin.link}...", - color=self.bot.main_color, - ) - else: - embed = discord.Embed( - description=f"Starting to download plugin from {plugin.link}...", - color=self.bot.main_color, - ) - msg = await ctx.send(embed=embed) - - try: - await self.download_plugin(plugin, force=True) - except Exception as e: - logger.warning("Unable to download plugin %s.", plugin, exc_info=True) - - embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", - color=self.bot.error_color, - ) - - return await msg.edit(embed=embed) - - self.bot.config["plugins"].append(str(plugin)) - await self.bot.config.update() - - if self.bot.config.get("enable_plugins"): - invalidate_caches() - - try: - await self.load_plugin(plugin) - except Exception as e: - logger.warning("Unable to load plugin %s.", plugin, exc_info=True) - - embed = discord.Embed( - description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", - color=self.bot.error_color, - ) - - else: - embed = discord.Embed( - description="Successfully installed plugin.\n" - "*Friendly reminder, plugins have absolute control over your bot. " - "Please only install plugins from developers you trust.*", - color=self.bot.main_color, - ) - else: - embed = discord.Embed( - description="Successfully installed plugin.\n" - "*Friendly reminder, plugins have absolute control over your bot. " - "Please only install plugins from developers you trust.*\n\n" - "This plugin is currently not enabled due to `ENABLE_PLUGINS=false`, " - "to re-enable plugins, remove or change `ENABLE_PLUGINS=true` and restart your bot.", - color=self.bot.main_color, - ) - return await msg.edit(embed=embed) - - @plugins.command(name="remove", aliases=["del", "delete"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_remove(self, ctx, *, plugin_name: str): - """ - Remove an installed plugin of the bot. - - `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. - """ - plugin = await self.parse_user_input(ctx, plugin_name) - if plugin is None: - return - - if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) - return await ctx.send(embed=embed) - - if self.bot.config.get("enable_plugins"): - try: - await self.unload_plugin(plugin) - self.loaded_plugins.remove(plugin) - except (commands.ExtensionNotLoaded, KeyError): - logger.warning("Plugin was never loaded.") - - self.bot.config["plugins"].remove(str(plugin)) - await self.bot.config.update() - if not plugin.local: - shutil.rmtree( - plugin.abs_path, - onerror=lambda *args: logger.warning( - "Failed to remove plugin files %s: %s", plugin, str(args[2]) - ), - ) - try: - plugin.abs_path.parent.rmdir() - plugin.abs_path.parent.parent.rmdir() - except OSError: - pass # dir not empty - - embed = discord.Embed( - description="The plugin is successfully uninstalled.", color=self.bot.main_color - ) - await ctx.send(embed=embed) - - async def update_plugin(self, ctx, plugin_name): - logger.debug("Updating %s.", plugin_name) - plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) - if plugin is None: - return - - if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) - return await ctx.send(embed=embed) - - async with ctx.typing(): - embed = discord.Embed( - description=f"Successfully updated {plugin.name}.", color=self.bot.main_color - ) - await self.download_plugin(plugin, force=True) - if self.bot.config.get("enable_plugins"): - try: - await self.unload_plugin(plugin) - except commands.ExtensionError: - logger.warning("Plugin unload fail.", exc_info=True) - - try: - await self.load_plugin(plugin) - except Exception: - embed = discord.Embed( - description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", - color=self.bot.error_color, - ) - self.bot.config["plugins"].remove(str(plugin)) - logger.debug("Failed to update %s. Removed plugin from config.", plugin) - else: - logger.debug("Updated %s.", plugin) - else: - logger.debug("Updated %s.", plugin) - return await ctx.send(embed=embed) - - @plugins.command(name="update") - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_update(self, ctx, *, plugin_name: str = None): - """ - Update a plugin for the bot. - - `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. - - To update all plugins, do `{prefix}plugins update`. - """ - - if plugin_name is None: - # pylint: disable=redefined-argument-from-local - for plugin_name in list(self.bot.config["plugins"]): - await self.update_plugin(ctx, plugin_name) - else: - await self.update_plugin(ctx, plugin_name) - - @plugins.command(name="reset") - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_reset(self, ctx): - """ - Reset all plugins for the bot. - - Deletes all cache and plugins from config and unloads from the bot. - """ - logger.warning("Purging plugins.") - for ext in list(self.bot.extensions): - if not ext.startswith("plugins."): - continue - logger.error("Unloading plugin: %s.", ext) - try: - plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) - if plugin: - await self.unload_plugin(plugin) - self.loaded_plugins.remove(plugin) - else: - await self.bot.unload_extension(ext) - except Exception: - logger.error("Failed to unload plugin: %s.", ext) - - for module in list(sys.modules.keys()): - if module.startswith("plugins."): - del sys.modules[module] - - self.bot.config["plugins"].clear() - await self.bot.config.update() - - cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" - if cache_path.exists(): - logger.warning("Removing cache path.") - shutil.rmtree(cache_path) - - for entry in os.scandir(Path(__file__).absolute().parent.parent / "plugins"): - if entry.is_dir() and entry.name != "@local": - shutil.rmtree(entry.path) - logger.warning("Removing %s.", entry.name) - - embed = discord.Embed( - description="Successfully purged all plugins from the bot.", color=self.bot.main_color - ) - return await ctx.send(embed=embed) - - @plugins.command(name="loaded", aliases=["enabled", "installed"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_loaded(self, ctx): - """ - Show a list of currently loaded plugins. - """ - - if not self.bot.config.get("enable_plugins"): - embed = discord.Embed( - description="No plugins are loaded due to `ENABLE_PLUGINS=false`, " - "to re-enable plugins, remove or set `ENABLE_PLUGINS=true` and restart your bot.", - color=self.bot.error_color, - ) - return await ctx.send(embed=embed) - - if not self._ready_event.is_set(): - embed = discord.Embed( - description="Plugins are still loading, please try again later.", - color=self.bot.main_color, - ) - return await ctx.send(embed=embed) - - if not self.loaded_plugins: - embed = discord.Embed( - description="There are no plugins currently loaded.", color=self.bot.error_color - ) - return await ctx.send(embed=embed) - - loaded_plugins = map(str, sorted(self.loaded_plugins)) - pages = ["```\n"] - for plugin in loaded_plugins: - msg = str(plugin) + "\n" - if len(msg) + len(pages[-1]) + 3 <= 2048: - pages[-1] += msg - else: - pages[-1] += "```" - pages.append(f"```\n{msg}") - - if pages[-1][-3:] != "```": - pages[-1] += "```" - - embeds = [] - for page in pages: - embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) - embeds.append(embed) - paginator = EmbedPaginatorSession(ctx, *embeds) - await paginator.run() - - @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): - """ - Shows a list of all approved plugins. - - Usage: - `{prefix}plugin registry` Details about all plugins. - `{prefix}plugin registry plugin-name` Details about the indicated plugin. - `{prefix}plugin registry page-number` Jump to a page in the registry. - """ - - await self.populate_registry() - - embeds = [] - - registry = sorted(self.registry.items(), key=lambda elem: elem[0]) - - if not registry: - embed = discord.Embed( - color=self.bot.error_color, - description="Registry is empty. This could be because it failed to load.", - ) - await ctx.send(embed=embed) - return - - if isinstance(plugin_name, int): - index = plugin_name - 1 - if index < 0: - index = 0 - if index >= len(registry): - index = len(registry) - 1 - else: - index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) - - if not index and plugin_name is not None: - embed = discord.Embed( - color=self.bot.error_color, - description=f'Could not find a plugin with name "{plugin_name}" within the registry.', - ) - - matches = get_close_matches(plugin_name, self.registry.keys()) - - if matches: - embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) - - return await ctx.send(embed=embed) - - for name, details in registry: - details = self.registry[name] - user, repo = details["repository"].split("/", maxsplit=1) - branch = details.get("branch") - - plugin = Plugin(user, repo, name, branch) - - embed = discord.Embed( - color=self.bot.main_color, - description=details["description"], - url=plugin.link, - title=details["repository"], - ) - - embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") - - embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) - - if details.get("thumbnail_url"): - embed.set_thumbnail(url=details.get("thumbnail_url")) - - if details.get("image_url"): - embed.set_image(url=details.get("image_url")) - - if plugin in self.loaded_plugins: - embed.set_footer(text="This plugin is currently loaded.") - else: - required_version = details.get("bot_version", False) - if required_version and self.bot.version < Version(required_version): - embed.set_footer( - text="Your bot is unable to install this plugin, " - f"minimum required version is v{required_version}." - ) - else: - embed.set_footer(text="Your bot is able to install this plugin.") - - embeds.append(embed) - - paginator = EmbedPaginatorSession(ctx, *embeds) - paginator.current = index - await paginator.run() - - @plugins_registry.command(name="compact", aliases=["slim"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_registry_compact(self, ctx): - """ - Shows a compact view of all plugins within the registry. - """ - - await self.populate_registry() - - registry = sorted(self.registry.items(), key=lambda elem: elem[0]) - - pages = [""] - - for plugin_name, details in registry: - details = self.registry[plugin_name] - user, repo = details["repository"].split("/", maxsplit=1) - branch = details.get("branch") - - plugin = Plugin(user, repo, plugin_name, branch) - - desc = discord.utils.escape_markdown(details["description"].replace("\n", "")) - - name = f"[`{plugin.name}`]({plugin.link})" - fmt = f"{name} - {desc}" - - if plugin_name in self.loaded_plugins: - limit = 75 - len(plugin_name) - 4 - 8 + len(name) - if limit < 0: - fmt = plugin.name - limit = 75 - fmt = truncate(fmt, limit) + "[loaded]\n" - else: - limit = 75 - len(plugin_name) - 4 + len(name) - if limit < 0: - fmt = plugin.name - limit = 75 - fmt = truncate(fmt, limit) + "\n" - - if len(fmt) + len(pages[-1]) <= 2048: - pages[-1] += fmt - else: - pages.append(fmt) - - embeds = [] - - for page in pages: - embed = discord.Embed(color=self.bot.main_color, description=page) - embed.set_author(name="Plugin Registry", icon_url=self.bot.user.display_avatar.url) - embeds.append(embed) - - paginator = EmbedPaginatorSession(ctx, *embeds) - await paginator.run() - - -async def setup(bot): - await bot.add_cog(Plugins(bot)) +import asyncio +import io +import json +import os +import shutil +import sys +import typing +import zipfile +from difflib import get_close_matches +from importlib import invalidate_caches +from pathlib import Path, PurePath +from re import match +from site import USER_SITE +from subprocess import PIPE + +import discord +from discord.ext import commands +from packaging.version import Version + +from core import checks +from core.models import PermissionLevel, getLogger +from core.paginator import EmbedPaginatorSession +from core.utils import trigger_typing, truncate + +logger = getLogger(__name__) + + +class InvalidPluginError(commands.BadArgument): + pass + + +class Plugin: + def __init__(self, user, repo=None, name=None, branch=None): + if repo is None: + self.user = "@local" + self.repo = "@local" + self.name = user + self.local = True + self.branch = "@local" + self.url = f"@local/{user}" + self.link = f"@local/{user}" + else: + self.user = user + self.repo = repo + self.name = name + self.local = False + self.branch = branch if branch is not None else "master" + self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" + self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" + + @property + def path(self): + if self.local: + return PurePath("plugins") / "@local" / self.name + return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" + + @property + def abs_path(self): + return Path(__file__).absolute().parent.parent / self.path + + @property + def cache_path(self): + if self.local: + raise ValueError("No cache path for local plugins!") + return ( + Path(__file__).absolute().parent.parent + / "temp" + / "plugins-cache" + / f"{self.user}-{self.repo}-{self.branch}.zip" + ) + + @property + def ext_string(self): + if self.local: + return f"plugins.@local.{self.name}.{self.name}" + return f"plugins.{self.user}.{self.repo}.{self.name}-{self.branch}.{self.name}" + + def __str__(self): + if self.local: + return f"@local/{self.name}" + return f"{self.user}/{self.repo}/{self.name}@{self.branch}" + + def __lt__(self, other): + return self.name.lower() < other.name.lower() + + @classmethod + def from_string(cls, s, strict=False): + m = match(r"^@?local/(.+)$", s) + if m is None: + if not strict: + m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) + else: + m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) + + if m is not None: + return Plugin(*m.groups()) + raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple + + def __hash__(self): + return hash((self.user, self.repo, self.name, self.branch)) + + def __repr__(self): + return f"" + + def __eq__(self, other): + return isinstance(other, Plugin) and self.__str__() == other.__str__() + + +class Plugins(commands.Cog): + """ + Plugins expand Modmail functionality by allowing third-party addons. + + These addons could have a range of features from moderation to simply + making your life as a moderator easier! + Learn how to create a plugin yourself here: + https://github.com/modmail-dev/modmail/wiki/Plugins + """ + + def __init__(self, bot): + self.bot = bot + self.registry = {} + self.loaded_plugins = set() + self._ready_event = asyncio.Event() + + async def cog_load(self): + await self.populate_registry() + if self.bot.config.get("enable_plugins"): + await self.initial_load_plugins() + else: + logger.info("Plugins not loaded since ENABLE_PLUGINS=false.") + + async def populate_registry(self): + url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" + try: + async with self.bot.session.get(url) as resp: + self.registry = json.loads(await resp.text()) + except asyncio.TimeoutError: + logger.warning("Failed to fetch registry. Loading with empty registry") + + async def initial_load_plugins(self): + for plugin_name in list(self.bot.config["plugins"]): + try: + plugin = Plugin.from_string(plugin_name, strict=True) + except InvalidPluginError: + self.bot.config["plugins"].remove(plugin_name) + try: + # For backwards compat + plugin = Plugin.from_string(plugin_name) + except InvalidPluginError: + logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) + continue + + logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) + self.bot.config["plugins"].append(str(plugin)) + + try: + await self.download_plugin(plugin) + await self.load_plugin(plugin) + except Exception: + self.bot.config["plugins"].remove(plugin_name) + logger.error( + "Error when loading plugin %s. Plugin removed from config.", + plugin, + exc_info=True, + ) + continue + + logger.debug("Finished loading all plugins.") + + self.bot.dispatch("plugins_ready") + + self._ready_event.set() + await self.bot.config.update() + + async def download_plugin(self, plugin, force=False): + if plugin.abs_path.exists() and (not force or plugin.local): + return + + if plugin.local: + raise InvalidPluginError(f"Local plugin {plugin} not found!") + + plugin.abs_path.mkdir(parents=True, exist_ok=True) + + if plugin.cache_path.exists() and not force: + plugin_io = plugin.cache_path.open("rb") + logger.debug("Loading cached %s.", plugin.cache_path) + else: + headers = {} + github_token = self.bot.config["github_token"] + if github_token is not None: + headers["Authorization"] = f"token {github_token}" + + async with self.bot.session.get(plugin.url, headers=headers) as resp: + logger.debug("Downloading %s.", plugin.url) + raw = await resp.read() + + try: + raw = await resp.text() + except UnicodeDecodeError: + pass + else: + if raw == "Not Found": + raise InvalidPluginError("Plugin not found") + else: + raise InvalidPluginError("Invalid download received, non-bytes object") + + plugin_io = io.BytesIO(raw) + if not plugin.cache_path.parent.exists(): + plugin.cache_path.parent.mkdir(parents=True) + + with plugin.cache_path.open("wb") as f: + f.write(raw) + + with zipfile.ZipFile(plugin_io) as zipf: + for info in zipf.infolist(): + path = PurePath(info.filename) + if len(path.parts) >= 3 and path.parts[1] == plugin.name: + plugin_path = plugin.abs_path / Path(*path.parts[2:]) + if info.is_dir(): + plugin_path.mkdir(parents=True, exist_ok=True) + else: + plugin_path.parent.mkdir(parents=True, exist_ok=True) + with zipf.open(info) as src, plugin_path.open("wb") as dst: + shutil.copyfileobj(src, dst) + + plugin_io.close() + + async def load_plugin(self, plugin): + if not (plugin.abs_path / f"{plugin.name}.py").exists(): + raise InvalidPluginError(f"{plugin.name}.py not found.") + + req_txt = plugin.abs_path / "requirements.txt" + + if req_txt.exists(): + # Install PIP requirements + + venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env + user_install = " --user" if not venv else "" + proc = await asyncio.create_subprocess_shell( + f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', + stderr=PIPE, + stdout=PIPE, + ) + + logger.debug("Downloading requirements for %s.", plugin.ext_string) + + stdout, stderr = await proc.communicate() + + if stdout: + logger.debug("[stdout]\n%s.", stdout.decode()) + + if stderr: + logger.debug("[stderr]\n%s.", stderr.decode()) + logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) + raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") + + if os.path.exists(USER_SITE): + sys.path.insert(0, USER_SITE) + + try: + await self.bot.load_extension(plugin.ext_string) + logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) + self.loaded_plugins.add(plugin) + + except commands.ExtensionError as exc: + logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) + raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc + + async def unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + + async def parse_user_input(self, ctx, plugin_name, check_version=False): + if not self.bot.config["enable_plugins"]: + embed = discord.Embed( + description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + return + + if not self._ready_event.is_set(): + embed = discord.Embed( + description="Plugins are still loading, please try again later.", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + return + + if plugin_name in self.registry: + details = self.registry[plugin_name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + if check_version: + required_version = details.get("bot_version", False) + + if required_version and self.bot.version < Version(required_version): + embed = discord.Embed( + description="Your bot's version is too low. " + f"This plugin requires version `{required_version}`.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return + + plugin = Plugin(user, repo, plugin_name, branch) + + else: + if self.bot.config.get("registry_plugins_only"): + embed = discord.Embed( + description="This plugin is not in the registry. To install this plugin, " + "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return + try: + plugin = Plugin.from_string(plugin_name) + except InvalidPluginError: + embed = discord.Embed( + description="Invalid plugin name, double check the plugin name " + "or use one of the following formats: " + "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return + return plugin + + @commands.group(aliases=["plugin"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins(self, ctx): + """ + Manage plugins for Modmail. + """ + + await ctx.send_help(ctx.command) + + @plugins.command(name="add", aliases=["install", "load"]) + @checks.has_permissions(PermissionLevel.OWNER) + @trigger_typing + async def plugins_add(self, ctx, *, plugin_name: str): + """ + Install a new plugin for the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, + or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) + or `local/name` for local plugins. + """ + + plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) + if plugin is None: + return + + if str(plugin) in self.bot.config["plugins"]: + embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) + return await ctx.send(embed=embed) + + if plugin.name in self.bot.cogs: + # another class with the same name + embed = discord.Embed( + description="Cannot install this plugin (dupe cog name).", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + if plugin.local: + embed = discord.Embed( + description=f"Starting to load local plugin from {plugin.link}...", + color=self.bot.main_color, + ) + else: + embed = discord.Embed( + description=f"Starting to download plugin from {plugin.link}...", + color=self.bot.main_color, + ) + msg = await ctx.send(embed=embed) + + try: + await self.download_plugin(plugin, force=True) + except Exception as e: + logger.warning("Unable to download plugin %s.", plugin, exc_info=True) + + embed = discord.Embed( + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", + color=self.bot.error_color, + ) + + return await msg.edit(embed=embed) + + self.bot.config["plugins"].append(str(plugin)) + await self.bot.config.update() + + if self.bot.config.get("enable_plugins"): + invalidate_caches() + + try: + await self.load_plugin(plugin) + except Exception as e: + logger.warning("Unable to load plugin %s.", plugin, exc_info=True) + + embed = discord.Embed( + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", + color=self.bot.error_color, + ) + + else: + embed = discord.Embed( + description="Successfully installed plugin.\n" + "*Friendly reminder, plugins have absolute control over your bot. " + "Please only install plugins from developers you trust.*", + color=self.bot.main_color, + ) + else: + embed = discord.Embed( + description="Successfully installed plugin.\n" + "*Friendly reminder, plugins have absolute control over your bot. " + "Please only install plugins from developers you trust.*\n\n" + "This plugin is currently not enabled due to `ENABLE_PLUGINS=false`, " + "to re-enable plugins, remove or change `ENABLE_PLUGINS=true` and restart your bot.", + color=self.bot.main_color, + ) + return await msg.edit(embed=embed) + + @plugins.command(name="remove", aliases=["del", "delete"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_remove(self, ctx, *, plugin_name: str): + """ + Remove an installed plugin of the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + """ + plugin = await self.parse_user_input(ctx, plugin_name) + if plugin is None: + return + + if str(plugin) not in self.bot.config["plugins"]: + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + return await ctx.send(embed=embed) + + if self.bot.config.get("enable_plugins"): + try: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + except (commands.ExtensionNotLoaded, KeyError): + logger.warning("Plugin was never loaded.") + + self.bot.config["plugins"].remove(str(plugin)) + await self.bot.config.update() + if not plugin.local: + shutil.rmtree( + plugin.abs_path, + onerror=lambda *args: logger.warning( + "Failed to remove plugin files %s: %s", plugin, str(args[2]) + ), + ) + try: + plugin.abs_path.parent.rmdir() + plugin.abs_path.parent.parent.rmdir() + except OSError: + pass # dir not empty + + embed = discord.Embed( + description="The plugin is successfully uninstalled.", color=self.bot.main_color + ) + await ctx.send(embed=embed) + + async def update_plugin(self, ctx, plugin_name): + logger.debug("Updating %s.", plugin_name) + plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) + if plugin is None: + return + + if str(plugin) not in self.bot.config["plugins"]: + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + return await ctx.send(embed=embed) + + async with ctx.typing(): + embed = discord.Embed( + description=f"Successfully updated {plugin.name}.", color=self.bot.main_color + ) + await self.download_plugin(plugin, force=True) + if self.bot.config.get("enable_plugins"): + try: + await self.unload_plugin(plugin) + except commands.ExtensionError: + logger.warning("Plugin unload fail.", exc_info=True) + + try: + await self.load_plugin(plugin) + except Exception: + embed = discord.Embed( + description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", + color=self.bot.error_color, + ) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) + else: + logger.debug("Updated %s.", plugin) + else: + logger.debug("Updated %s.", plugin) + return await ctx.send(embed=embed) + + @plugins.command(name="update") + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_update(self, ctx, *, plugin_name: str = None): + """ + Update a plugin for the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + + To update all plugins, do `{prefix}plugins update`. + """ + + if plugin_name is None: + # pylint: disable=redefined-argument-from-local + for plugin_name in list(self.bot.config["plugins"]): + await self.update_plugin(ctx, plugin_name) + else: + await self.update_plugin(ctx, plugin_name) + + @plugins.command(name="reset") + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_reset(self, ctx): + """ + Reset all plugins for the bot. + + Deletes all cache and plugins from config and unloads from the bot. + """ + logger.warning("Purging plugins.") + for ext in list(self.bot.extensions): + if not ext.startswith("plugins."): + continue + logger.error("Unloading plugin: %s.", ext) + try: + plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) + if plugin: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + else: + await self.bot.unload_extension(ext) + except Exception: + logger.error("Failed to unload plugin: %s.", ext) + + for module in list(sys.modules.keys()): + if module.startswith("plugins."): + del sys.modules[module] + + self.bot.config["plugins"].clear() + await self.bot.config.update() + + cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" + if cache_path.exists(): + logger.warning("Removing cache path.") + shutil.rmtree(cache_path) + + for entry in os.scandir(Path(__file__).absolute().parent.parent / "plugins"): + if entry.is_dir() and entry.name != "@local": + shutil.rmtree(entry.path) + logger.warning("Removing %s.", entry.name) + + embed = discord.Embed( + description="Successfully purged all plugins from the bot.", color=self.bot.main_color + ) + return await ctx.send(embed=embed) + + @plugins.command(name="loaded", aliases=["enabled", "installed"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_loaded(self, ctx): + """ + Show a list of currently loaded plugins. + """ + + if not self.bot.config.get("enable_plugins"): + embed = discord.Embed( + description="No plugins are loaded due to `ENABLE_PLUGINS=false`, " + "to re-enable plugins, remove or set `ENABLE_PLUGINS=true` and restart your bot.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + if not self._ready_event.is_set(): + embed = discord.Embed( + description="Plugins are still loading, please try again later.", + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + + if not self.loaded_plugins: + embed = discord.Embed( + description="There are no plugins currently loaded.", color=self.bot.error_color + ) + return await ctx.send(embed=embed) + + loaded_plugins = map(str, sorted(self.loaded_plugins)) + pages = ["```\n"] + for plugin in loaded_plugins: + msg = str(plugin) + "\n" + if len(msg) + len(pages[-1]) + 3 <= 2048: + pages[-1] += msg + else: + pages[-1] += "```" + pages.append(f"```\n{msg}") + + if pages[-1][-3:] != "```": + pages[-1] += "```" + + embeds = [] + for page in pages: + embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) + embeds.append(embed) + paginator = EmbedPaginatorSession(ctx, *embeds) + await paginator.run() + + @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): + """ + Shows a list of all approved plugins. + + Usage: + `{prefix}plugin registry` Details about all plugins. + `{prefix}plugin registry plugin-name` Details about the indicated plugin. + `{prefix}plugin registry page-number` Jump to a page in the registry. + """ + + await self.populate_registry() + + embeds = [] + + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) + + if not registry: + embed = discord.Embed( + color=self.bot.error_color, + description="Registry is empty. This could be because it failed to load.", + ) + await ctx.send(embed=embed) + return + + if isinstance(plugin_name, int): + index = plugin_name - 1 + if index < 0: + index = 0 + if index >= len(registry): + index = len(registry) - 1 + else: + index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) + + if not index and plugin_name is not None: + embed = discord.Embed( + color=self.bot.error_color, + description=f'Could not find a plugin with name "{plugin_name}" within the registry.', + ) + + matches = get_close_matches(plugin_name, self.registry.keys()) + + if matches: + embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) + + return await ctx.send(embed=embed) + + for name, details in registry: + details = self.registry[name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + plugin = Plugin(user, repo, name, branch) + + embed = discord.Embed( + color=self.bot.main_color, + description=details["description"], + url=plugin.link, + title=details["repository"], + ) + + embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") + + embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) + + if details.get("thumbnail_url"): + embed.set_thumbnail(url=details.get("thumbnail_url")) + + if details.get("image_url"): + embed.set_image(url=details.get("image_url")) + + if plugin in self.loaded_plugins: + embed.set_footer(text="This plugin is currently loaded.") + else: + required_version = details.get("bot_version", False) + if required_version and self.bot.version < Version(required_version): + embed.set_footer( + text="Your bot is unable to install this plugin, " + f"minimum required version is v{required_version}." + ) + else: + embed.set_footer(text="Your bot is able to install this plugin.") + + embeds.append(embed) + + paginator = EmbedPaginatorSession(ctx, *embeds) + paginator.current = index + await paginator.run() + + @plugins_registry.command(name="compact", aliases=["slim"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_registry_compact(self, ctx): + """ + Shows a compact view of all plugins within the registry. + """ + + await self.populate_registry() + + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) + + pages = [""] + + for plugin_name, details in registry: + details = self.registry[plugin_name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + plugin = Plugin(user, repo, plugin_name, branch) + + desc = discord.utils.escape_markdown(details["description"].replace("\n", "")) + + name = f"[`{plugin.name}`]({plugin.link})" + fmt = f"{name} - {desc}" + + if plugin_name in self.loaded_plugins: + limit = 75 - len(plugin_name) - 4 - 8 + len(name) + if limit < 0: + fmt = plugin.name + limit = 75 + fmt = truncate(fmt, limit) + "[loaded]\n" + else: + limit = 75 - len(plugin_name) - 4 + len(name) + if limit < 0: + fmt = plugin.name + limit = 75 + fmt = truncate(fmt, limit) + "\n" + + if len(fmt) + len(pages[-1]) <= 2048: + pages[-1] += fmt + else: + pages.append(fmt) + + embeds = [] + + for page in pages: + embed = discord.Embed(color=self.bot.main_color, description=page) + embed.set_author( + name="Plugin Registry", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + ) + embeds.append(embed) + + paginator = EmbedPaginatorSession(ctx, *embeds) + await paginator.run() + + +async def setup(bot): + await bot.add_cog(Plugins(bot)) diff --git a/cogs/utility.py b/cogs/utility.py index 31cb065a28..49ba3fdaff 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1,2131 +1,2151 @@ -import asyncio -import inspect -import os -import random -import re -import traceback -from contextlib import redirect_stdout -from difflib import get_close_matches -from io import BytesIO, StringIO -from itertools import takewhile, zip_longest -from json import JSONDecodeError, loads -from subprocess import PIPE -from textwrap import indent -from typing import Union - -import discord -from discord.enums import ActivityType, Status -from discord.ext import commands, tasks -from discord.ext.commands.view import StringView - -from aiohttp import ClientResponseError -from packaging.version import Version - -from core import checks, utils -from core.changelog import Changelog -from core.models import ( - HostingMethod, - InvalidConfigError, - PermissionLevel, - UnseenFormatter, - getLogger, -) -from core.utils import trigger_typing, truncate, DummyParam -from core.paginator import EmbedPaginatorSession, MessagePaginatorSession - - -logger = getLogger(__name__) - - -class ModmailHelpCommand(commands.HelpCommand): - async def command_callback(self, ctx, *, command=None): - """Overwrites original command_callback to ensure `help` without any arguments - returns with checks, `help all` returns without checks""" - if command is None: - self.verify_checks = True - else: - self.verify_checks = False - - if command == "all": - command = None - - return await super().command_callback(ctx, command=command) - - async def format_cog_help(self, cog, *, no_cog=False): - bot = self.context.bot - prefix = self.context.clean_prefix - - formats = [""] - for cmd in await self.filter_commands( - cog.get_commands() if not no_cog else cog, - sort=True, - key=lambda c: (bot.command_perm(c.qualified_name), c.qualified_name), - ): - perm_level = bot.command_perm(cmd.qualified_name) - if perm_level is PermissionLevel.INVALID: - format_ = f"`{prefix + cmd.qualified_name}` " - else: - format_ = f"`[{perm_level}] {prefix + cmd.qualified_name}` " - - format_ += f"- {cmd.short_doc}\n" if cmd.short_doc else "- *No description.*\n" - if not format_.strip(): - continue - if len(format_) + len(formats[-1]) >= 1024: - formats.append(format_) - else: - formats[-1] += format_ - - embeds = [] - for format_ in formats: - description = ( - cog.description or "No description." - if not no_cog - else "Miscellaneous commands without a category." - ) - embed = discord.Embed(description=f"*{description}*", color=bot.main_color) - - if not format_: - continue - - embed.add_field(name="Commands", value=format_ or "No commands.") - - name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" - embed.set_author(name=name, icon_url=bot.user.display_avatar.url) - - embed.set_footer( - text=f'Type "{prefix}{self.command_attrs["name"]} command" ' - "for more info on a specific command." - ) - embeds.append(embed) - - if len(embeds) > 1: - for n, em in enumerate(embeds): - em.set_author(name=f"{em.author.name} [{n + 1}]", icon_url=em.author.icon_url) - - return embeds - - def process_help_msg(self, help_: str): - return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." - - async def send_bot_help(self, mapping): - embeds = [] - no_cog_commands = sorted(mapping.pop(None), key=lambda c: c.qualified_name) - cogs = sorted(mapping, key=lambda c: c.qualified_name) - - bot = self.context.bot - - # always come first - default_cogs = [bot.get_cog("Modmail"), bot.get_cog("Utility"), bot.get_cog("Plugins")] - - default_cogs.extend(c for c in cogs if c not in default_cogs) - - for cog in default_cogs: - embeds.extend(await self.format_cog_help(cog)) - if no_cog_commands: - embeds.extend(await self.format_cog_help(no_cog_commands, no_cog=True)) - - session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination()) - return await session.run() - - async def send_cog_help(self, cog): - embeds = await self.format_cog_help(cog) - session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination()) - return await session.run() - - async def _get_help_embed(self, topic): - if not await self.filter_commands([topic]): - return - perm_level = self.context.bot.command_perm(topic.qualified_name) - if perm_level is not PermissionLevel.INVALID: - perm_level = f"{perm_level.name} [{perm_level}]" - else: - perm_level = "NONE" - - embed = discord.Embed( - title=f"`{self.get_command_signature(topic).strip()}`", - color=self.context.bot.main_color, - description=self.process_help_msg(topic.help), - ) - return embed, perm_level - - async def send_command_help(self, command): - topic = await self._get_help_embed(command) - if topic is not None: - topic[0].set_footer(text=f"Permission level: {topic[1]}") - await self.get_destination().send(embed=topic[0]) - - async def send_group_help(self, group): - topic = await self._get_help_embed(group) - if topic is None: - return - embed = topic[0] - embed.add_field(name="Permission Level", value=topic[1], inline=False) - - format_ = "" - length = len(group.commands) - - for i, command in enumerate( - await self.filter_commands(group.commands, sort=True, key=lambda c: c.name) - ): - # BUG: fmt may run over the embed limit - # TODO: paginate this - if length == i + 1: # last - branch = "└─" - else: - branch = "├─" - format_ += f"`{branch} {command.name}` - {command.short_doc}\n" - - embed.add_field(name="Sub Command(s)", value=format_[:1024], inline=False) - embed.set_footer( - text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} command" ' - "for more info on a command." - ) - - await self.get_destination().send(embed=embed) - - async def send_error_message(self, error): - command = self.context.kwargs.get("command") - val = self.context.bot.snippets.get(command) - if val is not None: - embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) - embed.add_field(name=f"`{command}` will send:", value=val, inline=False) - - snippet_aliases = [] - for alias in self.context.bot.aliases: - if self.context.bot._resolve_snippet(alias) == command: - snippet_aliases.append(f"`{alias}`") - - if snippet_aliases: - embed.add_field( - name="Aliases to this snippet:", value=",".join(snippet_aliases), inline=False - ) - - return await self.get_destination().send(embed=embed) - - val = self.context.bot.aliases.get(command) - if val is not None: - values = utils.parse_alias(val) - - if not values: - embed = discord.Embed( - title="Error", - color=self.context.bot.error_color, - description=f"Alias `{command}` is invalid, this alias will now be deleted." - "This alias will now be deleted.", - ) - embed.add_field(name=f"{command}` used to be:", value=val) - self.context.bot.aliases.pop(command) - await self.context.bot.config.update() - else: - if len(values) == 1: - embed = discord.Embed(title=f"{command} is an alias.", color=self.context.bot.main_color) - embed.add_field(name=f"`{command}` points to:", value=values[0]) - else: - embed = discord.Embed( - title=f"{command} is an alias.", - color=self.context.bot.main_color, - description=f"**`{command}` points to the following steps:**", - ) - for i, val in enumerate(values, start=1): - embed.add_field(name=f"Step {i}:", value=val) - - embed.set_footer( - text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} alias" ' - "for more details on aliases." - ) - return await self.get_destination().send(embed=embed) - - logger.warning("CommandNotFound: %s", error) - - embed = discord.Embed(color=self.context.bot.error_color) - embed.set_footer(text=f'Command/Category "{command}" not found.') - - choices = set() - - for cmd in self.context.bot.walk_commands(): - if not cmd.hidden: - choices.add(cmd.qualified_name) - - closest = get_close_matches(command, choices) - if closest: - embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)) - else: - embed.title = "Cannot find command or category" - embed.set_footer( - text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]}" ' - "for a list of all available commands." - ) - await self.get_destination().send(embed=embed) - - -class Utility(commands.Cog): - """General commands that provide utility.""" - - def __init__(self, bot): - self.bot = bot - self._original_help_command = bot.help_command - self.bot.help_command = ModmailHelpCommand( - command_attrs={ - "help": "Shows this help message.", - "checks": [checks.has_permissions_predicate(PermissionLevel.REGULAR)], - }, - ) - self.bot.help_command.cog = self - if not self.bot.config.get("enable_eval"): - self.eval_.enabled = False - logger.info("Eval disabled. enable_eval=False") - - async def cog_load(self): - self.loop_presence.start() # pylint: disable=no-member - - def cog_unload(self): - self.bot.help_command = self._original_help_command - - @commands.command() - @checks.has_permissions(PermissionLevel.REGULAR) - @utils.trigger_typing - async def changelog(self, ctx, version: str.lower = ""): - """Shows the changelog of the Modmail.""" - changelog = await Changelog.from_url(self.bot) - version = version.lstrip("v") if version else changelog.latest_version.version - - try: - index = [v.version for v in changelog.versions].index(version) - except ValueError: - return await ctx.send( - embed=discord.Embed( - color=self.bot.error_color, - description=f"The specified version `{version}` could not be found.", - ) - ) - - paginator = EmbedPaginatorSession(ctx, *changelog.embeds) - try: - paginator.current = index - await paginator.run() - except asyncio.CancelledError: - pass - except Exception: - try: - await paginator.close() - finally: - logger.warning("Failed to display changelog.", exc_info=True) - await ctx.send( - f"View the changelog here: {changelog.latest_version.changelog_url}#v{version[::2]}" - ) - - @commands.command(aliases=["info"]) - @checks.has_permissions(PermissionLevel.REGULAR) - @utils.trigger_typing - async def about(self, ctx): - """Shows information about this bot.""" - embed = discord.Embed(color=self.bot.main_color, timestamp=discord.utils.utcnow()) - embed.set_author( - name="Modmail - About", - icon_url=self.bot.user.display_avatar.url, - url="https://discord.gg/F34cRU8", - ) - embed.set_thumbnail(url=self.bot.user.display_avatar.url) - - desc = "This is an open source Discord bot that serves as a means for " - desc += "members to easily communicate with server administrators in " - desc += "an organised manner." - embed.description = desc - - embed.add_field(name="Uptime", value=self.bot.uptime) - embed.add_field(name="Latency", value=f"{self.bot.latency * 1000:.2f} ms") - embed.add_field(name="Version", value=f"`{self.bot.version}`") - embed.add_field(name="Authors", value="`kyb3r`, `Taki`, `fourjr`") - embed.add_field(name="Hosting Method", value=self.bot.hosting_method.name) - - changelog = await Changelog.from_url(self.bot) - latest = changelog.latest_version - - if self.bot.version.is_prerelease: - stable = next(filter(lambda v: not Version(v.version).is_prerelease, changelog.versions)) - footer = f"You are on the prerelease version • the latest version is v{stable.version}." - elif self.bot.version < Version(latest.version): - footer = f"A newer version is available v{latest.version}." - else: - footer = "You are up to date with the latest version." - - embed.add_field( - name="Want Modmail in Your Server?", - value="Follow the installation guide on [GitHub](https://github.com/modmail-dev/modmail/) " - "and join our [Discord server](https://discord.gg/cnUpwrnpYb)!", - inline=False, - ) - - embed.add_field( - name="Support the Developers", - value="This bot is completely free for everyone. We rely on kind individuals " - "like you to support us on [`Patreon`](https://patreon.com/kyber) (perks included) " - "to keep this bot free forever!", - inline=False, - ) - - embed.add_field( - name="Project Sponsors", - value=f"Checkout the people who supported Modmail with command `{self.bot.prefix}sponsors`!", - inline=False, - ) - - embed.set_footer(text=footer) - await ctx.send(embed=embed) - - @commands.command(aliases=["sponsor"]) - @checks.has_permissions(PermissionLevel.REGULAR) - @utils.trigger_typing - async def sponsors(self, ctx): - """Shows the sponsors of this project.""" - - async with self.bot.session.get( - "https://raw.githubusercontent.com/modmail-dev/modmail/master/SPONSORS.json" - ) as resp: - data = loads(await resp.text()) - - embeds = [] - - for elem in data: - embed = discord.Embed.from_dict(elem["embed"]) - embeds.append(embed) - - random.shuffle(embeds) - - session = EmbedPaginatorSession(ctx, *embeds) - await session.run() - - @commands.group(invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - @utils.trigger_typing - async def debug(self, ctx): - """Shows the recent application logs of the bot.""" - - with open(self.bot.log_file_path, "r+", encoding="utf-8") as f: - logs = f.read().strip() - - if not logs: - embed = discord.Embed( - color=self.bot.main_color, - title="Debug Logs:", - description="You don't have any logs at the moment.", - ) - embed.set_footer(text="Go to your console to see your logs.") - return await ctx.send(embed=embed) - - messages = [] - - # Using Haskell formatting because it's similar to Python for exceptions - # and it does a fine job formatting the logs. - msg = "```Haskell\n" - - for line in logs.splitlines(keepends=True): - if msg != "```Haskell\n": - if len(line) + len(msg) + 3 > 2000: - msg += "```" - messages.append(msg) - msg = "```Haskell\n" - msg += line - if len(msg) + 3 > 2000: - msg = msg[:1992] + "[...]```" - messages.append(msg) - msg = "```Haskell\n" - - if msg != "```Haskell\n": - msg += "```" - messages.append(msg) - - embed = discord.Embed(color=self.bot.main_color) - embed.set_footer(text="Debug logs - Navigate using the reactions below.") - - session = MessagePaginatorSession(ctx, *messages, embed=embed) - session.current = len(messages) - 1 - return await session.run() - - @debug.command(name="hastebin", aliases=["haste"]) - @checks.has_permissions(PermissionLevel.OWNER) - @utils.trigger_typing - async def debug_hastebin(self, ctx): - """Posts application-logs to Hastebin.""" - - haste_url = os.environ.get("HASTE_URL", "https://hastebin.cc") - - with open(self.bot.log_file_path, "rb+") as f: - logs = BytesIO(f.read().strip()) - - try: - async with self.bot.session.post(haste_url + "/documents", data=logs) as resp: - data = await resp.json() - try: - key = data["key"] - except KeyError: - logger.error(data["message"]) - raise - embed = discord.Embed( - title="Debug Logs", - color=self.bot.main_color, - description=f"{haste_url}/" + key, - ) - except (JSONDecodeError, ClientResponseError, IndexError, KeyError): - embed = discord.Embed( - title="Debug Logs", - color=self.bot.main_color, - description="Something's wrong. We're unable to upload your logs to hastebin.", - ) - embed.set_footer(text="Go to your console to see your logs.") - await ctx.send(embed=embed) - - @debug.command(name="clear", aliases=["wipe"]) - @checks.has_permissions(PermissionLevel.OWNER) - @utils.trigger_typing - async def debug_clear(self, ctx): - """Clears the locally cached logs.""" - - with open(self.bot.log_file_path, "w"): - pass - await ctx.send( - embed=discord.Embed(color=self.bot.main_color, description="Cached logs are now cleared.") - ) - - @commands.command(aliases=["presence"]) - @checks.has_permissions(PermissionLevel.ADMINISTRATOR) - async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): - """ - Set an activity status for the bot. - - Possible activity types: - - `playing` - - `streaming` - - `listening` - - `watching` - - `competing` - - `custom` - - When activity type is set to `listening`, - it must be followed by a "to": "listening to..." - - When activity type is set to `competing`, - it must be followed by a "in": "competing in..." - - When activity type is set to `streaming`, you can set - the linked twitch page: - - `{prefix}config set twitch_url https://www.twitch.tv/somechannel/` - - When activity type is set to `custom`, you can set - any custom text as the activity message. - - To remove the current activity status: - - `{prefix}activity clear` - """ - if activity_type == "clear": - self.bot.config.remove("activity_type") - self.bot.config.remove("activity_message") - await self.bot.config.update() - await self.set_presence() - embed = discord.Embed(title="Activity Removed", color=self.bot.main_color) - return await ctx.send(embed=embed) - - if not message: - raise commands.MissingRequiredArgument(DummyParam("message")) - - try: - activity_type = ActivityType[activity_type] - except KeyError: - raise commands.MissingRequiredArgument(DummyParam("activity")) - - activity, _ = await self.set_presence(activity_type=activity_type, activity_message=message) - - self.bot.config["activity_type"] = activity.type.value - self.bot.config["activity_message"] = activity.name - await self.bot.config.update() - - msg = f"Activity set to: {activity.type.name.capitalize()} " - if activity.type == ActivityType.listening: - msg += f"to {activity.name}." - elif activity.type == ActivityType.competing: - msg += f"in {activity.name}." - else: - msg += f"{activity.name}." - - embed = discord.Embed(title="Activity Changed", description=msg, color=self.bot.main_color) - return await ctx.send(embed=embed) - - @commands.command() - @checks.has_permissions(PermissionLevel.ADMINISTRATOR) - async def status(self, ctx, *, status_type: str.lower): - """ - Set a status for the bot. - - Possible status types: - - `online` - - `idle` - - `dnd` or `do not disturb` - - `invisible` or `offline` - - To remove the current status: - - `{prefix}status clear` - """ - if status_type == "clear": - self.bot.config.remove("status") - await self.bot.config.update() - await self.set_presence() - embed = discord.Embed(title="Status Removed", color=self.bot.main_color) - return await ctx.send(embed=embed) - - status_type = status_type.replace(" ", "_") - try: - status = Status[status_type] - except KeyError: - raise commands.MissingRequiredArgument(DummyParam("status")) - - _, status = await self.set_presence(status=status) - - self.bot.config["status"] = status.value - await self.bot.config.update() - - msg = f"Status set to: {status.value}." - embed = discord.Embed(title="Status Changed", description=msg, color=self.bot.main_color) - return await ctx.send(embed=embed) - - async def set_presence(self, *, status=None, activity_type=None, activity_message=None): - if status is None: - status = self.bot.config.get("status") - - if activity_type is None: - activity_type = self.bot.config.get("activity_type") - - url = None - activity_message = (activity_message or self.bot.config["activity_message"]).strip() - if activity_type is not None and not activity_message: - logger.warning('No activity message found whilst activity is provided, defaults to "Modmail".') - activity_message = "Modmail" - - if activity_type == ActivityType.listening: - if activity_message.lower().startswith("to "): - # The actual message is after listening to [...] - # discord automatically add the "to" - activity_message = activity_message[3:].strip() - elif activity_type == ActivityType.competing: - if activity_message.lower().startswith("in "): - # The actual message is after listening to [...] - # discord automatically add the "in" - activity_message = activity_message[3:].strip() - elif activity_type == ActivityType.streaming: - url = self.bot.config["twitch_url"] - - if activity_type == ActivityType.custom: - activity = discord.CustomActivity(name=activity_message) - elif activity_type is not None: - activity = discord.Activity(type=activity_type, name=activity_message, url=url) - else: - activity = None - await self.bot.change_presence(activity=activity, status=status) - - return activity, status - - @tasks.loop(minutes=30) - async def loop_presence(self): - """Set presence to the configured value every 30 minutes.""" - logger.debug("Resetting presence.") - await self.set_presence() - - @loop_presence.before_loop - async def before_loop_presence(self): - await self.bot.wait_for_connected() - logger.line() - activity, status = await self.set_presence() - - if activity is not None: - msg = f"Activity set to: {activity.type.name.capitalize()} " - if activity.type == ActivityType.listening: - msg += f"to {activity.name}." - else: - msg += f"{activity.name}." - logger.info(msg) - else: - logger.info("No activity has been set.") - if status is not None: - msg = f"Status set to: {status.value}." - logger.info(msg) - else: - logger.info("No status has been set.") - - await asyncio.sleep(1800) - logger.info("Starting presence loop.") - - @commands.command() - @checks.has_permissions(PermissionLevel.ADMINISTRATOR) - @utils.trigger_typing - async def ping(self, ctx): - """Pong! Returns your websocket latency.""" - embed = discord.Embed( - title="Pong! Websocket Latency:", - description=f"{self.bot.ws.latency * 1000:.4f} ms", - color=self.bot.main_color, - ) - return await ctx.send(embed=embed) - - @commands.command() - @checks.has_permissions(PermissionLevel.ADMINISTRATOR) - async def mention(self, ctx, *user_or_role: Union[discord.Role, discord.Member, str]): - """ - Change what the bot mentions at the start of each thread. - - `user_or_role` may be a user ID, mention, name, role ID, mention, or name. - You can also set it to mention multiple users or roles, just separate the arguments with space. - - Examples: - - `{prefix}mention @user` - - `{prefix}mention @user @role` - - `{prefix}mention 984301093849028 388218663326449` - - `{prefix}mention everyone` - - Do not ping `@everyone` to set mention to everyone, use "everyone" or "all" instead. - - Notes: - - Type only `{prefix}mention` to retrieve your current "mention" message. - - `{prefix}mention disable` to disable mention. - - `{prefix}mention reset` to reset it to default value, which is "@here". - """ - current = self.bot.config["mention"] - if not user_or_role: - embed = discord.Embed( - title="Current mention:", color=self.bot.main_color, description=str(current) - ) - elif ( - len(user_or_role) == 1 - and isinstance(user_or_role[0], str) - and user_or_role[0].lower() in ("disable", "reset") - ): - option = user_or_role[0].lower() - if option == "disable": - embed = discord.Embed( - description="Disabled mention on thread creation.", - color=self.bot.main_color, - ) - self.bot.config["mention"] = None - else: - embed = discord.Embed( - description="`mention` is reset to default.", - color=self.bot.main_color, - ) - self.bot.config.remove("mention") - await self.bot.config.update() - else: - mention = [] - everyone = ("all", "everyone") - for m in user_or_role: - if not isinstance(m, (discord.Role, discord.Member)) and m not in everyone: - raise commands.BadArgument(f'Role or Member "{m}" not found.') - elif m == ctx.guild.default_role or m in everyone: - mention.append("@everyone") - continue - mention.append(m.mention) - - mention = " ".join(mention) - embed = discord.Embed( - title="Changed mention!", - description=f'On thread creation the bot now says "{mention}".', - color=self.bot.main_color, - ) - self.bot.config["mention"] = mention - await self.bot.config.update() - - return await ctx.send(embed=embed) - - @commands.command() - @checks.has_permissions(PermissionLevel.ADMINISTRATOR) - async def prefix(self, ctx, *, prefix=None): - """ - Change the prefix of the bot. - - Type only `{prefix}prefix` to retrieve your current bot prefix. - """ - - current = self.bot.prefix - embed = discord.Embed(title="Current prefix", color=self.bot.main_color, description=f"{current}") - - if prefix is None: - await ctx.send(embed=embed) - else: - embed.title = "Changed prefix!" - embed.description = f"Set prefix to `{prefix}`" - self.bot.config["prefix"] = prefix - await self.bot.config.update() - await ctx.send(embed=embed) - - @commands.group(aliases=["configuration"], invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def config(self, ctx): - """ - Modify changeable configuration variables for this bot. - - Type `{prefix}config options` to view a list - of valid configuration variables. - - Type `{prefix}config help config-name` for info - on a config. - - To set a configuration variable: - - `{prefix}config set config-name value here` - - To remove a configuration variable: - - `{prefix}config remove config-name` - """ - await ctx.send_help(ctx.command) - - @config.command(name="options", aliases=["list"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def config_options(self, ctx): - """Return a list of valid configuration names you can change.""" - embeds = [] - for names in zip_longest(*(iter(sorted(self.bot.config.public_keys)),) * 15): - description = "\n".join(f"`{name}`" for name in takewhile(lambda x: x is not None, names)) - embed = discord.Embed( - title="Available configuration keys:", - color=self.bot.main_color, - description=description, - ) - embeds.append(embed) - - session = EmbedPaginatorSession(ctx, *embeds) - await session.run() - - @config.command(name="set", aliases=["add"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def config_set(self, ctx, key: str.lower, *, value: str): - """Set a configuration variable and its value.""" - - keys = self.bot.config.public_keys - - if key in keys: - try: - await self.bot.config.set(key, value) - await self.bot.config.update() - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Set `{key}` to `{self.bot.config[key]}`.", - ) - except InvalidConfigError as exc: - embed = exc.embed - else: - embed = discord.Embed( - title="Error", color=self.bot.error_color, description=f"{key} is an invalid key." - ) - valid_keys = [f"`{k}`" for k in sorted(keys)] - embed.add_field(name="Valid keys", value=truncate(", ".join(valid_keys), 1024)) - - return await ctx.send(embed=embed) - - @config.command(name="remove", aliases=["del", "delete"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def config_remove(self, ctx, *, key: str.lower): - """Delete a set configuration variable.""" - keys = self.bot.config.public_keys - if key in keys: - self.bot.config.remove(key) - await self.bot.config.update() - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"`{key}` had been reset to default.", - ) - else: - embed = discord.Embed( - title="Error", color=self.bot.error_color, description=f"{key} is an invalid key." - ) - valid_keys = [f"`{k}`" for k in sorted(keys)] - embed.add_field(name="Valid keys", value=", ".join(valid_keys)) - - return await ctx.send(embed=embed) - - @config.command(name="get") - @checks.has_permissions(PermissionLevel.OWNER) - async def config_get(self, ctx, *, key: str.lower = None): - """ - Show the configuration variables that are currently set. - - Leave `key` empty to show all currently set configuration variables. - """ - keys = self.bot.config.public_keys - - if key: - if key in keys: - desc = f"`{key}` is set to `{self.bot.config[key]}`" - embed = discord.Embed(color=self.bot.main_color, description=desc) - embed.set_author(name="Config variable", icon_url=self.bot.user.display_avatar.url) - - else: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"`{key}` is an invalid key.", - ) - embed.set_footer( - text=f'Type "{self.bot.prefix}config options" for a list of config variables.' - ) - - else: - embed = discord.Embed( - color=self.bot.main_color, - description="Here is a list of currently set configuration variable(s).", - ) - embed.set_author(name="Current config(s):", icon_url=self.bot.user.display_avatar.url) - config = self.bot.config.filter_default(self.bot.config) - - for name, value in config.items(): - if name in self.bot.config.public_keys: - embed.add_field(name=name, value=f"`{value}`", inline=False) - - return await ctx.send(embed=embed) - - @config.command(name="help", aliases=["info"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def config_help(self, ctx, key: str.lower = None): - """ - Show information on a specified configuration. - """ - if key is not None and not ( - key in self.bot.config.public_keys or key in self.bot.config.protected_keys - ): - closest = get_close_matches( - key, {**self.bot.config.public_keys, **self.bot.config.protected_keys} - ) - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"`{key}` is an invalid key.", - ) - if closest: - embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)) - return await ctx.send(embed=embed) - - config_help = self.bot.config.config_help - - if key is not None and key not in config_help: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"No help details found for `{key}`.", - ) - return await ctx.send(embed=embed) - - def fmt(val): - return UnseenFormatter().format(val, prefix=self.bot.prefix, bot=self.bot) - - index = 0 - embeds = [] - for i, (current_key, info) in enumerate(config_help.items()): - if current_key == key: - index = i - embed = discord.Embed(title=f"{current_key}", color=self.bot.main_color) - embed.add_field(name="Default:", value=fmt(info["default"]), inline=False) - embed.add_field(name="Information:", value=fmt(info["description"]), inline=False) - if info["examples"]: - example_text = "" - for example in info["examples"]: - example_text += f"- {fmt(example)}\n" - embed.add_field(name="Example(s):", value=example_text, inline=False) - - note_text = "" - for note in info.get("notes", []): - note_text += f"- {fmt(note)}\n" - if note_text: - embed.add_field(name="Note(s):", value=note_text, inline=False) - - if info.get("image") is not None: - embed.set_image(url=fmt(info["image"])) - - if info.get("thumbnail") is not None: - embed.set_thumbnail(url=fmt(info["thumbnail"])) - embeds += [embed] - - paginator = EmbedPaginatorSession(ctx, *embeds) - paginator.current = index - await paginator.run() - - @commands.group(aliases=["aliases"], invoke_without_command=True) - @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias(self, ctx, *, name: str.lower = None): - """ - Create shortcuts to bot commands. - - When `{prefix}alias` is used by itself, this will retrieve - a list of alias that are currently set. `{prefix}alias-name` will show what the - alias point to. - - To use alias: - - First create an alias using: - - `{prefix}alias add alias-name other-command` - - For example: - - `{prefix}alias add r reply` - - Now you can use `{prefix}r` as an replacement for `{prefix}reply`. - - See also `{prefix}snippet`. - """ - - if name is not None: - val = self.bot.aliases.get(name) - if val is None: - embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") - return await ctx.send(embed=embed) - - values = utils.parse_alias(val) - - if not values: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Alias `{name}` is invalid, this alias will now be deleted." - "This alias will now be deleted.", - ) - embed.add_field(name=f"{name}` used to be:", value=utils.truncate(val, 1024)) - self.bot.aliases.pop(name) - await self.bot.config.update() - return await ctx.send(embed=embed) - - if len(values) == 1: - embed = discord.Embed( - title=f'Alias - "{name}":', description=values[0], color=self.bot.main_color - ) - return await ctx.send(embed=embed) - - else: - embeds = [] - for i, val in enumerate(values, start=1): - embed = discord.Embed( - color=self.bot.main_color, - title=f'Alias - "{name}" - Step {i}:', - description=val, - ) - embeds += [embed] - session = EmbedPaginatorSession(ctx, *embeds) - return await session.run() - - if not self.bot.aliases: - embed = discord.Embed( - color=self.bot.error_color, description="You dont have any aliases at the moment." - ) - embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.') - embed.set_author(name="Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) - return await ctx.send(embed=embed) - - embeds = [] - - for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): - description = utils.format_description(i, names) - embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author( - name="Command Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128) - ) - embeds.append(embed) - - session = EmbedPaginatorSession(ctx, *embeds) - await session.run() - - @alias.command(name="raw") - @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias_raw(self, ctx, *, name: str.lower): - """ - View the raw content of an alias. - """ - val = self.bot.aliases.get(name) - if val is None: - embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") - return await ctx.send(embed=embed) - - val = utils.truncate(utils.escape_code_block(val), 2048 - 7) - embed = discord.Embed( - title=f'Raw alias - "{name}":', description=f"```\n{val}```", color=self.bot.main_color - ) - - return await ctx.send(embed=embed) - - async def make_alias(self, name, value, action): - values = utils.parse_alias(value) - if not values: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Invalid multi-step alias, try wrapping each steps in quotes.", - ) - embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.') - return embed - - if len(values) > 25: - embed = discord.Embed( - title="Error", description="Too many steps, max=25.", color=self.bot.error_color - ) - return embed - - save_aliases = [] - - multiple_alias = len(values) > 1 - - embed = discord.Embed(title=f"{action} alias", color=self.bot.main_color) - - if not multiple_alias: - embed.add_field(name=f"`{name}` points to:", value=utils.truncate(values[0], 1024)) - else: - embed.description = f"`{name}` now points to the following steps:" - - for i, val in enumerate(values, start=1): - view = StringView(val) - linked_command = view.get_word().lower() - message = view.read_rest() - - is_snippet = val in self.bot.snippets - - if not self.bot.get_command(linked_command) and not is_snippet: - alias_command = self.bot.aliases.get(linked_command) - if alias_command is not None: - save_aliases.extend(utils.normalize_alias(alias_command, message)) - else: - embed = discord.Embed(title="Error", color=self.bot.error_color) - - if multiple_alias: - embed.description = ( - "The command you are attempting to point " - f"to does not exist: `{linked_command}`." - ) - else: - embed.description = ( - "The command you are attempting to point " - f"to on step {i} does not exist: `{linked_command}`." - ) - - return embed - else: - save_aliases.append(val) - if multiple_alias: - embed.add_field(name=f"Step {i}:", value=utils.truncate(val, 1024)) - - self.bot.aliases[name] = " && ".join(f'"{a}"' for a in save_aliases) - await self.bot.config.update() - return embed - - @alias.command(name="add", aliases=["create", "make"]) - @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias_add(self, ctx, name: str.lower, *, value): - """ - Add an alias. - - Alias also supports multi-step aliases, to create a multi-step alias use quotes - to wrap each step and separate each step with `&&`. For example: - - - `{prefix}alias add movenreply "move admin-category" && "reply Thanks for reaching out to the admins"` - - However, if you run into problems, try wrapping the command with quotes. For example: - - - This will fail: `{prefix}alias add reply You'll need to type && to work` - - Correct method: `{prefix}alias add reply "You'll need to type && to work"` - """ - embed = None - if self.bot.get_command(name): - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"A command with the same name already exists: `{name}`.", - ) - - elif name in self.bot.aliases: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Another alias with the same name already exists: `{name}`.", - ) - - elif name in self.bot.snippets: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"A snippet with the same name already exists: `{name}`.", - ) - - elif len(name) > 120: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Alias names cannot be longer than 120 characters.", - ) - - if embed is None: - embed = await self.make_alias(name, value, "Added") - return await ctx.send(embed=embed) - - @alias.command(name="remove", aliases=["del", "delete"]) - @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias_remove(self, ctx, *, name: str.lower): - """Remove an alias.""" - - if name in self.bot.aliases: - self.bot.aliases.pop(name) - await self.bot.config.update() - - embed = discord.Embed( - title="Removed alias", - color=self.bot.main_color, - description=f"Successfully deleted `{name}`.", - ) - else: - embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") - - return await ctx.send(embed=embed) - - @alias.command(name="edit") - @checks.has_permissions(PermissionLevel.MODERATOR) - async def alias_edit(self, ctx, name: str.lower, *, value): - """ - Edit an alias. - """ - if name not in self.bot.aliases: - embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") - return await ctx.send(embed=embed) - - embed = await self.make_alias(name, value, "Edited") - return await ctx.send(embed=embed) - - @commands.group(aliases=["perms"], invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions(self, ctx): - """ - Set the permissions for Modmail commands. - - You may set permissions based on individual command names, or permission - levels. - - Acceptable permission levels are: - - **Owner** [5] (absolute control over the bot) - - **Administrator** [4] (administrative powers such as setting activities) - - **Moderator** [3] (ability to block) - - **Supporter** [2] (access to core Modmail supporting functions) - - **Regular** [1] (most basic interactions such as help and about) - - By default, owner is set to the absolute bot owner and regular is `@everyone`. - - To set permissions, see `{prefix}help permissions add`; and to change permission level for specific - commands see `{prefix}help permissions override`. - - Note: You will still have to manually give/take permission to the Modmail - category to users/roles. - """ - await ctx.send_help(ctx.command) - - @staticmethod - def _verify_user_or_role(user_or_role): - if isinstance(user_or_role, discord.Role): - if user_or_role.is_default(): - return -1 - elif user_or_role in {"everyone", "all"}: - return -1 - if hasattr(user_or_role, "id"): - return user_or_role.id - raise commands.BadArgument(f'User or Role "{user_or_role}" not found') - - @staticmethod - def _parse_level(name): - name = name.upper() - try: - return PermissionLevel[name] - except KeyError: - pass - transform = { - "1": PermissionLevel.REGULAR, - "2": PermissionLevel.SUPPORTER, - "3": PermissionLevel.MODERATOR, - "4": PermissionLevel.ADMINISTRATOR, - "5": PermissionLevel.OWNER, - } - return transform.get(name, PermissionLevel.INVALID) - - @permissions.command(name="override") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str): - """ - Change a permission level for a specific command. - - Examples: - - `{prefix}perms override reply administrator` - - `{prefix}perms override "plugin enabled" moderator` - - To undo a permission override, see `{prefix}help permissions remove`. - - Example: - - `{prefix}perms remove override reply` - - `{prefix}perms remove override plugin enabled` - - You can retrieve a single or all command level override(s), see`{prefix}help permissions get`. - """ - - command = self.bot.get_command(command_name) - if command is None: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The referenced command does not exist: `{command_name}`.", - ) - return await ctx.send(embed=embed) - - level = self._parse_level(level_name) - if level is PermissionLevel.INVALID: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The referenced level does not exist: `{level_name}`.", - ) - else: - logger.info( - "Updated command permission level for `%s` to `%s`.", - command.qualified_name, - level.name, - ) - self.bot.config["override_command_level"][command.qualified_name] = level.name - - await self.bot.config.update() - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description="Successfully set command permission level for " - f"`{command.qualified_name}` to `{level.name}`.", - ) - return await ctx.send(embed=embed) - - @permissions.command(name="add", usage="[command/level] [name] [user/role]") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_add( - self, - ctx, - type_: str.lower, - name: str, - *, - user_or_role: Union[discord.Role, utils.User, str], - ): - """ - Add a permission to a command or a permission level. - - For sub commands, wrap the complete command name with quotes. - To find a list of permission levels, see `{prefix}help perms`. - - Examples: - - `{prefix}perms add level REGULAR everyone` - - `{prefix}perms add command reply @user` - - `{prefix}perms add command "plugin enabled" @role` - - `{prefix}perms add command help 984301093849028` - - Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. - """ - - if type_ not in {"command", "level"}: - return await ctx.send_help(ctx.command) - - command = level = None - if type_ == "command": - name = name.lower() - command = self.bot.get_command(name) - check = command is not None - else: - level = self._parse_level(name) - check = level is not PermissionLevel.INVALID - - if not check: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The referenced {type_} does not exist: `{name}`.", - ) - return await ctx.send(embed=embed) - - value = self._verify_user_or_role(user_or_role) - if type_ == "command": - name = command.qualified_name - await self.bot.update_perms(name, value) - else: - await self.bot.update_perms(level, value) - name = level.name - if level > PermissionLevel.REGULAR: - if value == -1: - key = self.bot.modmail_guild.default_role - elif isinstance(user_or_role, discord.Role): - key = user_or_role - else: - key = self.bot.modmail_guild.get_member(value) - if key is not None: - logger.info("Granting %s access to Modmail category.", key.name) - await self.bot.main_category.set_permissions(key, read_messages=True) - - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Permission for `{name}` was successfully updated.", - ) - return await ctx.send(embed=embed) - - @permissions.command( - name="remove", - aliases=["del", "delete", "revoke"], - usage="[command/level] [name] [user/role] or [override] [command name]", - ) - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_remove( - self, - ctx, - type_: str.lower, - name: str, - *, - user_or_role: Union[discord.Role, utils.User, str] = None, - ): - """ - Remove permission to use a command, permission level, or command level override. - - For sub commands, wrap the complete command name with quotes. - To find a list of permission levels, see `{prefix}help perms`. - - Examples: - - `{prefix}perms remove level REGULAR everyone` - - `{prefix}perms remove command reply @user` - - `{prefix}perms remove command "plugin enabled" @role` - - `{prefix}perms remove command help 984301093849028` - - `{prefix}perms remove override block` - - `{prefix}perms remove override "snippet add"` - - Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. - """ - if type_ not in {"command", "level", "override"} or (type_ != "override" and user_or_role is None): - return await ctx.send_help(ctx.command) - - if type_ == "override": - extension = ctx.kwargs["user_or_role"] - if extension is not None: - name += f" {extension}" - name = name.lower() - name = getattr(self.bot.get_command(name), "qualified_name", name) - level = self.bot.config["override_command_level"].get(name) - if level is None: - perm = self.bot.command_perm(name) - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The command permission level was never overridden: `{name}`, " - f"current permission level is {perm.name}.", - ) - else: - logger.info("Restored command permission level for `%s`.", name) - self.bot.config["override_command_level"].pop(name) - await self.bot.config.update() - perm = self.bot.command_perm(name) - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Command permission level for `{name}` was successfully restored to {perm.name}.", - ) - return await ctx.send(embed=embed) - - level = None - if type_ == "command": - name = name.lower() - name = getattr(self.bot.get_command(name), "qualified_name", name) - else: - level = self._parse_level(name) - if level is PermissionLevel.INVALID: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The referenced level does not exist: `{name}`.", - ) - return await ctx.send(embed=embed) - name = level.name - - value = self._verify_user_or_role(user_or_role) - await self.bot.update_perms(level or name, value, add=False) - - if type_ == "level": - if level > PermissionLevel.REGULAR: - if value == -1: - logger.info("Denying @everyone access to Modmail category.") - await self.bot.main_category.set_permissions( - self.bot.modmail_guild.default_role, read_messages=False - ) - elif isinstance(user_or_role, discord.Role): - logger.info("Denying %s access to Modmail category.", user_or_role.name) - await self.bot.main_category.set_permissions(user_or_role, overwrite=None) - else: - member = self.bot.modmail_guild.get_member(value) - if member is not None and member != self.bot.modmail_guild.me: - logger.info("Denying %s access to Modmail category.", member.name) - await self.bot.main_category.set_permissions(member, overwrite=None) - - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Permission for `{name}` was successfully updated.", - ) - return await ctx.send(embed=embed) - - def _get_perm(self, ctx, name, type_): - if type_ == "command": - permissions = self.bot.config["command_permissions"].get(name, []) - else: - permissions = self.bot.config["level_permissions"].get(name, []) - if not permissions: - embed = discord.Embed( - title=f"Permission entries for {type_} `{name}`:", - description="No permission entries found.", - color=self.bot.main_color, - ) - else: - values = [] - for perm in permissions: - if perm == -1: - values.insert(0, "**everyone**") - continue - member = ctx.guild.get_member(int(perm)) - if member is not None: - values.append(member.mention) - continue - user = self.bot.get_user(int(perm)) - if user is not None: - values.append(user.mention) - continue - role = ctx.guild.get_role(int(perm)) - if role is not None: - values.append(role.mention) - else: - values.append(str(perm)) - - embed = discord.Embed( - title=f"Permission entries for {type_} `{name}`:", - description=", ".join(values), - color=self.bot.main_color, - ) - return embed - - @permissions.command(name="get", usage="[@user] or [command/level/override] [name]") - @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_get( - self, ctx, user_or_role: Union[discord.Role, utils.User, str], *, name: str = None - ): - """ - View the currently-set permissions. - - To find a list of permission levels, see `{prefix}help perms`. - - To view all command and level permissions: - - Examples: - - `{prefix}perms get @user` - - `{prefix}perms get 984301093849028` - - To view all users and roles of a command or level permission: - - Examples: - - `{prefix}perms get command reply` - - `{prefix}perms get command plugin remove` - - `{prefix}perms get level SUPPORTER` - - To view command level overrides: - - Examples: - - `{prefix}perms get override block` - - `{prefix}perms get override permissions add` - - Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. - """ - - if name is None and user_or_role not in {"command", "level", "override"}: - value = str(self._verify_user_or_role(user_or_role)) - - cmds = [] - levels = [] - - done = set() - command_permissions = self.bot.config["command_permissions"] - level_permissions = self.bot.config["level_permissions"] - for command in self.bot.walk_commands(): - if command not in done: - done.add(command) - permissions = command_permissions.get(command.qualified_name, []) - if value in permissions: - cmds.append(command.qualified_name) - - for level in PermissionLevel: - permissions = level_permissions.get(level.name, []) - if value in permissions: - levels.append(level.name) - - mention = getattr(user_or_role, "name", getattr(user_or_role, "id", user_or_role)) - desc_cmd = ", ".join(map(lambda x: f"`{x}`", cmds)) if cmds else "No permission entries found." - desc_level = ( - ", ".join(map(lambda x: f"`{x}`", levels)) if levels else "No permission entries found." - ) - - embeds = [ - discord.Embed( - title=f"{mention} has permission with the following commands:", - description=desc_cmd, - color=self.bot.main_color, - ), - discord.Embed( - title=f"{mention} has permission with the following permission levels:", - description=desc_level, - color=self.bot.main_color, - ), - ] - else: - user_or_role = (user_or_role or "").lower() - if user_or_role == "override": - if name is None: - done = set() - - overrides = {} - for command in self.bot.walk_commands(): - if command not in done: - done.add(command) - level = self.bot.config["override_command_level"].get(command.qualified_name) - if level is not None: - overrides[command.qualified_name] = level - - embeds = [] - if not overrides: - embeds.append( - discord.Embed( - title="Permission Overrides", - description="You don't have any command level overrides at the moment.", - color=self.bot.error_color, - ) - ) - else: - for items in zip_longest(*(iter(sorted(overrides.items())),) * 15): - description = "\n".join( - ": ".join((f"`{name}`", level)) - for name, level in takewhile(lambda x: x is not None, items) - ) - embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author( - name="Permission Overrides", - icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), - ) - embeds.append(embed) - - session = EmbedPaginatorSession(ctx, *embeds) - return await session.run() - - name = name.lower() - name = getattr(self.bot.get_command(name), "qualified_name", name) - level = self.bot.config["override_command_level"].get(name) - perm = self.bot.command_perm(name) - if level is None: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The command permission level was never overridden: `{name}`, " - f"current permission level is {perm.name}.", - ) - else: - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f'Permission override for command "{name}" is "{perm.name}".', - ) - - return await ctx.send(embed=embed) - - if user_or_role not in {"command", "level"}: - return await ctx.send_help(ctx.command) - embeds = [] - if name is not None: - name = name.strip('"') - command = level = None - if user_or_role == "command": - name = name.lower() - command = self.bot.get_command(name) - check = command is not None - else: - level = self._parse_level(name) - check = level is not PermissionLevel.INVALID - - if not check: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"The referenced {user_or_role} does not exist: `{name}`.", - ) - return await ctx.send(embed=embed) - - if user_or_role == "command": - embeds.append(self._get_perm(ctx, command.qualified_name, "command")) - else: - embeds.append(self._get_perm(ctx, level.name, "level")) - else: - if user_or_role == "command": - done = set() - for command in self.bot.walk_commands(): - if command not in done: - done.add(command) - embeds.append(self._get_perm(ctx, command.qualified_name, "command")) - else: - for perm_level in PermissionLevel: - embeds.append(self._get_perm(ctx, perm_level.name, "level")) - - session = EmbedPaginatorSession(ctx, *embeds) - return await session.run() - - @commands.group(invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def oauth(self, ctx): - """ - Commands relating to logviewer oauth2 login authentication. - - This functionality on your logviewer site is a [**Patron**](https://patreon.com/kyber) only feature. - """ - await ctx.send_help(ctx.command) - - @oauth.command(name="whitelist") - @checks.has_permissions(PermissionLevel.OWNER) - async def oauth_whitelist(self, ctx, target: Union[discord.Role, utils.User]): - """ - Whitelist or un-whitelist a user or role to have access to logs. - - `target` may be a role ID, name, mention, user ID, name, or mention. - """ - whitelisted = self.bot.config["oauth_whitelist"] - - # target.id is not int?? - if target.id in whitelisted: - whitelisted.remove(target.id) - removed = True - else: - whitelisted.append(target.id) - removed = False - - await self.bot.config.update() - - embed = discord.Embed(color=self.bot.main_color) - embed.title = "Success" - - if not hasattr(target, "mention"): - target = self.bot.get_user(target.id) or self.bot.modmail_guild.get_role(target.id) - - embed.description = f"{'Un-w' if removed else 'W'}hitelisted {target.mention} to view logs." - - await ctx.send(embed=embed) - - @oauth.command(name="show", aliases=["get", "list", "view"]) - @checks.has_permissions(PermissionLevel.OWNER) - async def oauth_show(self, ctx): - """Shows a list of users and roles that are whitelisted to view logs.""" - whitelisted = self.bot.config["oauth_whitelist"] - - users = [] - roles = [] - - for id_ in whitelisted: - user = self.bot.get_user(id_) - if user: - users.append(user) - role = self.bot.modmail_guild.get_role(id_) - if role: - roles.append(role) - - embed = discord.Embed(color=self.bot.main_color) - embed.title = "Oauth Whitelist" - - embed.add_field(name="Users", value=" ".join(u.mention for u in users) or "None") - embed.add_field(name="Roles", value=" ".join(r.mention for r in roles) or "None") - - await ctx.send(embed=embed) - - @commands.group(invoke_without_command=True) - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger(self, ctx): - """Automatically trigger alias-like commands based on a certain keyword in the user's inital message""" - await ctx.send_help(ctx.command) - - @autotrigger.command(name="add") - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger_add(self, ctx, keyword, *, command): - """Adds a trigger to automatically trigger an alias-like command""" - if keyword in self.bot.auto_triggers: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Another autotrigger with the same name already exists: `{keyword}`.", - ) - else: - # command validation - valid = False - split_cmd = command.split(" ") - for n in range(1, len(split_cmd) + 1): - if self.bot.get_command(" ".join(split_cmd[0:n])): - valid = True - break - - if not valid and self.bot.aliases: - for n in range(1, len(split_cmd) + 1): - if self.bot.aliases.get(" ".join(split_cmd[0:n])): - valid = True - break - - if valid: - self.bot.auto_triggers[keyword] = command - await self.bot.config.update() - - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Keyword `{keyword}` has been linked to `{command}`.", - ) - else: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Invalid command. Please provide a valid command or alias.", - ) - - await ctx.send(embed=embed) - - @autotrigger.command(name="edit") - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger_edit(self, ctx, keyword, *, command): - """Edits a pre-existing trigger to automatically trigger an alias-like command""" - if keyword not in self.bot.auto_triggers: - embed = utils.create_not_found_embed(keyword, self.bot.auto_triggers.keys(), "Autotrigger") - else: - # command validation - valid = False - split_cmd = command.split(" ") - for n in range(1, len(split_cmd) + 1): - if self.bot.get_command(" ".join(split_cmd[0:n])): - valid = True - break - - if not valid and self.bot.aliases: - for n in range(1, len(split_cmd) + 1): - if self.bot.aliases.get(" ".join(split_cmd[0:n])): - valid = True - break - - if valid: - self.bot.auto_triggers[keyword] = command - await self.bot.config.update() - - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Keyword `{keyword}` has been linked to `{command}`.", - ) - else: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Invalid command. Please provide a valid command or alias.", - ) - - await ctx.send(embed=embed) - - @autotrigger.command(name="remove") - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger_remove(self, ctx, keyword): - """Removes a trigger to automatically trigger an alias-like command""" - try: - del self.bot.auto_triggers[keyword] - except KeyError: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"Keyword `{keyword}` could not be found.", - ) - await ctx.send(embed=embed) - else: - await self.bot.config.update() - - embed = discord.Embed( - title="Success", - color=self.bot.main_color, - description=f"Keyword `{keyword}` has been removed.", - ) - await ctx.send(embed=embed) - - @autotrigger.command(name="test") - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger_test(self, ctx, *, text): - """Tests a string against the current autotrigger setup""" - for keyword in self.bot.auto_triggers: - if self.bot.config.get("use_regex_autotrigger"): - check = re.search(keyword, text) - regex = True - else: - check = keyword.lower() in text.lower() - regex = False - - if check: - alias = self.bot.auto_triggers[keyword] - embed = discord.Embed( - title=f"{'Regex ' if regex else ''}Keyword Found", - color=self.bot.main_color, - description=f"autotrigger keyword `{keyword}` found. Command executed: `{alias}`", - ) - return await ctx.send(embed=embed) - - embed = discord.Embed( - title="Keyword Not Found", - color=self.bot.error_color, - description="No autotrigger keyword found.", - ) - return await ctx.send(embed=embed) - - @autotrigger.command(name="list") - @checks.has_permissions(PermissionLevel.OWNER) - async def autotrigger_list(self, ctx): - """Lists all autotriggers set up""" - embeds = [] - for keyword in self.bot.auto_triggers: - command = self.bot.auto_triggers[keyword] - embed = discord.Embed( - title=keyword, - color=self.bot.main_color, - description=command, - ) - embeds.append(embed) - - if not embeds: - embeds.append( - discord.Embed( - title="No autotrigger set", - color=self.bot.error_color, - description=f"Use `{self.bot.prefix}autotrigger add` to add new autotriggers.", - ) - ) - - await EmbedPaginatorSession(ctx, *embeds).run() - - @commands.command() - @checks.has_permissions(PermissionLevel.OWNER) - @checks.github_token_required() - @trigger_typing - async def github(self, ctx): - """Shows the GitHub user your Github_Token is linked to.""" - data = await self.bot.api.get_user_info() - - if data: - embed = discord.Embed(title="GitHub", description="Current User", color=self.bot.main_color) - user = data["user"] - embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) - embed.set_thumbnail(url=user["avatar_url"]) - await ctx.send(embed=embed) - else: - await ctx.send(embed=discord.Embed(title="Invalid Github Token", color=self.bot.error_color)) - - @commands.command() - @checks.has_permissions(PermissionLevel.OWNER) - @checks.github_token_required(ignore_if_not_heroku=True) - @checks.updates_enabled() - @trigger_typing - async def update(self, ctx, *, flag: str = ""): - """ - Update Modmail. - To stay up-to-date with the latest commit from GitHub, specify "force" as the flag. - """ - - changelog = await Changelog.from_url(self.bot) - latest = changelog.latest_version - - desc = ( - f"The latest version is [`{self.bot.version}`]" - "(https://github.com/modmail-dev/modmail/blob/master/bot.py#L1)" - ) - - if self.bot.version >= Version(latest.version) and flag.lower() != "force": - embed = discord.Embed(title="Already up to date", description=desc, color=self.bot.main_color) - - data = await self.bot.api.get_user_info() - if data: - user = data["user"] - embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) - await ctx.send(embed=embed) - else: - error = None - data = {} - try: - # update fork if gh_token exists - data = await self.bot.api.update_repository() - except InvalidConfigError: - pass - except ClientResponseError as exc: - error = exc - - if self.bot.hosting_method == HostingMethod.HEROKU: - if error is not None: - embed = discord.Embed( - title="Update failed", - description=f"Error status: {error.status}.\nError message: {error.message}", - color=self.bot.error_color, - ) - return await ctx.send(embed=embed) - if not data: - # invalid gh_token - embed = discord.Embed( - title="Update failed", - description="Invalid Github token.", - color=self.bot.error_color, - ) - return await ctx.send(embed=embed) - - commit_data = data["data"] - user = data["user"] - if commit_data and commit_data.get("html_url"): - embed = discord.Embed(color=self.bot.main_color) - - embed.set_footer(text=f"Updating Modmail v{self.bot.version} -> v{latest.version}") - - embed.set_author( - name=user["username"] + " - Updating bot", - icon_url=user["avatar_url"], - url=user["url"], - ) - - embed.description = latest.description - for name, value in latest.fields.items(): - embed.add_field(name=name, value=truncate(value, 200)) - - html_url = commit_data["html_url"] - short_sha = commit_data["sha"][:6] - embed.add_field(name="Merge Commit", value=f"[`{short_sha}`]({html_url})") - else: - embed = discord.Embed( - title="Already up to date", - description="No further updates required.", - color=self.bot.main_color, - ) - embed.set_footer(text="Force update") - embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) - await ctx.send(embed=embed) - else: - command = "git pull" - proc = await asyncio.create_subprocess_shell( - command, - stderr=PIPE, - stdout=PIPE, - ) - err = await proc.stderr.read() - err = err.decode("utf-8").rstrip() - res = await proc.stdout.read() - res = res.decode("utf-8").rstrip() - - if err and not res: - embed = discord.Embed(title="Update failed", description=err, color=self.bot.error_color) - await ctx.send(embed=embed) - - elif res != "Already up to date.": - logger.info("Bot has been updated.") - - embed = discord.Embed( - title="Bot has been updated", - color=self.bot.main_color, - ) - embed.set_footer(text=f"Updating Modmail v{self.bot.version} " f"-> v{latest.version}") - embed.description = latest.description - for name, value in latest.fields.items(): - embed.add_field(name=name, value=truncate(value, 200)) - - if self.bot.hosting_method == HostingMethod.OTHER: - embed.description = ( - "If you do not have an auto-restart setup, please manually start the bot.", - ) - - await ctx.send(embed=embed) - return await self.bot.close() - else: - embed = discord.Embed( - title="Already up to date", - description=desc, - color=self.bot.main_color, - ) - embed.set_footer(text="Force update") - await ctx.send(embed=embed) - - @commands.command(hidden=True, name="eval") - @checks.has_permissions(PermissionLevel.OWNER) - async def eval_(self, ctx, *, body: str): - """Evaluates Python code.""" - - logger.warning("Running eval command:\n%s", body) - - env = { - "ctx": ctx, - "bot": self.bot, - "channel": ctx.channel, - "author": ctx.author, - "guild": ctx.guild, - "message": ctx.message, - "source": inspect.getsource, - "discord": __import__("discord"), - } - - env.update(globals()) - - body = utils.cleanup_code(body) - stdout = StringIO() - - to_compile = f'async def func():\n{indent(body, " ")}' - - def paginate(text: str): - """Simple generator that paginates text.""" - last = 0 - pages = [] - appd_index = curr = None - for curr in range(0, len(text)): - if curr % 1980 == 0: - pages.append(text[last:curr]) - last = curr - appd_index = curr - if appd_index != len(text) - 1: - pages.append(text[last:curr]) - return list(filter(lambda a: a != "", pages)) - - try: - exec(to_compile, env) # pylint: disable=exec-used - except Exception as exc: - await ctx.send(f"```py\n{exc.__class__.__name__}: {exc}\n```") - return await self.bot.add_reaction(ctx.message, "\u2049") - - func = env["func"] - try: - with redirect_stdout(stdout): - ret = await func() - except Exception: - value = stdout.getvalue() - await ctx.send(f"```py\n{value}{traceback.format_exc()}\n```") - return await self.bot.add_reaction(ctx.message, "\u2049") - - else: - value = stdout.getvalue() - if ret is None: - if value: - try: - await ctx.send(f"```py\n{value}\n```") - except Exception: - paginated_text = paginate(value) - for page in paginated_text: - if page == paginated_text[-1]: - await ctx.send(f"```py\n{page}\n```") - break - await ctx.send(f"```py\n{page}\n```") - else: - try: - await ctx.send(f"```py\n{value}{ret}\n```") - except Exception: - paginated_text = paginate(f"{value}{ret}") - for page in paginated_text: - if page == paginated_text[-1]: - await ctx.send(f"```py\n{page}\n```") - break - await ctx.send(f"```py\n{page}\n```") - - await self.bot.add_reaction(ctx.message, "\u2705") - - -async def setup(bot): - await bot.add_cog(Utility(bot)) +import asyncio +import inspect +import os +import random +import re +import traceback +from contextlib import redirect_stdout +from difflib import get_close_matches +from io import BytesIO, StringIO +from itertools import takewhile, zip_longest +from json import JSONDecodeError, loads +from subprocess import PIPE +from textwrap import indent +from typing import Union + +import discord +from discord.enums import ActivityType, Status +from discord.ext import commands, tasks +from discord.ext.commands.view import StringView + +from aiohttp import ClientResponseError +from packaging.version import Version + +from core import checks, utils +from core.changelog import Changelog +from core.models import ( + HostingMethod, + InvalidConfigError, + PermissionLevel, + UnseenFormatter, + getLogger, +) +from core.utils import trigger_typing, truncate, DummyParam +from core.paginator import EmbedPaginatorSession, MessagePaginatorSession + + +logger = getLogger(__name__) + + +class ModmailHelpCommand(commands.HelpCommand): + async def command_callback(self, ctx, *, command=None): + """Overwrites original command_callback to ensure `help` without any arguments + returns with checks, `help all` returns without checks""" + if command is None: + self.verify_checks = True + else: + self.verify_checks = False + + if command == "all": + command = None + + return await super().command_callback(ctx, command=command) + + async def format_cog_help(self, cog, *, no_cog=False): + bot = self.context.bot + prefix = self.context.clean_prefix + + formats = [""] + for cmd in await self.filter_commands( + cog.get_commands() if not no_cog else cog, + sort=True, + key=lambda c: (bot.command_perm(c.qualified_name), c.qualified_name), + ): + perm_level = bot.command_perm(cmd.qualified_name) + if perm_level is PermissionLevel.INVALID: + format_ = f"`{prefix + cmd.qualified_name}` " + else: + format_ = f"`[{perm_level}] {prefix + cmd.qualified_name}` " + + format_ += f"- {cmd.short_doc}\n" if cmd.short_doc else "- *No description.*\n" + if not format_.strip(): + continue + if len(format_) + len(formats[-1]) >= 1024: + formats.append(format_) + else: + formats[-1] += format_ + + embeds = [] + for format_ in formats: + description = ( + cog.description or "No description." + if not no_cog + else "Miscellaneous commands without a category." + ) + embed = discord.Embed(description=f"*{description}*", color=bot.main_color) + + if not format_: + continue + + embed.add_field(name="Commands", value=format_ or "No commands.") + + name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" + embed.set_author( + name=name, icon_url=bot.user.display_avatar.url if bot.user.display_avatar else None + ) + + embed.set_footer( + text=f'Type "{prefix}{self.command_attrs["name"]} command" ' + "for more info on a specific command." + ) + embeds.append(embed) + + if len(embeds) > 1: + for n, em in enumerate(embeds): + em.set_author(name=f"{em.author.name} [{n + 1}]", icon_url=em.author.icon_url) + + return embeds + + def process_help_msg(self, help_: str): + return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." + + async def send_bot_help(self, mapping): + embeds = [] + no_cog_commands = sorted(mapping.pop(None), key=lambda c: c.qualified_name) + cogs = sorted(mapping, key=lambda c: c.qualified_name) + + bot = self.context.bot + + # always come first + default_cogs = [bot.get_cog("Modmail"), bot.get_cog("Utility"), bot.get_cog("Plugins")] + + default_cogs.extend(c for c in cogs if c not in default_cogs) + + for cog in default_cogs: + embeds.extend(await self.format_cog_help(cog)) + if no_cog_commands: + embeds.extend(await self.format_cog_help(no_cog_commands, no_cog=True)) + + session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination()) + return await session.run() + + async def send_cog_help(self, cog): + embeds = await self.format_cog_help(cog) + session = EmbedPaginatorSession(self.context, *embeds, destination=self.get_destination()) + return await session.run() + + async def _get_help_embed(self, topic): + if not await self.filter_commands([topic]): + return + perm_level = self.context.bot.command_perm(topic.qualified_name) + if perm_level is not PermissionLevel.INVALID: + perm_level = f"{perm_level.name} [{perm_level}]" + else: + perm_level = "NONE" + + embed = discord.Embed( + title=f"`{self.get_command_signature(topic).strip()}`", + color=self.context.bot.main_color, + description=self.process_help_msg(topic.help), + ) + return embed, perm_level + + async def send_command_help(self, command): + topic = await self._get_help_embed(command) + if topic is not None: + topic[0].set_footer(text=f"Permission level: {topic[1]}") + await self.get_destination().send(embed=topic[0]) + + async def send_group_help(self, group): + topic = await self._get_help_embed(group) + if topic is None: + return + embed = topic[0] + embed.add_field(name="Permission Level", value=topic[1], inline=False) + + format_ = "" + length = len(group.commands) + + for i, command in enumerate( + await self.filter_commands(group.commands, sort=True, key=lambda c: c.name) + ): + # BUG: fmt may run over the embed limit + # TODO: paginate this + if length == i + 1: # last + branch = "└─" + else: + branch = "├─" + format_ += f"`{branch} {command.name}` - {command.short_doc}\n" + + embed.add_field(name="Sub Command(s)", value=format_[:1024], inline=False) + embed.set_footer( + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} command" ' + "for more info on a command." + ) + + await self.get_destination().send(embed=embed) + + async def send_error_message(self, error): + command = self.context.kwargs.get("command") + val = self.context.bot.snippets.get(command) + if val is not None: + embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) + embed.add_field(name=f"`{command}` will send:", value=val, inline=False) + + snippet_aliases = [] + for alias in self.context.bot.aliases: + if self.context.bot._resolve_snippet(alias) == command: + snippet_aliases.append(f"`{alias}`") + + if snippet_aliases: + embed.add_field( + name="Aliases to this snippet:", value=",".join(snippet_aliases), inline=False + ) + + return await self.get_destination().send(embed=embed) + + val = self.context.bot.aliases.get(command) + if val is not None: + values = utils.parse_alias(val) + + if not values: + embed = discord.Embed( + title="Error", + color=self.context.bot.error_color, + description=f"Alias `{command}` is invalid, this alias will now be deleted." + "This alias will now be deleted.", + ) + embed.add_field(name=f"{command}` used to be:", value=val) + self.context.bot.aliases.pop(command) + await self.context.bot.config.update() + else: + if len(values) == 1: + embed = discord.Embed(title=f"{command} is an alias.", color=self.context.bot.main_color) + embed.add_field(name=f"`{command}` points to:", value=values[0]) + else: + embed = discord.Embed( + title=f"{command} is an alias.", + color=self.context.bot.main_color, + description=f"**`{command}` points to the following steps:**", + ) + for i, val in enumerate(values, start=1): + embed.add_field(name=f"Step {i}:", value=val) + + embed.set_footer( + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} alias" ' + "for more details on aliases." + ) + return await self.get_destination().send(embed=embed) + + logger.warning("CommandNotFound: %s", error) + + embed = discord.Embed(color=self.context.bot.error_color) + embed.set_footer(text=f'Command/Category "{command}" not found.') + + choices = set() + + for cmd in self.context.bot.walk_commands(): + if not cmd.hidden: + choices.add(cmd.qualified_name) + + closest = get_close_matches(command, choices) + if closest: + embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)) + else: + embed.title = "Cannot find command or category" + embed.set_footer( + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]}" ' + "for a list of all available commands." + ) + await self.get_destination().send(embed=embed) + + +class Utility(commands.Cog): + """General commands that provide utility.""" + + def __init__(self, bot): + self.bot = bot + self._original_help_command = bot.help_command + self.bot.help_command = ModmailHelpCommand( + command_attrs={ + "help": "Shows this help message.", + "checks": [checks.has_permissions_predicate(PermissionLevel.REGULAR)], + }, + ) + self.bot.help_command.cog = self + if not self.bot.config.get("enable_eval"): + self.eval_.enabled = False + logger.info("Eval disabled. enable_eval=False") + + async def cog_load(self): + self.loop_presence.start() # pylint: disable=no-member + + def cog_unload(self): + self.bot.help_command = self._original_help_command + + @commands.command() + @checks.has_permissions(PermissionLevel.REGULAR) + @utils.trigger_typing + async def changelog(self, ctx, version: str.lower = ""): + """Shows the changelog of the Modmail.""" + changelog = await Changelog.from_url(self.bot) + version = version.lstrip("v") if version else changelog.latest_version.version + + try: + index = [v.version for v in changelog.versions].index(version) + except ValueError: + return await ctx.send( + embed=discord.Embed( + color=self.bot.error_color, + description=f"The specified version `{version}` could not be found.", + ) + ) + + paginator = EmbedPaginatorSession(ctx, *changelog.embeds) + try: + paginator.current = index + await paginator.run() + except asyncio.CancelledError: + pass + except Exception: + try: + await paginator.close() + finally: + logger.warning("Failed to display changelog.", exc_info=True) + await ctx.send( + f"View the changelog here: {changelog.latest_version.changelog_url}#v{version[::2]}" + ) + + @commands.command(aliases=["info"]) + @checks.has_permissions(PermissionLevel.REGULAR) + @utils.trigger_typing + async def about(self, ctx): + """Shows information about this bot.""" + embed = discord.Embed(color=self.bot.main_color, timestamp=discord.utils.utcnow()) + embed.set_author( + name="Modmail - About", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + url="https://discord.gg/F34cRU8", + ) + embed.set_thumbnail(url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None) + + desc = "This is an open source Discord bot that serves as a means for " + desc += "members to easily communicate with server administrators in " + desc += "an organised manner." + embed.description = desc + + embed.add_field(name="Uptime", value=self.bot.uptime) + embed.add_field(name="Latency", value=f"{self.bot.latency * 1000:.2f} ms") + embed.add_field(name="Version", value=f"`{self.bot.version}`") + embed.add_field(name="Authors", value="`kyb3r`, `Taki`, `fourjr`") + embed.add_field(name="Hosting Method", value=self.bot.hosting_method.name) + + changelog = await Changelog.from_url(self.bot) + latest = changelog.latest_version + + if self.bot.version.is_prerelease: + stable = next(filter(lambda v: not Version(v.version).is_prerelease, changelog.versions)) + footer = f"You are on the prerelease version • the latest version is v{stable.version}." + elif self.bot.version < Version(latest.version): + footer = f"A newer version is available v{latest.version}." + else: + footer = "You are up to date with the latest version." + + embed.add_field( + name="Want Modmail in Your Server?", + value="Follow the installation guide on [GitHub](https://github.com/modmail-dev/modmail/) " + "and join our [Discord server](https://discord.gg/cnUpwrnpYb)!", + inline=False, + ) + + embed.add_field( + name="Support the Developers", + value="This bot is completely free for everyone. We rely on kind individuals " + "like you to support us on [`Patreon`](https://patreon.com/kyber) (perks included) " + "to keep this bot free forever!", + inline=False, + ) + + embed.add_field( + name="Project Sponsors", + value=f"Checkout the people who supported Modmail with command `{self.bot.prefix}sponsors`!", + inline=False, + ) + + embed.set_footer(text=footer) + await ctx.send(embed=embed) + + @commands.command(aliases=["sponsor"]) + @checks.has_permissions(PermissionLevel.REGULAR) + @utils.trigger_typing + async def sponsors(self, ctx): + """Shows the sponsors of this project.""" + + async with self.bot.session.get( + "https://raw.githubusercontent.com/modmail-dev/modmail/master/SPONSORS.json" + ) as resp: + data = loads(await resp.text()) + + embeds = [] + + for elem in data: + embed = discord.Embed.from_dict(elem["embed"]) + embeds.append(embed) + + random.shuffle(embeds) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + @utils.trigger_typing + async def debug(self, ctx): + """Shows the recent application logs of the bot.""" + + with open(self.bot.log_file_path, "r+", encoding="utf-8") as f: + logs = f.read().strip() + + if not logs: + embed = discord.Embed( + color=self.bot.main_color, + title="Debug Logs:", + description="You don't have any logs at the moment.", + ) + embed.set_footer(text="Go to your console to see your logs.") + return await ctx.send(embed=embed) + + messages = [] + + # Using Haskell formatting because it's similar to Python for exceptions + # and it does a fine job formatting the logs. + msg = "```Haskell\n" + + for line in logs.splitlines(keepends=True): + if msg != "```Haskell\n": + if len(line) + len(msg) + 3 > 2000: + msg += "```" + messages.append(msg) + msg = "```Haskell\n" + msg += line + if len(msg) + 3 > 2000: + msg = msg[:1992] + "[...]```" + messages.append(msg) + msg = "```Haskell\n" + + if msg != "```Haskell\n": + msg += "```" + messages.append(msg) + + embed = discord.Embed(color=self.bot.main_color) + embed.set_footer(text="Debug logs - Navigate using the reactions below.") + + session = MessagePaginatorSession(ctx, *messages, embed=embed) + session.current = len(messages) - 1 + return await session.run() + + @debug.command(name="hastebin", aliases=["haste"]) + @checks.has_permissions(PermissionLevel.OWNER) + @utils.trigger_typing + async def debug_hastebin(self, ctx): + """Posts application-logs to Hastebin.""" + + haste_url = os.environ.get("HASTE_URL", "https://hastebin.cc") + + with open(self.bot.log_file_path, "rb+") as f: + logs = BytesIO(f.read().strip()) + + try: + async with self.bot.session.post(haste_url + "/documents", data=logs) as resp: + data = await resp.json() + try: + key = data["key"] + except KeyError: + logger.error(data["message"]) + raise + embed = discord.Embed( + title="Debug Logs", + color=self.bot.main_color, + description=f"{haste_url}/" + key, + ) + except (JSONDecodeError, ClientResponseError, IndexError, KeyError): + embed = discord.Embed( + title="Debug Logs", + color=self.bot.main_color, + description="Something's wrong. We're unable to upload your logs to hastebin.", + ) + embed.set_footer(text="Go to your console to see your logs.") + await ctx.send(embed=embed) + + @debug.command(name="clear", aliases=["wipe"]) + @checks.has_permissions(PermissionLevel.OWNER) + @utils.trigger_typing + async def debug_clear(self, ctx): + """Clears the locally cached logs.""" + + with open(self.bot.log_file_path, "w"): + pass + await ctx.send( + embed=discord.Embed(color=self.bot.main_color, description="Cached logs are now cleared.") + ) + + @commands.command(aliases=["presence"]) + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): + """ + Set an activity status for the bot. + + Possible activity types: + - `playing` + - `streaming` + - `listening` + - `watching` + - `competing` + - `custom` + + When activity type is set to `listening`, + it must be followed by a "to": "listening to..." + + When activity type is set to `competing`, + it must be followed by a "in": "competing in..." + + When activity type is set to `streaming`, you can set + the linked twitch page: + - `{prefix}config set twitch_url https://www.twitch.tv/somechannel/` + + When activity type is set to `custom`, you can set + any custom text as the activity message. + + To remove the current activity status: + - `{prefix}activity clear` + """ + if activity_type == "clear": + self.bot.config.remove("activity_type") + self.bot.config.remove("activity_message") + await self.bot.config.update() + await self.set_presence() + embed = discord.Embed(title="Activity Removed", color=self.bot.main_color) + return await ctx.send(embed=embed) + + if not message: + raise commands.MissingRequiredArgument(DummyParam("message")) + + try: + activity_type = ActivityType[activity_type] + except KeyError: + raise commands.MissingRequiredArgument(DummyParam("activity")) + + activity, _ = await self.set_presence(activity_type=activity_type, activity_message=message) + + self.bot.config["activity_type"] = activity.type.value + self.bot.config["activity_message"] = activity.name + await self.bot.config.update() + + msg = f"Activity set to: {activity.type.name.capitalize()} " + if activity.type == ActivityType.listening: + msg += f"to {activity.name}." + elif activity.type == ActivityType.competing: + msg += f"in {activity.name}." + else: + msg += f"{activity.name}." + + embed = discord.Embed(title="Activity Changed", description=msg, color=self.bot.main_color) + return await ctx.send(embed=embed) + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def status(self, ctx, *, status_type: str.lower): + """ + Set a status for the bot. + + Possible status types: + - `online` + - `idle` + - `dnd` or `do not disturb` + - `invisible` or `offline` + + To remove the current status: + - `{prefix}status clear` + """ + if status_type == "clear": + self.bot.config.remove("status") + await self.bot.config.update() + await self.set_presence() + embed = discord.Embed(title="Status Removed", color=self.bot.main_color) + return await ctx.send(embed=embed) + + status_type = status_type.replace(" ", "_") + try: + status = Status[status_type] + except KeyError: + raise commands.MissingRequiredArgument(DummyParam("status")) + + _, status = await self.set_presence(status=status) + + self.bot.config["status"] = status.value + await self.bot.config.update() + + msg = f"Status set to: {status.value}." + embed = discord.Embed(title="Status Changed", description=msg, color=self.bot.main_color) + return await ctx.send(embed=embed) + + async def set_presence(self, *, status=None, activity_type=None, activity_message=None): + if status is None: + status = self.bot.config.get("status") + + if activity_type is None: + activity_type = self.bot.config.get("activity_type") + + url = None + activity_message = (activity_message or self.bot.config["activity_message"]).strip() + if activity_type is not None and not activity_message: + logger.warning('No activity message found whilst activity is provided, defaults to "Modmail".') + activity_message = "Modmail" + + if activity_type == ActivityType.listening: + if activity_message.lower().startswith("to "): + # The actual message is after listening to [...] + # discord automatically add the "to" + activity_message = activity_message[3:].strip() + elif activity_type == ActivityType.competing: + if activity_message.lower().startswith("in "): + # The actual message is after listening to [...] + # discord automatically add the "in" + activity_message = activity_message[3:].strip() + elif activity_type == ActivityType.streaming: + url = self.bot.config["twitch_url"] + + if activity_type == ActivityType.custom: + activity = discord.CustomActivity(name=activity_message) + elif activity_type is not None: + activity = discord.Activity(type=activity_type, name=activity_message, url=url) + else: + activity = None + await self.bot.change_presence(activity=activity, status=status) + + return activity, status + + @tasks.loop(minutes=30) + async def loop_presence(self): + """Set presence to the configured value every 30 minutes.""" + logger.debug("Resetting presence.") + await self.set_presence() + + @loop_presence.before_loop + async def before_loop_presence(self): + await self.bot.wait_for_connected() + logger.line() + activity, status = await self.set_presence() + + if activity is not None: + msg = f"Activity set to: {activity.type.name.capitalize()} " + if activity.type == ActivityType.listening: + msg += f"to {activity.name}." + else: + msg += f"{activity.name}." + logger.info(msg) + else: + logger.info("No activity has been set.") + if status is not None: + msg = f"Status set to: {status.value}." + logger.info(msg) + else: + logger.info("No status has been set.") + + await asyncio.sleep(1800) + logger.info("Starting presence loop.") + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @utils.trigger_typing + async def ping(self, ctx): + """Pong! Returns your websocket latency.""" + embed = discord.Embed( + title="Pong! Websocket Latency:", + description=f"{self.bot.ws.latency * 1000:.4f} ms", + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def mention(self, ctx, *user_or_role: Union[discord.Role, discord.Member, str]): + """ + Change what the bot mentions at the start of each thread. + + `user_or_role` may be a user ID, mention, name, role ID, mention, or name. + You can also set it to mention multiple users or roles, just separate the arguments with space. + + Examples: + - `{prefix}mention @user` + - `{prefix}mention @user @role` + - `{prefix}mention 984301093849028 388218663326449` + - `{prefix}mention everyone` + + Do not ping `@everyone` to set mention to everyone, use "everyone" or "all" instead. + + Notes: + - Type only `{prefix}mention` to retrieve your current "mention" message. + - `{prefix}mention disable` to disable mention. + - `{prefix}mention reset` to reset it to default value, which is "@here". + """ + current = self.bot.config["mention"] + if not user_or_role: + embed = discord.Embed( + title="Current mention:", color=self.bot.main_color, description=str(current) + ) + elif ( + len(user_or_role) == 1 + and isinstance(user_or_role[0], str) + and user_or_role[0].lower() in ("disable", "reset") + ): + option = user_or_role[0].lower() + if option == "disable": + embed = discord.Embed( + description="Disabled mention on thread creation.", + color=self.bot.main_color, + ) + self.bot.config["mention"] = None + else: + embed = discord.Embed( + description="`mention` is reset to default.", + color=self.bot.main_color, + ) + self.bot.config.remove("mention") + await self.bot.config.update() + else: + mention = [] + everyone = ("all", "everyone") + for m in user_or_role: + if not isinstance(m, (discord.Role, discord.Member)) and m not in everyone: + raise commands.BadArgument(f'Role or Member "{m}" not found.') + elif m == ctx.guild.default_role or m in everyone: + mention.append("@everyone") + continue + mention.append(m.mention) + + mention = " ".join(mention) + embed = discord.Embed( + title="Changed mention!", + description=f'On thread creation the bot now says "{mention}".', + color=self.bot.main_color, + ) + self.bot.config["mention"] = mention + await self.bot.config.update() + + return await ctx.send(embed=embed) + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def prefix(self, ctx, *, prefix=None): + """ + Change the prefix of the bot. + + Type only `{prefix}prefix` to retrieve your current bot prefix. + """ + + current = self.bot.prefix + embed = discord.Embed(title="Current prefix", color=self.bot.main_color, description=f"{current}") + + if prefix is None: + await ctx.send(embed=embed) + else: + embed.title = "Changed prefix!" + embed.description = f"Set prefix to `{prefix}`" + self.bot.config["prefix"] = prefix + await self.bot.config.update() + await ctx.send(embed=embed) + + @commands.group(aliases=["configuration"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def config(self, ctx): + """ + Modify changeable configuration variables for this bot. + + Type `{prefix}config options` to view a list + of valid configuration variables. + + Type `{prefix}config help config-name` for info + on a config. + + To set a configuration variable: + - `{prefix}config set config-name value here` + + To remove a configuration variable: + - `{prefix}config remove config-name` + """ + await ctx.send_help(ctx.command) + + @config.command(name="options", aliases=["list"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def config_options(self, ctx): + """Return a list of valid configuration names you can change.""" + embeds = [] + for names in zip_longest(*(iter(sorted(self.bot.config.public_keys)),) * 15): + description = "\n".join(f"`{name}`" for name in takewhile(lambda x: x is not None, names)) + embed = discord.Embed( + title="Available configuration keys:", + color=self.bot.main_color, + description=description, + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + + @config.command(name="set", aliases=["add"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def config_set(self, ctx, key: str.lower, *, value: str): + """Set a configuration variable and its value.""" + + keys = self.bot.config.public_keys + + if key in keys: + try: + await self.bot.config.set(key, value) + await self.bot.config.update() + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Set `{key}` to `{self.bot.config[key]}`.", + ) + except InvalidConfigError as exc: + embed = exc.embed + else: + embed = discord.Embed( + title="Error", color=self.bot.error_color, description=f"{key} is an invalid key." + ) + valid_keys = [f"`{k}`" for k in sorted(keys)] + embed.add_field(name="Valid keys", value=truncate(", ".join(valid_keys), 1024)) + + return await ctx.send(embed=embed) + + @config.command(name="remove", aliases=["del", "delete"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def config_remove(self, ctx, *, key: str.lower): + """Delete a set configuration variable.""" + keys = self.bot.config.public_keys + if key in keys: + self.bot.config.remove(key) + await self.bot.config.update() + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"`{key}` had been reset to default.", + ) + else: + embed = discord.Embed( + title="Error", color=self.bot.error_color, description=f"{key} is an invalid key." + ) + valid_keys = [f"`{k}`" for k in sorted(keys)] + embed.add_field(name="Valid keys", value=", ".join(valid_keys)) + + return await ctx.send(embed=embed) + + @config.command(name="get") + @checks.has_permissions(PermissionLevel.OWNER) + async def config_get(self, ctx, *, key: str.lower = None): + """ + Show the configuration variables that are currently set. + + Leave `key` empty to show all currently set configuration variables. + """ + keys = self.bot.config.public_keys + + if key: + if key in keys: + desc = f"`{key}` is set to `{self.bot.config[key]}`" + embed = discord.Embed(color=self.bot.main_color, description=desc) + embed.set_author( + name="Config variable", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + ) + + else: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"`{key}` is an invalid key.", + ) + embed.set_footer( + text=f'Type "{self.bot.prefix}config options" for a list of config variables.' + ) + + else: + embed = discord.Embed( + color=self.bot.main_color, + description="Here is a list of currently set configuration variable(s).", + ) + embed.set_author( + name="Current config(s):", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + ) + config = self.bot.config.filter_default(self.bot.config) + + for name, value in config.items(): + if name in self.bot.config.public_keys: + embed.add_field(name=name, value=f"`{value}`", inline=False) + + return await ctx.send(embed=embed) + + @config.command(name="help", aliases=["info"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def config_help(self, ctx, key: str.lower = None): + """ + Show information on a specified configuration. + """ + if key is not None and not ( + key in self.bot.config.public_keys or key in self.bot.config.protected_keys + ): + closest = get_close_matches( + key, {**self.bot.config.public_keys, **self.bot.config.protected_keys} + ) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"`{key}` is an invalid key.", + ) + if closest: + embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)) + return await ctx.send(embed=embed) + + config_help = self.bot.config.config_help + + if key is not None and key not in config_help: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"No help details found for `{key}`.", + ) + return await ctx.send(embed=embed) + + def fmt(val): + return UnseenFormatter().format(val, prefix=self.bot.prefix, bot=self.bot) + + index = 0 + embeds = [] + for i, (current_key, info) in enumerate(config_help.items()): + if current_key == key: + index = i + embed = discord.Embed(title=f"{current_key}", color=self.bot.main_color) + embed.add_field(name="Default:", value=fmt(info["default"]), inline=False) + embed.add_field(name="Information:", value=fmt(info["description"]), inline=False) + if info["examples"]: + example_text = "" + for example in info["examples"]: + example_text += f"- {fmt(example)}\n" + embed.add_field(name="Example(s):", value=example_text, inline=False) + + note_text = "" + for note in info.get("notes", []): + note_text += f"- {fmt(note)}\n" + if note_text: + embed.add_field(name="Note(s):", value=note_text, inline=False) + + if info.get("image") is not None: + embed.set_image(url=fmt(info["image"])) + + if info.get("thumbnail") is not None: + embed.set_thumbnail(url=fmt(info["thumbnail"])) + embeds += [embed] + + paginator = EmbedPaginatorSession(ctx, *embeds) + paginator.current = index + await paginator.run() + + @commands.group(aliases=["aliases"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias(self, ctx, *, name: str.lower = None): + """ + Create shortcuts to bot commands. + + When `{prefix}alias` is used by itself, this will retrieve + a list of alias that are currently set. `{prefix}alias-name` will show what the + alias point to. + + To use alias: + + First create an alias using: + - `{prefix}alias add alias-name other-command` + + For example: + - `{prefix}alias add r reply` + - Now you can use `{prefix}r` as an replacement for `{prefix}reply`. + + See also `{prefix}snippet`. + """ + + if name is not None: + val = self.bot.aliases.get(name) + if val is None: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + + values = utils.parse_alias(val) + + if not values: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Alias `{name}` is invalid, this alias will now be deleted." + "This alias will now be deleted.", + ) + embed.add_field(name=f"{name}` used to be:", value=utils.truncate(val, 1024)) + self.bot.aliases.pop(name) + await self.bot.config.update() + return await ctx.send(embed=embed) + + if len(values) == 1: + embed = discord.Embed( + title=f'Alias - "{name}":', description=values[0], color=self.bot.main_color + ) + return await ctx.send(embed=embed) + + else: + embeds = [] + for i, val in enumerate(values, start=1): + embed = discord.Embed( + color=self.bot.main_color, + title=f'Alias - "{name}" - Step {i}:', + description=val, + ) + embeds += [embed] + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() + + if not self.bot.aliases: + embed = discord.Embed( + color=self.bot.error_color, description="You dont have any aliases at the moment." + ) + embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.') + embed.set_author(name="Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) + return await ctx.send(embed=embed) + + embeds = [] + + for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): + description = utils.format_description(i, names) + embed = discord.Embed(color=self.bot.main_color, description=description) + embed.set_author( + name="Command Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128) + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + + @alias.command(name="raw") + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_raw(self, ctx, *, name: str.lower): + """ + View the raw content of an alias. + """ + val = self.bot.aliases.get(name) + if val is None: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + + val = utils.truncate(utils.escape_code_block(val), 2048 - 7) + embed = discord.Embed( + title=f'Raw alias - "{name}":', description=f"```\n{val}```", color=self.bot.main_color + ) + + return await ctx.send(embed=embed) + + async def make_alias(self, name, value, action): + values = utils.parse_alias(value) + if not values: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Invalid multi-step alias, try wrapping each steps in quotes.", + ) + embed.set_footer(text=f'See "{self.bot.prefix}alias add" for more details.') + return embed + + if len(values) > 25: + embed = discord.Embed( + title="Error", description="Too many steps, max=25.", color=self.bot.error_color + ) + return embed + + save_aliases = [] + + multiple_alias = len(values) > 1 + + embed = discord.Embed(title=f"{action} alias", color=self.bot.main_color) + + if not multiple_alias: + embed.add_field(name=f"`{name}` points to:", value=utils.truncate(values[0], 1024)) + else: + embed.description = f"`{name}` now points to the following steps:" + + for i, val in enumerate(values, start=1): + view = StringView(val) + linked_command = view.get_word().lower() + message = view.read_rest() + + is_snippet = val in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_snippet: + alias_command = self.bot.aliases.get(linked_command) + if alias_command is not None: + save_aliases.extend(utils.normalize_alias(alias_command, message)) + else: + embed = discord.Embed(title="Error", color=self.bot.error_color) + + if multiple_alias: + embed.description = ( + "The command you are attempting to point " + f"to does not exist: `{linked_command}`." + ) + else: + embed.description = ( + "The command you are attempting to point " + f"to on step {i} does not exist: `{linked_command}`." + ) + + return embed + else: + save_aliases.append(val) + if multiple_alias: + embed.add_field(name=f"Step {i}:", value=utils.truncate(val, 1024)) + + self.bot.aliases[name] = " && ".join(f'"{a}"' for a in save_aliases) + await self.bot.config.update() + return embed + + @alias.command(name="add", aliases=["create", "make"]) + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_add(self, ctx, name: str.lower, *, value): + """ + Add an alias. + + Alias also supports multi-step aliases, to create a multi-step alias use quotes + to wrap each step and separate each step with `&&`. For example: + + - `{prefix}alias add movenreply "move admin-category" && "reply Thanks for reaching out to the admins"` + + However, if you run into problems, try wrapping the command with quotes. For example: + + - This will fail: `{prefix}alias add reply You'll need to type && to work` + - Correct method: `{prefix}alias add reply "You'll need to type && to work"` + """ + embed = None + if self.bot.get_command(name): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{name}`.", + ) + + elif name in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Another alias with the same name already exists: `{name}`.", + ) + + elif name in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A snippet with the same name already exists: `{name}`.", + ) + + elif len(name) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Alias names cannot be longer than 120 characters.", + ) + + if embed is None: + embed = await self.make_alias(name, value, "Added") + return await ctx.send(embed=embed) + + @alias.command(name="remove", aliases=["del", "delete"]) + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_remove(self, ctx, *, name: str.lower): + """Remove an alias.""" + + if name in self.bot.aliases: + self.bot.aliases.pop(name) + await self.bot.config.update() + + embed = discord.Embed( + title="Removed alias", + color=self.bot.main_color, + description=f"Successfully deleted `{name}`.", + ) + else: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + + return await ctx.send(embed=embed) + + @alias.command(name="edit") + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_edit(self, ctx, name: str.lower, *, value): + """ + Edit an alias. + """ + if name not in self.bot.aliases: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + + embed = await self.make_alias(name, value, "Edited") + return await ctx.send(embed=embed) + + @commands.group(aliases=["perms"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def permissions(self, ctx): + """ + Set the permissions for Modmail commands. + + You may set permissions based on individual command names, or permission + levels. + + Acceptable permission levels are: + - **Owner** [5] (absolute control over the bot) + - **Administrator** [4] (administrative powers such as setting activities) + - **Moderator** [3] (ability to block) + - **Supporter** [2] (access to core Modmail supporting functions) + - **Regular** [1] (most basic interactions such as help and about) + + By default, owner is set to the absolute bot owner and regular is `@everyone`. + + To set permissions, see `{prefix}help permissions add`; and to change permission level for specific + commands see `{prefix}help permissions override`. + + Note: You will still have to manually give/take permission to the Modmail + category to users/roles. + """ + await ctx.send_help(ctx.command) + + @staticmethod + def _verify_user_or_role(user_or_role): + if isinstance(user_or_role, discord.Role): + if user_or_role.is_default(): + return -1 + elif user_or_role in {"everyone", "all"}: + return -1 + if hasattr(user_or_role, "id"): + return user_or_role.id + raise commands.BadArgument(f'User or Role "{user_or_role}" not found') + + @staticmethod + def _parse_level(name): + name = name.upper() + try: + return PermissionLevel[name] + except KeyError: + pass + transform = { + "1": PermissionLevel.REGULAR, + "2": PermissionLevel.SUPPORTER, + "3": PermissionLevel.MODERATOR, + "4": PermissionLevel.ADMINISTRATOR, + "5": PermissionLevel.OWNER, + } + return transform.get(name, PermissionLevel.INVALID) + + @permissions.command(name="override") + @checks.has_permissions(PermissionLevel.OWNER) + async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str): + """ + Change a permission level for a specific command. + + Examples: + - `{prefix}perms override reply administrator` + - `{prefix}perms override "plugin enabled" moderator` + + To undo a permission override, see `{prefix}help permissions remove`. + + Example: + - `{prefix}perms remove override reply` + - `{prefix}perms remove override plugin enabled` + + You can retrieve a single or all command level override(s), see`{prefix}help permissions get`. + """ + + command = self.bot.get_command(command_name) + if command is None: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The referenced command does not exist: `{command_name}`.", + ) + return await ctx.send(embed=embed) + + level = self._parse_level(level_name) + if level is PermissionLevel.INVALID: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The referenced level does not exist: `{level_name}`.", + ) + else: + logger.info( + "Updated command permission level for `%s` to `%s`.", + command.qualified_name, + level.name, + ) + self.bot.config["override_command_level"][command.qualified_name] = level.name + + await self.bot.config.update() + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description="Successfully set command permission level for " + f"`{command.qualified_name}` to `{level.name}`.", + ) + return await ctx.send(embed=embed) + + @permissions.command(name="add", usage="[command/level] [name] [user/role]") + @checks.has_permissions(PermissionLevel.OWNER) + async def permissions_add( + self, + ctx, + type_: str.lower, + name: str, + *, + user_or_role: Union[discord.Role, utils.User, str], + ): + """ + Add a permission to a command or a permission level. + + For sub commands, wrap the complete command name with quotes. + To find a list of permission levels, see `{prefix}help perms`. + + Examples: + - `{prefix}perms add level REGULAR everyone` + - `{prefix}perms add command reply @user` + - `{prefix}perms add command "plugin enabled" @role` + - `{prefix}perms add command help 984301093849028` + + Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. + """ + + if type_ not in {"command", "level"}: + return await ctx.send_help(ctx.command) + + command = level = None + if type_ == "command": + name = name.lower() + command = self.bot.get_command(name) + check = command is not None + else: + level = self._parse_level(name) + check = level is not PermissionLevel.INVALID + + if not check: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The referenced {type_} does not exist: `{name}`.", + ) + return await ctx.send(embed=embed) + + value = self._verify_user_or_role(user_or_role) + if type_ == "command": + name = command.qualified_name + await self.bot.update_perms(name, value) + else: + await self.bot.update_perms(level, value) + name = level.name + if level > PermissionLevel.REGULAR: + if value == -1: + key = self.bot.modmail_guild.default_role + elif isinstance(user_or_role, discord.Role): + key = user_or_role + else: + key = self.bot.modmail_guild.get_member(value) + if key is not None: + logger.info("Granting %s access to Modmail category.", key.name) + await self.bot.main_category.set_permissions(key, read_messages=True) + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Permission for `{name}` was successfully updated.", + ) + return await ctx.send(embed=embed) + + @permissions.command( + name="remove", + aliases=["del", "delete", "revoke"], + usage="[command/level] [name] [user/role] or [override] [command name]", + ) + @checks.has_permissions(PermissionLevel.OWNER) + async def permissions_remove( + self, + ctx, + type_: str.lower, + name: str, + *, + user_or_role: Union[discord.Role, utils.User, str] = None, + ): + """ + Remove permission to use a command, permission level, or command level override. + + For sub commands, wrap the complete command name with quotes. + To find a list of permission levels, see `{prefix}help perms`. + + Examples: + - `{prefix}perms remove level REGULAR everyone` + - `{prefix}perms remove command reply @user` + - `{prefix}perms remove command "plugin enabled" @role` + - `{prefix}perms remove command help 984301093849028` + - `{prefix}perms remove override block` + - `{prefix}perms remove override "snippet add"` + + Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. + """ + if type_ not in {"command", "level", "override"} or (type_ != "override" and user_or_role is None): + return await ctx.send_help(ctx.command) + + if type_ == "override": + extension = ctx.kwargs["user_or_role"] + if extension is not None: + name += f" {extension}" + name = name.lower() + name = getattr(self.bot.get_command(name), "qualified_name", name) + level = self.bot.config["override_command_level"].get(name) + if level is None: + perm = self.bot.command_perm(name) + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The command permission level was never overridden: `{name}`, " + f"current permission level is {perm.name}.", + ) + else: + logger.info("Restored command permission level for `%s`.", name) + self.bot.config["override_command_level"].pop(name) + await self.bot.config.update() + perm = self.bot.command_perm(name) + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Command permission level for `{name}` was successfully restored to {perm.name}.", + ) + return await ctx.send(embed=embed) + + level = None + if type_ == "command": + name = name.lower() + name = getattr(self.bot.get_command(name), "qualified_name", name) + else: + level = self._parse_level(name) + if level is PermissionLevel.INVALID: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The referenced level does not exist: `{name}`.", + ) + return await ctx.send(embed=embed) + name = level.name + + value = self._verify_user_or_role(user_or_role) + await self.bot.update_perms(level or name, value, add=False) + + if type_ == "level": + if level > PermissionLevel.REGULAR: + if value == -1: + logger.info("Denying @everyone access to Modmail category.") + await self.bot.main_category.set_permissions( + self.bot.modmail_guild.default_role, read_messages=False + ) + elif isinstance(user_or_role, discord.Role): + logger.info("Denying %s access to Modmail category.", user_or_role.name) + await self.bot.main_category.set_permissions(user_or_role, overwrite=None) + else: + member = self.bot.modmail_guild.get_member(value) + if member is not None and member != self.bot.modmail_guild.me: + logger.info("Denying %s access to Modmail category.", member.name) + await self.bot.main_category.set_permissions(member, overwrite=None) + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Permission for `{name}` was successfully updated.", + ) + return await ctx.send(embed=embed) + + def _get_perm(self, ctx, name, type_): + if type_ == "command": + permissions = self.bot.config["command_permissions"].get(name, []) + else: + permissions = self.bot.config["level_permissions"].get(name, []) + if not permissions: + embed = discord.Embed( + title=f"Permission entries for {type_} `{name}`:", + description="No permission entries found.", + color=self.bot.main_color, + ) + else: + values = [] + for perm in permissions: + if perm == -1: + values.insert(0, "**everyone**") + continue + member = ctx.guild.get_member(int(perm)) + if member is not None: + values.append(member.mention) + continue + user = self.bot.get_user(int(perm)) + if user is not None: + values.append(user.mention) + continue + role = ctx.guild.get_role(int(perm)) + if role is not None: + values.append(role.mention) + else: + values.append(str(perm)) + + embed = discord.Embed( + title=f"Permission entries for {type_} `{name}`:", + description=", ".join(values), + color=self.bot.main_color, + ) + return embed + + @permissions.command(name="get", usage="[@user] or [command/level/override] [name]") + @checks.has_permissions(PermissionLevel.OWNER) + async def permissions_get( + self, ctx, user_or_role: Union[discord.Role, utils.User, str], *, name: str = None + ): + """ + View the currently-set permissions. + + To find a list of permission levels, see `{prefix}help perms`. + + To view all command and level permissions: + + Examples: + - `{prefix}perms get @user` + - `{prefix}perms get 984301093849028` + + To view all users and roles of a command or level permission: + + Examples: + - `{prefix}perms get command reply` + - `{prefix}perms get command plugin remove` + - `{prefix}perms get level SUPPORTER` + + To view command level overrides: + + Examples: + - `{prefix}perms get override block` + - `{prefix}perms get override permissions add` + + Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. + """ + + if name is None and user_or_role not in {"command", "level", "override"}: + value = str(self._verify_user_or_role(user_or_role)) + + cmds = [] + levels = [] + + done = set() + command_permissions = self.bot.config["command_permissions"] + level_permissions = self.bot.config["level_permissions"] + for command in self.bot.walk_commands(): + if command not in done: + done.add(command) + permissions = command_permissions.get(command.qualified_name, []) + if value in permissions: + cmds.append(command.qualified_name) + + for level in PermissionLevel: + permissions = level_permissions.get(level.name, []) + if value in permissions: + levels.append(level.name) + + mention = getattr(user_or_role, "name", getattr(user_or_role, "id", user_or_role)) + desc_cmd = ", ".join(map(lambda x: f"`{x}`", cmds)) if cmds else "No permission entries found." + desc_level = ( + ", ".join(map(lambda x: f"`{x}`", levels)) if levels else "No permission entries found." + ) + + embeds = [ + discord.Embed( + title=f"{mention} has permission with the following commands:", + description=desc_cmd, + color=self.bot.main_color, + ), + discord.Embed( + title=f"{mention} has permission with the following permission levels:", + description=desc_level, + color=self.bot.main_color, + ), + ] + else: + user_or_role = (user_or_role or "").lower() + if user_or_role == "override": + if name is None: + done = set() + + overrides = {} + for command in self.bot.walk_commands(): + if command not in done: + done.add(command) + level = self.bot.config["override_command_level"].get(command.qualified_name) + if level is not None: + overrides[command.qualified_name] = level + + embeds = [] + if not overrides: + embeds.append( + discord.Embed( + title="Permission Overrides", + description="You don't have any command level overrides at the moment.", + color=self.bot.error_color, + ) + ) + else: + for items in zip_longest(*(iter(sorted(overrides.items())),) * 15): + description = "\n".join( + ": ".join((f"`{name}`", level)) + for name, level in takewhile(lambda x: x is not None, items) + ) + embed = discord.Embed(color=self.bot.main_color, description=description) + embed.set_author( + name="Permission Overrides", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() + + name = name.lower() + name = getattr(self.bot.get_command(name), "qualified_name", name) + level = self.bot.config["override_command_level"].get(name) + perm = self.bot.command_perm(name) + if level is None: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The command permission level was never overridden: `{name}`, " + f"current permission level is {perm.name}.", + ) + else: + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f'Permission override for command "{name}" is "{perm.name}".', + ) + + return await ctx.send(embed=embed) + + if user_or_role not in {"command", "level"}: + return await ctx.send_help(ctx.command) + embeds = [] + if name is not None: + name = name.strip('"') + command = level = None + if user_or_role == "command": + name = name.lower() + command = self.bot.get_command(name) + check = command is not None + else: + level = self._parse_level(name) + check = level is not PermissionLevel.INVALID + + if not check: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"The referenced {user_or_role} does not exist: `{name}`.", + ) + return await ctx.send(embed=embed) + + if user_or_role == "command": + embeds.append(self._get_perm(ctx, command.qualified_name, "command")) + else: + embeds.append(self._get_perm(ctx, level.name, "level")) + else: + if user_or_role == "command": + done = set() + for command in self.bot.walk_commands(): + if command not in done: + done.add(command) + embeds.append(self._get_perm(ctx, command.qualified_name, "command")) + else: + for perm_level in PermissionLevel: + embeds.append(self._get_perm(ctx, perm_level.name, "level")) + + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def oauth(self, ctx): + """ + Commands relating to logviewer oauth2 login authentication. + + This functionality on your logviewer site is a [**Patron**](https://patreon.com/kyber) only feature. + """ + await ctx.send_help(ctx.command) + + @oauth.command(name="whitelist") + @checks.has_permissions(PermissionLevel.OWNER) + async def oauth_whitelist(self, ctx, target: Union[discord.Role, utils.User]): + """ + Whitelist or un-whitelist a user or role to have access to logs. + + `target` may be a role ID, name, mention, user ID, name, or mention. + """ + whitelisted = self.bot.config["oauth_whitelist"] + + # target.id is not int?? + if target.id in whitelisted: + whitelisted.remove(target.id) + removed = True + else: + whitelisted.append(target.id) + removed = False + + await self.bot.config.update() + + embed = discord.Embed(color=self.bot.main_color) + embed.title = "Success" + + if not hasattr(target, "mention"): + target = self.bot.get_user(target.id) or self.bot.modmail_guild.get_role(target.id) + + embed.description = f"{'Un-w' if removed else 'W'}hitelisted {target.mention} to view logs." + + await ctx.send(embed=embed) + + @oauth.command(name="show", aliases=["get", "list", "view"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def oauth_show(self, ctx): + """Shows a list of users and roles that are whitelisted to view logs.""" + whitelisted = self.bot.config["oauth_whitelist"] + + users = [] + roles = [] + + for id_ in whitelisted: + user = self.bot.get_user(id_) + if user: + users.append(user) + role = self.bot.modmail_guild.get_role(id_) + if role: + roles.append(role) + + embed = discord.Embed(color=self.bot.main_color) + embed.title = "Oauth Whitelist" + + embed.add_field(name="Users", value=" ".join(u.mention for u in users) or "None") + embed.add_field(name="Roles", value=" ".join(r.mention for r in roles) or "None") + + await ctx.send(embed=embed) + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger(self, ctx): + """Automatically trigger alias-like commands based on a certain keyword in the user's inital message""" + await ctx.send_help(ctx.command) + + @autotrigger.command(name="add") + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger_add(self, ctx, keyword, *, command): + """Adds a trigger to automatically trigger an alias-like command""" + if keyword in self.bot.auto_triggers: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Another autotrigger with the same name already exists: `{keyword}`.", + ) + else: + # command validation + valid = False + split_cmd = command.split(" ") + for n in range(1, len(split_cmd) + 1): + if self.bot.get_command(" ".join(split_cmd[0:n])): + valid = True + break + + if not valid and self.bot.aliases: + for n in range(1, len(split_cmd) + 1): + if self.bot.aliases.get(" ".join(split_cmd[0:n])): + valid = True + break + + if valid: + self.bot.auto_triggers[keyword] = command + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Keyword `{keyword}` has been linked to `{command}`.", + ) + else: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Invalid command. Please provide a valid command or alias.", + ) + + await ctx.send(embed=embed) + + @autotrigger.command(name="edit") + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger_edit(self, ctx, keyword, *, command): + """Edits a pre-existing trigger to automatically trigger an alias-like command""" + if keyword not in self.bot.auto_triggers: + embed = utils.create_not_found_embed(keyword, self.bot.auto_triggers.keys(), "Autotrigger") + else: + # command validation + valid = False + split_cmd = command.split(" ") + for n in range(1, len(split_cmd) + 1): + if self.bot.get_command(" ".join(split_cmd[0:n])): + valid = True + break + + if not valid and self.bot.aliases: + for n in range(1, len(split_cmd) + 1): + if self.bot.aliases.get(" ".join(split_cmd[0:n])): + valid = True + break + + if valid: + self.bot.auto_triggers[keyword] = command + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Keyword `{keyword}` has been linked to `{command}`.", + ) + else: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Invalid command. Please provide a valid command or alias.", + ) + + await ctx.send(embed=embed) + + @autotrigger.command(name="remove") + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger_remove(self, ctx, keyword): + """Removes a trigger to automatically trigger an alias-like command""" + try: + del self.bot.auto_triggers[keyword] + except KeyError: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Keyword `{keyword}` could not be found.", + ) + await ctx.send(embed=embed) + else: + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"Keyword `{keyword}` has been removed.", + ) + await ctx.send(embed=embed) + + @autotrigger.command(name="test") + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger_test(self, ctx, *, text): + """Tests a string against the current autotrigger setup""" + for keyword in self.bot.auto_triggers: + if self.bot.config.get("use_regex_autotrigger"): + check = re.search(keyword, text) + regex = True + else: + check = keyword.lower() in text.lower() + regex = False + + if check: + alias = self.bot.auto_triggers[keyword] + embed = discord.Embed( + title=f"{'Regex ' if regex else ''}Keyword Found", + color=self.bot.main_color, + description=f"autotrigger keyword `{keyword}` found. Command executed: `{alias}`", + ) + return await ctx.send(embed=embed) + + embed = discord.Embed( + title="Keyword Not Found", + color=self.bot.error_color, + description="No autotrigger keyword found.", + ) + return await ctx.send(embed=embed) + + @autotrigger.command(name="list") + @checks.has_permissions(PermissionLevel.OWNER) + async def autotrigger_list(self, ctx): + """Lists all autotriggers set up""" + embeds = [] + for keyword in self.bot.auto_triggers: + command = self.bot.auto_triggers[keyword] + embed = discord.Embed( + title=keyword, + color=self.bot.main_color, + description=command, + ) + embeds.append(embed) + + if not embeds: + embeds.append( + discord.Embed( + title="No autotrigger set", + color=self.bot.error_color, + description=f"Use `{self.bot.prefix}autotrigger add` to add new autotriggers.", + ) + ) + + await EmbedPaginatorSession(ctx, *embeds).run() + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + @checks.github_token_required() + @trigger_typing + async def github(self, ctx): + """Shows the GitHub user your Github_Token is linked to.""" + data = await self.bot.api.get_user_info() + + if data: + embed = discord.Embed(title="GitHub", description="Current User", color=self.bot.main_color) + user = data["user"] + embed.set_author( + name=user["username"], + icon_url=user["avatar_url"] if user["avatar_url"] else None, + url=user["url"], + ) + embed.set_thumbnail(url=user["avatar_url"] if user["avatar_url"] else None) + await ctx.send(embed=embed) + else: + await ctx.send(embed=discord.Embed(title="Invalid Github Token", color=self.bot.error_color)) + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + @checks.github_token_required(ignore_if_not_heroku=True) + @checks.updates_enabled() + @trigger_typing + async def update(self, ctx, *, flag: str = ""): + """ + Update Modmail. + To stay up-to-date with the latest commit from GitHub, specify "force" as the flag. + """ + + changelog = await Changelog.from_url(self.bot) + latest = changelog.latest_version + + desc = ( + f"The latest version is [`{self.bot.version}`]" + "(https://github.com/modmail-dev/modmail/blob/master/bot.py#L1)" + ) + + if self.bot.version >= Version(latest.version) and flag.lower() != "force": + embed = discord.Embed(title="Already up to date", description=desc, color=self.bot.main_color) + + data = await self.bot.api.get_user_info() + if data: + user = data["user"] + embed.set_author( + name=user["username"], + icon_url=user["avatar_url"] if user["avatar_url"] else None, + url=user["url"], + ) + await ctx.send(embed=embed) + else: + error = None + data = {} + try: + # update fork if gh_token exists + data = await self.bot.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + + if self.bot.hosting_method == HostingMethod.HEROKU: + if error is not None: + embed = discord.Embed( + title="Update failed", + description=f"Error status: {error.status}.\nError message: {error.message}", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + if not data: + # invalid gh_token + embed = discord.Embed( + title="Update failed", + description="Invalid Github token.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + commit_data = data["data"] + user = data["user"] + if commit_data and commit_data.get("html_url"): + embed = discord.Embed(color=self.bot.main_color) + + embed.set_footer(text=f"Updating Modmail v{self.bot.version} -> v{latest.version}") + + embed.set_author( + name=user["username"] + " - Updating bot", + icon_url=user["avatar_url"] if user["avatar_url"] else None, + url=user["url"], + ) + + embed.description = latest.description + for name, value in latest.fields.items(): + embed.add_field(name=name, value=truncate(value, 200)) + + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] + embed.add_field(name="Merge Commit", value=f"[`{short_sha}`]({html_url})") + else: + embed = discord.Embed( + title="Already up to date", + description="No further updates required.", + color=self.bot.main_color, + ) + embed.set_footer(text="Force update") + embed.set_author( + name=user["username"], + icon_url=user["avatar_url"] if user["avatar_url"] else None, + url=user["url"], + ) + await ctx.send(embed=embed) + else: + command = "git pull" + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) + err = await proc.stderr.read() + err = err.decode("utf-8").rstrip() + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if err and not res: + embed = discord.Embed(title="Update failed", description=err, color=self.bot.error_color) + await ctx.send(embed=embed) + + elif res != "Already up to date.": + logger.info("Bot has been updated.") + + embed = discord.Embed( + title="Bot has been updated", + color=self.bot.main_color, + ) + embed.set_footer(text=f"Updating Modmail v{self.bot.version} " f"-> v{latest.version}") + embed.description = latest.description + for name, value in latest.fields.items(): + embed.add_field(name=name, value=truncate(value, 200)) + + if self.bot.hosting_method == HostingMethod.OTHER: + embed.description = ( + "If you do not have an auto-restart setup, please manually start the bot.", + ) + + await ctx.send(embed=embed) + return await self.bot.close() + else: + embed = discord.Embed( + title="Already up to date", + description=desc, + color=self.bot.main_color, + ) + embed.set_footer(text="Force update") + await ctx.send(embed=embed) + + @commands.command(hidden=True, name="eval") + @checks.has_permissions(PermissionLevel.OWNER) + async def eval_(self, ctx, *, body: str): + """Evaluates Python code.""" + + logger.warning("Running eval command:\n%s", body) + + env = { + "ctx": ctx, + "bot": self.bot, + "channel": ctx.channel, + "author": ctx.author, + "guild": ctx.guild, + "message": ctx.message, + "source": inspect.getsource, + "discord": __import__("discord"), + } + + env.update(globals()) + + body = utils.cleanup_code(body) + stdout = StringIO() + + to_compile = f'async def func():\n{indent(body, " ")}' + + def paginate(text: str): + """Simple generator that paginates text.""" + last = 0 + pages = [] + appd_index = curr = None + for curr in range(0, len(text)): + if curr % 1980 == 0: + pages.append(text[last:curr]) + last = curr + appd_index = curr + if appd_index != len(text) - 1: + pages.append(text[last:curr]) + return list(filter(lambda a: a != "", pages)) + + try: + exec(to_compile, env) # pylint: disable=exec-used + except Exception as exc: + await ctx.send(f"```py\n{exc.__class__.__name__}: {exc}\n```") + return await self.bot.add_reaction(ctx.message, "\u2049") + + func = env["func"] + try: + with redirect_stdout(stdout): + ret = await func() + except Exception: + value = stdout.getvalue() + await ctx.send(f"```py\n{value}{traceback.format_exc()}\n```") + return await self.bot.add_reaction(ctx.message, "\u2049") + + else: + value = stdout.getvalue() + if ret is None: + if value: + try: + await ctx.send(f"```py\n{value}\n```") + except Exception: + paginated_text = paginate(value) + for page in paginated_text: + if page == paginated_text[-1]: + await ctx.send(f"```py\n{page}\n```") + break + await ctx.send(f"```py\n{page}\n```") + else: + try: + await ctx.send(f"```py\n{value}{ret}\n```") + except Exception: + paginated_text = paginate(f"{value}{ret}") + for page in paginated_text: + if page == paginated_text[-1]: + await ctx.send(f"```py\n{page}\n```") + break + await ctx.send(f"```py\n{page}\n```") + + await self.bot.add_reaction(ctx.message, "\u2705") + + +async def setup(bot): + await bot.add_cog(Utility(bot)) diff --git a/core/_color_data.py b/core/_color_data.py index 13ec45620e..a1c652782e 100644 --- a/core/_color_data.py +++ b/core/_color_data.py @@ -1,1193 +1,1193 @@ -""" -Retrieved from matplotlib colors library. -Slightly modified to conform with usage. -""" - -BASE_COLORS = { - "b": "0000ff", - "g": "007f00", - "r": "ff0000", - "c": "00bfbf", - "m": "bf00bf", - "y": "bfbf00", - "k": "000000", - "w": "ffffff", -} - -# Discord native colors -DISCORD_COLORS = { - "default": "000000", - "teal": "1abc9c", - "dark teal": "11806a", - "green": "2ecc71", - "dark green": "1f8b4c", - "blue": "3498db", - "dark blue": "206694", - "purple": "9b59b6", - "dark purple": "71368a", - "magenta": "e91e63", - "dark magenta": "ad1457", - "gold": "f1c40f", - "dark gold": "c27c0e", - "orange": "e67e22", - "dark orange": "a84300", - "red": "e74c3c", - "dark red": "992d22", - "lighter gray": "95a5a6", - "darker gray": "546e7a", - "light gray": "979c9f", - "dark gray": "607d8b", - "blurple": "7289da", - "grayple": "99aab5", -} - -# Normalize name to "discord:" to avoid name collisions. -DISCORD_COLORS_NORM = {"discord:" + name: value for name, value in DISCORD_COLORS.items()} - - -# These colors are from Tableau -TABLEAU_COLORS = { - "blue": "1f77b4", - "orange": "ff7f0e", - "green": "2ca02c", - "red": "d62728", - "purple": "9467bd", - "brown": "8c564b", - "pink": "e377c2", - "gray": "7f7f7f", - "olive": "bcbd22", - "cyan": "17becf", -} - -# Normalize name to "tab:" to avoid name collisions. -TABLEAU_COLORS_NORM = {"tab:" + name: value for name, value in TABLEAU_COLORS.items()} - - -# This mapping of color names -> hex values is taken from -# a survey run by Randel Monroe see: -# http://blog.xkcd.com/2010/05/03/color-survey-results/ -# for more details. The results are hosted at -# https://xkcd.com/color/rgb.txt -# -# License: http://creativecommons.org/publicdomain/zero/1.0/ -XKCD_COLORS = { - "cloudy blue": "acc2d9", - "dark pastel green": "56ae57", - "dust": "b2996e", - "electric lime": "a8ff04", - "fresh green": "69d84f", - "light eggplant": "894585", - "nasty green": "70b23f", - "really light blue": "d4ffff", - "tea": "65ab7c", - "warm purple": "952e8f", - "yellowish tan": "fcfc81", - "cement": "a5a391", - "dark grass green": "388004", - "dusty teal": "4c9085", - "grey teal": "5e9b8a", - "macaroni and cheese": "efb435", - "pinkish tan": "d99b82", - "spruce": "0a5f38", - "strong blue": "0c06f7", - "toxic green": "61de2a", - "windows blue": "3778bf", - "blue blue": "2242c7", - "blue with a hint of purple": "533cc6", - "booger": "9bb53c", - "bright sea green": "05ffa6", - "dark green blue": "1f6357", - "deep turquoise": "017374", - "green teal": "0cb577", - "strong pink": "ff0789", - "bland": "afa88b", - "deep aqua": "08787f", - "lavender pink": "dd85d7", - "light moss green": "a6c875", - "light seafoam green": "a7ffb5", - "olive yellow": "c2b709", - "pig pink": "e78ea5", - "deep lilac": "966ebd", - "desert": "ccad60", - "dusty lavender": "ac86a8", - "purpley grey": "947e94", - "purply": "983fb2", - "candy pink": "ff63e9", - "light pastel green": "b2fba5", - "boring green": "63b365", - "kiwi green": "8ee53f", - "light grey green": "b7e1a1", - "orange pink": "ff6f52", - "tea green": "bdf8a3", - "very light brown": "d3b683", - "egg shell": "fffcc4", - "eggplant purple": "430541", - "powder pink": "ffb2d0", - "reddish grey": "997570", - "baby shit brown": "ad900d", - "liliac": "c48efd", - "stormy blue": "507b9c", - "ugly brown": "7d7103", - "custard": "fffd78", - "darkish pink": "da467d", - "deep brown": "410200", - "greenish beige": "c9d179", - "manilla": "fffa86", - "off blue": "5684ae", - "battleship grey": "6b7c85", - "browny green": "6f6c0a", - "bruise": "7e4071", - "kelley green": "009337", - "sickly yellow": "d0e429", - "sunny yellow": "fff917", - "azul": "1d5dec", - "darkgreen": "054907", - "green/yellow": "b5ce08", - "lichen": "8fb67b", - "light light green": "c8ffb0", - "pale gold": "fdde6c", - "sun yellow": "ffdf22", - "tan green": "a9be70", - "burple": "6832e3", - "butterscotch": "fdb147", - "toupe": "c7ac7d", - "dark cream": "fff39a", - "indian red": "850e04", - "light lavendar": "efc0fe", - "poison green": "40fd14", - "baby puke green": "b6c406", - "bright yellow green": "9dff00", - "charcoal grey": "3c4142", - "squash": "f2ab15", - "cinnamon": "ac4f06", - "light pea green": "c4fe82", - "radioactive green": "2cfa1f", - "raw sienna": "9a6200", - "baby purple": "ca9bf7", - "cocoa": "875f42", - "light royal blue": "3a2efe", - "orangeish": "fd8d49", - "rust brown": "8b3103", - "sand brown": "cba560", - "swamp": "698339", - "tealish green": "0cdc73", - "burnt siena": "b75203", - "camo": "7f8f4e", - "dusk blue": "26538d", - "fern": "63a950", - "old rose": "c87f89", - "pale light green": "b1fc99", - "peachy pink": "ff9a8a", - "rosy pink": "f6688e", - "light bluish green": "76fda8", - "light bright green": "53fe5c", - "light neon green": "4efd54", - "light seafoam": "a0febf", - "tiffany blue": "7bf2da", - "washed out green": "bcf5a6", - "browny orange": "ca6b02", - "nice blue": "107ab0", - "sapphire": "2138ab", - "greyish teal": "719f91", - "orangey yellow": "fdb915", - "parchment": "fefcaf", - "straw": "fcf679", - "very dark brown": "1d0200", - "terracota": "cb6843", - "ugly blue": "31668a", - "clear blue": "247afd", - "creme": "ffffb6", - "foam green": "90fda9", - "grey/green": "86a17d", - "light gold": "fddc5c", - "seafoam blue": "78d1b6", - "topaz": "13bbaf", - "violet pink": "fb5ffc", - "wintergreen": "20f986", - "yellow tan": "ffe36e", - "dark fuchsia": "9d0759", - "indigo blue": "3a18b1", - "light yellowish green": "c2ff89", - "pale magenta": "d767ad", - "rich purple": "720058", - "sunflower yellow": "ffda03", - "green/blue": "01c08d", - "leather": "ac7434", - "racing green": "014600", - "vivid purple": "9900fa", - "dark royal blue": "02066f", - "hazel": "8e7618", - "muted pink": "d1768f", - "booger green": "96b403", - "canary": "fdff63", - "cool grey": "95a3a6", - "dark taupe": "7f684e", - "darkish purple": "751973", - "true green": "089404", - "coral pink": "ff6163", - "dark sage": "598556", - "dark slate blue": "214761", - "flat blue": "3c73a8", - "mushroom": "ba9e88", - "rich blue": "021bf9", - "dirty purple": "734a65", - "greenblue": "23c48b", - "icky green": "8fae22", - "light khaki": "e6f2a2", - "warm blue": "4b57db", - "dark hot pink": "d90166", - "deep sea blue": "015482", - "carmine": "9d0216", - "dark yellow green": "728f02", - "pale peach": "ffe5ad", - "plum purple": "4e0550", - "golden rod": "f9bc08", - "neon red": "ff073a", - "old pink": "c77986", - "very pale blue": "d6fffe", - "blood orange": "fe4b03", - "grapefruit": "fd5956", - "sand yellow": "fce166", - "clay brown": "b2713d", - "dark blue grey": "1f3b4d", - "flat green": "699d4c", - "light green blue": "56fca2", - "warm pink": "fb5581", - "dodger blue": "3e82fc", - "gross green": "a0bf16", - "ice": "d6fffa", - "metallic blue": "4f738e", - "pale salmon": "ffb19a", - "sap green": "5c8b15", - "algae": "54ac68", - "bluey grey": "89a0b0", - "greeny grey": "7ea07a", - "highlighter green": "1bfc06", - "light light blue": "cafffb", - "light mint": "b6ffbb", - "raw umber": "a75e09", - "vivid blue": "152eff", - "deep lavender": "8d5eb7", - "dull teal": "5f9e8f", - "light greenish blue": "63f7b4", - "mud green": "606602", - "pinky": "fc86aa", - "red wine": "8c0034", - "shit green": "758000", - "tan brown": "ab7e4c", - "darkblue": "030764", - "rosa": "fe86a4", - "lipstick": "d5174e", - "pale mauve": "fed0fc", - "claret": "680018", - "dandelion": "fedf08", - "orangered": "fe420f", - "poop green": "6f7c00", - "ruby": "ca0147", - "dark": "1b2431", - "greenish turquoise": "00fbb0", - "pastel red": "db5856", - "piss yellow": "ddd618", - "bright cyan": "41fdfe", - "dark coral": "cf524e", - "algae green": "21c36f", - "darkish red": "a90308", - "reddy brown": "6e1005", - "blush pink": "fe828c", - "camouflage green": "4b6113", - "lawn green": "4da409", - "putty": "beae8a", - "vibrant blue": "0339f8", - "dark sand": "a88f59", - "purple/blue": "5d21d0", - "saffron": "feb209", - "twilight": "4e518b", - "warm brown": "964e02", - "bluegrey": "85a3b2", - "bubble gum pink": "ff69af", - "duck egg blue": "c3fbf4", - "greenish cyan": "2afeb7", - "petrol": "005f6a", - "royal": "0c1793", - "butter": "ffff81", - "dusty orange": "f0833a", - "off yellow": "f1f33f", - "pale olive green": "b1d27b", - "orangish": "fc824a", - "leaf": "71aa34", - "light blue grey": "b7c9e2", - "dried blood": "4b0101", - "lightish purple": "a552e6", - "rusty red": "af2f0d", - "lavender blue": "8b88f8", - "light grass green": "9af764", - "light mint green": "a6fbb2", - "sunflower": "ffc512", - "velvet": "750851", - "brick orange": "c14a09", - "lightish red": "fe2f4a", - "pure blue": "0203e2", - "twilight blue": "0a437a", - "violet red": "a50055", - "yellowy brown": "ae8b0c", - "carnation": "fd798f", - "muddy yellow": "bfac05", - "dark seafoam green": "3eaf76", - "deep rose": "c74767", - "dusty red": "b9484e", - "grey/blue": "647d8e", - "lemon lime": "bffe28", - "purple/pink": "d725de", - "brown yellow": "b29705", - "purple brown": "673a3f", - "wisteria": "a87dc2", - "banana yellow": "fafe4b", - "lipstick red": "c0022f", - "water blue": "0e87cc", - "brown grey": "8d8468", - "vibrant purple": "ad03de", - "baby green": "8cff9e", - "barf green": "94ac02", - "eggshell blue": "c4fff7", - "sandy yellow": "fdee73", - "cool green": "33b864", - "pale": "fff9d0", - "blue/grey": "758da3", - "hot magenta": "f504c9", - "greyblue": "77a1b5", - "purpley": "8756e4", - "baby shit green": "889717", - "brownish pink": "c27e79", - "dark aquamarine": "017371", - "diarrhea": "9f8303", - "light mustard": "f7d560", - "pale sky blue": "bdf6fe", - "turtle green": "75b84f", - "bright olive": "9cbb04", - "dark grey blue": "29465b", - "greeny brown": "696006", - "lemon green": "adf802", - "light periwinkle": "c1c6fc", - "seaweed green": "35ad6b", - "sunshine yellow": "fffd37", - "ugly purple": "a442a0", - "medium pink": "f36196", - "puke brown": "947706", - "very light pink": "fff4f2", - "viridian": "1e9167", - "bile": "b5c306", - "faded yellow": "feff7f", - "very pale green": "cffdbc", - "vibrant green": "0add08", - "bright lime": "87fd05", - "spearmint": "1ef876", - "light aquamarine": "7bfdc7", - "light sage": "bcecac", - "yellowgreen": "bbf90f", - "baby poo": "ab9004", - "dark seafoam": "1fb57a", - "deep teal": "00555a", - "heather": "a484ac", - "rust orange": "c45508", - "dirty blue": "3f829d", - "fern green": "548d44", - "bright lilac": "c95efb", - "weird green": "3ae57f", - "peacock blue": "016795", - "avocado green": "87a922", - "faded orange": "f0944d", - "grape purple": "5d1451", - "hot green": "25ff29", - "lime yellow": "d0fe1d", - "mango": "ffa62b", - "shamrock": "01b44c", - "bubblegum": "ff6cb5", - "purplish brown": "6b4247", - "vomit yellow": "c7c10c", - "pale cyan": "b7fffa", - "key lime": "aeff6e", - "tomato red": "ec2d01", - "lightgreen": "76ff7b", - "merlot": "730039", - "night blue": "040348", - "purpleish pink": "df4ec8", - "apple": "6ecb3c", - "baby poop green": "8f9805", - "green apple": "5edc1f", - "heliotrope": "d94ff5", - "yellow/green": "c8fd3d", - "almost black": "070d0d", - "cool blue": "4984b8", - "leafy green": "51b73b", - "mustard brown": "ac7e04", - "dusk": "4e5481", - "dull brown": "876e4b", - "frog green": "58bc08", - "vivid green": "2fef10", - "bright light green": "2dfe54", - "fluro green": "0aff02", - "kiwi": "9cef43", - "seaweed": "18d17b", - "navy green": "35530a", - "ultramarine blue": "1805db", - "iris": "6258c4", - "pastel orange": "ff964f", - "yellowish orange": "ffab0f", - "perrywinkle": "8f8ce7", - "tealish": "24bca8", - "dark plum": "3f012c", - "pear": "cbf85f", - "pinkish orange": "ff724c", - "midnight purple": "280137", - "light urple": "b36ff6", - "dark mint": "48c072", - "greenish tan": "bccb7a", - "light burgundy": "a8415b", - "turquoise blue": "06b1c4", - "ugly pink": "cd7584", - "sandy": "f1da7a", - "electric pink": "ff0490", - "muted purple": "805b87", - "mid green": "50a747", - "greyish": "a8a495", - "neon yellow": "cfff04", - "banana": "ffff7e", - "carnation pink": "ff7fa7", - "tomato": "ef4026", - "sea": "3c9992", - "muddy brown": "886806", - "turquoise green": "04f489", - "buff": "fef69e", - "fawn": "cfaf7b", - "muted blue": "3b719f", - "pale rose": "fdc1c5", - "dark mint green": "20c073", - "amethyst": "9b5fc0", - "blue/green": "0f9b8e", - "chestnut": "742802", - "sick green": "9db92c", - "pea": "a4bf20", - "rusty orange": "cd5909", - "stone": "ada587", - "rose red": "be013c", - "pale aqua": "b8ffeb", - "deep orange": "dc4d01", - "earth": "a2653e", - "mossy green": "638b27", - "grassy green": "419c03", - "pale lime green": "b1ff65", - "light grey blue": "9dbcd4", - "pale grey": "fdfdfe", - "asparagus": "77ab56", - "blueberry": "464196", - "purple red": "990147", - "pale lime": "befd73", - "greenish teal": "32bf84", - "caramel": "af6f09", - "deep magenta": "a0025c", - "light peach": "ffd8b1", - "milk chocolate": "7f4e1e", - "ocher": "bf9b0c", - "off green": "6ba353", - "purply pink": "f075e6", - "lightblue": "7bc8f6", - "dusky blue": "475f94", - "golden": "f5bf03", - "light beige": "fffeb6", - "butter yellow": "fffd74", - "dusky purple": "895b7b", - "french blue": "436bad", - "ugly yellow": "d0c101", - "greeny yellow": "c6f808", - "orangish red": "f43605", - "shamrock green": "02c14d", - "orangish brown": "b25f03", - "tree green": "2a7e19", - "deep violet": "490648", - "gunmetal": "536267", - "blue/purple": "5a06ef", - "cherry": "cf0234", - "sandy brown": "c4a661", - "warm grey": "978a84", - "dark indigo": "1f0954", - "midnight": "03012d", - "bluey green": "2bb179", - "grey pink": "c3909b", - "soft purple": "a66fb5", - "blood": "770001", - "brown red": "922b05", - "medium grey": "7d7f7c", - "berry": "990f4b", - "poo": "8f7303", - "purpley pink": "c83cb9", - "light salmon": "fea993", - "snot": "acbb0d", - "easter purple": "c071fe", - "light yellow green": "ccfd7f", - "dark navy blue": "00022e", - "drab": "828344", - "light rose": "ffc5cb", - "rouge": "ab1239", - "purplish red": "b0054b", - "slime green": "99cc04", - "baby poop": "937c00", - "irish green": "019529", - "pink/purple": "ef1de7", - "dark navy": "000435", - "greeny blue": "42b395", - "light plum": "9d5783", - "pinkish grey": "c8aca9", - "dirty orange": "c87606", - "rust red": "aa2704", - "pale lilac": "e4cbff", - "orangey red": "fa4224", - "primary blue": "0804f9", - "kermit green": "5cb200", - "brownish purple": "76424e", - "murky green": "6c7a0e", - "wheat": "fbdd7e", - "very dark purple": "2a0134", - "bottle green": "044a05", - "watermelon": "fd4659", - "deep sky blue": "0d75f8", - "fire engine red": "fe0002", - "yellow ochre": "cb9d06", - "pumpkin orange": "fb7d07", - "pale olive": "b9cc81", - "light lilac": "edc8ff", - "lightish green": "61e160", - "carolina blue": "8ab8fe", - "mulberry": "920a4e", - "shocking pink": "fe02a2", - "auburn": "9a3001", - "bright lime green": "65fe08", - "celadon": "befdb7", - "pinkish brown": "b17261", - "poo brown": "885f01", - "bright sky blue": "02ccfe", - "celery": "c1fd95", - "dirt brown": "836539", - "strawberry": "fb2943", - "dark lime": "84b701", - "copper": "b66325", - "medium brown": "7f5112", - "muted green": "5fa052", - "robin's egg": "6dedfd", - "bright aqua": "0bf9ea", - "bright lavender": "c760ff", - "ivory": "ffffcb", - "very light purple": "f6cefc", - "light navy": "155084", - "pink red": "f5054f", - "olive brown": "645403", - "poop brown": "7a5901", - "mustard green": "a8b504", - "ocean green": "3d9973", - "very dark blue": "000133", - "dusty green": "76a973", - "light navy blue": "2e5a88", - "minty green": "0bf77d", - "adobe": "bd6c48", - "barney": "ac1db8", - "jade green": "2baf6a", - "bright light blue": "26f7fd", - "light lime": "aefd6c", - "dark khaki": "9b8f55", - "orange yellow": "ffad01", - "ocre": "c69c04", - "maize": "f4d054", - "faded pink": "de9dac", - "british racing green": "05480d", - "sandstone": "c9ae74", - "mud brown": "60460f", - "light sea green": "98f6b0", - "robin egg blue": "8af1fe", - "aqua marine": "2ee8bb", - "dark sea green": "11875d", - "soft pink": "fdb0c0", - "orangey brown": "b16002", - "cherry red": "f7022a", - "burnt yellow": "d5ab09", - "brownish grey": "86775f", - "camel": "c69f59", - "purplish grey": "7a687f", - "marine": "042e60", - "greyish pink": "c88d94", - "pale turquoise": "a5fbd5", - "pastel yellow": "fffe71", - "bluey purple": "6241c7", - "canary yellow": "fffe40", - "faded red": "d3494e", - "sepia": "985e2b", - "coffee": "a6814c", - "bright magenta": "ff08e8", - "mocha": "9d7651", - "ecru": "feffca", - "purpleish": "98568d", - "cranberry": "9e003a", - "darkish green": "287c37", - "brown orange": "b96902", - "dusky rose": "ba6873", - "melon": "ff7855", - "sickly green": "94b21c", - "silver": "c5c9c7", - "purply blue": "661aee", - "purpleish blue": "6140ef", - "hospital green": "9be5aa", - "shit brown": "7b5804", - "mid blue": "276ab3", - "amber": "feb308", - "easter green": "8cfd7e", - "soft blue": "6488ea", - "cerulean blue": "056eee", - "golden brown": "b27a01", - "bright turquoise": "0ffef9", - "red pink": "fa2a55", - "red purple": "820747", - "greyish brown": "7a6a4f", - "vermillion": "f4320c", - "russet": "a13905", - "steel grey": "6f828a", - "lighter purple": "a55af4", - "bright violet": "ad0afd", - "prussian blue": "004577", - "slate green": "658d6d", - "dirty pink": "ca7b80", - "dark blue green": "005249", - "pine": "2b5d34", - "yellowy green": "bff128", - "dark gold": "b59410", - "bluish": "2976bb", - "darkish blue": "014182", - "dull red": "bb3f3f", - "pinky red": "fc2647", - "bronze": "a87900", - "pale teal": "82cbb2", - "military green": "667c3e", - "barbie pink": "fe46a5", - "bubblegum pink": "fe83cc", - "pea soup green": "94a617", - "dark mustard": "a88905", - "shit": "7f5f00", - "medium purple": "9e43a2", - "very dark green": "062e03", - "dirt": "8a6e45", - "dusky pink": "cc7a8b", - "red violet": "9e0168", - "lemon yellow": "fdff38", - "pistachio": "c0fa8b", - "dull yellow": "eedc5b", - "dark lime green": "7ebd01", - "denim blue": "3b5b92", - "teal blue": "01889f", - "lightish blue": "3d7afd", - "purpley blue": "5f34e7", - "light indigo": "6d5acf", - "swamp green": "748500", - "brown green": "706c11", - "dark maroon": "3c0008", - "hot purple": "cb00f5", - "dark forest green": "002d04", - "faded blue": "658cbb", - "drab green": "749551", - "light lime green": "b9ff66", - "snot green": "9dc100", - "yellowish": "faee66", - "light blue green": "7efbb3", - "bordeaux": "7b002c", - "light mauve": "c292a1", - "ocean": "017b92", - "marigold": "fcc006", - "muddy green": "657432", - "dull orange": "d8863b", - "steel": "738595", - "electric purple": "aa23ff", - "fluorescent green": "08ff08", - "yellowish brown": "9b7a01", - "blush": "f29e8e", - "soft green": "6fc276", - "bright orange": "ff5b00", - "lemon": "fdff52", - "purple grey": "866f85", - "acid green": "8ffe09", - "pale lavender": "eecffe", - "violet blue": "510ac9", - "light forest green": "4f9153", - "burnt red": "9f2305", - "khaki green": "728639", - "cerise": "de0c62", - "faded purple": "916e99", - "apricot": "ffb16d", - "dark olive green": "3c4d03", - "grey brown": "7f7053", - "green grey": "77926f", - "true blue": "010fcc", - "pale violet": "ceaefa", - "periwinkle blue": "8f99fb", - "light sky blue": "c6fcff", - "blurple": "5539cc", - "green brown": "544e03", - "bluegreen": "017a79", - "bright teal": "01f9c6", - "brownish yellow": "c9b003", - "pea soup": "929901", - "forest": "0b5509", - "barney purple": "a00498", - "ultramarine": "2000b1", - "purplish": "94568c", - "puke yellow": "c2be0e", - "bluish grey": "748b97", - "dark periwinkle": "665fd1", - "dark lilac": "9c6da5", - "reddish": "c44240", - "light maroon": "a24857", - "dusty purple": "825f87", - "terra cotta": "c9643b", - "avocado": "90b134", - "marine blue": "01386a", - "teal green": "25a36f", - "slate grey": "59656d", - "lighter green": "75fd63", - "electric green": "21fc0d", - "dusty blue": "5a86ad", - "golden yellow": "fec615", - "bright yellow": "fffd01", - "light lavender": "dfc5fe", - "umber": "b26400", - "poop": "7f5e00", - "dark peach": "de7e5d", - "jungle green": "048243", - "eggshell": "ffffd4", - "denim": "3b638c", - "yellow brown": "b79400", - "dull purple": "84597e", - "chocolate brown": "411900", - "wine red": "7b0323", - "neon blue": "04d9ff", - "dirty green": "667e2c", - "light tan": "fbeeac", - "ice blue": "d7fffe", - "cadet blue": "4e7496", - "dark mauve": "874c62", - "very light blue": "d5ffff", - "grey purple": "826d8c", - "pastel pink": "ffbacd", - "very light green": "d1ffbd", - "dark sky blue": "448ee4", - "evergreen": "05472a", - "dull pink": "d5869d", - "aubergine": "3d0734", - "mahogany": "4a0100", - "reddish orange": "f8481c", - "deep green": "02590f", - "vomit green": "89a203", - "purple pink": "e03fd8", - "dusty pink": "d58a94", - "faded green": "7bb274", - "camo green": "526525", - "pinky purple": "c94cbe", - "pink purple": "db4bda", - "brownish red": "9e3623", - "dark rose": "b5485d", - "mud": "735c12", - "brownish": "9c6d57", - "emerald green": "028f1e", - "pale brown": "b1916e", - "dull blue": "49759c", - "burnt umber": "a0450e", - "medium green": "39ad48", - "clay": "b66a50", - "light aqua": "8cffdb", - "light olive green": "a4be5c", - "brownish orange": "cb7723", - "dark aqua": "05696b", - "purplish pink": "ce5dae", - "dark salmon": "c85a53", - "greenish grey": "96ae8d", - "jade": "1fa774", - "ugly green": "7a9703", - "dark beige": "ac9362", - "emerald": "01a049", - "pale red": "d9544d", - "light magenta": "fa5ff7", - "sky": "82cafc", - "light cyan": "acfffc", - "yellow orange": "fcb001", - "reddish purple": "910951", - "reddish pink": "fe2c54", - "orchid": "c875c4", - "dirty yellow": "cdc50a", - "orange red": "fd411e", - "deep red": "9a0200", - "orange brown": "be6400", - "cobalt blue": "030aa7", - "neon pink": "fe019a", - "rose pink": "f7879a", - "greyish purple": "887191", - "raspberry": "b00149", - "aqua green": "12e193", - "salmon pink": "fe7b7c", - "tangerine": "ff9408", - "brownish green": "6a6e09", - "red brown": "8b2e16", - "greenish brown": "696112", - "pumpkin": "e17701", - "pine green": "0a481e", - "charcoal": "343837", - "baby pink": "ffb7ce", - "cornflower": "6a79f7", - "blue violet": "5d06e9", - "chocolate": "3d1c02", - "greyish green": "82a67d", - "scarlet": "be0119", - "green yellow": "c9ff27", - "dark olive": "373e02", - "sienna": "a9561e", - "pastel purple": "caa0ff", - "terracotta": "ca6641", - "aqua blue": "02d8e9", - "sage green": "88b378", - "blood red": "980002", - "deep pink": "cb0162", - "grass": "5cac2d", - "moss": "769958", - "pastel blue": "a2bffe", - "bluish green": "10a674", - "green blue": "06b48b", - "dark tan": "af884a", - "greenish blue": "0b8b87", - "pale orange": "ffa756", - "vomit": "a2a415", - "forrest green": "154406", - "dark lavender": "856798", - "dark violet": "34013f", - "purple blue": "632de9", - "dark cyan": "0a888a", - "olive drab": "6f7632", - "pinkish": "d46a7e", - "cobalt": "1e488f", - "neon purple": "bc13fe", - "light turquoise": "7ef4cc", - "apple green": "76cd26", - "dull green": "74a662", - "wine": "80013f", - "powder blue": "b1d1fc", - "off white": "ffffe4", - "electric blue": "0652ff", - "dark turquoise": "045c5a", - "blue purple": "5729ce", - "azure": "069af3", - "bright red": "ff000d", - "pinkish red": "f10c45", - "cornflower blue": "5170d7", - "light olive": "acbf69", - "grape": "6c3461", - "greyish blue": "5e819d", - "purplish blue": "601ef9", - "yellowish green": "b0dd16", - "greenish yellow": "cdfd02", - "medium blue": "2c6fbb", - "dusty rose": "c0737a", - "light violet": "d6b4fc", - "midnight blue": "020035", - "bluish purple": "703be7", - "red orange": "fd3c06", - "dark magenta": "960056", - "greenish": "40a368", - "ocean blue": "03719c", - "coral": "fc5a50", - "cream": "ffffc2", - "reddish brown": "7f2b0a", - "burnt sienna": "b04e0f", - "brick": "a03623", - "sage": "87ae73", - "grey green": "789b73", - "white": "ffffff", - "robin's egg blue": "98eff9", - "moss green": "658b38", - "steel blue": "5a7d9a", - "eggplant": "380835", - "light yellow": "fffe7a", - "leaf green": "5ca904", - "light grey": "d8dcd6", - "puke": "a5a502", - "pinkish purple": "d648d7", - "sea blue": "047495", - "pale purple": "b790d4", - "slate blue": "5b7c99", - "blue grey": "607c8e", - "hunter green": "0b4008", - "fuchsia": "ed0dd9", - "crimson": "8c000f", - "pale yellow": "ffff84", - "ochre": "bf9005", - "mustard yellow": "d2bd0a", - "light red": "ff474c", - "cerulean": "0485d1", - "pale pink": "ffcfdc", - "deep blue": "040273", - "rust": "a83c09", - "light teal": "90e4c1", - "slate": "516572", - "goldenrod": "fac205", - "dark yellow": "d5b60a", - "dark grey": "363737", - "army green": "4b5d16", - "grey blue": "6b8ba4", - "seafoam": "80f9ad", - "puce": "a57e52", - "spring green": "a9f971", - "dark orange": "c65102", - "sand": "e2ca76", - "pastel green": "b0ff9d", - "mint": "9ffeb0", - "light orange": "fdaa48", - "bright pink": "fe01b1", - "chartreuse": "c1f80a", - "deep purple": "36013f", - "dark brown": "341c02", - "taupe": "b9a281", - "pea green": "8eab12", - "puke green": "9aae07", - "kelly green": "02ab2e", - "seafoam green": "7af9ab", - "blue green": "137e6d", - "khaki": "aaa662", - "burgundy": "610023", - "dark teal": "014d4e", - "brick red": "8f1402", - "royal purple": "4b006e", - "plum": "580f41", - "mint green": "8fff9f", - "gold": "dbb40c", - "baby blue": "a2cffe", - "yellow green": "c0fb2d", - "bright purple": "be03fd", - "dark red": "840000", - "pale blue": "d0fefe", - "grass green": "3f9b0b", - "navy": "01153e", - "aquamarine": "04d8b2", - "burnt orange": "c04e01", - "neon green": "0cff0c", - "bright blue": "0165fc", - "rose": "cf6275", - "light pink": "ffd1df", - "mustard": "ceb301", - "indigo": "380282", - "lime": "aaff32", - "sea green": "53fca1", - "periwinkle": "8e82fe", - "dark pink": "cb416b", - "olive green": "677a04", - "peach": "ffb07c", - "pale green": "c7fdb5", - "light brown": "ad8150", - "hot pink": "ff028d", - "black": "000000", - "lilac": "cea2fd", - "navy blue": "001146", - "royal blue": "0504aa", - "beige": "e6daa6", - "salmon": "ff796c", - "olive": "6e750e", - "maroon": "650021", - "bright green": "01ff07", - "dark purple": "35063e", - "mauve": "ae7181", - "forest green": "06470c", - "aqua": "13eac9", - "cyan": "00ffff", - "tan": "d1b26f", - "dark blue": "00035b", - "lavender": "c79fef", - "turquoise": "06c2ac", - "dark green": "033500", - "violet": "9a0eea", - "light purple": "bf77f6", - "lime green": "89fe05", - "grey": "929591", - "sky blue": "75bbfd", - "yellow": "ffff14", - "magenta": "c20078", - "light green": "96f97b", - "orange": "f97306", - "teal": "029386", - "light blue": "95d0fc", - "red": "e50000", - "brown": "653700", - "pink": "ff81c0", - "blue": "0343df", - "green": "15b01a", - "purple": "7e1e9c", -} - -# Normalize name to "xkcd:" to avoid name collisions. -XKCD_COLORS_NORM = {"xkcd:" + name: value for name, value in XKCD_COLORS.items()} - - -# https://drafts.csswg.org/css-color-4/#named-colors -CSS4_COLORS = { - "aliceblue": "F0F8FF", - "antiquewhite": "FAEBD7", - "aqua": "00FFFF", - "aquamarine": "7FFFD4", - "azure": "F0FFFF", - "beige": "F5F5DC", - "bisque": "FFE4C4", - "black": "000000", - "blanchedalmond": "FFEBCD", - "blue": "0000FF", - "blueviolet": "8A2BE2", - "brown": "A52A2A", - "burlywood": "DEB887", - "cadetblue": "5F9EA0", - "chartreuse": "7FFF00", - "chocolate": "D2691E", - "coral": "FF7F50", - "cornflowerblue": "6495ED", - "cornsilk": "FFF8DC", - "crimson": "DC143C", - "cyan": "00FFFF", - "darkblue": "00008B", - "darkcyan": "008B8B", - "darkgoldenrod": "B8860B", - "darkgray": "A9A9A9", - "darkgreen": "006400", - "darkgrey": "A9A9A9", - "darkkhaki": "BDB76B", - "darkmagenta": "8B008B", - "darkolivegreen": "556B2F", - "darkorange": "FF8C00", - "darkorchid": "9932CC", - "darkred": "8B0000", - "darksalmon": "E9967A", - "darkseagreen": "8FBC8F", - "darkslateblue": "483D8B", - "darkslategray": "2F4F4F", - "darkslategrey": "2F4F4F", - "darkturquoise": "00CED1", - "darkviolet": "9400D3", - "deeppink": "FF1493", - "deepskyblue": "00BFFF", - "dimgray": "696969", - "dimgrey": "696969", - "dodgerblue": "1E90FF", - "firebrick": "B22222", - "floralwhite": "FFFAF0", - "forestgreen": "228B22", - "fuchsia": "FF00FF", - "gainsboro": "DCDCDC", - "ghostwhite": "F8F8FF", - "gold": "FFD700", - "goldenrod": "DAA520", - "gray": "808080", - "green": "008000", - "greenyellow": "ADFF2F", - "grey": "808080", - "honeydew": "F0FFF0", - "hotpink": "FF69B4", - "indianred": "CD5C5C", - "indigo": "4B0082", - "ivory": "FFFFF0", - "khaki": "F0E68C", - "lavender": "E6E6FA", - "lavenderblush": "FFF0F5", - "lawngreen": "7CFC00", - "lemonchiffon": "FFFACD", - "lightblue": "ADD8E6", - "lightcoral": "F08080", - "lightcyan": "E0FFFF", - "lightgoldenrodyellow": "FAFAD2", - "lightgray": "D3D3D3", - "lightgreen": "90EE90", - "lightgrey": "D3D3D3", - "lightpink": "FFB6C1", - "lightsalmon": "FFA07A", - "lightseagreen": "20B2AA", - "lightskyblue": "87CEFA", - "lightslategray": "778899", - "lightslategrey": "778899", - "lightsteelblue": "B0C4DE", - "lightyellow": "FFFFE0", - "lime": "00FF00", - "limegreen": "32CD32", - "linen": "FAF0E6", - "magenta": "FF00FF", - "maroon": "800000", - "mediumaquamarine": "66CDAA", - "mediumblue": "0000CD", - "mediumorchid": "BA55D3", - "mediumpurple": "9370DB", - "mediumseagreen": "3CB371", - "mediumslateblue": "7B68EE", - "mediumspringgreen": "00FA9A", - "mediumturquoise": "48D1CC", - "mediumvioletred": "C71585", - "midnightblue": "191970", - "mintcream": "F5FFFA", - "mistyrose": "FFE4E1", - "moccasin": "FFE4B5", - "navajowhite": "FFDEAD", - "navy": "000080", - "oldlace": "FDF5E6", - "olive": "808000", - "olivedrab": "6B8E23", - "orange": "FFA500", - "orangered": "FF4500", - "orchid": "DA70D6", - "palegoldenrod": "EEE8AA", - "palegreen": "98FB98", - "paleturquoise": "AFEEEE", - "palevioletred": "DB7093", - "papayawhip": "FFEFD5", - "peachpuff": "FFDAB9", - "peru": "CD853F", - "pink": "FFC0CB", - "plum": "DDA0DD", - "powderblue": "B0E0E6", - "purple": "800080", - "rebeccapurple": "663399", - "red": "FF0000", - "rosybrown": "BC8F8F", - "royalblue": "4169E1", - "saddlebrown": "8B4513", - "salmon": "FA8072", - "sandybrown": "F4A460", - "seagreen": "2E8B57", - "seashell": "FFF5EE", - "sienna": "A0522D", - "silver": "C0C0C0", - "skyblue": "87CEEB", - "slateblue": "6A5ACD", - "slategray": "708090", - "slategrey": "708090", - "snow": "FFFAFA", - "springgreen": "00FF7F", - "steelblue": "4682B4", - "tan": "D2B48C", - "teal": "008080", - "thistle": "D8BFD8", - "tomato": "FF6347", - "turquoise": "40E0D0", - "violet": "EE82EE", - "wheat": "F5DEB3", - "white": "FFFFFF", - "whitesmoke": "F5F5F5", - "yellow": "FFFF00", - "yellowgreen": "9ACD32", -} - -# Normalize name to "tab:" to avoid name collisions. -CSS4_COLORS_NORM = {"css:" + name: value for name, value in CSS4_COLORS.items()} - -ALL_COLORS = {} -ALL_COLORS.update(BASE_COLORS) -ALL_COLORS.update(CSS4_COLORS) -ALL_COLORS.update(CSS4_COLORS_NORM) -ALL_COLORS.update(XKCD_COLORS) -ALL_COLORS.update(XKCD_COLORS_NORM) -ALL_COLORS.update(TABLEAU_COLORS) -ALL_COLORS.update(TABLEAU_COLORS_NORM) -ALL_COLORS.update(DISCORD_COLORS) -ALL_COLORS.update(DISCORD_COLORS_NORM) +""" +Retrieved from matplotlib colors library. +Slightly modified to conform with usage. +""" + +BASE_COLORS = { + "b": "0000ff", + "g": "007f00", + "r": "ff0000", + "c": "00bfbf", + "m": "bf00bf", + "y": "bfbf00", + "k": "000000", + "w": "ffffff", +} + +# Discord native colors +DISCORD_COLORS = { + "default": "000000", + "teal": "1abc9c", + "dark teal": "11806a", + "green": "2ecc71", + "dark green": "1f8b4c", + "blue": "3498db", + "dark blue": "206694", + "purple": "9b59b6", + "dark purple": "71368a", + "magenta": "e91e63", + "dark magenta": "ad1457", + "gold": "f1c40f", + "dark gold": "c27c0e", + "orange": "e67e22", + "dark orange": "a84300", + "red": "e74c3c", + "dark red": "992d22", + "lighter gray": "95a5a6", + "darker gray": "546e7a", + "light gray": "979c9f", + "dark gray": "607d8b", + "blurple": "7289da", + "grayple": "99aab5", +} + +# Normalize name to "discord:" to avoid name collisions. +DISCORD_COLORS_NORM = {"discord:" + name: value for name, value in DISCORD_COLORS.items()} + + +# These colors are from Tableau +TABLEAU_COLORS = { + "blue": "1f77b4", + "orange": "ff7f0e", + "green": "2ca02c", + "red": "d62728", + "purple": "9467bd", + "brown": "8c564b", + "pink": "e377c2", + "gray": "7f7f7f", + "olive": "bcbd22", + "cyan": "17becf", +} + +# Normalize name to "tab:" to avoid name collisions. +TABLEAU_COLORS_NORM = {"tab:" + name: value for name, value in TABLEAU_COLORS.items()} + + +# This mapping of color names -> hex values is taken from +# a survey run by Randel Monroe see: +# http://blog.xkcd.com/2010/05/03/color-survey-results/ +# for more details. The results are hosted at +# https://xkcd.com/color/rgb.txt +# +# License: http://creativecommons.org/publicdomain/zero/1.0/ +XKCD_COLORS = { + "cloudy blue": "acc2d9", + "dark pastel green": "56ae57", + "dust": "b2996e", + "electric lime": "a8ff04", + "fresh green": "69d84f", + "light eggplant": "894585", + "nasty green": "70b23f", + "really light blue": "d4ffff", + "tea": "65ab7c", + "warm purple": "952e8f", + "yellowish tan": "fcfc81", + "cement": "a5a391", + "dark grass green": "388004", + "dusty teal": "4c9085", + "grey teal": "5e9b8a", + "macaroni and cheese": "efb435", + "pinkish tan": "d99b82", + "spruce": "0a5f38", + "strong blue": "0c06f7", + "toxic green": "61de2a", + "windows blue": "3778bf", + "blue blue": "2242c7", + "blue with a hint of purple": "533cc6", + "booger": "9bb53c", + "bright sea green": "05ffa6", + "dark green blue": "1f6357", + "deep turquoise": "017374", + "green teal": "0cb577", + "strong pink": "ff0789", + "bland": "afa88b", + "deep aqua": "08787f", + "lavender pink": "dd85d7", + "light moss green": "a6c875", + "light seafoam green": "a7ffb5", + "olive yellow": "c2b709", + "pig pink": "e78ea5", + "deep lilac": "966ebd", + "desert": "ccad60", + "dusty lavender": "ac86a8", + "purpley grey": "947e94", + "purply": "983fb2", + "candy pink": "ff63e9", + "light pastel green": "b2fba5", + "boring green": "63b365", + "kiwi green": "8ee53f", + "light grey green": "b7e1a1", + "orange pink": "ff6f52", + "tea green": "bdf8a3", + "very light brown": "d3b683", + "egg shell": "fffcc4", + "eggplant purple": "430541", + "powder pink": "ffb2d0", + "reddish grey": "997570", + "baby shit brown": "ad900d", + "liliac": "c48efd", + "stormy blue": "507b9c", + "ugly brown": "7d7103", + "custard": "fffd78", + "darkish pink": "da467d", + "deep brown": "410200", + "greenish beige": "c9d179", + "manilla": "fffa86", + "off blue": "5684ae", + "battleship grey": "6b7c85", + "browny green": "6f6c0a", + "bruise": "7e4071", + "kelley green": "009337", + "sickly yellow": "d0e429", + "sunny yellow": "fff917", + "azul": "1d5dec", + "darkgreen": "054907", + "green/yellow": "b5ce08", + "lichen": "8fb67b", + "light light green": "c8ffb0", + "pale gold": "fdde6c", + "sun yellow": "ffdf22", + "tan green": "a9be70", + "burple": "6832e3", + "butterscotch": "fdb147", + "toupe": "c7ac7d", + "dark cream": "fff39a", + "indian red": "850e04", + "light lavendar": "efc0fe", + "poison green": "40fd14", + "baby puke green": "b6c406", + "bright yellow green": "9dff00", + "charcoal grey": "3c4142", + "squash": "f2ab15", + "cinnamon": "ac4f06", + "light pea green": "c4fe82", + "radioactive green": "2cfa1f", + "raw sienna": "9a6200", + "baby purple": "ca9bf7", + "cocoa": "875f42", + "light royal blue": "3a2efe", + "orangeish": "fd8d49", + "rust brown": "8b3103", + "sand brown": "cba560", + "swamp": "698339", + "tealish green": "0cdc73", + "burnt siena": "b75203", + "camo": "7f8f4e", + "dusk blue": "26538d", + "fern": "63a950", + "old rose": "c87f89", + "pale light green": "b1fc99", + "peachy pink": "ff9a8a", + "rosy pink": "f6688e", + "light bluish green": "76fda8", + "light bright green": "53fe5c", + "light neon green": "4efd54", + "light seafoam": "a0febf", + "tiffany blue": "7bf2da", + "washed out green": "bcf5a6", + "browny orange": "ca6b02", + "nice blue": "107ab0", + "sapphire": "2138ab", + "greyish teal": "719f91", + "orangey yellow": "fdb915", + "parchment": "fefcaf", + "straw": "fcf679", + "very dark brown": "1d0200", + "terracota": "cb6843", + "ugly blue": "31668a", + "clear blue": "247afd", + "creme": "ffffb6", + "foam green": "90fda9", + "grey/green": "86a17d", + "light gold": "fddc5c", + "seafoam blue": "78d1b6", + "topaz": "13bbaf", + "violet pink": "fb5ffc", + "wintergreen": "20f986", + "yellow tan": "ffe36e", + "dark fuchsia": "9d0759", + "indigo blue": "3a18b1", + "light yellowish green": "c2ff89", + "pale magenta": "d767ad", + "rich purple": "720058", + "sunflower yellow": "ffda03", + "green/blue": "01c08d", + "leather": "ac7434", + "racing green": "014600", + "vivid purple": "9900fa", + "dark royal blue": "02066f", + "hazel": "8e7618", + "muted pink": "d1768f", + "booger green": "96b403", + "canary": "fdff63", + "cool grey": "95a3a6", + "dark taupe": "7f684e", + "darkish purple": "751973", + "true green": "089404", + "coral pink": "ff6163", + "dark sage": "598556", + "dark slate blue": "214761", + "flat blue": "3c73a8", + "mushroom": "ba9e88", + "rich blue": "021bf9", + "dirty purple": "734a65", + "greenblue": "23c48b", + "icky green": "8fae22", + "light khaki": "e6f2a2", + "warm blue": "4b57db", + "dark hot pink": "d90166", + "deep sea blue": "015482", + "carmine": "9d0216", + "dark yellow green": "728f02", + "pale peach": "ffe5ad", + "plum purple": "4e0550", + "golden rod": "f9bc08", + "neon red": "ff073a", + "old pink": "c77986", + "very pale blue": "d6fffe", + "blood orange": "fe4b03", + "grapefruit": "fd5956", + "sand yellow": "fce166", + "clay brown": "b2713d", + "dark blue grey": "1f3b4d", + "flat green": "699d4c", + "light green blue": "56fca2", + "warm pink": "fb5581", + "dodger blue": "3e82fc", + "gross green": "a0bf16", + "ice": "d6fffa", + "metallic blue": "4f738e", + "pale salmon": "ffb19a", + "sap green": "5c8b15", + "algae": "54ac68", + "bluey grey": "89a0b0", + "greeny grey": "7ea07a", + "highlighter green": "1bfc06", + "light light blue": "cafffb", + "light mint": "b6ffbb", + "raw umber": "a75e09", + "vivid blue": "152eff", + "deep lavender": "8d5eb7", + "dull teal": "5f9e8f", + "light greenish blue": "63f7b4", + "mud green": "606602", + "pinky": "fc86aa", + "red wine": "8c0034", + "shit green": "758000", + "tan brown": "ab7e4c", + "darkblue": "030764", + "rosa": "fe86a4", + "lipstick": "d5174e", + "pale mauve": "fed0fc", + "claret": "680018", + "dandelion": "fedf08", + "orangered": "fe420f", + "poop green": "6f7c00", + "ruby": "ca0147", + "dark": "1b2431", + "greenish turquoise": "00fbb0", + "pastel red": "db5856", + "piss yellow": "ddd618", + "bright cyan": "41fdfe", + "dark coral": "cf524e", + "algae green": "21c36f", + "darkish red": "a90308", + "reddy brown": "6e1005", + "blush pink": "fe828c", + "camouflage green": "4b6113", + "lawn green": "4da409", + "putty": "beae8a", + "vibrant blue": "0339f8", + "dark sand": "a88f59", + "purple/blue": "5d21d0", + "saffron": "feb209", + "twilight": "4e518b", + "warm brown": "964e02", + "bluegrey": "85a3b2", + "bubble gum pink": "ff69af", + "duck egg blue": "c3fbf4", + "greenish cyan": "2afeb7", + "petrol": "005f6a", + "royal": "0c1793", + "butter": "ffff81", + "dusty orange": "f0833a", + "off yellow": "f1f33f", + "pale olive green": "b1d27b", + "orangish": "fc824a", + "leaf": "71aa34", + "light blue grey": "b7c9e2", + "dried blood": "4b0101", + "lightish purple": "a552e6", + "rusty red": "af2f0d", + "lavender blue": "8b88f8", + "light grass green": "9af764", + "light mint green": "a6fbb2", + "sunflower": "ffc512", + "velvet": "750851", + "brick orange": "c14a09", + "lightish red": "fe2f4a", + "pure blue": "0203e2", + "twilight blue": "0a437a", + "violet red": "a50055", + "yellowy brown": "ae8b0c", + "carnation": "fd798f", + "muddy yellow": "bfac05", + "dark seafoam green": "3eaf76", + "deep rose": "c74767", + "dusty red": "b9484e", + "grey/blue": "647d8e", + "lemon lime": "bffe28", + "purple/pink": "d725de", + "brown yellow": "b29705", + "purple brown": "673a3f", + "wisteria": "a87dc2", + "banana yellow": "fafe4b", + "lipstick red": "c0022f", + "water blue": "0e87cc", + "brown grey": "8d8468", + "vibrant purple": "ad03de", + "baby green": "8cff9e", + "barf green": "94ac02", + "eggshell blue": "c4fff7", + "sandy yellow": "fdee73", + "cool green": "33b864", + "pale": "fff9d0", + "blue/grey": "758da3", + "hot magenta": "f504c9", + "greyblue": "77a1b5", + "purpley": "8756e4", + "baby shit green": "889717", + "brownish pink": "c27e79", + "dark aquamarine": "017371", + "diarrhea": "9f8303", + "light mustard": "f7d560", + "pale sky blue": "bdf6fe", + "turtle green": "75b84f", + "bright olive": "9cbb04", + "dark grey blue": "29465b", + "greeny brown": "696006", + "lemon green": "adf802", + "light periwinkle": "c1c6fc", + "seaweed green": "35ad6b", + "sunshine yellow": "fffd37", + "ugly purple": "a442a0", + "medium pink": "f36196", + "puke brown": "947706", + "very light pink": "fff4f2", + "viridian": "1e9167", + "bile": "b5c306", + "faded yellow": "feff7f", + "very pale green": "cffdbc", + "vibrant green": "0add08", + "bright lime": "87fd05", + "spearmint": "1ef876", + "light aquamarine": "7bfdc7", + "light sage": "bcecac", + "yellowgreen": "bbf90f", + "baby poo": "ab9004", + "dark seafoam": "1fb57a", + "deep teal": "00555a", + "heather": "a484ac", + "rust orange": "c45508", + "dirty blue": "3f829d", + "fern green": "548d44", + "bright lilac": "c95efb", + "weird green": "3ae57f", + "peacock blue": "016795", + "avocado green": "87a922", + "faded orange": "f0944d", + "grape purple": "5d1451", + "hot green": "25ff29", + "lime yellow": "d0fe1d", + "mango": "ffa62b", + "shamrock": "01b44c", + "bubblegum": "ff6cb5", + "purplish brown": "6b4247", + "vomit yellow": "c7c10c", + "pale cyan": "b7fffa", + "key lime": "aeff6e", + "tomato red": "ec2d01", + "lightgreen": "76ff7b", + "merlot": "730039", + "night blue": "040348", + "purpleish pink": "df4ec8", + "apple": "6ecb3c", + "baby poop green": "8f9805", + "green apple": "5edc1f", + "heliotrope": "d94ff5", + "yellow/green": "c8fd3d", + "almost black": "070d0d", + "cool blue": "4984b8", + "leafy green": "51b73b", + "mustard brown": "ac7e04", + "dusk": "4e5481", + "dull brown": "876e4b", + "frog green": "58bc08", + "vivid green": "2fef10", + "bright light green": "2dfe54", + "fluro green": "0aff02", + "kiwi": "9cef43", + "seaweed": "18d17b", + "navy green": "35530a", + "ultramarine blue": "1805db", + "iris": "6258c4", + "pastel orange": "ff964f", + "yellowish orange": "ffab0f", + "perrywinkle": "8f8ce7", + "tealish": "24bca8", + "dark plum": "3f012c", + "pear": "cbf85f", + "pinkish orange": "ff724c", + "midnight purple": "280137", + "light urple": "b36ff6", + "dark mint": "48c072", + "greenish tan": "bccb7a", + "light burgundy": "a8415b", + "turquoise blue": "06b1c4", + "ugly pink": "cd7584", + "sandy": "f1da7a", + "electric pink": "ff0490", + "muted purple": "805b87", + "mid green": "50a747", + "greyish": "a8a495", + "neon yellow": "cfff04", + "banana": "ffff7e", + "carnation pink": "ff7fa7", + "tomato": "ef4026", + "sea": "3c9992", + "muddy brown": "886806", + "turquoise green": "04f489", + "buff": "fef69e", + "fawn": "cfaf7b", + "muted blue": "3b719f", + "pale rose": "fdc1c5", + "dark mint green": "20c073", + "amethyst": "9b5fc0", + "blue/green": "0f9b8e", + "chestnut": "742802", + "sick green": "9db92c", + "pea": "a4bf20", + "rusty orange": "cd5909", + "stone": "ada587", + "rose red": "be013c", + "pale aqua": "b8ffeb", + "deep orange": "dc4d01", + "earth": "a2653e", + "mossy green": "638b27", + "grassy green": "419c03", + "pale lime green": "b1ff65", + "light grey blue": "9dbcd4", + "pale grey": "fdfdfe", + "asparagus": "77ab56", + "blueberry": "464196", + "purple red": "990147", + "pale lime": "befd73", + "greenish teal": "32bf84", + "caramel": "af6f09", + "deep magenta": "a0025c", + "light peach": "ffd8b1", + "milk chocolate": "7f4e1e", + "ocher": "bf9b0c", + "off green": "6ba353", + "purply pink": "f075e6", + "lightblue": "7bc8f6", + "dusky blue": "475f94", + "golden": "f5bf03", + "light beige": "fffeb6", + "butter yellow": "fffd74", + "dusky purple": "895b7b", + "french blue": "436bad", + "ugly yellow": "d0c101", + "greeny yellow": "c6f808", + "orangish red": "f43605", + "shamrock green": "02c14d", + "orangish brown": "b25f03", + "tree green": "2a7e19", + "deep violet": "490648", + "gunmetal": "536267", + "blue/purple": "5a06ef", + "cherry": "cf0234", + "sandy brown": "c4a661", + "warm grey": "978a84", + "dark indigo": "1f0954", + "midnight": "03012d", + "bluey green": "2bb179", + "grey pink": "c3909b", + "soft purple": "a66fb5", + "blood": "770001", + "brown red": "922b05", + "medium grey": "7d7f7c", + "berry": "990f4b", + "poo": "8f7303", + "purpley pink": "c83cb9", + "light salmon": "fea993", + "snot": "acbb0d", + "easter purple": "c071fe", + "light yellow green": "ccfd7f", + "dark navy blue": "00022e", + "drab": "828344", + "light rose": "ffc5cb", + "rouge": "ab1239", + "purplish red": "b0054b", + "slime green": "99cc04", + "baby poop": "937c00", + "irish green": "019529", + "pink/purple": "ef1de7", + "dark navy": "000435", + "greeny blue": "42b395", + "light plum": "9d5783", + "pinkish grey": "c8aca9", + "dirty orange": "c87606", + "rust red": "aa2704", + "pale lilac": "e4cbff", + "orangey red": "fa4224", + "primary blue": "0804f9", + "kermit green": "5cb200", + "brownish purple": "76424e", + "murky green": "6c7a0e", + "wheat": "fbdd7e", + "very dark purple": "2a0134", + "bottle green": "044a05", + "watermelon": "fd4659", + "deep sky blue": "0d75f8", + "fire engine red": "fe0002", + "yellow ochre": "cb9d06", + "pumpkin orange": "fb7d07", + "pale olive": "b9cc81", + "light lilac": "edc8ff", + "lightish green": "61e160", + "carolina blue": "8ab8fe", + "mulberry": "920a4e", + "shocking pink": "fe02a2", + "auburn": "9a3001", + "bright lime green": "65fe08", + "celadon": "befdb7", + "pinkish brown": "b17261", + "poo brown": "885f01", + "bright sky blue": "02ccfe", + "celery": "c1fd95", + "dirt brown": "836539", + "strawberry": "fb2943", + "dark lime": "84b701", + "copper": "b66325", + "medium brown": "7f5112", + "muted green": "5fa052", + "robin's egg": "6dedfd", + "bright aqua": "0bf9ea", + "bright lavender": "c760ff", + "ivory": "ffffcb", + "very light purple": "f6cefc", + "light navy": "155084", + "pink red": "f5054f", + "olive brown": "645403", + "poop brown": "7a5901", + "mustard green": "a8b504", + "ocean green": "3d9973", + "very dark blue": "000133", + "dusty green": "76a973", + "light navy blue": "2e5a88", + "minty green": "0bf77d", + "adobe": "bd6c48", + "barney": "ac1db8", + "jade green": "2baf6a", + "bright light blue": "26f7fd", + "light lime": "aefd6c", + "dark khaki": "9b8f55", + "orange yellow": "ffad01", + "ocre": "c69c04", + "maize": "f4d054", + "faded pink": "de9dac", + "british racing green": "05480d", + "sandstone": "c9ae74", + "mud brown": "60460f", + "light sea green": "98f6b0", + "robin egg blue": "8af1fe", + "aqua marine": "2ee8bb", + "dark sea green": "11875d", + "soft pink": "fdb0c0", + "orangey brown": "b16002", + "cherry red": "f7022a", + "burnt yellow": "d5ab09", + "brownish grey": "86775f", + "camel": "c69f59", + "purplish grey": "7a687f", + "marine": "042e60", + "greyish pink": "c88d94", + "pale turquoise": "a5fbd5", + "pastel yellow": "fffe71", + "bluey purple": "6241c7", + "canary yellow": "fffe40", + "faded red": "d3494e", + "sepia": "985e2b", + "coffee": "a6814c", + "bright magenta": "ff08e8", + "mocha": "9d7651", + "ecru": "feffca", + "purpleish": "98568d", + "cranberry": "9e003a", + "darkish green": "287c37", + "brown orange": "b96902", + "dusky rose": "ba6873", + "melon": "ff7855", + "sickly green": "94b21c", + "silver": "c5c9c7", + "purply blue": "661aee", + "purpleish blue": "6140ef", + "hospital green": "9be5aa", + "shit brown": "7b5804", + "mid blue": "276ab3", + "amber": "feb308", + "easter green": "8cfd7e", + "soft blue": "6488ea", + "cerulean blue": "056eee", + "golden brown": "b27a01", + "bright turquoise": "0ffef9", + "red pink": "fa2a55", + "red purple": "820747", + "greyish brown": "7a6a4f", + "vermillion": "f4320c", + "russet": "a13905", + "steel grey": "6f828a", + "lighter purple": "a55af4", + "bright violet": "ad0afd", + "prussian blue": "004577", + "slate green": "658d6d", + "dirty pink": "ca7b80", + "dark blue green": "005249", + "pine": "2b5d34", + "yellowy green": "bff128", + "dark gold": "b59410", + "bluish": "2976bb", + "darkish blue": "014182", + "dull red": "bb3f3f", + "pinky red": "fc2647", + "bronze": "a87900", + "pale teal": "82cbb2", + "military green": "667c3e", + "barbie pink": "fe46a5", + "bubblegum pink": "fe83cc", + "pea soup green": "94a617", + "dark mustard": "a88905", + "shit": "7f5f00", + "medium purple": "9e43a2", + "very dark green": "062e03", + "dirt": "8a6e45", + "dusky pink": "cc7a8b", + "red violet": "9e0168", + "lemon yellow": "fdff38", + "pistachio": "c0fa8b", + "dull yellow": "eedc5b", + "dark lime green": "7ebd01", + "denim blue": "3b5b92", + "teal blue": "01889f", + "lightish blue": "3d7afd", + "purpley blue": "5f34e7", + "light indigo": "6d5acf", + "swamp green": "748500", + "brown green": "706c11", + "dark maroon": "3c0008", + "hot purple": "cb00f5", + "dark forest green": "002d04", + "faded blue": "658cbb", + "drab green": "749551", + "light lime green": "b9ff66", + "snot green": "9dc100", + "yellowish": "faee66", + "light blue green": "7efbb3", + "bordeaux": "7b002c", + "light mauve": "c292a1", + "ocean": "017b92", + "marigold": "fcc006", + "muddy green": "657432", + "dull orange": "d8863b", + "steel": "738595", + "electric purple": "aa23ff", + "fluorescent green": "08ff08", + "yellowish brown": "9b7a01", + "blush": "f29e8e", + "soft green": "6fc276", + "bright orange": "ff5b00", + "lemon": "fdff52", + "purple grey": "866f85", + "acid green": "8ffe09", + "pale lavender": "eecffe", + "violet blue": "510ac9", + "light forest green": "4f9153", + "burnt red": "9f2305", + "khaki green": "728639", + "cerise": "de0c62", + "faded purple": "916e99", + "apricot": "ffb16d", + "dark olive green": "3c4d03", + "grey brown": "7f7053", + "green grey": "77926f", + "true blue": "010fcc", + "pale violet": "ceaefa", + "periwinkle blue": "8f99fb", + "light sky blue": "c6fcff", + "blurple": "5539cc", + "green brown": "544e03", + "bluegreen": "017a79", + "bright teal": "01f9c6", + "brownish yellow": "c9b003", + "pea soup": "929901", + "forest": "0b5509", + "barney purple": "a00498", + "ultramarine": "2000b1", + "purplish": "94568c", + "puke yellow": "c2be0e", + "bluish grey": "748b97", + "dark periwinkle": "665fd1", + "dark lilac": "9c6da5", + "reddish": "c44240", + "light maroon": "a24857", + "dusty purple": "825f87", + "terra cotta": "c9643b", + "avocado": "90b134", + "marine blue": "01386a", + "teal green": "25a36f", + "slate grey": "59656d", + "lighter green": "75fd63", + "electric green": "21fc0d", + "dusty blue": "5a86ad", + "golden yellow": "fec615", + "bright yellow": "fffd01", + "light lavender": "dfc5fe", + "umber": "b26400", + "poop": "7f5e00", + "dark peach": "de7e5d", + "jungle green": "048243", + "eggshell": "ffffd4", + "denim": "3b638c", + "yellow brown": "b79400", + "dull purple": "84597e", + "chocolate brown": "411900", + "wine red": "7b0323", + "neon blue": "04d9ff", + "dirty green": "667e2c", + "light tan": "fbeeac", + "ice blue": "d7fffe", + "cadet blue": "4e7496", + "dark mauve": "874c62", + "very light blue": "d5ffff", + "grey purple": "826d8c", + "pastel pink": "ffbacd", + "very light green": "d1ffbd", + "dark sky blue": "448ee4", + "evergreen": "05472a", + "dull pink": "d5869d", + "aubergine": "3d0734", + "mahogany": "4a0100", + "reddish orange": "f8481c", + "deep green": "02590f", + "vomit green": "89a203", + "purple pink": "e03fd8", + "dusty pink": "d58a94", + "faded green": "7bb274", + "camo green": "526525", + "pinky purple": "c94cbe", + "pink purple": "db4bda", + "brownish red": "9e3623", + "dark rose": "b5485d", + "mud": "735c12", + "brownish": "9c6d57", + "emerald green": "028f1e", + "pale brown": "b1916e", + "dull blue": "49759c", + "burnt umber": "a0450e", + "medium green": "39ad48", + "clay": "b66a50", + "light aqua": "8cffdb", + "light olive green": "a4be5c", + "brownish orange": "cb7723", + "dark aqua": "05696b", + "purplish pink": "ce5dae", + "dark salmon": "c85a53", + "greenish grey": "96ae8d", + "jade": "1fa774", + "ugly green": "7a9703", + "dark beige": "ac9362", + "emerald": "01a049", + "pale red": "d9544d", + "light magenta": "fa5ff7", + "sky": "82cafc", + "light cyan": "acfffc", + "yellow orange": "fcb001", + "reddish purple": "910951", + "reddish pink": "fe2c54", + "orchid": "c875c4", + "dirty yellow": "cdc50a", + "orange red": "fd411e", + "deep red": "9a0200", + "orange brown": "be6400", + "cobalt blue": "030aa7", + "neon pink": "fe019a", + "rose pink": "f7879a", + "greyish purple": "887191", + "raspberry": "b00149", + "aqua green": "12e193", + "salmon pink": "fe7b7c", + "tangerine": "ff9408", + "brownish green": "6a6e09", + "red brown": "8b2e16", + "greenish brown": "696112", + "pumpkin": "e17701", + "pine green": "0a481e", + "charcoal": "343837", + "baby pink": "ffb7ce", + "cornflower": "6a79f7", + "blue violet": "5d06e9", + "chocolate": "3d1c02", + "greyish green": "82a67d", + "scarlet": "be0119", + "green yellow": "c9ff27", + "dark olive": "373e02", + "sienna": "a9561e", + "pastel purple": "caa0ff", + "terracotta": "ca6641", + "aqua blue": "02d8e9", + "sage green": "88b378", + "blood red": "980002", + "deep pink": "cb0162", + "grass": "5cac2d", + "moss": "769958", + "pastel blue": "a2bffe", + "bluish green": "10a674", + "green blue": "06b48b", + "dark tan": "af884a", + "greenish blue": "0b8b87", + "pale orange": "ffa756", + "vomit": "a2a415", + "forrest green": "154406", + "dark lavender": "856798", + "dark violet": "34013f", + "purple blue": "632de9", + "dark cyan": "0a888a", + "olive drab": "6f7632", + "pinkish": "d46a7e", + "cobalt": "1e488f", + "neon purple": "bc13fe", + "light turquoise": "7ef4cc", + "apple green": "76cd26", + "dull green": "74a662", + "wine": "80013f", + "powder blue": "b1d1fc", + "off white": "ffffe4", + "electric blue": "0652ff", + "dark turquoise": "045c5a", + "blue purple": "5729ce", + "azure": "069af3", + "bright red": "ff000d", + "pinkish red": "f10c45", + "cornflower blue": "5170d7", + "light olive": "acbf69", + "grape": "6c3461", + "greyish blue": "5e819d", + "purplish blue": "601ef9", + "yellowish green": "b0dd16", + "greenish yellow": "cdfd02", + "medium blue": "2c6fbb", + "dusty rose": "c0737a", + "light violet": "d6b4fc", + "midnight blue": "020035", + "bluish purple": "703be7", + "red orange": "fd3c06", + "dark magenta": "960056", + "greenish": "40a368", + "ocean blue": "03719c", + "coral": "fc5a50", + "cream": "ffffc2", + "reddish brown": "7f2b0a", + "burnt sienna": "b04e0f", + "brick": "a03623", + "sage": "87ae73", + "grey green": "789b73", + "white": "ffffff", + "robin's egg blue": "98eff9", + "moss green": "658b38", + "steel blue": "5a7d9a", + "eggplant": "380835", + "light yellow": "fffe7a", + "leaf green": "5ca904", + "light grey": "d8dcd6", + "puke": "a5a502", + "pinkish purple": "d648d7", + "sea blue": "047495", + "pale purple": "b790d4", + "slate blue": "5b7c99", + "blue grey": "607c8e", + "hunter green": "0b4008", + "fuchsia": "ed0dd9", + "crimson": "8c000f", + "pale yellow": "ffff84", + "ochre": "bf9005", + "mustard yellow": "d2bd0a", + "light red": "ff474c", + "cerulean": "0485d1", + "pale pink": "ffcfdc", + "deep blue": "040273", + "rust": "a83c09", + "light teal": "90e4c1", + "slate": "516572", + "goldenrod": "fac205", + "dark yellow": "d5b60a", + "dark grey": "363737", + "army green": "4b5d16", + "grey blue": "6b8ba4", + "seafoam": "80f9ad", + "puce": "a57e52", + "spring green": "a9f971", + "dark orange": "c65102", + "sand": "e2ca76", + "pastel green": "b0ff9d", + "mint": "9ffeb0", + "light orange": "fdaa48", + "bright pink": "fe01b1", + "chartreuse": "c1f80a", + "deep purple": "36013f", + "dark brown": "341c02", + "taupe": "b9a281", + "pea green": "8eab12", + "puke green": "9aae07", + "kelly green": "02ab2e", + "seafoam green": "7af9ab", + "blue green": "137e6d", + "khaki": "aaa662", + "burgundy": "610023", + "dark teal": "014d4e", + "brick red": "8f1402", + "royal purple": "4b006e", + "plum": "580f41", + "mint green": "8fff9f", + "gold": "dbb40c", + "baby blue": "a2cffe", + "yellow green": "c0fb2d", + "bright purple": "be03fd", + "dark red": "840000", + "pale blue": "d0fefe", + "grass green": "3f9b0b", + "navy": "01153e", + "aquamarine": "04d8b2", + "burnt orange": "c04e01", + "neon green": "0cff0c", + "bright blue": "0165fc", + "rose": "cf6275", + "light pink": "ffd1df", + "mustard": "ceb301", + "indigo": "380282", + "lime": "aaff32", + "sea green": "53fca1", + "periwinkle": "8e82fe", + "dark pink": "cb416b", + "olive green": "677a04", + "peach": "ffb07c", + "pale green": "c7fdb5", + "light brown": "ad8150", + "hot pink": "ff028d", + "black": "000000", + "lilac": "cea2fd", + "navy blue": "001146", + "royal blue": "0504aa", + "beige": "e6daa6", + "salmon": "ff796c", + "olive": "6e750e", + "maroon": "650021", + "bright green": "01ff07", + "dark purple": "35063e", + "mauve": "ae7181", + "forest green": "06470c", + "aqua": "13eac9", + "cyan": "00ffff", + "tan": "d1b26f", + "dark blue": "00035b", + "lavender": "c79fef", + "turquoise": "06c2ac", + "dark green": "033500", + "violet": "9a0eea", + "light purple": "bf77f6", + "lime green": "89fe05", + "grey": "929591", + "sky blue": "75bbfd", + "yellow": "ffff14", + "magenta": "c20078", + "light green": "96f97b", + "orange": "f97306", + "teal": "029386", + "light blue": "95d0fc", + "red": "e50000", + "brown": "653700", + "pink": "ff81c0", + "blue": "0343df", + "green": "15b01a", + "purple": "7e1e9c", +} + +# Normalize name to "xkcd:" to avoid name collisions. +XKCD_COLORS_NORM = {"xkcd:" + name: value for name, value in XKCD_COLORS.items()} + + +# https://drafts.csswg.org/css-color-4/#named-colors +CSS4_COLORS = { + "aliceblue": "F0F8FF", + "antiquewhite": "FAEBD7", + "aqua": "00FFFF", + "aquamarine": "7FFFD4", + "azure": "F0FFFF", + "beige": "F5F5DC", + "bisque": "FFE4C4", + "black": "000000", + "blanchedalmond": "FFEBCD", + "blue": "0000FF", + "blueviolet": "8A2BE2", + "brown": "A52A2A", + "burlywood": "DEB887", + "cadetblue": "5F9EA0", + "chartreuse": "7FFF00", + "chocolate": "D2691E", + "coral": "FF7F50", + "cornflowerblue": "6495ED", + "cornsilk": "FFF8DC", + "crimson": "DC143C", + "cyan": "00FFFF", + "darkblue": "00008B", + "darkcyan": "008B8B", + "darkgoldenrod": "B8860B", + "darkgray": "A9A9A9", + "darkgreen": "006400", + "darkgrey": "A9A9A9", + "darkkhaki": "BDB76B", + "darkmagenta": "8B008B", + "darkolivegreen": "556B2F", + "darkorange": "FF8C00", + "darkorchid": "9932CC", + "darkred": "8B0000", + "darksalmon": "E9967A", + "darkseagreen": "8FBC8F", + "darkslateblue": "483D8B", + "darkslategray": "2F4F4F", + "darkslategrey": "2F4F4F", + "darkturquoise": "00CED1", + "darkviolet": "9400D3", + "deeppink": "FF1493", + "deepskyblue": "00BFFF", + "dimgray": "696969", + "dimgrey": "696969", + "dodgerblue": "1E90FF", + "firebrick": "B22222", + "floralwhite": "FFFAF0", + "forestgreen": "228B22", + "fuchsia": "FF00FF", + "gainsboro": "DCDCDC", + "ghostwhite": "F8F8FF", + "gold": "FFD700", + "goldenrod": "DAA520", + "gray": "808080", + "green": "008000", + "greenyellow": "ADFF2F", + "grey": "808080", + "honeydew": "F0FFF0", + "hotpink": "FF69B4", + "indianred": "CD5C5C", + "indigo": "4B0082", + "ivory": "FFFFF0", + "khaki": "F0E68C", + "lavender": "E6E6FA", + "lavenderblush": "FFF0F5", + "lawngreen": "7CFC00", + "lemonchiffon": "FFFACD", + "lightblue": "ADD8E6", + "lightcoral": "F08080", + "lightcyan": "E0FFFF", + "lightgoldenrodyellow": "FAFAD2", + "lightgray": "D3D3D3", + "lightgreen": "90EE90", + "lightgrey": "D3D3D3", + "lightpink": "FFB6C1", + "lightsalmon": "FFA07A", + "lightseagreen": "20B2AA", + "lightskyblue": "87CEFA", + "lightslategray": "778899", + "lightslategrey": "778899", + "lightsteelblue": "B0C4DE", + "lightyellow": "FFFFE0", + "lime": "00FF00", + "limegreen": "32CD32", + "linen": "FAF0E6", + "magenta": "FF00FF", + "maroon": "800000", + "mediumaquamarine": "66CDAA", + "mediumblue": "0000CD", + "mediumorchid": "BA55D3", + "mediumpurple": "9370DB", + "mediumseagreen": "3CB371", + "mediumslateblue": "7B68EE", + "mediumspringgreen": "00FA9A", + "mediumturquoise": "48D1CC", + "mediumvioletred": "C71585", + "midnightblue": "191970", + "mintcream": "F5FFFA", + "mistyrose": "FFE4E1", + "moccasin": "FFE4B5", + "navajowhite": "FFDEAD", + "navy": "000080", + "oldlace": "FDF5E6", + "olive": "808000", + "olivedrab": "6B8E23", + "orange": "FFA500", + "orangered": "FF4500", + "orchid": "DA70D6", + "palegoldenrod": "EEE8AA", + "palegreen": "98FB98", + "paleturquoise": "AFEEEE", + "palevioletred": "DB7093", + "papayawhip": "FFEFD5", + "peachpuff": "FFDAB9", + "peru": "CD853F", + "pink": "FFC0CB", + "plum": "DDA0DD", + "powderblue": "B0E0E6", + "purple": "800080", + "rebeccapurple": "663399", + "red": "FF0000", + "rosybrown": "BC8F8F", + "royalblue": "4169E1", + "saddlebrown": "8B4513", + "salmon": "FA8072", + "sandybrown": "F4A460", + "seagreen": "2E8B57", + "seashell": "FFF5EE", + "sienna": "A0522D", + "silver": "C0C0C0", + "skyblue": "87CEEB", + "slateblue": "6A5ACD", + "slategray": "708090", + "slategrey": "708090", + "snow": "FFFAFA", + "springgreen": "00FF7F", + "steelblue": "4682B4", + "tan": "D2B48C", + "teal": "008080", + "thistle": "D8BFD8", + "tomato": "FF6347", + "turquoise": "40E0D0", + "violet": "EE82EE", + "wheat": "F5DEB3", + "white": "FFFFFF", + "whitesmoke": "F5F5F5", + "yellow": "FFFF00", + "yellowgreen": "9ACD32", +} + +# Normalize name to "tab:" to avoid name collisions. +CSS4_COLORS_NORM = {"css:" + name: value for name, value in CSS4_COLORS.items()} + +ALL_COLORS = {} +ALL_COLORS.update(BASE_COLORS) +ALL_COLORS.update(CSS4_COLORS) +ALL_COLORS.update(CSS4_COLORS_NORM) +ALL_COLORS.update(XKCD_COLORS) +ALL_COLORS.update(XKCD_COLORS_NORM) +ALL_COLORS.update(TABLEAU_COLORS) +ALL_COLORS.update(TABLEAU_COLORS_NORM) +ALL_COLORS.update(DISCORD_COLORS) +ALL_COLORS.update(DISCORD_COLORS_NORM) diff --git a/core/changelog.py b/core/changelog.py index 06f141fce1..ebc36cbd63 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -1,192 +1,192 @@ -import asyncio -import re -from subprocess import PIPE -from typing import List - -from discord import Embed - -from core.models import getLogger -from core.utils import truncate - -logger = getLogger(__name__) - - -class Version: - """ - This class represents a single version of Modmail. - - Parameters - ---------- - bot : Bot - The Modmail bot. - version : str - The version string (ie. "v2.12.0"). - lines : str - The lines of changelog messages for this version. - - Attributes - ---------- - bot : Bot - The Modmail bot. - version : str - The version string (ie. "v2.12.0"). - lines : str - A list of lines of changelog messages for this version. - fields : Dict[str, str] - A dict of fields separated by "Fixed", "Changed", etc sections. - description : str - General description of the version. - - Class Attributes - ---------------- - ACTION_REGEX : str - The regex used to parse the actions. - DESCRIPTION_REGEX: str - The regex used to parse the description. - """ - - ACTION_REGEX = r"###\s*(.+?)\s*\n(.*?)(?=###\s*.+?|$)" - DESCRIPTION_REGEX = r"^(.*?)(?=###\s*.+?|$)" - - def __init__(self, bot, branch: str, version: str, lines: str): - self.bot = bot - self.version = version.lstrip("vV") - self.lines = lines.strip() - self.fields = {} - self.changelog_url = f"https://github.com/modmail-dev/modmail/blob/{branch}/CHANGELOG.md" - self.description = "" - self.parse() - - def __repr__(self) -> str: - return f'Version(v{self.version}, description="{self.description}")' - - def parse(self) -> None: - """ - Parse the lines and split them into `description` and `fields`. - """ - self.description = re.match(self.DESCRIPTION_REGEX, self.lines, re.DOTALL) - self.description = self.description.group(1).strip() if self.description is not None else "" - - matches = re.finditer(self.ACTION_REGEX, self.lines, re.DOTALL) - for m in matches: - try: - self.fields[m.group(1).strip()] = m.group(2).strip() - except AttributeError: - logger.error( - "Something went wrong when parsing the changelog for version %s.", - self.version, - exc_info=True, - ) - - @property - def url(self) -> str: - return f"{self.changelog_url}#v{self.version[::2]}" - - @property - def embed(self) -> Embed: - """ - Embed: the formatted `Embed` of this `Version`. - """ - embed = Embed(color=self.bot.main_color, description=self.description) - embed.set_author( - name=f"v{self.version} - Changelog", - icon_url=self.bot.user.display_avatar.url, - url=self.url, - ) - - for name, value in self.fields.items(): - embed.add_field(name=name, value=truncate(value, 1024), inline=False) - embed.set_footer(text=f"Current version: v{self.bot.version}") - - embed.set_thumbnail(url=self.bot.user.display_avatar.url) - return embed - - -class Changelog: - """ - This class represents the complete changelog of Modmail. - - Parameters - ---------- - bot : Bot - The Modmail bot. - text : str - The complete changelog text. - - Attributes - ---------- - bot : Bot - The Modmail bot. - text : str - The complete changelog text. - versions : List[Version] - A list of `Version`'s within the changelog. - - Class Attributes - ---------------- - VERSION_REGEX : re.Pattern - The regex used to parse the versions. - """ - - VERSION_REGEX = re.compile( - r"#\s*([vV]\d+\.\d+(?:\.\d+)?(?:-\w+?)?)\s+(.*?)(?=#\s*[vV]\d+\.\d+(?:\.\d+)(?:-\w+?)?|$)", - flags=re.DOTALL, - ) - - def __init__(self, bot, branch: str, text: str): - self.bot = bot - self.text = text - logger.debug("Fetching changelog from GitHub.") - self.versions = [Version(bot, branch, *m) for m in self.VERSION_REGEX.findall(text)] - - @property - def latest_version(self) -> Version: - """ - Version: The latest `Version` of the `Changelog`. - """ - return self.versions[0] - - @property - def embeds(self) -> List[Embed]: - """ - List[Embed]: A list of `Embed`'s for each of the `Version`. - """ - return [v.embed for v in self.versions] - - @classmethod - async def from_url(cls, bot, url: str = "") -> "Changelog": - """ - Create a `Changelog` from a URL. - - Parameters - ---------- - bot : Bot - The Modmail bot. - url : str, optional - The URL to the changelog. - - Returns - ------- - Changelog - The newly created `Changelog` parsed from the `url`. - """ - # get branch via git cli if available - proc = await asyncio.create_subprocess_shell( - "git branch --show-current", - stderr=PIPE, - stdout=PIPE, - ) - err = await proc.stderr.read() - err = err.decode("utf-8").rstrip() - res = await proc.stdout.read() - branch = res.decode("utf-8").rstrip() - if not branch or err: - branch = "master" if not bot.version.is_prerelease else "development" - - if branch not in ("master", "development"): - branch = "master" - - url = url or f"https://raw.githubusercontent.com/modmail-dev/modmail/{branch}/CHANGELOG.md" - - async with await bot.session.get(url) as resp: - return cls(bot, branch, await resp.text()) +import asyncio +import re +from subprocess import PIPE +from typing import List + +from discord import Embed + +from core.models import getLogger +from core.utils import truncate + +logger = getLogger(__name__) + + +class Version: + """ + This class represents a single version of Modmail. + + Parameters + ---------- + bot : Bot + The Modmail bot. + version : str + The version string (ie. "v2.12.0"). + lines : str + The lines of changelog messages for this version. + + Attributes + ---------- + bot : Bot + The Modmail bot. + version : str + The version string (ie. "v2.12.0"). + lines : str + A list of lines of changelog messages for this version. + fields : Dict[str, str] + A dict of fields separated by "Fixed", "Changed", etc sections. + description : str + General description of the version. + + Class Attributes + ---------------- + ACTION_REGEX : str + The regex used to parse the actions. + DESCRIPTION_REGEX: str + The regex used to parse the description. + """ + + ACTION_REGEX = r"###\s*(.+?)\s*\n(.*?)(?=###\s*.+?|$)" + DESCRIPTION_REGEX = r"^(.*?)(?=###\s*.+?|$)" + + def __init__(self, bot, branch: str, version: str, lines: str): + self.bot = bot + self.version = version.lstrip("vV") + self.lines = lines.strip() + self.fields = {} + self.changelog_url = f"https://github.com/modmail-dev/modmail/blob/{branch}/CHANGELOG.md" + self.description = "" + self.parse() + + def __repr__(self) -> str: + return f'Version(v{self.version}, description="{self.description}")' + + def parse(self) -> None: + """ + Parse the lines and split them into `description` and `fields`. + """ + self.description = re.match(self.DESCRIPTION_REGEX, self.lines, re.DOTALL) + self.description = self.description.group(1).strip() if self.description is not None else "" + + matches = re.finditer(self.ACTION_REGEX, self.lines, re.DOTALL) + for m in matches: + try: + self.fields[m.group(1).strip()] = m.group(2).strip() + except AttributeError: + logger.error( + "Something went wrong when parsing the changelog for version %s.", + self.version, + exc_info=True, + ) + + @property + def url(self) -> str: + return f"{self.changelog_url}#v{self.version[::2]}" + + @property + def embed(self) -> Embed: + """ + Embed: the formatted `Embed` of this `Version`. + """ + embed = Embed(color=self.bot.main_color, description=self.description) + embed.set_author( + name=f"v{self.version} - Changelog", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + url=self.url, + ) + + for name, value in self.fields.items(): + embed.add_field(name=name, value=truncate(value, 1024), inline=False) + embed.set_footer(text=f"Current version: v{self.bot.version}") + + embed.set_thumbnail(url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None) + return embed + + +class Changelog: + """ + This class represents the complete changelog of Modmail. + + Parameters + ---------- + bot : Bot + The Modmail bot. + text : str + The complete changelog text. + + Attributes + ---------- + bot : Bot + The Modmail bot. + text : str + The complete changelog text. + versions : List[Version] + A list of `Version`'s within the changelog. + + Class Attributes + ---------------- + VERSION_REGEX : re.Pattern + The regex used to parse the versions. + """ + + VERSION_REGEX = re.compile( + r"#\s*([vV]\d+\.\d+(?:\.\d+)?(?:-\w+?)?)\s+(.*?)(?=#\s*[vV]\d+\.\d+(?:\.\d+)(?:-\w+?)?|$)", + flags=re.DOTALL, + ) + + def __init__(self, bot, branch: str, text: str): + self.bot = bot + self.text = text + logger.debug("Fetching changelog from GitHub.") + self.versions = [Version(bot, branch, *m) for m in self.VERSION_REGEX.findall(text)] + + @property + def latest_version(self) -> Version: + """ + Version: The latest `Version` of the `Changelog`. + """ + return self.versions[0] + + @property + def embeds(self) -> List[Embed]: + """ + List[Embed]: A list of `Embed`'s for each of the `Version`. + """ + return [v.embed for v in self.versions] + + @classmethod + async def from_url(cls, bot, url: str = "") -> "Changelog": + """ + Create a `Changelog` from a URL. + + Parameters + ---------- + bot : Bot + The Modmail bot. + url : str, optional + The URL to the changelog. + + Returns + ------- + Changelog + The newly created `Changelog` parsed from the `url`. + """ + # get branch via git cli if available + proc = await asyncio.create_subprocess_shell( + "git branch --show-current", + stderr=PIPE, + stdout=PIPE, + ) + err = await proc.stderr.read() + err = err.decode("utf-8").rstrip() + res = await proc.stdout.read() + branch = res.decode("utf-8").rstrip() + if not branch or err: + branch = "master" if not bot.version.is_prerelease else "development" + + if branch not in ("master", "development"): + branch = "master" + + url = url or f"https://raw.githubusercontent.com/modmail-dev/modmail/{branch}/CHANGELOG.md" + + async with await bot.session.get(url) as resp: + return cls(bot, branch, await resp.text()) diff --git a/core/checks.py b/core/checks.py index 15dcb098da..1c051d191a 100644 --- a/core/checks.py +++ b/core/checks.py @@ -1,142 +1,142 @@ -from discord.ext import commands - -from core.models import HostingMethod, PermissionLevel, getLogger - -logger = getLogger(__name__) - - -def has_permissions_predicate( - permission_level: PermissionLevel = PermissionLevel.REGULAR, -): - async def predicate(ctx): - return await check_permissions(ctx, ctx.command.qualified_name) - - predicate.permission_level = permission_level - return predicate - - -def has_permissions(permission_level: PermissionLevel = PermissionLevel.REGULAR): - """ - A decorator that checks if the author has the required permissions. - - Parameters - ---------- - - permission_level : PermissionLevel - The lowest level of permission needed to use this command. - Defaults to REGULAR. - - Examples - -------- - :: - @has_permissions(PermissionLevel.OWNER) - async def setup(ctx): - await ctx.send('Success') - """ - - return commands.check(has_permissions_predicate(permission_level)) - - -async def check_permissions(ctx, command_name) -> bool: - """Logic for checking permissions for a command for a user""" - if await ctx.bot.is_owner(ctx.author) or ctx.author.id == ctx.bot.user.id: - # Bot owner(s) (and creator) has absolute power over the bot - return True - - permission_level = ctx.bot.command_perm(command_name) - - if permission_level is PermissionLevel.INVALID: - logger.warning("Invalid permission level for command %s.", command_name) - return True - - if ( - permission_level is not PermissionLevel.OWNER - and ctx.channel.permissions_for(ctx.author).administrator - and ctx.guild == ctx.bot.modmail_guild - ): - # Administrators have permission to all non-owner commands in the Modmail Guild - logger.debug("Allowed due to administrator.") - return True - - command_permissions = ctx.bot.config["command_permissions"] - checkables = {*ctx.author.roles, ctx.author} - - if command_name in command_permissions: - # -1 is for @everyone - if -1 in command_permissions[command_name] or any( - str(check.id) in command_permissions[command_name] for check in checkables - ): - return True - - level_permissions = ctx.bot.config["level_permissions"] - - for level in PermissionLevel: - if level >= permission_level and level.name in level_permissions: - # -1 is for @everyone - if -1 in level_permissions[level.name] or any( - str(check.id) in level_permissions[level.name] for check in checkables - ): - return True - return False - - -def thread_only(): - """ - A decorator that checks if the command - is being ran within a Modmail thread. - """ - - async def predicate(ctx): - """ - Parameters - ---------- - ctx : Context - The current discord.py `Context`. - - Returns - ------- - Bool - `True` if the current `Context` is within a Modmail thread. - Otherwise, `False`. - """ - return ctx.thread is not None - - predicate.fail_msg = "This is not a Modmail thread." - return commands.check(predicate) - - -def github_token_required(ignore_if_not_heroku=False): - """ - A decorator that ensures github token - is set - """ - - async def predicate(ctx): - if ignore_if_not_heroku and ctx.bot.hosting_method != HostingMethod.HEROKU: - return True - else: - return ctx.bot.config.get("github_token") - - predicate.fail_msg = ( - "You can only use this command if you have a " - "configured `GITHUB_TOKEN`. Get a " - "personal access token from developer settings." - ) - return commands.check(predicate) - - -def updates_enabled(): - """ - A decorator that ensures - updates are enabled - """ - - async def predicate(ctx): - return not ctx.bot.config["disable_updates"] - - predicate.fail_msg = ( - "Updates are disabled on this bot instance. " - "View `?config help disable_updates` for " - "more information." - ) - return commands.check(predicate) +from discord.ext import commands + +from core.models import HostingMethod, PermissionLevel, getLogger + +logger = getLogger(__name__) + + +def has_permissions_predicate( + permission_level: PermissionLevel = PermissionLevel.REGULAR, +): + async def predicate(ctx): + return await check_permissions(ctx, ctx.command.qualified_name) + + predicate.permission_level = permission_level + return predicate + + +def has_permissions(permission_level: PermissionLevel = PermissionLevel.REGULAR): + """ + A decorator that checks if the author has the required permissions. + + Parameters + ---------- + + permission_level : PermissionLevel + The lowest level of permission needed to use this command. + Defaults to REGULAR. + + Examples + -------- + :: + @has_permissions(PermissionLevel.OWNER) + async def setup(ctx): + await ctx.send('Success') + """ + + return commands.check(has_permissions_predicate(permission_level)) + + +async def check_permissions(ctx, command_name) -> bool: + """Logic for checking permissions for a command for a user""" + if await ctx.bot.is_owner(ctx.author) or ctx.author.id == ctx.bot.user.id: + # Bot owner(s) (and creator) has absolute power over the bot + return True + + permission_level = ctx.bot.command_perm(command_name) + + if permission_level is PermissionLevel.INVALID: + logger.warning("Invalid permission level for command %s.", command_name) + return True + + if ( + permission_level is not PermissionLevel.OWNER + and ctx.channel.permissions_for(ctx.author).administrator + and ctx.guild == ctx.bot.modmail_guild + ): + # Administrators have permission to all non-owner commands in the Modmail Guild + logger.debug("Allowed due to administrator.") + return True + + command_permissions = ctx.bot.config["command_permissions"] + checkables = {*ctx.author.roles, ctx.author} + + if command_name in command_permissions: + # -1 is for @everyone + if -1 in command_permissions[command_name] or any( + str(check.id) in command_permissions[command_name] for check in checkables + ): + return True + + level_permissions = ctx.bot.config["level_permissions"] + + for level in PermissionLevel: + if level >= permission_level and level.name in level_permissions: + # -1 is for @everyone + if -1 in level_permissions[level.name] or any( + str(check.id) in level_permissions[level.name] for check in checkables + ): + return True + return False + + +def thread_only(): + """ + A decorator that checks if the command + is being ran within a Modmail thread. + """ + + async def predicate(ctx): + """ + Parameters + ---------- + ctx : Context + The current discord.py `Context`. + + Returns + ------- + Bool + `True` if the current `Context` is within a Modmail thread. + Otherwise, `False`. + """ + return ctx.thread is not None + + predicate.fail_msg = "This is not a Modmail thread." + return commands.check(predicate) + + +def github_token_required(ignore_if_not_heroku=False): + """ + A decorator that ensures github token + is set + """ + + async def predicate(ctx): + if ignore_if_not_heroku and ctx.bot.hosting_method != HostingMethod.HEROKU: + return True + else: + return ctx.bot.config.get("github_token") + + predicate.fail_msg = ( + "You can only use this command if you have a " + "configured `GITHUB_TOKEN`. Get a " + "personal access token from developer settings." + ) + return commands.check(predicate) + + +def updates_enabled(): + """ + A decorator that ensures + updates are enabled + """ + + async def predicate(ctx): + return not ctx.bot.config["disable_updates"] + + predicate.fail_msg = ( + "Updates are disabled on this bot instance. " + "View `?config help disable_updates` for " + "more information." + ) + return commands.check(predicate) diff --git a/core/clients.py b/core/clients.py index 61c39fdd4b..13a012db35 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,771 +1,773 @@ -import secrets -import sys -from json import JSONDecodeError -from typing import Any, Dict, Union, Optional - -import discord -from discord import Member, DMChannel, TextChannel, Message -from discord.ext import commands - -from aiohttp import ClientResponseError, ClientResponse -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo.errors import ConfigurationError - -from core.models import InvalidConfigError, getLogger - -logger = getLogger(__name__) - - -class GitHub: - """ - The client for interacting with GitHub API. - - Parameters - ---------- - bot : Bot - The Modmail bot. - access_token : str, optional - GitHub's access token. - username : str, optional - GitHub username. - avatar_url : str, optional - URL to the avatar in GitHub. - url : str, optional - URL to the GitHub profile. - - Attributes - ---------- - bot : Bot - The Modmail bot. - access_token : str - GitHub's access token. - username : str - GitHub username. - avatar_url : str - URL to the avatar in GitHub. - url : str - URL to the GitHub profile. - - Class Attributes - ---------------- - BASE : str - GitHub API base URL. - REPO : str - Modmail repo URL for GitHub API. - HEAD : str - Modmail HEAD URL for GitHub API. - MERGE_URL : str - URL for merging upstream to master. - FORK_URL : str - URL to fork Modmail. - STAR_URL : str - URL to star Modmail. - """ - - BASE = "https://api.github.com" - REPO = BASE + "/repos/modmail-dev/modmail" - MERGE_URL = BASE + "/repos/{username}/modmail/merges" - FORK_URL = REPO + "/forks" - STAR_URL = BASE + "/user/starred/modmail-dev/modmail" - - def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): - self.bot = bot - self.session = bot.session - self.headers: Optional[dict] = None - self.access_token = access_token - self.username = username - self.avatar_url: str = kwargs.pop("avatar_url", "") - self.url: str = kwargs.pop("url", "") - if self.access_token: - self.headers = {"Authorization": "token " + str(access_token)} - - @property - def BRANCH(self) -> str: - return "master" if not self.bot.version.is_prerelease else "development" - - async def request( - self, - url: str, - method: str = "GET", - payload: dict = None, - headers: dict = None, - return_response: bool = False, - read_before_return: bool = False, - ) -> Union[ClientResponse, Dict[str, Any], str]: - """ - Makes a HTTP request. - - Parameters - ---------- - url : str - The destination URL of the request. - method : str - The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). - payload : Dict[str, Any] - The json payload to be sent along the request. - headers : Dict[str, str] - Additional headers to `headers`. - return_response : bool - Whether the `ClientResponse` object should be returned. - read_before_return : bool - Whether to perform `.read()` method before returning the `ClientResponse` object. - Only valid if `return_response` is set to `True`. - - Returns - ------- - ClientResponse or Dict[str, Any] or List[Any] or str - `ClientResponse` if `return_response` is `True`. - `Dict[str, Any]` if the returned data is a json object. - `List[Any]` if the returned data is a json list. - `str` if the returned data is not a valid json data, - the raw response. - """ - if headers is not None: - headers.update(self.headers) - else: - headers = self.headers - async with self.session.request(method, url, headers=headers, json=payload) as resp: - if return_response: - if read_before_return: - await resp.read() - return resp - - return await self._get_response_data(resp) - - @staticmethod - async def _get_response_data(response: ClientResponse) -> Union[Dict[str, Any], str]: - """ - Internal method to convert the response data to `dict` if the data is a - json object, or to `str` (raw response) if the data is not a valid json. - """ - try: - return await response.json() - except (JSONDecodeError, ClientResponseError): - return await response.text() - - def filter_valid(self, data) -> Dict[str, Any]: - """ - Filters configuration keys that are accepted. - - Parameters - ---------- - data : Dict[str, Any] - The data that needs to be cleaned. - - Returns - ------- - Dict[str, Any] - Filtered `data` to keep only the accepted pairs. - """ - valid_keys = self.bot.config.valid_keys.difference(self.bot.config.protected_keys) - return {k: v for k, v in data.items() if k in valid_keys} - - async def update_repository(self, sha: str = None) -> Dict[str, Any]: - """ - Update the repository from Modmail main repo. - - Parameters - ---------- - sha : Optional[str] - The commit SHA to update the repository. If `None`, the latest - commit SHA will be fetched. - - Returns - ------- - Dict[str, Any] - A dictionary that contains response data. - """ - if not self.username: - raise commands.CommandInvokeError("Username not found.") - - if sha is None: - resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) - sha = resp["object"]["sha"] - - payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} - - merge_url = self.MERGE_URL.format(username=self.username) - - resp = await self.request( - merge_url, - method="POST", - payload=payload, - return_response=True, - read_before_return=True, - ) - - repo_url = self.BASE + f"/repos/{self.username}/modmail" - status_map = { - 201: "Successful response.", - 204: "Already merged.", - 403: "Forbidden.", - 404: f"Repository '{repo_url}' not found.", - 409: "There is a merge conflict.", - 422: "Validation failed.", - } - # source https://docs.github.com/en/rest/branches/branches#merge-a-branch - - status = resp.status - data = await self._get_response_data(resp) - if status in (201, 204): - return data - - args = (resp.request_info, resp.history) - try: - # try to get the response error message if any - message = data.get("message") - except AttributeError: - message = None - kwargs = { - "status": status, - "message": message if message else status_map.get(status), - } - # just raise - raise ClientResponseError(*args, **kwargs) - - async def fork_repository(self) -> None: - """ - Forks Modmail's repository. - """ - await self.request(self.FORK_URL, method="POST", return_response=True) - - async def has_starred(self) -> bool: - """ - Checks if shared Modmail. - - Returns - ------- - bool - `True`, if Modmail was starred. - Otherwise `False`. - """ - resp = await self.request(self.STAR_URL, return_response=True) - return resp.status == 204 - - async def star_repository(self) -> None: - """ - Stars Modmail's repository. - """ - await self.request( - self.STAR_URL, - method="PUT", - headers={"Content-Length": "0"}, - return_response=True, - ) - - @classmethod - async def login(cls, bot) -> "GitHub": - """ - Logs in to GitHub with configuration variable information. - - Parameters - ---------- - bot : Bot - The Modmail bot. - - Returns - ------- - GitHub - The newly created `GitHub` object. - """ - self = cls(bot, bot.config.get("github_token")) - resp: Dict[str, Any] = await self.request(self.BASE + "/user") - if resp.get("login"): - self.username = resp["login"] - self.avatar_url = resp["avatar_url"] - self.url = resp["html_url"] - logger.info(f"GitHub logged in to: {self.username}") - return self - else: - raise InvalidConfigError("Invalid github token") - - -class ApiClient: - """ - This class represents the general request class for all type of clients. - - Parameters - ---------- - bot : Bot - The Modmail bot. - - Attributes - ---------- - bot : Bot - The Modmail bot. - session : ClientSession - The bot's current running `ClientSession`. - """ - - def __init__(self, bot, db): - self.bot = bot - self.db = db - self.session = bot.session - - async def request( - self, - url: str, - method: str = "GET", - payload: dict = None, - return_response: bool = False, - headers: dict = None, - ) -> Union[ClientResponse, dict, str]: - """ - Makes a HTTP request. - - Parameters - ---------- - url : str - The destination URL of the request. - method : str - The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). - payload : Dict[str, Any] - The json payload to be sent along the request. - return_response : bool - Whether the `ClientResponse` object should be returned. - headers : Dict[str, str] - Additional headers to `headers`. - - Returns - ------- - ClientResponse or Dict[str, Any] or List[Any] or str - `ClientResponse` if `return_response` is `True`. - `dict` if the returned data is a json object. - `list` if the returned data is a json list. - `str` if the returned data is not a valid json data, - the raw response. - """ - async with self.session.request(method, url, headers=headers, json=payload) as resp: - if return_response: - return resp - try: - return await resp.json() - except (JSONDecodeError, ClientResponseError): - return await resp.text() - - @property - def logs(self): - return self.db.logs - - async def setup_indexes(self): - return NotImplemented - - async def validate_database_connection(self): - return NotImplemented - - async def get_user_logs(self, user_id: Union[str, int]) -> list: - return NotImplemented - - async def find_log_entry(self, key: str) -> list: - return NotImplemented - - async def get_latest_user_logs(self, user_id: Union[str, int]): - return NotImplemented - - async def get_responded_logs(self, user_id: Union[str, int]) -> list: - return NotImplemented - - async def get_open_logs(self) -> list: - return NotImplemented - - async def get_log(self, channel_id: Union[str, int]) -> dict: - return NotImplemented - - async def get_log_link(self, channel_id: Union[str, int]) -> str: - return NotImplemented - - async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: - return NotImplemented - - async def delete_log_entry(self, key: str) -> bool: - return NotImplemented - - async def get_config(self) -> dict: - return NotImplemented - - async def update_config(self, data: dict): - return NotImplemented - - async def edit_message(self, message_id: Union[int, str], new_content: str): - return NotImplemented - - async def append_log( - self, - message: Message, - *, - message_id: str = "", - channel_id: str = "", - type_: str = "thread_message", - ) -> dict: - return NotImplemented - - async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: - return NotImplemented - - async def search_closed_by(self, user_id: Union[int, str]): - return NotImplemented - - async def search_by_text(self, text: str, limit: Optional[int]): - return NotImplemented - - async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): - return NotImplemented - - async def find_notes(self, recipient: Member): - return NotImplemented - - async def update_note_ids(self, ids: dict): - return NotImplemented - - async def delete_note(self, message_id: Union[int, str]): - return NotImplemented - - async def edit_note(self, message_id: Union[int, str], message: str): - return NotImplemented - - def get_plugin_partition(self, cog): - return NotImplemented - - async def update_repository(self) -> dict: - return NotImplemented - - async def get_user_info(self) -> Optional[dict]: - return NotImplemented - - -class MongoDBClient(ApiClient): - def __init__(self, bot): - mongo_uri = bot.config["connection_uri"] - if mongo_uri is None: - mongo_uri = bot.config["mongo_uri"] - if mongo_uri is not None: - logger.warning( - "You're using the old config MONGO_URI, " - "consider switching to the new CONNECTION_URI config." - ) - else: - logger.critical("A Mongo URI is necessary for the bot to function.") - raise RuntimeError - - try: - db = AsyncIOMotorClient(mongo_uri).modmail_bot - except ConfigurationError as e: - logger.critical( - "Your MongoDB CONNECTION_URI might be copied wrong, try re-copying from the source again. " - "Otherwise noted in the following message:\n%s", - e, - ) - sys.exit(0) - - super().__init__(bot, db) - - async def setup_indexes(self): - """Setup text indexes so we can use the $search operator""" - coll = self.db.logs - index_name = "messages.content_text_messages.author.name_text_key_text" - - index_info = await coll.index_information() - - # Backwards compatibility - old_index = "messages.content_text_messages.author.name_text" - if old_index in index_info: - logger.info("Dropping old index: %s", old_index) - await coll.drop_index(old_index) - - if index_name not in index_info: - logger.info('Creating "text" index for logs collection.') - logger.info("Name: %s", index_name) - await coll.create_index( - [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")] - ) - logger.debug("Successfully configured and verified database indexes.") - - async def validate_database_connection(self, *, ssl_retry=True): - try: - await self.db.command("buildinfo") - except Exception as exc: - logger.critical("Something went wrong while connecting to the database.") - message = f"{type(exc).__name__}: {str(exc)}" - logger.critical(message) - if "CERTIFICATE_VERIFY_FAILED" in message and ssl_retry: - mongo_uri = self.bot.config["connection_uri"] - if mongo_uri is None: - mongo_uri = self.bot.config["mongo_uri"] - for _ in range(3): - logger.warning( - "FAILED TO VERIFY SSL CERTIFICATE, ATTEMPTING TO START WITHOUT SSL (UNSAFE)." - ) - logger.warning( - "To fix this warning, check there's no proxies blocking SSL cert verification, " - 'run "Certificate.command" on MacOS, ' - 'and check certifi is up to date "pip3 install --upgrade certifi".' - ) - self.db = AsyncIOMotorClient(mongo_uri, tlsAllowInvalidCertificates=True).modmail_bot - return await self.validate_database_connection(ssl_retry=False) - if "ServerSelectionTimeoutError" in message: - logger.critical( - "This may have been caused by not whitelisting " - "IPs correctly. Make sure to whitelist all " - "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" - ) - - if "OperationFailure" in message: - logger.critical( - "This is due to having invalid credentials in your MongoDB CONNECTION_URI. " - "Remember you need to substitute `` with your actual password." - ) - logger.critical( - "Be sure to URL encode your username and password (not the entire URL!!), " - "https://www.urlencoder.io/, if this issue persists, try changing your username and password " - "to only include alphanumeric characters, no symbols." - "" - ) - raise - else: - logger.debug("Successfully connected to the database.") - logger.line("debug") - - async def get_user_logs(self, user_id: Union[str, int]) -> list: - query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} - projection = {"messages": {"$slice": 5}} - logger.debug("Retrieving user %s logs.", user_id) - - return await self.logs.find(query, projection).to_list(None) - - async def find_log_entry(self, key: str) -> list: - query = {"key": key} - projection = {"messages": {"$slice": 5}} - logger.debug(f"Retrieving log ID {key}.") - - return await self.logs.find(query, projection).to_list(None) - - async def get_latest_user_logs(self, user_id: Union[str, int]): - query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} - projection = {"messages": {"$slice": 5}} - logger.debug("Retrieving user %s latest logs.", user_id) - - return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)]) - - async def get_responded_logs(self, user_id: Union[str, int]) -> list: - query = { - "open": False, - "messages": { - "$elemMatch": { - "author.id": str(user_id), - "author.mod": True, - "type": {"$in": ["anonymous", "thread_message"]}, - } - }, - } - return await self.logs.find(query).to_list(None) - - async def get_open_logs(self) -> list: - query = {"open": True} - return await self.logs.find(query).to_list(None) - - async def get_log(self, channel_id: Union[str, int]) -> dict: - logger.debug("Retrieving channel %s logs.", channel_id) - return await self.logs.find_one({"channel_id": str(channel_id)}) - - async def get_log_link(self, channel_id: Union[str, int]) -> str: - doc = await self.get_log(channel_id) - logger.debug("Retrieving log link for channel %s.", channel_id) - prefix = self.bot.config["log_url_prefix"].strip("/") - if prefix == "NONE": - prefix = "" - return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" - - async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: - key = secrets.token_hex(6) - - await self.logs.insert_one( - { - "_id": key, - "key": key, - "open": True, - "created_at": str(discord.utils.utcnow()), - "closed_at": None, - "channel_id": str(channel.id), - "guild_id": str(self.bot.guild_id), - "bot_id": str(self.bot.user.id), - "recipient": { - "id": str(recipient.id), - "name": recipient.name, - "discriminator": recipient.discriminator, - "avatar_url": recipient.display_avatar.url, - "mod": False, - }, - "creator": { - "id": str(creator.id), - "name": creator.name, - "discriminator": creator.discriminator, - "avatar_url": creator.display_avatar.url, - "mod": isinstance(creator, Member), - }, - "closer": None, - "messages": [], - } - ) - logger.debug("Created a log entry, key %s.", key) - prefix = self.bot.config["log_url_prefix"].strip("/") - if prefix == "NONE": - prefix = "" - return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" - - async def delete_log_entry(self, key: str) -> bool: - result = await self.logs.delete_one({"key": key}) - return result.deleted_count == 1 - - async def get_config(self) -> dict: - conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) - if conf is None: - logger.debug("Creating a new config entry for bot %s.", self.bot.user.id) - await self.db.config.insert_one({"bot_id": self.bot.user.id}) - return {"bot_id": self.bot.user.id} - return conf - - async def update_config(self, data: dict): - toset = self.bot.config.filter_valid(data) - unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) - - if toset and unset: - return await self.db.config.update_one( - {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} - ) - if toset: - return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$set": toset}) - if unset: - return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$unset": unset}) - - async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: - await self.logs.update_one( - {"messages.message_id": str(message_id)}, - {"$set": {"messages.$.content": new_content, "messages.$.edited": True}}, - ) - - async def append_log( - self, - message: Message, - *, - message_id: str = "", - channel_id: str = "", - type_: str = "thread_message", - ) -> dict: - channel_id = str(channel_id) or str(message.channel.id) - message_id = str(message_id) or str(message.id) - - data = { - "timestamp": str(message.created_at), - "message_id": message_id, - "author": { - "id": str(message.author.id), - "name": message.author.name, - "discriminator": message.author.discriminator, - "avatar_url": message.author.display_avatar.url, - "mod": not isinstance(message.channel, DMChannel), - }, - "content": message.content, - "type": type_, - "attachments": [ - { - "id": a.id, - "filename": a.filename, - "is_image": a.width is not None, - "size": a.size, - "url": a.url, - } - for a in message.attachments - ], - } - - return await self.logs.find_one_and_update( - {"channel_id": channel_id}, {"$push": {"messages": data}}, return_document=True - ) - - async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: - return await self.logs.find_one_and_update( - {"channel_id": str(channel_id)}, {"$set": data}, return_document=True - ) - - async def search_closed_by(self, user_id: Union[int, str]): - return await self.logs.find( - {"guild_id": str(self.bot.guild_id), "open": False, "closer.id": str(user_id)}, - {"messages": {"$slice": 5}}, - ).to_list(None) - - async def search_by_text(self, text: str, limit: Optional[int]): - return await self.bot.db.logs.find( - { - "guild_id": str(self.bot.guild_id), - "open": False, - "$text": {"$search": f'"{text}"'}, - }, - {"messages": {"$slice": 5}}, - ).to_list(limit) - - async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): - await self.db.notes.insert_one( - { - "recipient": str(recipient.id), - "author": { - "id": str(message.author.id), - "name": message.author.name, - "discriminator": message.author.discriminator, - "avatar_url": message.author.display_avatar.url, - }, - "message": message.content, - "message_id": str(message_id), - } - ) - - async def find_notes(self, recipient: Member): - return await self.db.notes.find({"recipient": str(recipient.id)}).to_list(None) - - async def update_note_ids(self, ids: dict): - for object_id, message_id in ids.items(): - await self.db.notes.update_one({"_id": object_id}, {"$set": {"message_id": message_id}}) - - async def delete_note(self, message_id: Union[int, str]): - await self.db.notes.delete_one({"message_id": str(message_id)}) - - async def edit_note(self, message_id: Union[int, str], message: str): - await self.db.notes.update_one({"message_id": str(message_id)}, {"$set": {"message": message}}) - - def get_plugin_partition(self, cog): - cls_name = cog.__class__.__name__ - return self.db.plugins[cls_name] - - async def update_repository(self) -> dict: - user = await GitHub.login(self.bot) - data = await user.update_repository() - return { - "data": data, - "user": { - "username": user.username, - "avatar_url": user.avatar_url, - "url": user.url, - }, - } - - async def get_user_info(self) -> Optional[dict]: - try: - user = await GitHub.login(self.bot) - except InvalidConfigError: - return None - else: - return { - "user": { - "username": user.username, - "avatar_url": user.avatar_url, - "url": user.url, - } - } - - -class PluginDatabaseClient: - def __init__(self, bot): - self.bot = bot - - def get_partition(self, cog): - cls_name = cog.__class__.__name__ - return self.bot.api.db.plugins[cls_name] +import secrets +import sys +from json import JSONDecodeError +from typing import Any, Dict, Union, Optional + +import discord +from discord import Member, DMChannel, TextChannel, Message +from discord.ext import commands + +from aiohttp import ClientResponseError, ClientResponse +from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.errors import ConfigurationError + +from core.models import InvalidConfigError, getLogger + +logger = getLogger(__name__) + + +class GitHub: + """ + The client for interacting with GitHub API. + + Parameters + ---------- + bot : Bot + The Modmail bot. + access_token : str, optional + GitHub's access token. + username : str, optional + GitHub username. + avatar_url : str, optional + URL to the avatar in GitHub. + url : str, optional + URL to the GitHub profile. + + Attributes + ---------- + bot : Bot + The Modmail bot. + access_token : str + GitHub's access token. + username : str + GitHub username. + avatar_url : str + URL to the avatar in GitHub. + url : str + URL to the GitHub profile. + + Class Attributes + ---------------- + BASE : str + GitHub API base URL. + REPO : str + Modmail repo URL for GitHub API. + HEAD : str + Modmail HEAD URL for GitHub API. + MERGE_URL : str + URL for merging upstream to master. + FORK_URL : str + URL to fork Modmail. + STAR_URL : str + URL to star Modmail. + """ + + BASE = "https://api.github.com" + REPO = BASE + "/repos/modmail-dev/modmail" + MERGE_URL = BASE + "/repos/{username}/modmail/merges" + FORK_URL = REPO + "/forks" + STAR_URL = BASE + "/user/starred/modmail-dev/modmail" + + def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): + self.bot = bot + self.session = bot.session + self.headers: Optional[dict] = None + self.access_token = access_token + self.username = username + self.avatar_url: str = kwargs.pop("avatar_url", "") + self.url: str = kwargs.pop("url", "") + if self.access_token: + self.headers = {"Authorization": "token " + str(access_token)} + + @property + def BRANCH(self) -> str: + return "master" if not self.bot.version.is_prerelease else "development" + + async def request( + self, + url: str, + method: str = "GET", + payload: dict = None, + headers: dict = None, + return_response: bool = False, + read_before_return: bool = False, + ) -> Union[ClientResponse, Dict[str, Any], str]: + """ + Makes a HTTP request. + + Parameters + ---------- + url : str + The destination URL of the request. + method : str + The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). + payload : Dict[str, Any] + The json payload to be sent along the request. + headers : Dict[str, str] + Additional headers to `headers`. + return_response : bool + Whether the `ClientResponse` object should be returned. + read_before_return : bool + Whether to perform `.read()` method before returning the `ClientResponse` object. + Only valid if `return_response` is set to `True`. + + Returns + ------- + ClientResponse or Dict[str, Any] or List[Any] or str + `ClientResponse` if `return_response` is `True`. + `Dict[str, Any]` if the returned data is a json object. + `List[Any]` if the returned data is a json list. + `str` if the returned data is not a valid json data, + the raw response. + """ + if headers is not None: + headers.update(self.headers) + else: + headers = self.headers + async with self.session.request(method, url, headers=headers, json=payload) as resp: + if return_response: + if read_before_return: + await resp.read() + return resp + + return await self._get_response_data(resp) + + @staticmethod + async def _get_response_data(response: ClientResponse) -> Union[Dict[str, Any], str]: + """ + Internal method to convert the response data to `dict` if the data is a + json object, or to `str` (raw response) if the data is not a valid json. + """ + try: + return await response.json() + except (JSONDecodeError, ClientResponseError): + return await response.text() + + def filter_valid(self, data) -> Dict[str, Any]: + """ + Filters configuration keys that are accepted. + + Parameters + ---------- + data : Dict[str, Any] + The data that needs to be cleaned. + + Returns + ------- + Dict[str, Any] + Filtered `data` to keep only the accepted pairs. + """ + valid_keys = self.bot.config.valid_keys.difference(self.bot.config.protected_keys) + return {k: v for k, v in data.items() if k in valid_keys} + + async def update_repository(self, sha: str = None) -> Dict[str, Any]: + """ + Update the repository from Modmail main repo. + + Parameters + ---------- + sha : Optional[str] + The commit SHA to update the repository. If `None`, the latest + commit SHA will be fetched. + + Returns + ------- + Dict[str, Any] + A dictionary that contains response data. + """ + if not self.username: + raise commands.CommandInvokeError("Username not found.") + + if sha is None: + resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) + sha = resp["object"]["sha"] + + payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} + + merge_url = self.MERGE_URL.format(username=self.username) + + resp = await self.request( + merge_url, + method="POST", + payload=payload, + return_response=True, + read_before_return=True, + ) + + repo_url = self.BASE + f"/repos/{self.username}/modmail" + status_map = { + 201: "Successful response.", + 204: "Already merged.", + 403: "Forbidden.", + 404: f"Repository '{repo_url}' not found.", + 409: "There is a merge conflict.", + 422: "Validation failed.", + } + # source https://docs.github.com/en/rest/branches/branches#merge-a-branch + + status = resp.status + data = await self._get_response_data(resp) + if status in (201, 204): + return data + + args = (resp.request_info, resp.history) + try: + # try to get the response error message if any + message = data.get("message") + except AttributeError: + message = None + kwargs = { + "status": status, + "message": message if message else status_map.get(status), + } + # just raise + raise ClientResponseError(*args, **kwargs) + + async def fork_repository(self) -> None: + """ + Forks Modmail's repository. + """ + await self.request(self.FORK_URL, method="POST", return_response=True) + + async def has_starred(self) -> bool: + """ + Checks if shared Modmail. + + Returns + ------- + bool + `True`, if Modmail was starred. + Otherwise `False`. + """ + resp = await self.request(self.STAR_URL, return_response=True) + return resp.status == 204 + + async def star_repository(self) -> None: + """ + Stars Modmail's repository. + """ + await self.request( + self.STAR_URL, + method="PUT", + headers={"Content-Length": "0"}, + return_response=True, + ) + + @classmethod + async def login(cls, bot) -> "GitHub": + """ + Logs in to GitHub with configuration variable information. + + Parameters + ---------- + bot : Bot + The Modmail bot. + + Returns + ------- + GitHub + The newly created `GitHub` object. + """ + self = cls(bot, bot.config.get("github_token")) + resp: Dict[str, Any] = await self.request(self.BASE + "/user") + if resp.get("login"): + self.username = resp["login"] + self.avatar_url = resp["avatar_url"] + self.url = resp["html_url"] + logger.info(f"GitHub logged in to: {self.username}") + return self + else: + raise InvalidConfigError("Invalid github token") + + +class ApiClient: + """ + This class represents the general request class for all type of clients. + + Parameters + ---------- + bot : Bot + The Modmail bot. + + Attributes + ---------- + bot : Bot + The Modmail bot. + session : ClientSession + The bot's current running `ClientSession`. + """ + + def __init__(self, bot, db): + self.bot = bot + self.db = db + self.session = bot.session + + async def request( + self, + url: str, + method: str = "GET", + payload: dict = None, + return_response: bool = False, + headers: dict = None, + ) -> Union[ClientResponse, dict, str]: + """ + Makes a HTTP request. + + Parameters + ---------- + url : str + The destination URL of the request. + method : str + The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). + payload : Dict[str, Any] + The json payload to be sent along the request. + return_response : bool + Whether the `ClientResponse` object should be returned. + headers : Dict[str, str] + Additional headers to `headers`. + + Returns + ------- + ClientResponse or Dict[str, Any] or List[Any] or str + `ClientResponse` if `return_response` is `True`. + `dict` if the returned data is a json object. + `list` if the returned data is a json list. + `str` if the returned data is not a valid json data, + the raw response. + """ + async with self.session.request(method, url, headers=headers, json=payload) as resp: + if return_response: + return resp + try: + return await resp.json() + except (JSONDecodeError, ClientResponseError): + return await resp.text() + + @property + def logs(self): + return self.db.logs + + async def setup_indexes(self): + return NotImplemented + + async def validate_database_connection(self): + return NotImplemented + + async def get_user_logs(self, user_id: Union[str, int]) -> list: + return NotImplemented + + async def find_log_entry(self, key: str) -> list: + return NotImplemented + + async def get_latest_user_logs(self, user_id: Union[str, int]): + return NotImplemented + + async def get_responded_logs(self, user_id: Union[str, int]) -> list: + return NotImplemented + + async def get_open_logs(self) -> list: + return NotImplemented + + async def get_log(self, channel_id: Union[str, int]) -> dict: + return NotImplemented + + async def get_log_link(self, channel_id: Union[str, int]) -> str: + return NotImplemented + + async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: + return NotImplemented + + async def delete_log_entry(self, key: str) -> bool: + return NotImplemented + + async def get_config(self) -> dict: + return NotImplemented + + async def update_config(self, data: dict): + return NotImplemented + + async def edit_message(self, message_id: Union[int, str], new_content: str): + return NotImplemented + + async def append_log( + self, + message: Message, + *, + message_id: str = "", + channel_id: str = "", + type_: str = "thread_message", + ) -> dict: + return NotImplemented + + async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: + return NotImplemented + + async def search_closed_by(self, user_id: Union[int, str]): + return NotImplemented + + async def search_by_text(self, text: str, limit: Optional[int]): + return NotImplemented + + async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): + return NotImplemented + + async def find_notes(self, recipient: Member): + return NotImplemented + + async def update_note_ids(self, ids: dict): + return NotImplemented + + async def delete_note(self, message_id: Union[int, str]): + return NotImplemented + + async def edit_note(self, message_id: Union[int, str], message: str): + return NotImplemented + + def get_plugin_partition(self, cog): + return NotImplemented + + async def update_repository(self) -> dict: + return NotImplemented + + async def get_user_info(self) -> Optional[dict]: + return NotImplemented + + +class MongoDBClient(ApiClient): + def __init__(self, bot): + mongo_uri = bot.config["connection_uri"] + if mongo_uri is None: + mongo_uri = bot.config["mongo_uri"] + if mongo_uri is not None: + logger.warning( + "You're using the old config MONGO_URI, " + "consider switching to the new CONNECTION_URI config." + ) + else: + logger.critical("A Mongo URI is necessary for the bot to function.") + raise RuntimeError + + try: + db = AsyncIOMotorClient(mongo_uri).modmail_bot + except ConfigurationError as e: + logger.critical( + "Your MongoDB CONNECTION_URI might be copied wrong, try re-copying from the source again. " + "Otherwise noted in the following message:\n%s", + e, + ) + sys.exit(0) + + super().__init__(bot, db) + + async def setup_indexes(self): + """Setup text indexes so we can use the $search operator""" + coll = self.db.logs + index_name = "messages.content_text_messages.author.name_text_key_text" + + index_info = await coll.index_information() + + # Backwards compatibility + old_index = "messages.content_text_messages.author.name_text" + if old_index in index_info: + logger.info("Dropping old index: %s", old_index) + await coll.drop_index(old_index) + + if index_name not in index_info: + logger.info('Creating "text" index for logs collection.') + logger.info("Name: %s", index_name) + await coll.create_index( + [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")] + ) + logger.debug("Successfully configured and verified database indexes.") + + async def validate_database_connection(self, *, ssl_retry=True): + try: + await self.db.command("buildinfo") + except Exception as exc: + logger.critical("Something went wrong while connecting to the database.") + message = f"{type(exc).__name__}: {str(exc)}" + logger.critical(message) + if "CERTIFICATE_VERIFY_FAILED" in message and ssl_retry: + mongo_uri = self.bot.config["connection_uri"] + if mongo_uri is None: + mongo_uri = self.bot.config["mongo_uri"] + for _ in range(3): + logger.warning( + "FAILED TO VERIFY SSL CERTIFICATE, ATTEMPTING TO START WITHOUT SSL (UNSAFE)." + ) + logger.warning( + "To fix this warning, check there's no proxies blocking SSL cert verification, " + 'run "Certificate.command" on MacOS, ' + 'and check certifi is up to date "pip3 install --upgrade certifi".' + ) + self.db = AsyncIOMotorClient(mongo_uri, tlsAllowInvalidCertificates=True).modmail_bot + return await self.validate_database_connection(ssl_retry=False) + if "ServerSelectionTimeoutError" in message: + logger.critical( + "This may have been caused by not whitelisting " + "IPs correctly. Make sure to whitelist all " + "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" + ) + + if "OperationFailure" in message: + logger.critical( + "This is due to having invalid credentials in your MongoDB CONNECTION_URI. " + "Remember you need to substitute `` with your actual password." + ) + logger.critical( + "Be sure to URL encode your username and password (not the entire URL!!), " + "https://www.urlencoder.io/, if this issue persists, try changing your username and password " + "to only include alphanumeric characters, no symbols." + "" + ) + raise + else: + logger.debug("Successfully connected to the database.") + logger.line("debug") + + async def get_user_logs(self, user_id: Union[str, int]) -> list: + query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id)} + projection = {"messages": {"$slice": 5}} + logger.debug("Retrieving user %s logs.", user_id) + + return await self.logs.find(query, projection).to_list(None) + + async def find_log_entry(self, key: str) -> list: + query = {"key": key} + projection = {"messages": {"$slice": 5}} + logger.debug(f"Retrieving log ID {key}.") + + return await self.logs.find(query, projection).to_list(None) + + async def get_latest_user_logs(self, user_id: Union[str, int]): + query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} + projection = {"messages": {"$slice": 5}} + logger.debug("Retrieving user %s latest logs.", user_id) + + return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)]) + + async def get_responded_logs(self, user_id: Union[str, int]) -> list: + query = { + "open": False, + "messages": { + "$elemMatch": { + "author.id": str(user_id), + "author.mod": True, + "type": {"$in": ["anonymous", "thread_message"]}, + } + }, + } + return await self.logs.find(query).to_list(None) + + async def get_open_logs(self) -> list: + query = {"open": True} + return await self.logs.find(query).to_list(None) + + async def get_log(self, channel_id: Union[str, int]) -> dict: + logger.debug("Retrieving channel %s logs.", channel_id) + return await self.logs.find_one({"channel_id": str(channel_id)}) + + async def get_log_link(self, channel_id: Union[str, int]) -> str: + doc = await self.get_log(channel_id) + logger.debug("Retrieving log link for channel %s.", channel_id) + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" + return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" + + async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: + key = secrets.token_hex(6) + + await self.logs.insert_one( + { + "_id": key, + "key": key, + "open": True, + "created_at": str(discord.utils.utcnow()), + "closed_at": None, + "channel_id": str(channel.id), + "guild_id": str(self.bot.guild_id), + "bot_id": str(self.bot.user.id), + "recipient": { + "id": str(recipient.id), + "name": recipient.name, + "discriminator": recipient.discriminator, + "avatar_url": recipient.display_avatar.url if recipient.display_avatar else None, + "mod": False, + }, + "creator": { + "id": str(creator.id), + "name": creator.name, + "discriminator": creator.discriminator, + "avatar_url": creator.display_avatar.url if creator.display_avatar else None, + "mod": isinstance(creator, Member), + }, + "closer": None, + "messages": [], + } + ) + logger.debug("Created a log entry, key %s.", key) + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" + return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}" + + async def delete_log_entry(self, key: str) -> bool: + result = await self.logs.delete_one({"key": key}) + return result.deleted_count == 1 + + async def get_config(self) -> dict: + conf = await self.db.config.find_one({"bot_id": self.bot.user.id}) + if conf is None: + logger.debug("Creating a new config entry for bot %s.", self.bot.user.id) + await self.db.config.insert_one({"bot_id": self.bot.user.id}) + return {"bot_id": self.bot.user.id} + return conf + + async def update_config(self, data: dict): + toset = self.bot.config.filter_valid(data) + unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) + + if toset and unset: + return await self.db.config.update_one( + {"bot_id": self.bot.user.id}, {"$set": toset, "$unset": unset} + ) + if toset: + return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$set": toset}) + if unset: + return await self.db.config.update_one({"bot_id": self.bot.user.id}, {"$unset": unset}) + + async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: + await self.logs.update_one( + {"messages.message_id": str(message_id)}, + {"$set": {"messages.$.content": new_content, "messages.$.edited": True}}, + ) + + async def append_log( + self, + message: Message, + *, + message_id: str = "", + channel_id: str = "", + type_: str = "thread_message", + ) -> dict: + channel_id = str(channel_id) or str(message.channel.id) + message_id = str(message_id) or str(message.id) + + data = { + "timestamp": str(message.created_at), + "message_id": message_id, + "author": { + "id": str(message.author.id), + "name": message.author.name, + "discriminator": message.author.discriminator, + "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, + "mod": not isinstance(message.channel, DMChannel), + }, + "content": message.content, + "type": type_, + "attachments": [ + { + "id": a.id, + "filename": a.filename, + "is_image": a.width is not None, + "size": a.size, + "url": a.url, + } + for a in message.attachments + ], + } + + return await self.logs.find_one_and_update( + {"channel_id": channel_id}, {"$push": {"messages": data}}, return_document=True + ) + + async def post_log(self, channel_id: Union[int, str], data: dict) -> dict: + return await self.logs.find_one_and_update( + {"channel_id": str(channel_id)}, {"$set": data}, return_document=True + ) + + async def search_closed_by(self, user_id: Union[int, str]): + return await self.logs.find( + {"guild_id": str(self.bot.guild_id), "open": False, "closer.id": str(user_id)}, + {"messages": {"$slice": 5}}, + ).to_list(None) + + async def search_by_text(self, text: str, limit: Optional[int]): + return await self.bot.db.logs.find( + { + "guild_id": str(self.bot.guild_id), + "open": False, + "$text": {"$search": f'"{text}"'}, + }, + {"messages": {"$slice": 5}}, + ).to_list(limit) + + async def create_note(self, recipient: Member, message: Message, message_id: Union[int, str]): + await self.db.notes.insert_one( + { + "recipient": str(recipient.id), + "author": { + "id": str(message.author.id), + "name": message.author.name, + "discriminator": message.author.discriminator, + "avatar_url": ( + message.author.display_avatar.url if message.author.display_avatar else None + ), + }, + "message": message.content, + "message_id": str(message_id), + } + ) + + async def find_notes(self, recipient: Member): + return await self.db.notes.find({"recipient": str(recipient.id)}).to_list(None) + + async def update_note_ids(self, ids: dict): + for object_id, message_id in ids.items(): + await self.db.notes.update_one({"_id": object_id}, {"$set": {"message_id": message_id}}) + + async def delete_note(self, message_id: Union[int, str]): + await self.db.notes.delete_one({"message_id": str(message_id)}) + + async def edit_note(self, message_id: Union[int, str], message: str): + await self.db.notes.update_one({"message_id": str(message_id)}, {"$set": {"message": message}}) + + def get_plugin_partition(self, cog): + cls_name = cog.__class__.__name__ + return self.db.plugins[cls_name] + + async def update_repository(self) -> dict: + user = await GitHub.login(self.bot) + data = await user.update_repository() + return { + "data": data, + "user": { + "username": user.username, + "avatar_url": user.avatar_url, + "url": user.url, + }, + } + + async def get_user_info(self) -> Optional[dict]: + try: + user = await GitHub.login(self.bot) + except InvalidConfigError: + return None + else: + return { + "user": { + "username": user.username, + "avatar_url": user.avatar_url, + "url": user.url, + } + } + + +class PluginDatabaseClient: + def __init__(self, bot): + self.bot = bot + + def get_partition(self, cog): + cls_name = cog.__class__.__name__ + return self.bot.api.db.plugins[cls_name] diff --git a/core/config.py b/core/config.py index 87cef553c4..ea750ea3e4 100644 --- a/core/config.py +++ b/core/config.py @@ -1,476 +1,482 @@ -import asyncio -import json -import os -import re -import typing -from copy import deepcopy - -from dotenv import load_dotenv -import isodate - -import discord -from discord.ext.commands import BadArgument - -from core._color_data import ALL_COLORS -from core.models import DMDisabled, InvalidConfigError, Default, getLogger -from core.time import UserFriendlyTime -from core.utils import strtobool - -logger = getLogger(__name__) -load_dotenv() - - -class ConfigManager: - public_keys = { - # activity - "twitch_url": "https://www.twitch.tv/discordmodmail/", - # bot settings - "main_category_id": None, - "fallback_category_id": None, - "prefix": "?", - "mention": "@here", - "main_color": str(discord.Color.blurple()), - "error_color": str(discord.Color.red()), - "user_typing": False, - "mod_typing": False, - "account_age": isodate.Duration(), - "guild_age": isodate.Duration(), - "thread_cooldown": isodate.Duration(), - "log_expiration": isodate.Duration(), - "reply_without_command": False, - "anon_reply_without_command": False, - "plain_reply_without_command": False, - # logging - "log_channel_id": None, - "mention_channel_id": None, - "update_channel_id": None, - # updates - "update_notifications": True, - # threads - "sent_emoji": "\N{WHITE HEAVY CHECK MARK}", - "blocked_emoji": "\N{NO ENTRY SIGN}", - "close_emoji": "\N{LOCK}", - "use_user_id_channel_name": False, - "use_timestamp_channel_name": False, - "use_nickname_channel_name": False, - "use_random_channel_name": False, - "recipient_thread_close": False, - "thread_show_roles": True, - "thread_show_account_age": True, - "thread_show_join_age": True, - "thread_cancelled": "Cancelled", - "thread_auto_close_silently": False, - "thread_auto_close": isodate.Duration(), - "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", - "thread_creation_response": "The staff team will get back to you as soon as possible.", - "thread_creation_footer": "Your message has been sent", - "thread_contact_silently": False, - "thread_self_closable_creation_footer": "Click the lock to close the thread", - "thread_creation_contact_title": "New Thread", - "thread_creation_self_contact_response": "You have opened a Modmail thread.", - "thread_creation_contact_response": "{creator.name} has opened a Modmail thread.", - "thread_creation_title": "Thread Created", - "thread_close_footer": "Replying will create a new thread", - "thread_close_title": "Thread Closed", - "thread_close_response": "{closer.mention} has closed this Modmail thread.", - "thread_self_close_response": "You have closed this Modmail thread.", - "thread_move_title": "Thread Moved", - "thread_move_notify": False, - "thread_move_notify_mods": False, - "thread_move_response": "This thread has been moved.", - "cooldown_thread_title": "Message not sent!", - "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", - "disabled_new_thread_title": "Not Delivered", - "disabled_new_thread_response": "We are not accepting new threads.", - "disabled_new_thread_footer": "Please try again later...", - "disabled_current_thread_title": "Not Delivered", - "disabled_current_thread_response": "We are not accepting any messages.", - "disabled_current_thread_footer": "Please try again later...", - "transfer_reactions": True, - "close_on_leave": False, - "close_on_leave_reason": "The recipient has left the server.", - "alert_on_mention": False, - "silent_alert_on_mention": False, - "show_timestamp": True, - "anonymous_snippets": False, - "plain_snippets": False, - "require_close_reason": False, - "show_log_url_button": False, - # group conversations - "private_added_to_group_title": "New Thread (Group)", - "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", - "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.", - "public_added_to_group_title": "New User", - "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.", - "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.", - "private_removed_from_group_title": "Removed From Thread (Group)", - "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.", - "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.", - "public_removed_from_group_title": "User Removed", - "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.", - "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.", - # moderation - "recipient_color": str(discord.Color.gold()), - "mod_color": str(discord.Color.green()), - "mod_tag": None, - # anonymous message - "anon_username": None, - "anon_avatar_url": None, - "anon_tag": "Response", - # react to contact - "react_to_contact_message": None, - "react_to_contact_emoji": "\N{WHITE HEAVY CHECK MARK}", - # confirm thread creation - "confirm_thread_creation": False, - "confirm_thread_creation_title": "Confirm thread creation", - "confirm_thread_response": "Click the button to confirm thread creation which will directly contact the moderators.", - "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}", - "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", - # regex - "use_regex_autotrigger": False, - "use_hoisted_top_role": True, - # Minimum characters for thread creation - "thread_min_characters": 0, - "thread_min_characters_title": "Message too short", - "thread_min_characters_response": "Your message is too short to create a thread. Please provide more details.", - "thread_min_characters_footer": "Minimum {min_characters} characters required.", - } - - private_keys = { - # bot presence - "activity_message": "", - "activity_type": None, - "status": None, - "dm_disabled": DMDisabled.NONE, - "oauth_whitelist": [], - # moderation - "blocked": {}, - "blocked_roles": {}, - "blocked_whitelist": [], - "command_permissions": {}, - "level_permissions": {}, - "override_command_level": {}, - # threads - "snippets": {}, - "notification_squad": {}, - "subscriptions": {}, - "closures": {}, - # misc - "plugins": [], - "aliases": {}, - "auto_triggers": {}, - } - - protected_keys = { - # Modmail - "modmail_guild_id": None, - "guild_id": None, - "log_url": "https://example.com/", - "log_url_prefix": "/logs", - "mongo_uri": None, - "database_type": "mongodb", - "connection_uri": None, # replace mongo uri in the future - "owners": None, - "enable_presence_intent": False, - "registry_plugins_only": False, - # bot - "token": None, - "enable_plugins": True, - "enable_eval": True, - # github access token for private repositories - "github_token": None, - "disable_autoupdates": False, - "disable_updates": False, - # Logging - "log_level": "INFO", - "stream_log_format": "plain", - "file_log_format": "plain", - "discord_log_level": "INFO", - # data collection - "data_collection": True, - } - - colors = {"mod_color", "recipient_color", "main_color", "error_color"} - - time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown", "log_expiration"} - - booleans = { - "use_user_id_channel_name", - "use_timestamp_channel_name", - "use_nickname_channel_name", - "use_random_channel_name", - "user_typing", - "mod_typing", - "reply_without_command", - "anon_reply_without_command", - "plain_reply_without_command", - "show_log_url_button", - "recipient_thread_close", - "thread_auto_close_silently", - "thread_move_notify", - "thread_move_notify_mods", - "transfer_reactions", - "close_on_leave", - "alert_on_mention", - "silent_alert_on_mention", - "show_timestamp", - "confirm_thread_creation", - "use_regex_autotrigger", - "enable_plugins", - "data_collection", - "enable_eval", - "disable_autoupdates", - "disable_updates", - "update_notifications", - "thread_contact_silently", - "anonymous_snippets", - "plain_snippets", - "require_close_reason", - "recipient_thread_close", - "thread_show_roles", - "thread_show_account_age", - "thread_show_join_age", - "use_hoisted_top_role", - "enable_presence_intent", - "registry_plugins_only", - } - - enums = { - "dm_disabled": DMDisabled, - "status": discord.Status, - "activity_type": discord.ActivityType, - } - - force_str = {"command_permissions", "level_permissions"} - - defaults = {**public_keys, **private_keys, **protected_keys} - all_keys = set(defaults.keys()) - - def __init__(self, bot): - self.bot = bot - self._cache = {} - self.ready_event = asyncio.Event() - self.config_help = {} - - def __repr__(self): - return repr(self._cache) - - def populate_cache(self) -> dict: - data = deepcopy(self.defaults) - - # populate from env var and .env file - data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) - config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") - if os.path.exists(config_json): - logger.debug("Loading envs from config.json.") - with open(config_json, "r", encoding="utf-8") as f: - # Config json should override env vars - try: - data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) - except json.JSONDecodeError: - logger.critical("Failed to load config.json env values.", exc_info=True) - self._cache = data - - config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json") - with open(config_help_json, "r", encoding="utf-8") as f: - self.config_help = dict(sorted(json.load(f).items())) - - return self._cache - - async def update(self): - """Updates the config with data from the cache""" - await self.bot.api.update_config(self.filter_default(self._cache)) - - async def refresh(self) -> dict: - """Refreshes internal cache with data from database""" - for k, v in (await self.bot.api.get_config()).items(): - k = k.lower() - if k in self.all_keys: - self._cache[k] = v - if not self.ready_event.is_set(): - self.ready_event.set() - logger.debug("Successfully fetched configurations from database.") - return self._cache - - async def wait_until_ready(self) -> None: - await self.ready_event.wait() - - def __setitem__(self, key: str, item: typing.Any) -> None: - key = key.lower() - logger.info("Setting %s.", key) - if key not in self.all_keys: - raise InvalidConfigError(f'Configuration "{key}" is invalid.') - self._cache[key] = item - - def __getitem__(self, key: str) -> typing.Any: - # make use of the custom methods in func:get: - return self.get(key) - - def __delitem__(self, key: str) -> None: - return self.remove(key) - - def get(self, key: str, *, convert: bool = True) -> typing.Any: - key = key.lower() - if key not in self.all_keys: - raise InvalidConfigError(f'Configuration "{key}" is invalid.') - if key not in self._cache: - self._cache[key] = deepcopy(self.defaults[key]) - value = self._cache[key] - - if not convert: - return value - - if key in self.colors: - try: - return int(value.lstrip("#"), base=16) - except ValueError: - logger.error("Invalid %s provided.", key) - value = int(self.remove(key).lstrip("#"), base=16) - - elif key in self.time_deltas: - if not isinstance(value, isodate.Duration): - try: - value = isodate.parse_duration(value) - except isodate.ISO8601Error: - logger.warning( - "The {account} age limit needs to be a " - 'ISO-8601 duration formatted duration, not "%s".', - value, - ) - value = self.remove(key) - - elif key in self.booleans: - try: - value = strtobool(value) - except ValueError: - value = self.remove(key) - - elif key in self.enums: - if value is None: - return None - try: - value = self.enums[key](value) - except ValueError: - logger.warning("Invalid %s %s.", key, value) - value = self.remove(key) - - elif key in self.force_str: - # Temporary: as we saved in int previously, leading to int32 overflow, - # this is transitioning IDs to strings - new_value = {} - changed = False - for k, v in value.items(): - new_v = v - if isinstance(v, list): - new_v = [] - for n in v: - if n != -1 and not isinstance(n, str): - changed = True - n = str(n) - new_v.append(n) - new_value[k] = new_v - - if changed: - # transition the database as well - self.set(key, new_value) - - value = new_value - - return value - - async def set(self, key: str, item: typing.Any, convert=True) -> None: - if not convert: - return self.__setitem__(key, item) - - if key in self.colors: - try: - hex_ = str(item) - if hex_.startswith("#"): - hex_ = hex_[1:] - if len(hex_) == 3: - hex_ = "".join(s for s in hex_ for _ in range(2)) - if len(hex_) != 6: - raise InvalidConfigError("Invalid color name or hex.") - try: - int(hex_, 16) - except ValueError: - raise InvalidConfigError("Invalid color name or hex.") - - except InvalidConfigError: - name = str(item).lower() - name = re.sub(r"[\-+|. ]+", " ", name) - hex_ = ALL_COLORS.get(name) - if hex_ is None: - name = re.sub(r"[\-+|. ]+", "", name) - hex_ = ALL_COLORS.get(name) - if hex_ is None: - raise - return self.__setitem__(key, "#" + hex_) - - if key in self.time_deltas: - try: - isodate.parse_duration(item) - except isodate.ISO8601Error: - try: - converter = UserFriendlyTime() - time = await converter.convert(None, item, now=discord.utils.utcnow()) - if time.arg: - raise ValueError - except BadArgument as exc: - raise InvalidConfigError(*exc.args) - except Exception as e: - logger.debug(e) - raise InvalidConfigError( - "Unrecognized time, please use ISO-8601 duration format " - 'string or a simpler "human readable" time.' - ) - now = discord.utils.utcnow() - item = isodate.duration_isoformat(time.dt - now) - return self.__setitem__(key, item) - - if key in self.booleans: - try: - return self.__setitem__(key, strtobool(item)) - except ValueError: - raise InvalidConfigError("Must be a yes/no value.") - - elif key in self.enums: - if isinstance(item, self.enums[key]): - # value is an enum type - item = item.value - - return self.__setitem__(key, item) - - def remove(self, key: str) -> typing.Any: - key = key.lower() - logger.info("Removing %s.", key) - if key not in self.all_keys: - raise InvalidConfigError(f'Configuration "{key}" is invalid.') - if key in self._cache: - del self._cache[key] - self._cache[key] = deepcopy(self.defaults[key]) - return self._cache[key] - - def items(self) -> typing.Iterable: - return self._cache.items() - - @classmethod - def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - return { - k.lower(): v - for k, v in data.items() - if k.lower() in cls.public_keys or k.lower() in cls.private_keys - } - - @classmethod - def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: - # TODO: use .get to prevent errors - filtered = {} - for k, v in data.items(): - default = cls.defaults.get(k.lower(), Default) - if default is Default: - logger.error("Unexpected configuration detected: %s.", k) - continue - if v != default: - filtered[k.lower()] = v - return filtered +import asyncio +import json +import os +import re +import typing +from copy import deepcopy + +from dotenv import load_dotenv +import isodate + +import discord +from discord.ext.commands import BadArgument + +from core._color_data import ALL_COLORS +from core.models import DMDisabled, InvalidConfigError, Default, getLogger +from core.time import UserFriendlyTime +from core.utils import strtobool + +logger = getLogger(__name__) +load_dotenv() + + +class ConfigManager: + public_keys = { + # activity + "twitch_url": "https://www.twitch.tv/discordmodmail/", + # bot settings + "main_category_id": None, + "fallback_category_id": None, + "prefix": "?", + "mention": "@here", + "main_color": str(discord.Color.blurple()), + "error_color": str(discord.Color.red()), + "user_typing": False, + "mod_typing": False, + "account_age": isodate.Duration(), + "guild_age": isodate.Duration(), + "thread_cooldown": isodate.Duration(), + "log_expiration": isodate.Duration(), + "reply_without_command": False, + "anon_reply_without_command": False, + "plain_reply_without_command": False, + # logging + "log_channel_id": None, + "mention_channel_id": None, + "update_channel_id": None, + # updates + "update_notifications": True, + # threads + "sent_emoji": "\N{WHITE HEAVY CHECK MARK}", + "blocked_emoji": "\N{NO ENTRY SIGN}", + "close_emoji": "\N{LOCK}", + "use_user_id_channel_name": False, + "use_timestamp_channel_name": False, + "use_nickname_channel_name": False, + "use_random_channel_name": False, + "recipient_thread_close": False, + "thread_show_roles": True, + "thread_show_account_age": True, + "thread_show_join_age": True, + "thread_cancelled": "Cancelled", + "thread_auto_close_silently": False, + "thread_auto_close": isodate.Duration(), + "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", + "thread_creation_response": "The staff team will get back to you as soon as possible.", + "thread_creation_footer": "Your message has been sent", + "thread_contact_silently": False, + "thread_self_closable_creation_footer": "Click the lock to close the thread", + "thread_creation_contact_title": "New Thread", + "thread_creation_self_contact_response": "You have opened a Modmail thread.", + "thread_creation_contact_response": "{creator.name} has opened a Modmail thread.", + "thread_creation_title": "Thread Created", + "thread_close_footer": "Replying will create a new thread", + "thread_close_title": "Thread Closed", + "thread_close_response": "{closer.mention} has closed this Modmail thread.", + "thread_self_close_response": "You have closed this Modmail thread.", + "thread_move_title": "Thread Moved", + "thread_move_notify": False, + "thread_move_notify_mods": False, + "thread_move_response": "This thread has been moved.", + "cooldown_thread_title": "Message not sent!", + "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", + "disabled_new_thread_title": "Not Delivered", + "disabled_new_thread_response": "We are not accepting new threads.", + "disabled_new_thread_footer": "Please try again later...", + "disabled_current_thread_title": "Not Delivered", + "disabled_current_thread_response": "We are not accepting any messages.", + "disabled_current_thread_footer": "Please try again later...", + "transfer_reactions": True, + "close_on_leave": False, + "close_on_leave_reason": "The recipient has left the server.", + "alert_on_mention": False, + "silent_alert_on_mention": False, + "show_timestamp": True, + "anonymous_snippets": False, + "plain_snippets": False, + "require_close_reason": False, + "show_log_url_button": False, + # group conversations + "private_added_to_group_title": "New Thread (Group)", + "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", + "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.", + "public_added_to_group_title": "New User", + "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.", + "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.", + "private_removed_from_group_title": "Removed From Thread (Group)", + "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.", + "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.", + "public_removed_from_group_title": "User Removed", + "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.", + "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.", + # moderation + "recipient_color": str(discord.Color.gold()), + "mod_color": str(discord.Color.green()), + "mod_tag": None, + # anonymous message + "anon_username": None, + "anon_avatar_url": None, + "anon_tag": "Response", + # react to contact + "react_to_contact_message": None, + "react_to_contact_emoji": "\N{WHITE HEAVY CHECK MARK}", + # confirm thread creation + "confirm_thread_creation": False, + "confirm_thread_creation_title": "Confirm thread creation", + "confirm_thread_response": "Click the button to confirm thread creation which will directly contact the moderators.", + "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}", + "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", + # regex + "use_regex_autotrigger": False, + "use_hoisted_top_role": True, + # Minimum characters for thread creation + "thread_min_characters": 0, + "thread_min_characters_title": "Message too short", + "thread_min_characters_response": "Your message is too short to create a thread. Please provide more details.", + "thread_min_characters_footer": "Minimum {min_characters} characters required.", + # --- SNOOZE FEATURE CONFIG --- + "max_snooze_time": 604800, # in seconds, default 7 days + "snooze_title": "Thread Snoozed", + "snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.", + "unsnooze_text": "This thread has been unsnoozed and restored.", + "unsnooze_notify_channel": "thread", # Can be a channel ID or 'thread' for the thread's own channel + } + + private_keys = { + # bot presence + "activity_message": "", + "activity_type": None, + "status": None, + "dm_disabled": DMDisabled.NONE, + "oauth_whitelist": [], + # moderation + "blocked": {}, + "blocked_roles": {}, + "blocked_whitelist": [], + "command_permissions": {}, + "level_permissions": {}, + "override_command_level": {}, + # threads + "snippets": {}, + "notification_squad": {}, + "subscriptions": {}, + "closures": {}, + # misc + "plugins": [], + "aliases": {}, + "auto_triggers": {}, + } + + protected_keys = { + # Modmail + "modmail_guild_id": None, + "guild_id": None, + "log_url": "https://example.com/", + "log_url_prefix": "/logs", + "mongo_uri": None, + "database_type": "mongodb", + "connection_uri": None, # replace mongo uri in the future + "owners": None, + "enable_presence_intent": False, + "registry_plugins_only": False, + # bot + "token": None, + "enable_plugins": True, + "enable_eval": True, + # github access token for private repositories + "github_token": None, + "disable_autoupdates": False, + "disable_updates": False, + # Logging + "log_level": "INFO", + "stream_log_format": "plain", + "file_log_format": "plain", + "discord_log_level": "INFO", + # data collection + "data_collection": True, + } + + colors = {"mod_color", "recipient_color", "main_color", "error_color"} + + time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown", "log_expiration"} + + booleans = { + "use_user_id_channel_name", + "use_timestamp_channel_name", + "use_nickname_channel_name", + "use_random_channel_name", + "user_typing", + "mod_typing", + "reply_without_command", + "anon_reply_without_command", + "plain_reply_without_command", + "show_log_url_button", + "recipient_thread_close", + "thread_auto_close_silently", + "thread_move_notify", + "thread_move_notify_mods", + "transfer_reactions", + "close_on_leave", + "alert_on_mention", + "silent_alert_on_mention", + "show_timestamp", + "confirm_thread_creation", + "use_regex_autotrigger", + "enable_plugins", + "data_collection", + "enable_eval", + "disable_autoupdates", + "disable_updates", + "update_notifications", + "thread_contact_silently", + "anonymous_snippets", + "plain_snippets", + "require_close_reason", + "recipient_thread_close", + "thread_show_roles", + "thread_show_account_age", + "thread_show_join_age", + "use_hoisted_top_role", + "enable_presence_intent", + "registry_plugins_only", + } + + enums = { + "dm_disabled": DMDisabled, + "status": discord.Status, + "activity_type": discord.ActivityType, + } + + force_str = {"command_permissions", "level_permissions"} + + defaults = {**public_keys, **private_keys, **protected_keys} + all_keys = set(defaults.keys()) + + def __init__(self, bot): + self.bot = bot + self._cache = {} + self.ready_event = asyncio.Event() + self.config_help = {} + + def __repr__(self): + return repr(self._cache) + + def populate_cache(self) -> dict: + data = deepcopy(self.defaults) + + # populate from env var and .env file + data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) + config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") + if os.path.exists(config_json): + logger.debug("Loading envs from config.json.") + with open(config_json, "r", encoding="utf-8") as f: + # Config json should override env vars + try: + data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) + except json.JSONDecodeError: + logger.critical("Failed to load config.json env values.", exc_info=True) + self._cache = data + + config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json") + with open(config_help_json, "r", encoding="utf-8") as f: + self.config_help = dict(sorted(json.load(f).items())) + + return self._cache + + async def update(self): + """Updates the config with data from the cache""" + await self.bot.api.update_config(self.filter_default(self._cache)) + + async def refresh(self) -> dict: + """Refreshes internal cache with data from database""" + for k, v in (await self.bot.api.get_config()).items(): + k = k.lower() + if k in self.all_keys: + self._cache[k] = v + if not self.ready_event.is_set(): + self.ready_event.set() + logger.debug("Successfully fetched configurations from database.") + return self._cache + + async def wait_until_ready(self) -> None: + await self.ready_event.wait() + + def __setitem__(self, key: str, item: typing.Any) -> None: + key = key.lower() + logger.info("Setting %s.", key) + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + self._cache[key] = item + + def __getitem__(self, key: str) -> typing.Any: + # make use of the custom methods in func:get: + return self.get(key) + + def __delitem__(self, key: str) -> None: + return self.remove(key) + + def get(self, key: str, *, convert: bool = True) -> typing.Any: + key = key.lower() + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + if key not in self._cache: + self._cache[key] = deepcopy(self.defaults[key]) + value = self._cache[key] + + if not convert: + return value + + if key in self.colors: + try: + return int(value.lstrip("#"), base=16) + except ValueError: + logger.error("Invalid %s provided.", key) + value = int(self.remove(key).lstrip("#"), base=16) + + elif key in self.time_deltas: + if not isinstance(value, isodate.Duration): + try: + value = isodate.parse_duration(value) + except isodate.ISO8601Error: + logger.warning( + "The {account} age limit needs to be a " + 'ISO-8601 duration formatted duration, not "%s".', + value, + ) + value = self.remove(key) + + elif key in self.booleans: + try: + value = strtobool(value) + except ValueError: + value = self.remove(key) + + elif key in self.enums: + if value is None: + return None + try: + value = self.enums[key](value) + except ValueError: + logger.warning("Invalid %s %s.", key, value) + value = self.remove(key) + + elif key in self.force_str: + # Temporary: as we saved in int previously, leading to int32 overflow, + # this is transitioning IDs to strings + new_value = {} + changed = False + for k, v in value.items(): + new_v = v + if isinstance(v, list): + new_v = [] + for n in v: + if n != -1 and not isinstance(n, str): + changed = True + n = str(n) + new_v.append(n) + new_value[k] = new_v + + if changed: + # transition the database as well + self.set(key, new_value) + + value = new_value + + return value + + async def set(self, key: str, item: typing.Any, convert=True) -> None: + if not convert: + return self.__setitem__(key, item) + + if key in self.colors: + try: + hex_ = str(item) + if hex_.startswith("#"): + hex_ = hex_[1:] + if len(hex_) == 3: + hex_ = "".join(s for s in hex_ for _ in range(2)) + if len(hex_) != 6: + raise InvalidConfigError("Invalid color name or hex.") + try: + int(hex_, 16) + except ValueError: + raise InvalidConfigError("Invalid color name or hex.") + + except InvalidConfigError: + name = str(item).lower() + name = re.sub(r"[\-+|. ]+", " ", name) + hex_ = ALL_COLORS.get(name) + if hex_ is None: + name = re.sub(r"[\-+|. ]+", "", name) + hex_ = ALL_COLORS.get(name) + if hex_ is None: + raise + return self.__setitem__(key, "#" + hex_) + + if key in self.time_deltas: + try: + isodate.parse_duration(item) + except isodate.ISO8601Error: + try: + converter = UserFriendlyTime() + time = await converter.convert(None, item, now=discord.utils.utcnow()) + if time.arg: + raise ValueError + except BadArgument as exc: + raise InvalidConfigError(*exc.args) + except Exception as e: + logger.debug(e) + raise InvalidConfigError( + "Unrecognized time, please use ISO-8601 duration format " + 'string or a simpler "human readable" time.' + ) + now = discord.utils.utcnow() + item = isodate.duration_isoformat(time.dt - now) + return self.__setitem__(key, item) + + if key in self.booleans: + try: + return self.__setitem__(key, strtobool(item)) + except ValueError: + raise InvalidConfigError("Must be a yes/no value.") + + elif key in self.enums: + if isinstance(item, self.enums[key]): + # value is an enum type + item = item.value + + return self.__setitem__(key, item) + + def remove(self, key: str) -> typing.Any: + key = key.lower() + logger.info("Removing %s.", key) + if key not in self.all_keys: + raise InvalidConfigError(f'Configuration "{key}" is invalid.') + if key in self._cache: + del self._cache[key] + self._cache[key] = deepcopy(self.defaults[key]) + return self._cache[key] + + def items(self) -> typing.Iterable: + return self._cache.items() + + @classmethod + def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + return { + k.lower(): v + for k, v in data.items() + if k.lower() in cls.public_keys or k.lower() in cls.private_keys + } + + @classmethod + def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]: + # TODO: use .get to prevent errors + filtered = {} + for k, v in data.items(): + default = cls.defaults.get(k.lower(), Default) + if default is Default: + logger.error("Unexpected configuration detected: %s.", k) + continue + if v != default: + filtered[k.lower()] = v + return filtered diff --git a/core/config_help.json b/core/config_help.json index 6547c85ebd..d20e1e1b01 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1,1269 +1,1269 @@ -{ - "twitch_url": { - "default": "`https://www.twitch.tv/discordmodmail/`", - "description": "This channel dictates the linked Twitch channel when the activity is set to \"Streaming\".", - "examples": [ - "`{prefix}config set twitch_url https://www.twitch.tv/yourchannelname/`" - ], - "notes": [ - "This has no effect when the activity is not set to \"Streaming\".", - "See also: `{prefix}help activity`." - ] - }, - "main_category_id": { - "default": "`Modmail` (created with `{prefix}setup`)", - "description": "This is the category where all new threads will be created.\n\nTo change the Modmail category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).", - "examples": [ - "`{prefix}config set main_category_id 9234932582312` (`9234932582312` is the category ID)" - ], - "notes": [ - "If the Modmail category ended up being non-existent/invalid, Modmail will break. To fix this, run `{prefix}setup` again or set `main_category_id` to a valid category.", - "When the Modmail category is full, new channels will be created in the fallback category.", - "See also: `fallback_category_id`." - ] - }, - "fallback_category_id": { - "default": "`Fallback Modmail` (created when the main category is full)", - "description": "This is the category that will hold the threads when the main category is full.\n\nTo change the Fallback category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).", - "examples": [ - "`{prefix}config set fallback_category_id 9234932582312` (`9234932582312` is the category ID)" - ], - "notes": [ - "If the Fallback category ended up being non-existent/invalid, Modmail will create a new one. To fix this, set `fallback_category_id` to a valid category.", - "See also: `main_category_id`." - ] - }, - "prefix": { - "default": "`?`", - "description": "The prefix of the bot.", - "examples": [ - "`{prefix}prefix !`", - "`{prefix}config set prefix !`" - ], - "notes": [ - "If you forgot the bot prefix, Modmail will always respond to its mention (ping)." - ] - }, - "mention": { - "default": "@here", - "description": "This is the message above user information for when a new thread is created in the channel.", - "examples": [ - "`{prefix}config set mention Yo~ Here's a new thread for ya!`", - "`{prefix}mention Yo~ Here's a new thread for ya!`" - ], - "notes": [ - "To disable mention, use command `{prefix}mention disable`.", - "See also: `{prefix}help mention`." - ] - }, - "main_color": { - "default": "Discord Blurple [#7289DA](https://placehold.it/100/7289da?text=+)", - "description": "This is the main color for Modmail (help/about/ping embed messages, subscribe, move, etc.).", - "examples": [ - "`{prefix}config set main_color olive green`", - "`{prefix}config set main_color 12de3a`", - "`{prefix}config set main_color #12de3a`", - "`{prefix}config set main_color fff`" - ], - "notes": [ - "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", - "See also: `error_color`, `mod_color`, `recipient_color`." - ], - "thumbnail": "https://placehold.it/100/7289da?text=+" - }, - "error_color": { - "default": "Discord Red [#E74C3C](https://placehold.it/100/e74c3c?text=+)", - "description": "This is the color for Modmail when anything goes wrong, unsuccessful commands, or a stern warning.", - "examples": [ - "`{prefix}config set error_color ocean blue`", - "`{prefix}config set error_color ff1242`", - "`{prefix}config set error_color #ff1242`", - "`{prefix}config set error_color fa1`" - ], - "notes": [ - "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", - "See also: `main_color`, `mod_color`, `recipient_color`." - ], - "thumbnail": "https://placehold.it/100/e74c3c?text=+" - }, - "user_typing": { - "default": "Enabled", - "description": "When this is set to `yes`, whenever the recipient user starts to type in their DM channel, the moderator will see “{bot.user.display_name} is typing…” in the thread channel.", - "examples": [ - "`{prefix}config set user_typing yes`", - "`{prefix}config set user_typing no`" - ], - "notes": [ - "See also: `mod_typing`." - ] - }, - "use_user_id_channel_name": { - "default": "No", - "description": "When this is set to `yes`, new thread channels will be named with the recipient's ID instead of the recipient's name.", - "examples": [ - "`{prefix}config set use_user_id_channel_name yes`", - "`{prefix}config set use_user_id_channel_name no`" - ], - "notes": [ - "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", - "See also: `use_timestamp_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." - ] - }, - "use_timestamp_channel_name": { - "default": "No", - "description": "When this is set to `yes`, new thread channels will be named with the recipient's account creation date instead of the recipient's name.", - "examples": [ - "`{prefix}config set use_timestamp_channel_name yes`", - "`{prefix}config set use_timestamp_channel_name no`" - ], - "notes": [ - "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_user_id_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", - "See also: `use_user_id_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." - ] - }, - "use_nickname_channel_name": { - "default": "No", - "description": "When this is set to `yes`, new thread channels will be named with the recipient's nickname instead of the recipient's name.", - "examples": [ - "`{prefix}config set use_nickname_channel_name yes`", - "`{prefix}config set use_nickname_channel_name no`" - ], - "notes": [ - "This config is NOT suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_user_id_channel_name`.", - "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_random_channel_name`." - ] - }, - "use_random_channel_name": { - "default": "No", - "description": "When this is set to `yes`, new thread channels will be named with random characters tied to their user ID.", - "examples": [ - "`{prefix}config set use_random_channel_name yes`", - "`{prefix}config set use_random_channel_name no`" - ], - "notes": [ - "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_timestamp_channel_name`, `use_nickname_channel_name`, or `use_user_id_channel_name`.", - "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_nickname_channel_name`." - ] - }, - "mod_typing": { - "default": "Disabled", - "description": "When this is set to `yes`, whenever a moderator starts to type in the thread channel, the recipient user will see \"{bot.user.display_name} is typing…\" in their DM channel.", - "examples": [ - "`{prefix}config set mod_typing yes`", - "`{prefix}config set mod_typing no`" - ], - "notes": [ - "See also: `mod_typing`." - ] - }, - "account_age": { - "default": "No age threshold", - "description": "The creation date of the recipient user account must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", - "examples": [ - "`{prefix}config set account_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", - "`{prefix}config set account_age 3 days and 5 hours` (accepted readable time)" - ], - "notes": [ - "To remove this restriction, do `{prefix}config del account_age`.", - "See also: `guild_age`." - ] - }, - "guild_age": { - "default": "No age threshold", - "description": "The join date of the recipient user into this server must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", - "examples": [ - "`{prefix}config set guild_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", - "`{prefix}config set guild_age 3 days and 5 hours` (accepted readable time)" - ], - "notes": [ - "To remove this restriction, do `{prefix}config del guild_age`.", - "See also: `account_age`." - ] - }, - "reply_without_command": { - "default": "Disabled", - "description": "Setting this configuration will make all non-command messages sent in the thread channel to be forwarded to the recipient without the need of `{prefix}reply`.", - "examples": [ - "`{prefix}config set reply_without_command yes`", - "`{prefix}config set reply_without_command no`" - ], - "notes": [ - "See also: `anon_reply_without_command`, `plain_reply_without_command`." - ] - }, - "anon_reply_without_command": { - "default": "Disabled", - "description": "Setting this configuration will make all non-command messages sent in the thread channel to be anonymously forwarded to the recipient without the need of `{prefix}reply`.", - "examples": [ - "`{prefix}config set anon_reply_without_command yes`", - "`{prefix}config set anon_reply_without_command no`" - ], - "notes": [ - "See also: `reply_without_command`, `plain_reply_without_command`." - ] - }, - "plain_reply_without_command": { - "default": "Disabled", - "description": "Setting this configuration will make all non-command messages sent in the thread channel to be forwarded to the recipient in a plain form without the need of `{prefix}reply`.", - "examples": [ - "`{prefix}config set plain_reply_without_command yes`", - "`{prefix}config set plain_reply_without_command no`" - ], - "notes": [ - "See also: `reply_without_command`, `anon_reply_without_command`." - ] - }, - "log_channel_id": { - "default": "`#bot-logs` (created with `{prefix}setup`)", - "description": "This is the channel where all log messages will be sent (ie. thread close message, update message, etc.).\n\nTo change the log channel, you will need to find the [channel’s ID](https://support.discordapp.com/hc/en-us/articles/206346498). The channel doesn’t necessary have to be under the `main_category`.", - "examples": [ - "`{prefix}config set log_channel_id 9234932582312` (9234932582312 is the channel ID)" - ], - "notes": [ - "If the Modmail logging channel ended up being non-existent/invalid, no logs will be sent." - ] - }, - "mention_channel_id": { - "default": "Log Channel (normally `#bot-logs`)", - "description": "This is the channel where bot mentions are sent to.", - "examples": [ - "`{prefix}config set mention_channel_id 9234932582312` (9234932582312 is the channel ID)" - ], - "notes": [ - "This has no effect unless `alert_on_mention` is set to yes.", - "See also: `log_channel_id`" - ] - }, - "update_channel_id": { - "default": "Log Channel (normally `#bot-logs`)", - "description": "This is the channel where update notifications are sent to.", - "examples": [ - "`{prefix}config set update_channel_id 9234932582312` (9234932582312 is the channel ID)" - ], - "notes": [ - "This has no effect unless `disable_autoupdates` is set to no and `update_notifications` is set to yes.", - "See also: `log_channel_id`" - ] - }, - "update_notifications": { - "default": "Yes", - "description": "This is the channel where update notifications are sent to.", - "examples": [ - "`{prefix}config set update_notifications no`" - ], - "notes": [ - "This has no effect unless `disable_autoupdates` is set to no.", - "See also: `update_channel_id`" - ] - }, - "sent_emoji": { - "default": "✅", - "description": "This is the emoji added to the message when when a Modmail action is invoked successfully (ie. DM Modmail, edit message, etc.).", - "examples": [ - "`{prefix}config set sent_emoji ✨`" - ], - "notes": [ - "You can disable `sent_emoji` with `{prefix}config set sent_emoji disable`.", - "Custom/animated emojis are also supported, however, the emoji must be added to the server.", - "See also: `blocked_emoji`." - ] - }, - "blocked_emoji": { - "default": "🚫", - "description": "This is the emoji added to the message when when a Modmail action is invoked unsuccessfully (ie. DM Modmail when blocked, failed to reply, etc.).", - "examples": [ - "`{prefix}config set blocked_emoji 🙅‍`" - ], - "notes": [ - "You can disable `blocked_emoji` with `{prefix}config set blocked_emoji disable`.", - "Custom/animated emojis are also supported, however, the emoji must be added to the server.", - "See also: `sent_emoji`." - ] - }, - "close_emoji": { - "default": "🔒", - "description": "This is the emoji the recipient can click to close a thread themselves. The emoji is automatically added to the `thread_creation_response` embed.", - "examples": [ - "`{prefix}config set close_emoji 👍‍`" - ], - "notes": [ - "This will only have an effect when `recipient_thread_close` is enabled.", - "See also: `recipient_thread_close`." - ] - }, - "recipient_thread_close": { - "default": "Disabled", - "description": "Setting this configuration will allow recipients to use the `close_emoji` to close the thread themselves.", - "examples": [ - "`{prefix}config set recipient_thread_close yes`", - "`{prefix}config set recipient_thread_close no`" - ], - "notes": [ - "The close emoji is dictated by the configuration `close_emoji`.", - "See also: `close_emoji`." - ] - }, - "thread_show_roles": { - "default": "Yes", - "description": "Shows roles on first message sent in thread channels to mods", - "examples":[ - "`{prefix}config set thread_show_roles no`" - ], - "notes": [ - "See also: `thread_show_account_age`, `thread_show_join_age`." - ] - }, - "thread_show_account_age": { - "default": "Yes", - "description": "Shows account age on first message sent in thread channels to mods", - "examples":[ - "`{prefix}config set thread_show_account_age no`" - ], - "notes": [ - "See also: `thread_show_roles`, `thread_show_join_age`." - ] - }, - "thread_show_join_age": { - "default": "Yes", - "description": "Shows join age on first message sent in thread channels to mods", - "examples":[ - "`{prefix}config set thread_show_join_age no`" - ], - "notes": [ - "See also: `thread_show_account_age`, `thread_show_roles`." - ] - }, - "thread_auto_close_silently": { - "default": "No", - "description": "Setting this configuration will close silently when the thread auto-closes.", - "examples": [ - "`{prefix}config set thread_auto_close_silently yes`", - "`{prefix}config set thread_auto_close_silently no`" - ], - "notes": [ - "This will only have an effect when `thread_auto_close` is set.", - "See also: `thread_auto_close`." - ] - }, - "thread_auto_close": { - "default": "Never", - "description": "Setting this configuration will close threads automatically after the number of days, hours, minutes or any time-interval specified by this configuration.", - "examples": [ - "`{prefix}config set thread_auto_close P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", - "`{prefix}config set thread_auto_close 3 days and 5 hours` (accepted readable time)" - ], - "notes": [ - "To disable auto close, do `{prefix}config del thread_auto_close`.", - "To prevent a thread from auto-closing, do `{prefix}close cancel`.", - "See also: `thread_auto_close_silently`, `thread_auto_close_response`." - ] - }, - "thread_cooldown": { - "default": "Never", - "description": "Specify the time required for the recipient to wait before allowed to create a new thread.", - "examples": [ - "`{prefix}config set thread_cooldown P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", - "`{prefix}config set thread_cooldown 3 days and 5 hours` (accepted readable time)" - ], - "notes": [ - "To disable thread cooldown, do `{prefix}config del thread_cooldown`." - ] - }, - "log_expiration": { - "default": "Never", - "description": "The duration closed threads will be stored within the database before deletion. Logs that have been closed for longer than this duration will be deleted automatically.", - "examples": [ - "`{prefix}config set log_expiration P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", - "`{prefix}config set log_expiration 3 days and 5 hours` (accepted readable time)" - ], - "notes": [ - "To disable log expiration, do `{prefix}config del log_expiration`." - ] - }, - "thread_cancelled": { - "default": "\"Cancelled\"", - "description": "This is the message to display when a thread times out and creation is cancelled.", - "examples": [ - "`{prefix}config set thread_cancelled Gone.`" - ], - "notes": [] - }, - "thread_auto_close_response": { - "default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"", - "description": "This is the message to display when the thread when the thread auto-closes.", - "examples": [ - "`{prefix}config set thread_auto_close_response Your close message here.`" - ], - "notes": [ - "Its possible to use `{{timeout}}` as a placeholder for a formatted timeout text.", - "This will not have an effect when `thread_auto_close_silently` is enabled.", - "Discord flavoured markdown is fully supported in `thread_auto_close_response`.", - "See also: `thread_auto_close`, `thread_auto_close_silently`." - ] - }, - "thread_creation_response": { - "default": "\"The staff team will get back to you as soon as possible.\"", - "description": "This is the message embed content sent to the recipient upon the creation of a new thread.", - "examples": [ - "`{prefix}config set thread_creation_response You will be contacted shortly.`" - ], - "notes": [ - "Discord flavoured markdown is fully supported in `thread_creation_response`.", - "See also: `thread_creation_title`, `thread_creation_footer`, `thread_close_response`." - ] - }, - "thread_creation_footer": { - "default": "\"Your message has been sent\"", - "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", - "examples": [ - "`{prefix}config set thread_creation_footer Please Hold...`" - ], - "notes": [ - "This is used in place of `thread_self_closable_creation_footer` when `recipient_thread_close` is enabled.", - "See also: `thread_creation_title`, `thread_creation_response`, `thread_self_closable_creation_footer`, `thread_close_footer`." - ] - }, - "thread_contact_silently": { - "default": "No", - "description": "Setting this configuration will always open a new thread silently in contact.", - "examples": [ - "`{prefix}config set thread_contact_silently yes`", - "`{prefix}config set thread_contact_silently no`" - ], - "notes": [ - "Works like `{prefix}contact silent` for every new thread." - ] - }, - "thread_self_closable_creation_footer": { - "default": "\"Click the lock to close the thread\"", - "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", - "examples": [ - "`{prefix}config set thread_self_closable_creation_footer Please Hold...`" - ], - "notes": [ - "This is used in place of `thread_creation_footer` when `recipient_thread_close` is disabled.", - "See also: `thread_creation_title`, `thread_creation_response`, `thread_creation_footer`." - ] - }, - "thread_creation_contact_title": { - "default": "\"New Thread\"", - "description": "This is the message embed title sent to recipients when contacted.", - "examples": [ - "`{prefix}config set thread_creation_contact_title New Message!`" - ], - "notes": [ - "See also: `thread_creation_self_contact_response`, `thread_creation_contact_response`." - ] - }, - "thread_creation_self_contact_response": { - "default": "\"You have opened a Modmail thread.\"", - "description": "This is the message embed description sent to recipients when self-contacted.", - "examples": [ - "`{prefix}config set thread_creation_self_contact_response You contacted yourself.`" - ], - "notes": [ - "`thread_creation_contact_response` is used when contacted by another user.", - "See also: `thread_creation_contact_title`, `thread_creation_contact_response`." - ] - }, - "thread_creation_contact_response": { - "default": "\"{{creator.name}} has opened a Modmail thread.\"", - "description": "This is the message embed description sent to recipients when contacted by a mod.", - "examples": [ - "`{prefix}config set thread_creation_contact_response New thread opened.`" - ], - "notes": [ - "You may use the `{{creator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that created the thread.", - "`thread_creation_self_contact_response` is used when contacted by self.", - "See also: `thread_creation_contact_title`, `thread_creation_self_contact_response`." - ] - }, - "thread_creation_title": { - "default": "\"Thread Created\"", - "description": "This is the message embed title sent to the recipient upon the creation of a new thread.", - "examples": [ - "`{prefix}config set thread_creation_title Hello!`" - ], - "notes": [ - "See also: `thread_creation_response`, `thread_creation_footer`, `thread_close_title`." - ] - }, - "thread_close_footer": { - "default": "\"Replying will create a new thread\"", - "description": "This is the message embed footer sent to the recipient upon the closure of a thread.", - "examples": [ - "`{prefix}config set thread_close_footer Bye!`" - ], - "notes": [ - "See also: `thread_close_title`, `thread_close_response`, `thread_creation_footer`." - ] - }, - "thread_close_title": { - "default": "\"Thread Closed\"", - "description": "This is the message embed title sent to the recipient upon the closure of a thread.", - "examples": [ - "`{prefix}config set thread_close_title Farewell!`" - ], - "notes": [ - "See also: `thread_close_response`, `thread_close_footer`, `thread_creation_title`." - ] - }, - "thread_close_response": { - "default": "\"{{closer.mention}} has closed this Modmail thread\"", - "description": "This is the message embed content sent to the recipient upon the closure of a thread.", - "examples": [ - "`{prefix}config set thread_close_response Your message is appreciated!`" - ], - "notes": [ - "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", - "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", - "Discord flavoured markdown is fully supported in `thread_close_response`.", - "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." - ] - }, - "thread_self_close_response": { - "default": "\"You have closed this Modmail thread.\"", - "description": "This is the message embed content sent to the recipient upon the closure of a their own thread.", - "examples": [ - "`{prefix}config set thread_self_close_response You have closed your own thread...`" - ], - "notes": [ - "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", - "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", - "Discord flavoured markdown is fully supported in `thread_self_close_response`.", - "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." - ] - }, - "thread_move_title": { - "default": "Thread Moved", - "description": "The title of the message embed when a thread is moved.", - "examples": [ - "`{prefix}config set thread_move_title Thread transferred to another channel!`" - ], - "notes": [ - "See also: `thread_move_notify`, `thread_move_notify_mods`, `thread_move_response`." - ] - }, - "thread_move_notify": { - "default": "No", - "description": "Notify the recipient if the thread was moved.", - "examples": [ - "`{prefix}config set thread_move_notify yes`", - "`{prefix}config set thread_move_notify no`" - ], - "notes": [ - "See also: `thread_move_title`, `thread_move_response`, `thread_move_notify_mods`." - ] - }, - "thread_move_notify_mods": { - "default": "No", - "description": "Notify mods again after the thread is moved", - "examples": [ - "`{prefix}config set thread_move_notify_mods yes`", - "`{prefix}config set thread_move_notify_mods no`" - ], - "notes": [ - "See also: `thread_move_title`, `thread_move_response`, `thread_move_notify`." - ] - }, - "thread_move_response": { - "default": "This thread has been moved.", - "description": "This is the message to display to the user when the thread is moved.", - "examples": [ - "`{prefix}config set thread_move_response This thread has been moved to another category for review!`" - ], - "notes": [ - "Only has an effect when `thread_move_notify` is on.", - "See also: `thread_move_title`, `thread_move_notify`." - ] - }, - "cooldown_thread_title": { - "default": "Message not sent!", - "description": "The title of the message embed when the user has a cooldown before creating a new thread.", - "examples": [ - "`{prefix}config set cooldown_thread_title Error`" - ], - "notes": [ - "Only has an effect when `thread_cooldown` is set", - "See also: `cooldown_thread_response`." - ] - }, - "cooldown_thread_response": { - "default": "Your cooldown ends {delta}. Try contacting me then.", - "description": "The description of the message embed when the user has a cooldown before creating a new thread.", - "examples": [ - "`{prefix}config set cooldown_thread_response Be patient! You are on cooldown, wait {delta} more.`" - ], - "notes": [ - "Only has an effect when `thread_cooldown` is set", - "Must have a {delta} included which will be replaced with the duration of time.", - "See also: `cooldown_thread_title`." - ] - }, - "disabled_new_thread_title": { - "default": "Not Delivered.", - "description": "The title of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", - "examples": [ - "`{prefix}config set disabled_new_thread_title Closed`" - ], - "notes": [ - "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", - "See also: `disabled_new_thread_response`, `disabled_new_thread_footer`, `disabled_current_thread_title`." - ] - }, - "disabled_new_thread_response": { - "default": "We are not accepting new threads.", - "description": "The body of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", - "examples": [ - "`{prefix}config set disabled_new_thread_response Our working hours is between 8am - 6pm EST.`" - ], - "notes": [ - "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", - "See also: `disabled_new_thread_title`, `disabled_new_thread_footer`, `disabled_current_thread_response`." - ] - }, - "disabled_new_thread_footer": { - "default": "Please try again later...", - "description": "The footer of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", - "examples": [ - "`{prefix}config set disabled_new_thread_footer Contact us later`" - ], - "notes": [ - "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", - "See also: `disabled_new_thread_title`, `disabled_new_thread_response`, `disabled_current_thread_footer`." - ] - }, - "disabled_current_thread_title": { - "default": "Not Delivered.", - "description": "The title of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", - "examples": [ - "`{prefix}config set disabled_current_thread_title Unavailable`" - ], - "notes": [ - "Only has an effect when `{prefix}disable all` is set.", - "See also: `disabled_current_thread_response`, `disabled_current_thread_footer`, `disabled_new_thread_title`." - ] - }, - "disabled_current_thread_response": { - "default": "We are not accepting any messages.", - "description": "The body of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", - "examples": [ - "`{prefix}config set disabled_current_thread_response On break right now.`" - ], - "notes": [ - "Only has an effect when `{prefix}disable all` is set.", - "See also: `disabled_current_thread_title`, `disabled_current_thread_footer`, `disabled_new_thread_response`." - ] - }, - "disabled_current_thread_footer": { - "default": "Please try again later...", - "description": "The footer of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", - "examples": [ - "`{prefix}config set disabled_current_thread_footer Message back!`" - ], - "notes": [ - "Only has an effect when `{prefix}disable all` is set.", - "See also: `disabled_current_thread_title`, `disabled_current_thread_response`, `disabled_new_thread_footer`." - ] - }, - "recipient_color": { - "default": "Discord Gold [#F1C40F](https://placehold.it/100/f1c40f?text=+)", - "description": "This is the color of the messages sent by the recipient, this applies to messages received in the thread channel.", - "examples": [ - "`{prefix}config set recipient_color dark beige`", - "`{prefix}config set recipient_color cb7723`", - "`{prefix}config set recipient_color #cb7723`", - "`{prefix}config set recipient_color c4k`" - ], - "notes": [ - "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", - "See also: `mod_color`, `main_color`, `error_color`." - ], - "thumbnail": "https://placehold.it/100/f1c40f?text=+" - }, - "mod_color": { - "default": "Discord Green [#2ECC71](https://placehold.it/100/2ecc71?text=+)", - "description": "This is the color of the messages sent by the moderators, this applies to messages within in the thread channel and the DM thread messages received by the recipient.", - "examples": [ - "`{prefix}config set mod_color dark beige`", - "`{prefix}config set mod_color cb7723`", - "`{prefix}config set mod_color #cb7723`", - "`{prefix}config set mod_color c4k`" - ], - "notes": [ - "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", - "See also: `recipient_color`, `main_color`, `error_color`." - ], - "thumbnail": "https://placehold.it/100/2ecc71?text=+" - }, - "mod_tag": { - "default": "The moderator's highest role", - "description": "This is the name tag in the “footer” section of the embeds sent by moderators in the recipient DM and thread channel.", - "examples": [ - "`{prefix}config set mod_tag Moderator`" - ], - "notes": [ - "When the message is sent anonymously, `anon_tag` is used instead.", - "See also: `anon_tag`." - ] - }, - "anon_username": { - "default": "Fallback on `mod_tag`", - "description": "This is the name in the “author” section of the embeds sent by anonymous moderators in the recipient DM.", - "examples": [ - "`{prefix}config set anon_username Incognito Mod`" - ], - "notes": [ - "See also: `anon_avatar_url`, `anon_tag`." - ], - "image": "https://i.imgur.com/SKOC42Z.png" - }, - "anon_avatar_url": { - "default": "Server avatar", - "description": "This is the avatar of the embeds sent by anonymous moderators in the recipient DM.", - "examples": [ - "`{prefix}config set anon_avatar_url https://path.to/your/avatar.png` (you will need to upload the avatar to somewhere)" - ], - "notes": [ - "See also: `anon_username`, `anon_tag`." - ], - "image": "https://i.imgur.com/SKOC42Z.png" - }, - "anon_tag": { - "default": "\"Response\"", - "description": "This is the name tag in the “footer” section of the embeds sent by anonymous moderators in the recipient DM.", - "examples": [ - "`{prefix}config set anon_tag Support Agent`" - ], - "notes": [ - "See also: `anon_avatar_url`, `anon_username`, `mod_tag`." - ], - "image": "https://i.imgur.com/SKOC42Z.png" - }, - "react_to_contact_message": { - "default": "None", - "description": "A message ID where reactions are tracked. If the `react_to_contact_emoji` is added, the bot opens a thread with them.", - "examples": [ - "`{prefix}config set react_to_contact_message 773575608814534717`" - ], - "notes": [ - "See also: `react_to_contact_emoji`" - ] - }, - "react_to_contact_emoji": { - "default": "\u2705", - "description": "An emoji which is tracked in `react_to_contact_message`", - "examples": [ - "`{prefix}config set react_to_contact_emoji \u2705`" - ], - "notes": [ - "See also: `react_to_contact_message \u2705`" - ] - }, - "transfer_reactions": { - "default": "Yes", - "description": "Transfer users reactions to mods and vice versa", - "examples":[ - "`{prefix}config set transfer_reactions no`" - ], - "notes": [] - }, - "close_on_leave": { - "default": "No", - "description": "Closes a modmail thread upon user leave automatically", - "examples":[ - "`{prefix}config set close_on_leave yes`" - ], - "notes": [ - "See also: `close_on_leave_reason`." - ] - }, - "close_on_leave_reason": { - "default": "The recipient has left the server.", - "description": "Reason for closing the thread once member leaves", - "examples":[ - "`{prefix}config set close_on_leave_reason Member left`" - ], - "notes": [ - "This has no effect unless `close_on_leave` is set.", - "See also: `close_on_leave`." - ] - }, - "alert_on_mention": { - "default": "No", - "description": "Mentions all mods (mention) in mention channel when bot is mentioned", - "examples":[ - "`{prefix}config set alert_on_mention yes`" - ], - "notes": [ - "See also: `mention`, `mention_channel_id`" - ] - }, - "silent_alert_on_mention": { - "default": "No", - "description": "Send a message in the mention channel without mentioning all mods (mention).", - "examples":[ - "`{prefix}config set alert_on_mention yes`" - ], - "notes": [ - "This has no effect unless `alert_on_mention` is set to yes.", - "See also: `mention`, `mention_channel_id`" - ] - }, - "show_timestamp": { - "default": "Yes", - "description": "Shows timestamps on thread embeds", - "examples":[ - "`{prefix}config set show_timestamp no`" - ], - "notes": [] - }, - "anonymous_snippets": { - "default": "No", - "description": "Sends snippets anonymously.", - "examples":[ - "`{prefix}config set anonymous_snippets yes`" - ], - "notes": [ - "See also: `anon_avatar_url`, `anon_tag`, `plain_snippets`." - ] - }, - "plain_snippets": { - "default": "No", - "description": "Sends snippets with a plain interface.", - "examples":[ - "`{prefix}config set plain_snippets yes`" - ], - "notes": [ - "See also: `anonymous_snippets`." - ] - }, - "require_close_reason": { - "default" : "No", - "description": "Require a reason to close threads.", - "examples": [ - "`{prefix}config set require_close_reason yes`" - ], - "notes": [] - }, - "show_log_url_button": { - "default" : "No", - "description": "Shows the button to open the Log URL.", - "examples": [ - "`{prefix}config set show_log_url_button yes`" - ], - "notes": [] - }, - "private_added_to_group_title": { - "default": "New Thread (Group)", - "description": "This is the message embed title sent to the recipient that is just added to a thread.", - "examples": [ - "`{prefix}config set private_added_to_group_title Welcome to this new group thread!`" - ], - "notes": [ - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_added_to_group_description`, `public_added_to_group_title`" - ] - }, - "private_added_to_group_response": { - "default": "\"{{moderator.name}} has added you to a Modmail thread.\"", - "description": "This is the message embed content sent to the recipient that is just added to a thread.", - "examples": [ - "`{prefix}config set private_added_to_group_response Any message sent here will be sent to all other thread recipients.`" - ], - "notes": [ - "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", - "When anonadduser is used, `private_added_to_group_description_anon` is used instead.", - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_added_to_group_title`, `public_added_to_group_description`" - ] - }, - "private_added_to_group_description_anon": { - "default": "A moderator has added you to a Modmail thread.", - "description": "This is the message embed content sent to the recipient that is just added to a thread when adduser is used anonymously.", - "examples": [ - "`{prefix}config set private_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" - ], - "notes": [ - "When adduser (no anon) is used, `private_added_to_group_description` is used instead.", - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_added_to_group_title`, `public_added_to_group_description_anon`" - ] - }, - "public_added_to_group_title": { - "default": "New User", - "description": "This is the message embed title sent to all other recipients when someone is added to the thread.", - "examples": [ - "`{prefix}config set public_added_to_group_title Welcome to our new user!`" - ], - "notes": [ - "The private_ variant is used when sending to the new user.", - "See also: `private_added_to_group_title`, `private_added_to_group_title`" - ] - }, - "public_added_to_group_response": { - "default": "\"{{moderator.name}} has added {{users}} to the Modmail thread.\"", - "description": "This is the message embed content sent to all other recipients when someone is added to the thread.", - "examples": [ - "`{prefix}config set public_added_to_group_description Welcome {users}!`" - ], - "notes": [ - "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", - "When anonadduser is used, `public_added_to_group_description_anon` is used instead.", - "The private_ variant is used when sending to the new user.", - "See also: `public_added_to_group_title`, `private_added_to_group_description`" - ] - }, - "public_added_to_group_description_anon": { - "default": "\"A moderator has added {{users}} to the Modmail thread.\"", - "description": "This is the message embed content sent to all other recipients when someone is added to the thread when adduser is used anonymously.", - "examples": [ - "`{prefix}config set public_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" - ], - "notes": [ - "When adduser (no anon) is used, `public_added_to_group_description` is used instead.", - "The private_ variant is used when sending to the new user.", - "See also: `public_added_to_group_title`, `private_added_to_group_description_anon`" - ] - }, - "private_removed_from_group_title": { - "default": "Removed From Thread (Group)", - "description": "This is the message embed title sent to the recipient that is just removed from a thread.", - "examples": [ - "`{prefix}config set private_removed_from_group_title Welcome to this new group thread!`" - ], - "notes": [ - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_removed_from_group_description`, `public_removed_from_group_title`" - ] - }, - "private_removed_from_group_response": { - "default": "\"{{moderator.name}} has removed you from the Modmail thread.\"", - "description": "This is the message embed content sent to the recipient that is just removed from a thread.", - "examples": [ - "`{prefix}config set private_removed_from_group_response Bye`" - ], - "notes": [ - "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", - "When anonremoveuser is used, `private_removed_from_group_description_anon` is used instead.", - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_removed_from_group_title`, `public_removed_from_group_description`" - ] - }, - "private_removed_from_group_description_anon": { - "default": "A moderator has removed you from the Modmail thread.", - "description": "This is the message embed content sent to the recipient that is just removed from a thread when removeuser is used anonymously.", - "examples": [ - "`{prefix}config set private_removed_from_group_description_anon You are permenantly removed from this thread.`" - ], - "notes": [ - "When adduser (no anon) is used, `private_removed_from_group_description` is used instead.", - "The public_ variant is used when sending to other thread recipients.", - "See also: `private_removed_from_group_title`, `public_removed_from_group_description_anon`" - ] - }, - "public_removed_from_group_title": { - "default": "User Removed", - "description": "This is the message embed title sent to all other recipients when someone is removed from the thread.", - "examples": [ - "`{prefix}config set public_removed_from_group_title User is now gone!`" - ], - "notes": [ - "The private_ variant is used when sending to the new user.", - "See also: `private_removed_from_group_title`, `private_removed_from_group_title`" - ] - }, - "public_removed_from_group_response": { - "default": "\"{{moderator.name}} has removed {{users}} from the Modmail thread.\"", - "description": "This is the message embed content sent to all other recipients when someone is removed from the thread.", - "examples": [ - "`{prefix}config set public_removed_from_group_description Goodbye {users}!`" - ], - "notes": [ - "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", - "When anonremoveuser is used, `public_removed_from_group_description_anon` is used instead.", - "The private_ variant is used when sending to the new user.", - "See also: `public_removed_from_group_title`, `private_removed_from_group_description`" - ] - }, - "public_removed_from_group_description_anon": { - "default": "\"A moderator has removed {{users}} from the Modmail thread.\"", - "description": "This is the message embed content sent to all other recipients when someone is removed from the thread when removeuser is used anonymously.", - "examples": [ - "`{prefix}config set public_removed_from_group_description_anon Goodbye {users}!`" - ], - "notes": [ - "When adduser (no anon) is used, `public_removed_from_group_description` is used instead.", - "The private_ variant is used when sending to the new user.", - "See also: `public_removed_from_group_title`, `private_removed_from_group_description_anon`" - ] - }, - "confirm_thread_creation": { - "default": "No", - "description": "Ensure users confirm that they want to create a new thread", - "examples":[ - "`{prefix}config set confirm_thread_creation yes`" - ], - "notes": [ - "See also: `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" - ] - }, - "confirm_thread_creation_title": { - "default": "Confirm thread creation", - "description": "Title for the embed message sent to users to confirm a thread creation", - "examples":[ - "`{prefix}config set confirm_thread_creation_title Are you sure you want to create a new thread?`" - ], - "notes": [ - "See also: `confirm_thread_creation`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" - ] - }, - "confirm_thread_response": { - "default": "Click the button to confirm thread creation which will directly contact the moderators.", - "description": "Description for the embed message sent to users to confirm a thread creation", - "examples":[ - "`{prefix}config set confirm_thread_response Click to confirm`" - ], - "notes": [ - "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" - ] - }, - "confirm_thread_creation_accept": { - "default": "\u2705", - "description": "Emoji to accept a thread creation", - "examples":[ - "`{prefix}config set confirm_thread_creation_accept \u2611`" - ], - "notes": [ - "This has no effect unless `confirm_thread_creation` is set", - "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_deny`" - ] - }, - "confirm_thread_creation_deny": { - "default": "\uD83D\uDEAB", - "description": "Emoji to accept deny thread creation", - "examples":[ - "`{prefix}config set confirm_thread_creation_deny \u26D4`" - ], - "notes": [ - "This has no effect unless `confirm_thread_creation` is set", - "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`" - ] - }, - "use_regex_autotrigger": { - "default": "No", - "description": "Whether to use regex to compare in autotriggers.", - "examples":[ - "`{prefix}config set use_regex_autotrigger yes`" - ], - "notes": [ - "This is meant for advanced user that understand regular expressions.", - "You can test it out with https://regexr.com on `PCRE (Server)` mode", - "See command: `autotrigger`" - ] - }, - "modmail_guild_id": { - "default": "Fallback on `GUILD_ID`", - "description": "The ID of the discord server where the threads channels should be created (receiving server).", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "guild_id": { - "default": "None, required", - "description": "The ID of the discord server where recipient users reside (users server).", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "log_url": { - "default": "https://example.com/", - "description": "The base log viewer URL link, leave this as-is to not configure a log viewer.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "log_url_prefix": { - "default": "`/logs`", - "description": "The path to your log viewer extending from your `LOG_URL`, set this to `/` to specify no extra path to the log viewer.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "mongo_uri": { - "default": "None, required", - "description": "A MongoDB connection string.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "owners": { - "default": "None, required", - "description": "A list of definite bot owners, use `{prefix}perms add level OWNER @user` to set flexible bot owners.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "token": { - "default": "None, required", - "description": "Your bot token as found in the Discord Developer Portal.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "log_level": { - "default": "INFO", - "description": "The logging level for logging to stdout.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "stream_log_format": { - "default": "plain", - "description": "The logging format when through a stream, can be 'plain' or 'json'", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "file_log_format": { - "default": "plain", - "description": "The logging format when logging to a file, can be 'plain' or 'json'", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "discord_log_level": { - "default": "INFO", - "description": "The `discord.py` library logging level for logging to stdout.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "enable_plugins": { - "default": "Yes", - "description": "Whether plugins should be enabled and loaded into Modmail.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "data_collection": { - "default": "Yes", - "description": "Controls if bot metadata should be sent to the development team.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "github_token": { - "default": "None, required for update functionality", - "description": "A github personal access token with the repo scope: https://github.com/settings/tokens.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "disable_autoupdates": { - "default": "No", - "description": "Controls if autoupdates should be disabled or not.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "disable_updates": { - "default": "No", - "description": "Controls if the update command should be disabled or not.", - "examples": [ - ], - "notes": [ - "This configuration can only to be set through `.env` file or environment (config) variables." - ] - }, - "use_hoisted_top_role": { - "default": "Yes", - "description": "Controls if only hoisted roles are evaluated when finding top role.", - "examples": [ - ], - "notes": [ - "Top role is displayed in embeds when replying or adding/removing users to a thread in the case mod_tag and anon_username are not set.", - "If this configuration is enabled, only roles that are hoisted (displayed seperately in member list) will be used. If a user has no hoisted roles, it will return 'None'.", - "If you would like to display the top role of a user regardless of if it's hoisted or not, disable `use_hoisted_top_role`." - ] - }, - "thread_min_characters": { - "default": "0", - "description": "The minimum number of characters required in the initial message to create a thread. Set to 0 to disable.", - "examples": [ - "`{prefix}config set thread_min_characters 20`" - ], - "notes": [ - "If a user tries to create a thread with a message shorter than this, an error will be shown.", - "See also: `thread_min_characters_title`, `thread_min_characters_response`, `thread_min_characters_footer`." - ] - }, - "thread_min_characters_title": { - "default": "Message too short", - "description": "The title of the error embed when a user tries to create a thread with too few characters.", - "examples": [ - "`{prefix}config set thread_min_characters_title Too short!`" - ], - "notes": [ - "See also: `thread_min_characters`, `thread_min_characters_response`, `thread_min_characters_footer`." - ] - }, - "thread_min_characters_response": { - "default": "Your message is too short to create a thread. Please provide more details.", - "description": "The description of the error embed when a user tries to create a thread with too few characters.", - "examples": [ - "`{prefix}config set thread_min_characters_response Please write a longer message.`" - ], - "notes": [ - "You can use `{min_characters}` as a placeholder for the minimum required characters.", - "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_footer`." - ] - }, - "thread_min_characters_footer": { - "default": "Minimum {min_characters} characters required.", - "description": "The footer of the error embed when a user tries to create a thread with too few characters.", - "examples": [ - "`{prefix}config set thread_min_characters_footer At least {min_characters} characters needed.`" - ], - "notes": [ - "You can use `{min_characters}` as a placeholder for the minimum required characters.", - "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_response`." - ] - } +{ + "twitch_url": { + "default": "`https://www.twitch.tv/discordmodmail/`", + "description": "This channel dictates the linked Twitch channel when the activity is set to \"Streaming\".", + "examples": [ + "`{prefix}config set twitch_url https://www.twitch.tv/yourchannelname/`" + ], + "notes": [ + "This has no effect when the activity is not set to \"Streaming\".", + "See also: `{prefix}help activity`." + ] + }, + "main_category_id": { + "default": "`Modmail` (created with `{prefix}setup`)", + "description": "This is the category where all new threads will be created.\n\nTo change the Modmail category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).", + "examples": [ + "`{prefix}config set main_category_id 9234932582312` (`9234932582312` is the category ID)" + ], + "notes": [ + "If the Modmail category ended up being non-existent/invalid, Modmail will break. To fix this, run `{prefix}setup` again or set `main_category_id` to a valid category.", + "When the Modmail category is full, new channels will be created in the fallback category.", + "See also: `fallback_category_id`." + ] + }, + "fallback_category_id": { + "default": "`Fallback Modmail` (created when the main category is full)", + "description": "This is the category that will hold the threads when the main category is full.\n\nTo change the Fallback category, you will need to find the [category’s ID](https://support.discordapp.com/hc/en-us/articles/206346498).", + "examples": [ + "`{prefix}config set fallback_category_id 9234932582312` (`9234932582312` is the category ID)" + ], + "notes": [ + "If the Fallback category ended up being non-existent/invalid, Modmail will create a new one. To fix this, set `fallback_category_id` to a valid category.", + "See also: `main_category_id`." + ] + }, + "prefix": { + "default": "`?`", + "description": "The prefix of the bot.", + "examples": [ + "`{prefix}prefix !`", + "`{prefix}config set prefix !`" + ], + "notes": [ + "If you forgot the bot prefix, Modmail will always respond to its mention (ping)." + ] + }, + "mention": { + "default": "@here", + "description": "This is the message above user information for when a new thread is created in the channel.", + "examples": [ + "`{prefix}config set mention Yo~ Here's a new thread for ya!`", + "`{prefix}mention Yo~ Here's a new thread for ya!`" + ], + "notes": [ + "To disable mention, use command `{prefix}mention disable`.", + "See also: `{prefix}help mention`." + ] + }, + "main_color": { + "default": "Discord Blurple [#7289DA](https://placehold.it/100/7289da?text=+)", + "description": "This is the main color for Modmail (help/about/ping embed messages, subscribe, move, etc.).", + "examples": [ + "`{prefix}config set main_color olive green`", + "`{prefix}config set main_color 12de3a`", + "`{prefix}config set main_color #12de3a`", + "`{prefix}config set main_color fff`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `error_color`, `mod_color`, `recipient_color`." + ], + "thumbnail": "https://placehold.it/100/7289da?text=+" + }, + "error_color": { + "default": "Discord Red [#E74C3C](https://placehold.it/100/e74c3c?text=+)", + "description": "This is the color for Modmail when anything goes wrong, unsuccessful commands, or a stern warning.", + "examples": [ + "`{prefix}config set error_color ocean blue`", + "`{prefix}config set error_color ff1242`", + "`{prefix}config set error_color #ff1242`", + "`{prefix}config set error_color fa1`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `main_color`, `mod_color`, `recipient_color`." + ], + "thumbnail": "https://placehold.it/100/e74c3c?text=+" + }, + "user_typing": { + "default": "Enabled", + "description": "When this is set to `yes`, whenever the recipient user starts to type in their DM channel, the moderator will see “{bot.user.display_name} is typing…” in the thread channel.", + "examples": [ + "`{prefix}config set user_typing yes`", + "`{prefix}config set user_typing no`" + ], + "notes": [ + "See also: `mod_typing`." + ] + }, + "use_user_id_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's ID instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_user_id_channel_name yes`", + "`{prefix}config set use_user_id_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." + ] + }, + "use_timestamp_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's account creation date instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_timestamp_channel_name yes`", + "`{prefix}config set use_timestamp_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_user_id_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_user_id_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." + ] + }, + "use_nickname_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's nickname instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_nickname_channel_name yes`", + "`{prefix}config set use_nickname_channel_name no`" + ], + "notes": [ + "This config is NOT suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_random_channel_name`." + ] + }, + "use_random_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with random characters tied to their user ID.", + "examples": [ + "`{prefix}config set use_random_channel_name yes`", + "`{prefix}config set use_random_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_nickname_channel_name`, or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_nickname_channel_name`." + ] + }, + "mod_typing": { + "default": "Disabled", + "description": "When this is set to `yes`, whenever a moderator starts to type in the thread channel, the recipient user will see \"{bot.user.display_name} is typing…\" in their DM channel.", + "examples": [ + "`{prefix}config set mod_typing yes`", + "`{prefix}config set mod_typing no`" + ], + "notes": [ + "See also: `mod_typing`." + ] + }, + "account_age": { + "default": "No age threshold", + "description": "The creation date of the recipient user account must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set account_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set account_age 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To remove this restriction, do `{prefix}config del account_age`.", + "See also: `guild_age`." + ] + }, + "guild_age": { + "default": "No age threshold", + "description": "The join date of the recipient user into this server must be greater than the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set guild_age P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set guild_age 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To remove this restriction, do `{prefix}config del guild_age`.", + "See also: `account_age`." + ] + }, + "reply_without_command": { + "default": "Disabled", + "description": "Setting this configuration will make all non-command messages sent in the thread channel to be forwarded to the recipient without the need of `{prefix}reply`.", + "examples": [ + "`{prefix}config set reply_without_command yes`", + "`{prefix}config set reply_without_command no`" + ], + "notes": [ + "See also: `anon_reply_without_command`, `plain_reply_without_command`." + ] + }, + "anon_reply_without_command": { + "default": "Disabled", + "description": "Setting this configuration will make all non-command messages sent in the thread channel to be anonymously forwarded to the recipient without the need of `{prefix}reply`.", + "examples": [ + "`{prefix}config set anon_reply_without_command yes`", + "`{prefix}config set anon_reply_without_command no`" + ], + "notes": [ + "See also: `reply_without_command`, `plain_reply_without_command`." + ] + }, + "plain_reply_without_command": { + "default": "Disabled", + "description": "Setting this configuration will make all non-command messages sent in the thread channel to be forwarded to the recipient in a plain form without the need of `{prefix}reply`.", + "examples": [ + "`{prefix}config set plain_reply_without_command yes`", + "`{prefix}config set plain_reply_without_command no`" + ], + "notes": [ + "See also: `reply_without_command`, `anon_reply_without_command`." + ] + }, + "log_channel_id": { + "default": "`#bot-logs` (created with `{prefix}setup`)", + "description": "This is the channel where all log messages will be sent (ie. thread close message, update message, etc.).\n\nTo change the log channel, you will need to find the [channel’s ID](https://support.discordapp.com/hc/en-us/articles/206346498). The channel doesn’t necessary have to be under the `main_category`.", + "examples": [ + "`{prefix}config set log_channel_id 9234932582312` (9234932582312 is the channel ID)" + ], + "notes": [ + "If the Modmail logging channel ended up being non-existent/invalid, no logs will be sent." + ] + }, + "mention_channel_id": { + "default": "Log Channel (normally `#bot-logs`)", + "description": "This is the channel where bot mentions are sent to.", + "examples": [ + "`{prefix}config set mention_channel_id 9234932582312` (9234932582312 is the channel ID)" + ], + "notes": [ + "This has no effect unless `alert_on_mention` is set to yes.", + "See also: `log_channel_id`" + ] + }, + "update_channel_id": { + "default": "Log Channel (normally `#bot-logs`)", + "description": "This is the channel where update notifications are sent to.", + "examples": [ + "`{prefix}config set update_channel_id 9234932582312` (9234932582312 is the channel ID)" + ], + "notes": [ + "This has no effect unless `disable_autoupdates` is set to no and `update_notifications` is set to yes.", + "See also: `log_channel_id`" + ] + }, + "update_notifications": { + "default": "Yes", + "description": "This is the channel where update notifications are sent to.", + "examples": [ + "`{prefix}config set update_notifications no`" + ], + "notes": [ + "This has no effect unless `disable_autoupdates` is set to no.", + "See also: `update_channel_id`" + ] + }, + "sent_emoji": { + "default": "✅", + "description": "This is the emoji added to the message when when a Modmail action is invoked successfully (ie. DM Modmail, edit message, etc.).", + "examples": [ + "`{prefix}config set sent_emoji ✨`" + ], + "notes": [ + "You can disable `sent_emoji` with `{prefix}config set sent_emoji disable`.", + "Custom/animated emojis are also supported, however, the emoji must be added to the server.", + "See also: `blocked_emoji`." + ] + }, + "blocked_emoji": { + "default": "🚫", + "description": "This is the emoji added to the message when when a Modmail action is invoked unsuccessfully (ie. DM Modmail when blocked, failed to reply, etc.).", + "examples": [ + "`{prefix}config set blocked_emoji 🙅‍`" + ], + "notes": [ + "You can disable `blocked_emoji` with `{prefix}config set blocked_emoji disable`.", + "Custom/animated emojis are also supported, however, the emoji must be added to the server.", + "See also: `sent_emoji`." + ] + }, + "close_emoji": { + "default": "🔒", + "description": "This is the emoji the recipient can click to close a thread themselves. The emoji is automatically added to the `thread_creation_response` embed.", + "examples": [ + "`{prefix}config set close_emoji 👍‍`" + ], + "notes": [ + "This will only have an effect when `recipient_thread_close` is enabled.", + "See also: `recipient_thread_close`." + ] + }, + "recipient_thread_close": { + "default": "Disabled", + "description": "Setting this configuration will allow recipients to use the `close_emoji` to close the thread themselves.", + "examples": [ + "`{prefix}config set recipient_thread_close yes`", + "`{prefix}config set recipient_thread_close no`" + ], + "notes": [ + "The close emoji is dictated by the configuration `close_emoji`.", + "See also: `close_emoji`." + ] + }, + "thread_show_roles": { + "default": "Yes", + "description": "Shows roles on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_roles no`" + ], + "notes": [ + "See also: `thread_show_account_age`, `thread_show_join_age`." + ] + }, + "thread_show_account_age": { + "default": "Yes", + "description": "Shows account age on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_account_age no`" + ], + "notes": [ + "See also: `thread_show_roles`, `thread_show_join_age`." + ] + }, + "thread_show_join_age": { + "default": "Yes", + "description": "Shows join age on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_join_age no`" + ], + "notes": [ + "See also: `thread_show_account_age`, `thread_show_roles`." + ] + }, + "thread_auto_close_silently": { + "default": "No", + "description": "Setting this configuration will close silently when the thread auto-closes.", + "examples": [ + "`{prefix}config set thread_auto_close_silently yes`", + "`{prefix}config set thread_auto_close_silently no`" + ], + "notes": [ + "This will only have an effect when `thread_auto_close` is set.", + "See also: `thread_auto_close`." + ] + }, + "thread_auto_close": { + "default": "Never", + "description": "Setting this configuration will close threads automatically after the number of days, hours, minutes or any time-interval specified by this configuration.", + "examples": [ + "`{prefix}config set thread_auto_close P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set thread_auto_close 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable auto close, do `{prefix}config del thread_auto_close`.", + "To prevent a thread from auto-closing, do `{prefix}close cancel`.", + "See also: `thread_auto_close_silently`, `thread_auto_close_response`." + ] + }, + "thread_cooldown": { + "default": "Never", + "description": "Specify the time required for the recipient to wait before allowed to create a new thread.", + "examples": [ + "`{prefix}config set thread_cooldown P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set thread_cooldown 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable thread cooldown, do `{prefix}config del thread_cooldown`." + ] + }, + "log_expiration": { + "default": "Never", + "description": "The duration closed threads will be stored within the database before deletion. Logs that have been closed for longer than this duration will be deleted automatically.", + "examples": [ + "`{prefix}config set log_expiration P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set log_expiration 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable log expiration, do `{prefix}config del log_expiration`." + ] + }, + "thread_cancelled": { + "default": "\"Cancelled\"", + "description": "This is the message to display when a thread times out and creation is cancelled.", + "examples": [ + "`{prefix}config set thread_cancelled Gone.`" + ], + "notes": [] + }, + "thread_auto_close_response": { + "default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"", + "description": "This is the message to display when the thread when the thread auto-closes.", + "examples": [ + "`{prefix}config set thread_auto_close_response Your close message here.`" + ], + "notes": [ + "Its possible to use `{{timeout}}` as a placeholder for a formatted timeout text.", + "This will not have an effect when `thread_auto_close_silently` is enabled.", + "Discord flavoured markdown is fully supported in `thread_auto_close_response`.", + "See also: `thread_auto_close`, `thread_auto_close_silently`." + ] + }, + "thread_creation_response": { + "default": "\"The staff team will get back to you as soon as possible.\"", + "description": "This is the message embed content sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_response You will be contacted shortly.`" + ], + "notes": [ + "Discord flavoured markdown is fully supported in `thread_creation_response`.", + "See also: `thread_creation_title`, `thread_creation_footer`, `thread_close_response`." + ] + }, + "thread_creation_footer": { + "default": "\"Your message has been sent\"", + "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_footer Please Hold...`" + ], + "notes": [ + "This is used in place of `thread_self_closable_creation_footer` when `recipient_thread_close` is enabled.", + "See also: `thread_creation_title`, `thread_creation_response`, `thread_self_closable_creation_footer`, `thread_close_footer`." + ] + }, + "thread_contact_silently": { + "default": "No", + "description": "Setting this configuration will always open a new thread silently in contact.", + "examples": [ + "`{prefix}config set thread_contact_silently yes`", + "`{prefix}config set thread_contact_silently no`" + ], + "notes": [ + "Works like `{prefix}contact silent` for every new thread." + ] + }, + "thread_self_closable_creation_footer": { + "default": "\"Click the lock to close the thread\"", + "description": "This is the message embed footer sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_self_closable_creation_footer Please Hold...`" + ], + "notes": [ + "This is used in place of `thread_creation_footer` when `recipient_thread_close` is disabled.", + "See also: `thread_creation_title`, `thread_creation_response`, `thread_creation_footer`." + ] + }, + "thread_creation_contact_title": { + "default": "\"New Thread\"", + "description": "This is the message embed title sent to recipients when contacted.", + "examples": [ + "`{prefix}config set thread_creation_contact_title New Message!`" + ], + "notes": [ + "See also: `thread_creation_self_contact_response`, `thread_creation_contact_response`." + ] + }, + "thread_creation_self_contact_response": { + "default": "\"You have opened a Modmail thread.\"", + "description": "This is the message embed description sent to recipients when self-contacted.", + "examples": [ + "`{prefix}config set thread_creation_self_contact_response You contacted yourself.`" + ], + "notes": [ + "`thread_creation_contact_response` is used when contacted by another user.", + "See also: `thread_creation_contact_title`, `thread_creation_contact_response`." + ] + }, + "thread_creation_contact_response": { + "default": "\"{{creator.name}} has opened a Modmail thread.\"", + "description": "This is the message embed description sent to recipients when contacted by a mod.", + "examples": [ + "`{prefix}config set thread_creation_contact_response New thread opened.`" + ], + "notes": [ + "You may use the `{{creator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that created the thread.", + "`thread_creation_self_contact_response` is used when contacted by self.", + "See also: `thread_creation_contact_title`, `thread_creation_self_contact_response`." + ] + }, + "thread_creation_title": { + "default": "\"Thread Created\"", + "description": "This is the message embed title sent to the recipient upon the creation of a new thread.", + "examples": [ + "`{prefix}config set thread_creation_title Hello!`" + ], + "notes": [ + "See also: `thread_creation_response`, `thread_creation_footer`, `thread_close_title`." + ] + }, + "thread_close_footer": { + "default": "\"Replying will create a new thread\"", + "description": "This is the message embed footer sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_footer Bye!`" + ], + "notes": [ + "See also: `thread_close_title`, `thread_close_response`, `thread_creation_footer`." + ] + }, + "thread_close_title": { + "default": "\"Thread Closed\"", + "description": "This is the message embed title sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_title Farewell!`" + ], + "notes": [ + "See also: `thread_close_response`, `thread_close_footer`, `thread_creation_title`." + ] + }, + "thread_close_response": { + "default": "\"{{closer.mention}} has closed this Modmail thread\"", + "description": "This is the message embed content sent to the recipient upon the closure of a thread.", + "examples": [ + "`{prefix}config set thread_close_response Your message is appreciated!`" + ], + "notes": [ + "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", + "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "Discord flavoured markdown is fully supported in `thread_close_response`.", + "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." + ] + }, + "thread_self_close_response": { + "default": "\"You have closed this Modmail thread.\"", + "description": "This is the message embed content sent to the recipient upon the closure of a their own thread.", + "examples": [ + "`{prefix}config set thread_self_close_response You have closed your own thread...`" + ], + "notes": [ + "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", + "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "Discord flavoured markdown is fully supported in `thread_self_close_response`.", + "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." + ] + }, + "thread_move_title": { + "default": "Thread Moved", + "description": "The title of the message embed when a thread is moved.", + "examples": [ + "`{prefix}config set thread_move_title Thread transferred to another channel!`" + ], + "notes": [ + "See also: `thread_move_notify`, `thread_move_notify_mods`, `thread_move_response`." + ] + }, + "thread_move_notify": { + "default": "No", + "description": "Notify the recipient if the thread was moved.", + "examples": [ + "`{prefix}config set thread_move_notify yes`", + "`{prefix}config set thread_move_notify no`" + ], + "notes": [ + "See also: `thread_move_title`, `thread_move_response`, `thread_move_notify_mods`." + ] + }, + "thread_move_notify_mods": { + "default": "No", + "description": "Notify mods again after the thread is moved", + "examples": [ + "`{prefix}config set thread_move_notify_mods yes`", + "`{prefix}config set thread_move_notify_mods no`" + ], + "notes": [ + "See also: `thread_move_title`, `thread_move_response`, `thread_move_notify`." + ] + }, + "thread_move_response": { + "default": "This thread has been moved.", + "description": "This is the message to display to the user when the thread is moved.", + "examples": [ + "`{prefix}config set thread_move_response This thread has been moved to another category for review!`" + ], + "notes": [ + "Only has an effect when `thread_move_notify` is on.", + "See also: `thread_move_title`, `thread_move_notify`." + ] + }, + "cooldown_thread_title": { + "default": "Message not sent!", + "description": "The title of the message embed when the user has a cooldown before creating a new thread.", + "examples": [ + "`{prefix}config set cooldown_thread_title Error`" + ], + "notes": [ + "Only has an effect when `thread_cooldown` is set", + "See also: `cooldown_thread_response`." + ] + }, + "cooldown_thread_response": { + "default": "Your cooldown ends {delta}. Try contacting me then.", + "description": "The description of the message embed when the user has a cooldown before creating a new thread.", + "examples": [ + "`{prefix}config set cooldown_thread_response Be patient! You are on cooldown, wait {delta} more.`" + ], + "notes": [ + "Only has an effect when `thread_cooldown` is set", + "Must have a {delta} included which will be replaced with the duration of time.", + "See also: `cooldown_thread_title`." + ] + }, + "disabled_new_thread_title": { + "default": "Not Delivered.", + "description": "The title of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", + "examples": [ + "`{prefix}config set disabled_new_thread_title Closed`" + ], + "notes": [ + "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", + "See also: `disabled_new_thread_response`, `disabled_new_thread_footer`, `disabled_current_thread_title`." + ] + }, + "disabled_new_thread_response": { + "default": "We are not accepting new threads.", + "description": "The body of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", + "examples": [ + "`{prefix}config set disabled_new_thread_response Our working hours is between 8am - 6pm EST.`" + ], + "notes": [ + "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", + "See also: `disabled_new_thread_title`, `disabled_new_thread_footer`, `disabled_current_thread_response`." + ] + }, + "disabled_new_thread_footer": { + "default": "Please try again later...", + "description": "The footer of the message embed when Modmail new thread creation is disabled and user tries to create a new thread.", + "examples": [ + "`{prefix}config set disabled_new_thread_footer Contact us later`" + ], + "notes": [ + "Only has an effect when `{prefix}disable` or `{prefix}disable all` is set.", + "See also: `disabled_new_thread_title`, `disabled_new_thread_response`, `disabled_current_thread_footer`." + ] + }, + "disabled_current_thread_title": { + "default": "Not Delivered.", + "description": "The title of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", + "examples": [ + "`{prefix}config set disabled_current_thread_title Unavailable`" + ], + "notes": [ + "Only has an effect when `{prefix}disable all` is set.", + "See also: `disabled_current_thread_response`, `disabled_current_thread_footer`, `disabled_new_thread_title`." + ] + }, + "disabled_current_thread_response": { + "default": "We are not accepting any messages.", + "description": "The body of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", + "examples": [ + "`{prefix}config set disabled_current_thread_response On break right now.`" + ], + "notes": [ + "Only has an effect when `{prefix}disable all` is set.", + "See also: `disabled_current_thread_title`, `disabled_current_thread_footer`, `disabled_new_thread_response`." + ] + }, + "disabled_current_thread_footer": { + "default": "Please try again later...", + "description": "The footer of the message embed when Modmail DM is disabled and user DMs Modmail from existing thread.", + "examples": [ + "`{prefix}config set disabled_current_thread_footer Message back!`" + ], + "notes": [ + "Only has an effect when `{prefix}disable all` is set.", + "See also: `disabled_current_thread_title`, `disabled_current_thread_response`, `disabled_new_thread_footer`." + ] + }, + "recipient_color": { + "default": "Discord Gold [#F1C40F](https://placehold.it/100/f1c40f?text=+)", + "description": "This is the color of the messages sent by the recipient, this applies to messages received in the thread channel.", + "examples": [ + "`{prefix}config set recipient_color dark beige`", + "`{prefix}config set recipient_color cb7723`", + "`{prefix}config set recipient_color #cb7723`", + "`{prefix}config set recipient_color c4k`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `mod_color`, `main_color`, `error_color`." + ], + "thumbnail": "https://placehold.it/100/f1c40f?text=+" + }, + "mod_color": { + "default": "Discord Green [#2ECC71](https://placehold.it/100/2ecc71?text=+)", + "description": "This is the color of the messages sent by the moderators, this applies to messages within in the thread channel and the DM thread messages received by the recipient.", + "examples": [ + "`{prefix}config set mod_color dark beige`", + "`{prefix}config set mod_color cb7723`", + "`{prefix}config set mod_color #cb7723`", + "`{prefix}config set mod_color c4k`" + ], + "notes": [ + "Available color names can be found on [Taki's Blog](https://taaku18.github.io/modmail/colors/).", + "See also: `recipient_color`, `main_color`, `error_color`." + ], + "thumbnail": "https://placehold.it/100/2ecc71?text=+" + }, + "mod_tag": { + "default": "The moderator's highest role", + "description": "This is the name tag in the “footer” section of the embeds sent by moderators in the recipient DM and thread channel.", + "examples": [ + "`{prefix}config set mod_tag Moderator`" + ], + "notes": [ + "When the message is sent anonymously, `anon_tag` is used instead.", + "See also: `anon_tag`." + ] + }, + "anon_username": { + "default": "Fallback on `mod_tag`", + "description": "This is the name in the “author” section of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_username Incognito Mod`" + ], + "notes": [ + "See also: `anon_avatar_url`, `anon_tag`." + ], + "image": "https://i.imgur.com/SKOC42Z.png" + }, + "anon_avatar_url": { + "default": "Server avatar", + "description": "This is the avatar of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_avatar_url https://path.to/your/avatar.png` (you will need to upload the avatar to somewhere)" + ], + "notes": [ + "See also: `anon_username`, `anon_tag`." + ], + "image": "https://i.imgur.com/SKOC42Z.png" + }, + "anon_tag": { + "default": "\"Response\"", + "description": "This is the name tag in the “footer” section of the embeds sent by anonymous moderators in the recipient DM.", + "examples": [ + "`{prefix}config set anon_tag Support Agent`" + ], + "notes": [ + "See also: `anon_avatar_url`, `anon_username`, `mod_tag`." + ], + "image": "https://i.imgur.com/SKOC42Z.png" + }, + "react_to_contact_message": { + "default": "None", + "description": "A message ID where reactions are tracked. If the `react_to_contact_emoji` is added, the bot opens a thread with them.", + "examples": [ + "`{prefix}config set react_to_contact_message 773575608814534717`" + ], + "notes": [ + "See also: `react_to_contact_emoji`" + ] + }, + "react_to_contact_emoji": { + "default": "\u2705", + "description": "An emoji which is tracked in `react_to_contact_message`", + "examples": [ + "`{prefix}config set react_to_contact_emoji \u2705`" + ], + "notes": [ + "See also: `react_to_contact_message \u2705`" + ] + }, + "transfer_reactions": { + "default": "Yes", + "description": "Transfer users reactions to mods and vice versa", + "examples":[ + "`{prefix}config set transfer_reactions no`" + ], + "notes": [] + }, + "close_on_leave": { + "default": "No", + "description": "Closes a modmail thread upon user leave automatically", + "examples":[ + "`{prefix}config set close_on_leave yes`" + ], + "notes": [ + "See also: `close_on_leave_reason`." + ] + }, + "close_on_leave_reason": { + "default": "The recipient has left the server.", + "description": "Reason for closing the thread once member leaves", + "examples":[ + "`{prefix}config set close_on_leave_reason Member left`" + ], + "notes": [ + "This has no effect unless `close_on_leave` is set.", + "See also: `close_on_leave`." + ] + }, + "alert_on_mention": { + "default": "No", + "description": "Mentions all mods (mention) in mention channel when bot is mentioned", + "examples":[ + "`{prefix}config set alert_on_mention yes`" + ], + "notes": [ + "See also: `mention`, `mention_channel_id`" + ] + }, + "silent_alert_on_mention": { + "default": "No", + "description": "Send a message in the mention channel without mentioning all mods (mention).", + "examples":[ + "`{prefix}config set alert_on_mention yes`" + ], + "notes": [ + "This has no effect unless `alert_on_mention` is set to yes.", + "See also: `mention`, `mention_channel_id`" + ] + }, + "show_timestamp": { + "default": "Yes", + "description": "Shows timestamps on thread embeds", + "examples":[ + "`{prefix}config set show_timestamp no`" + ], + "notes": [] + }, + "anonymous_snippets": { + "default": "No", + "description": "Sends snippets anonymously.", + "examples":[ + "`{prefix}config set anonymous_snippets yes`" + ], + "notes": [ + "See also: `anon_avatar_url`, `anon_tag`, `plain_snippets`." + ] + }, + "plain_snippets": { + "default": "No", + "description": "Sends snippets with a plain interface.", + "examples":[ + "`{prefix}config set plain_snippets yes`" + ], + "notes": [ + "See also: `anonymous_snippets`." + ] + }, + "require_close_reason": { + "default" : "No", + "description": "Require a reason to close threads.", + "examples": [ + "`{prefix}config set require_close_reason yes`" + ], + "notes": [] + }, + "show_log_url_button": { + "default" : "No", + "description": "Shows the button to open the Log URL.", + "examples": [ + "`{prefix}config set show_log_url_button yes`" + ], + "notes": [] + }, + "private_added_to_group_title": { + "default": "New Thread (Group)", + "description": "This is the message embed title sent to the recipient that is just added to a thread.", + "examples": [ + "`{prefix}config set private_added_to_group_title Welcome to this new group thread!`" + ], + "notes": [ + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_description`, `public_added_to_group_title`" + ] + }, + "private_added_to_group_response": { + "default": "\"{{moderator.name}} has added you to a Modmail thread.\"", + "description": "This is the message embed content sent to the recipient that is just added to a thread.", + "examples": [ + "`{prefix}config set private_added_to_group_response Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonadduser is used, `private_added_to_group_description_anon` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_title`, `public_added_to_group_description`" + ] + }, + "private_added_to_group_description_anon": { + "default": "A moderator has added you to a Modmail thread.", + "description": "This is the message embed content sent to the recipient that is just added to a thread when adduser is used anonymously.", + "examples": [ + "`{prefix}config set private_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "When adduser (no anon) is used, `private_added_to_group_description` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_title`, `public_added_to_group_description_anon`" + ] + }, + "public_added_to_group_title": { + "default": "New User", + "description": "This is the message embed title sent to all other recipients when someone is added to the thread.", + "examples": [ + "`{prefix}config set public_added_to_group_title Welcome to our new user!`" + ], + "notes": [ + "The private_ variant is used when sending to the new user.", + "See also: `private_added_to_group_title`, `private_added_to_group_title`" + ] + }, + "public_added_to_group_response": { + "default": "\"{{moderator.name}} has added {{users}} to the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is added to the thread.", + "examples": [ + "`{prefix}config set public_added_to_group_description Welcome {users}!`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonadduser is used, `public_added_to_group_description_anon` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_added_to_group_title`, `private_added_to_group_description`" + ] + }, + "public_added_to_group_description_anon": { + "default": "\"A moderator has added {{users}} to the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is added to the thread when adduser is used anonymously.", + "examples": [ + "`{prefix}config set public_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "When adduser (no anon) is used, `public_added_to_group_description` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_added_to_group_title`, `private_added_to_group_description_anon`" + ] + }, + "private_removed_from_group_title": { + "default": "Removed From Thread (Group)", + "description": "This is the message embed title sent to the recipient that is just removed from a thread.", + "examples": [ + "`{prefix}config set private_removed_from_group_title Welcome to this new group thread!`" + ], + "notes": [ + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_description`, `public_removed_from_group_title`" + ] + }, + "private_removed_from_group_response": { + "default": "\"{{moderator.name}} has removed you from the Modmail thread.\"", + "description": "This is the message embed content sent to the recipient that is just removed from a thread.", + "examples": [ + "`{prefix}config set private_removed_from_group_response Bye`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonremoveuser is used, `private_removed_from_group_description_anon` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_title`, `public_removed_from_group_description`" + ] + }, + "private_removed_from_group_description_anon": { + "default": "A moderator has removed you from the Modmail thread.", + "description": "This is the message embed content sent to the recipient that is just removed from a thread when removeuser is used anonymously.", + "examples": [ + "`{prefix}config set private_removed_from_group_description_anon You are permenantly removed from this thread.`" + ], + "notes": [ + "When adduser (no anon) is used, `private_removed_from_group_description` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_title`, `public_removed_from_group_description_anon`" + ] + }, + "public_removed_from_group_title": { + "default": "User Removed", + "description": "This is the message embed title sent to all other recipients when someone is removed from the thread.", + "examples": [ + "`{prefix}config set public_removed_from_group_title User is now gone!`" + ], + "notes": [ + "The private_ variant is used when sending to the new user.", + "See also: `private_removed_from_group_title`, `private_removed_from_group_title`" + ] + }, + "public_removed_from_group_response": { + "default": "\"{{moderator.name}} has removed {{users}} from the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is removed from the thread.", + "examples": [ + "`{prefix}config set public_removed_from_group_description Goodbye {users}!`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonremoveuser is used, `public_removed_from_group_description_anon` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_removed_from_group_title`, `private_removed_from_group_description`" + ] + }, + "public_removed_from_group_description_anon": { + "default": "\"A moderator has removed {{users}} from the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is removed from the thread when removeuser is used anonymously.", + "examples": [ + "`{prefix}config set public_removed_from_group_description_anon Goodbye {users}!`" + ], + "notes": [ + "When adduser (no anon) is used, `public_removed_from_group_description` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_removed_from_group_title`, `private_removed_from_group_description_anon`" + ] + }, + "confirm_thread_creation": { + "default": "No", + "description": "Ensure users confirm that they want to create a new thread", + "examples":[ + "`{prefix}config set confirm_thread_creation yes`" + ], + "notes": [ + "See also: `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" + ] + }, + "confirm_thread_creation_title": { + "default": "Confirm thread creation", + "description": "Title for the embed message sent to users to confirm a thread creation", + "examples":[ + "`{prefix}config set confirm_thread_creation_title Are you sure you want to create a new thread?`" + ], + "notes": [ + "See also: `confirm_thread_creation`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" + ] + }, + "confirm_thread_response": { + "default": "Click the button to confirm thread creation which will directly contact the moderators.", + "description": "Description for the embed message sent to users to confirm a thread creation", + "examples":[ + "`{prefix}config set confirm_thread_response Click to confirm`" + ], + "notes": [ + "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" + ] + }, + "confirm_thread_creation_accept": { + "default": "\u2705", + "description": "Emoji to accept a thread creation", + "examples":[ + "`{prefix}config set confirm_thread_creation_accept \u2611`" + ], + "notes": [ + "This has no effect unless `confirm_thread_creation` is set", + "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_deny`" + ] + }, + "confirm_thread_creation_deny": { + "default": "\uD83D\uDEAB", + "description": "Emoji to accept deny thread creation", + "examples":[ + "`{prefix}config set confirm_thread_creation_deny \u26D4`" + ], + "notes": [ + "This has no effect unless `confirm_thread_creation` is set", + "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`" + ] + }, + "use_regex_autotrigger": { + "default": "No", + "description": "Whether to use regex to compare in autotriggers.", + "examples":[ + "`{prefix}config set use_regex_autotrigger yes`" + ], + "notes": [ + "This is meant for advanced user that understand regular expressions.", + "You can test it out with https://regexr.com on `PCRE (Server)` mode", + "See command: `autotrigger`" + ] + }, + "modmail_guild_id": { + "default": "Fallback on `GUILD_ID`", + "description": "The ID of the discord server where the threads channels should be created (receiving server).", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "guild_id": { + "default": "None, required", + "description": "The ID of the discord server where recipient users reside (users server).", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "log_url": { + "default": "https://example.com/", + "description": "The base log viewer URL link, leave this as-is to not configure a log viewer.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "log_url_prefix": { + "default": "`/logs`", + "description": "The path to your log viewer extending from your `LOG_URL`, set this to `/` to specify no extra path to the log viewer.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "mongo_uri": { + "default": "None, required", + "description": "A MongoDB connection string.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "owners": { + "default": "None, required", + "description": "A list of definite bot owners, use `{prefix}perms add level OWNER @user` to set flexible bot owners.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "token": { + "default": "None, required", + "description": "Your bot token as found in the Discord Developer Portal.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "log_level": { + "default": "INFO", + "description": "The logging level for logging to stdout.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "stream_log_format": { + "default": "plain", + "description": "The logging format when through a stream, can be 'plain' or 'json'", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "file_log_format": { + "default": "plain", + "description": "The logging format when logging to a file, can be 'plain' or 'json'", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "discord_log_level": { + "default": "INFO", + "description": "The `discord.py` library logging level for logging to stdout.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "enable_plugins": { + "default": "Yes", + "description": "Whether plugins should be enabled and loaded into Modmail.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "data_collection": { + "default": "Yes", + "description": "Controls if bot metadata should be sent to the development team.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "github_token": { + "default": "None, required for update functionality", + "description": "A github personal access token with the repo scope: https://github.com/settings/tokens.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "disable_autoupdates": { + "default": "No", + "description": "Controls if autoupdates should be disabled or not.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "disable_updates": { + "default": "No", + "description": "Controls if the update command should be disabled or not.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "use_hoisted_top_role": { + "default": "Yes", + "description": "Controls if only hoisted roles are evaluated when finding top role.", + "examples": [ + ], + "notes": [ + "Top role is displayed in embeds when replying or adding/removing users to a thread in the case mod_tag and anon_username are not set.", + "If this configuration is enabled, only roles that are hoisted (displayed seperately in member list) will be used. If a user has no hoisted roles, it will return 'None'.", + "If you would like to display the top role of a user regardless of if it's hoisted or not, disable `use_hoisted_top_role`." + ] + }, + "thread_min_characters": { + "default": "0", + "description": "The minimum number of characters required in the initial message to create a thread. Set to 0 to disable.", + "examples": [ + "`{prefix}config set thread_min_characters 20`" + ], + "notes": [ + "If a user tries to create a thread with a message shorter than this, an error will be shown.", + "See also: `thread_min_characters_title`, `thread_min_characters_response`, `thread_min_characters_footer`." + ] + }, + "thread_min_characters_title": { + "default": "Message too short", + "description": "The title of the error embed when a user tries to create a thread with too few characters.", + "examples": [ + "`{prefix}config set thread_min_characters_title Too short!`" + ], + "notes": [ + "See also: `thread_min_characters`, `thread_min_characters_response`, `thread_min_characters_footer`." + ] + }, + "thread_min_characters_response": { + "default": "Your message is too short to create a thread. Please provide more details.", + "description": "The description of the error embed when a user tries to create a thread with too few characters.", + "examples": [ + "`{prefix}config set thread_min_characters_response Please write a longer message.`" + ], + "notes": [ + "You can use `{min_characters}` as a placeholder for the minimum required characters.", + "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_footer`." + ] + }, + "thread_min_characters_footer": { + "default": "Minimum {min_characters} characters required.", + "description": "The footer of the error embed when a user tries to create a thread with too few characters.", + "examples": [ + "`{prefix}config set thread_min_characters_footer At least {min_characters} characters needed.`" + ], + "notes": [ + "You can use `{min_characters}` as a placeholder for the minimum required characters.", + "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_response`." + ] + } } \ No newline at end of file diff --git a/core/models.py b/core/models.py index 611db375f0..ac5a28abd2 100644 --- a/core/models.py +++ b/core/models.py @@ -1,489 +1,489 @@ -import json -import logging -import os -import re -import sys -import _string - -from difflib import get_close_matches -from enum import IntEnum -from logging import FileHandler, StreamHandler, Handler -from logging.handlers import RotatingFileHandler -from string import Formatter -from typing import Dict, Optional - -import discord -from discord.ext import commands - - -try: - from colorama import Fore, Style -except ImportError: - Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() - - -if ".heroku" in os.environ.get("PYTHONHOME", ""): - # heroku - Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() - - -class ModmailLogger(logging.Logger): - @staticmethod - def _debug_(*msgs): - return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' - - @staticmethod - def _info_(*msgs): - return f'{Fore.LIGHTMAGENTA_EX}{" ".join(msgs)}{Style.RESET_ALL}' - - @staticmethod - def _error_(*msgs): - return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' - - def debug(self, msg, *args, **kwargs): - if self.isEnabledFor(logging.DEBUG): - self._log(logging.DEBUG, self._debug_(msg), args, **kwargs) - - def info(self, msg, *args, **kwargs): - if self.isEnabledFor(logging.INFO): - self._log(logging.INFO, self._info_(msg), args, **kwargs) - - def warning(self, msg, *args, **kwargs): - if self.isEnabledFor(logging.WARNING): - self._log(logging.WARNING, self._error_(msg), args, **kwargs) - - def error(self, msg, *args, **kwargs): - if self.isEnabledFor(logging.ERROR): - self._log(logging.ERROR, self._error_(msg), args, **kwargs) - - def critical(self, msg, *args, **kwargs): - if self.isEnabledFor(logging.CRITICAL): - self._log(logging.CRITICAL, self._error_(msg), args, **kwargs) - - def line(self, level="info"): - if level == "info": - level = logging.INFO - elif level == "debug": - level = logging.DEBUG - else: - level = logging.INFO - if self.isEnabledFor(level): - self._log( - level, - Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL, - [], - ) - - -class JsonFormatter(logging.Formatter): - """ - Formatter that outputs JSON strings after parsing the LogRecord. - - Parameters - ---------- - fmt_dict : Optional[Dict[str, str]] - {key: logging format attribute} pairs. Defaults to {"message": "message"}. - time_format: str - time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" - msec_format: str - Microsecond formatting. Appended at the end. Default: "%s.%03dZ" - """ - - def __init__( - self, - fmt_dict: Optional[Dict[str, str]] = None, - time_format: str = "%Y-%m-%dT%H:%M:%S", - msec_format: str = "%s.%03dZ", - ): - self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"} - self.default_time_format: str = time_format - self.default_msec_format: str = msec_format - self.datefmt: Optional[str] = None - - def usesTime(self) -> bool: - """ - Overwritten to look for the attribute in the format dict values instead of the fmt string. - """ - return "asctime" in self.fmt_dict.values() - - def formatMessage(self, record) -> Dict[str, str]: - """ - Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. - KeyError is raised if an unknown attribute is provided in the fmt_dict. - """ - return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} - - def format(self, record) -> str: - """ - Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON - instead of a string. - """ - record.message = record.getMessage() - - if self.usesTime(): - record.asctime = self.formatTime(record, self.datefmt) - - message_dict = self.formatMessage(record) - - if record.exc_info: - # Cache the traceback text to avoid converting it multiple times - # (it's constant anyway) - if not record.exc_text: - record.exc_text = self.formatException(record.exc_info) - - if record.exc_text: - message_dict["exc_info"] = record.exc_text - - if record.stack_info: - message_dict["stack_info"] = self.formatStack(record.stack_info) - - return json.dumps(message_dict, default=str) - - -class FileFormatter(logging.Formatter): - ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") - - def format(self, record): - record.msg = self.ansi_escape.sub("", record.msg) - return super().format(record) - - -log_stream_formatter = logging.Formatter( - "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" -) - -log_file_formatter = FileFormatter( - "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) - -json_formatter = JsonFormatter( - { - "level": "levelname", - "message": "message", - "loggerName": "name", - "processName": "processName", - "processID": "process", - "threadName": "threadName", - "threadID": "thread", - "timestamp": "asctime", - } -) - - -def create_log_handler( - filename: Optional[str] = None, - *, - rotating: bool = False, - level: int = logging.DEBUG, - mode: str = "a+", - encoding: str = "utf-8", - format: str = "plain", - maxBytes: int = 28000000, - backupCount: int = 1, - **kwargs, -) -> Handler: - """ - Creates a pre-configured log handler. This function is made for consistency's sake with - pre-defined default values for parameters and formatters to pass to handler class. - Additional keyword arguments also can be specified, just in case. - - Plugin developers should not use this and use `models.getLogger` instead. - - Parameters - ---------- - filename : Optional[Path] - Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, - rather than a `StreamHandler`. Defaults to `None`. - rotating : bool - Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this - argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. - level : int - The root logger level for the handler. Defaults to `logging.DEBUG`. - mode : str - If filename is specified, open the file in this mode. Defaults to 'a+'. - encoding : str - If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, - and thus used when opening the output file. Defaults to 'utf-8'. - format : str - The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, - based on other conditional logic. - maxBytes : int - The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current - log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, - rollover never occurs, so you generally want to set `backupCount` to at least 1. - backupCount : int - Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. - - Returns - ------- - `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` - depending on the `rotating` value. - """ - if filename is None and rotating: - raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") - - if filename is None: - handler = StreamHandler(stream=sys.stdout, **kwargs) - formatter = log_stream_formatter - elif not rotating: - handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) - formatter = log_file_formatter - else: - handler = RotatingFileHandler( - filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs - ) - formatter = log_file_formatter - - if format == "json": - formatter = json_formatter - - handler.setLevel(level) - handler.setFormatter(formatter) - return handler - - -logging.setLoggerClass(ModmailLogger) -log_level = logging.INFO -loggers = set() - -ch = create_log_handler(level=log_level) -ch_debug: Optional[RotatingFileHandler] = None - - -def getLogger(name=None) -> ModmailLogger: - logger = logging.getLogger(name) - logger.setLevel(log_level) - logger.addHandler(ch) - if ch_debug is not None: - logger.addHandler(ch_debug) - loggers.add(logger) - return logger - - -def configure_logging(bot) -> None: - global ch_debug, log_level, ch - - stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"] - if stream_log_format == "json": - ch.setFormatter(json_formatter) - - logger = getLogger(__name__) - level_text = bot.config["log_level"].upper() - logging_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - logger.line() - - level = logging_levels.get(level_text) - if level is None: - level = bot.config.remove("log_level") - logger.warning("Invalid logging level set: %s.", level_text) - logger.warning("Using default logging level: %s.", level) - level = logging_levels[level] - else: - logger.info("Logging level: %s", level_text) - log_level = level - - logger.info("Log file: %s", bot.log_file_path) - ch_debug = create_log_handler(bot.log_file_path, rotating=True) - - if file_log_format == "json": - ch_debug.setFormatter(json_formatter) - - ch.setLevel(log_level) - - logger.info("Stream log format: %s", stream_log_format) - logger.info("File log format: %s", file_log_format) - - for log in loggers: - log.setLevel(log_level) - log.addHandler(ch_debug) - - # Set up discord.py logging - d_level_text = bot.config["discord_log_level"].upper() - d_level = logging_levels.get(d_level_text) - if d_level is None: - d_level = bot.config.remove("discord_log_level") - logger.warning("Invalid discord logging level set: %s.", d_level_text) - logger.warning("Using default discord logging level: %s.", d_level) - d_level = logging_levels[d_level] - d_logger = logging.getLogger("discord") - d_logger.setLevel(d_level) - - non_verbose_log_level = max(d_level, logging.INFO) - stream_handler = create_log_handler(level=non_verbose_log_level) - if non_verbose_log_level != d_level: - logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level)) - logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) - else: - logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) - d_logger.addHandler(stream_handler) - d_logger.addHandler(ch_debug) - - logger.debug("Successfully configured logging.") - - -class InvalidConfigError(commands.BadArgument): - def __init__(self, msg, *args): - super().__init__(msg, *args) - self.msg = msg - - @property - def embed(self): - # Single reference of Color.red() - return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) - - -class _Default: - pass - - -Default = _Default() - - -class SafeFormatter(Formatter): - def get_field(self, field_name, args, kwargs): - first, rest = _string.formatter_field_name_split(field_name) - - try: - obj = self.get_value(first, args, kwargs) - except (IndexError, KeyError): - return "", first - - # loop through the rest of the field_name, doing - # getattr or getitem as needed - # stops when reaches the depth of 2 or starts with _. - try: - for n, (is_attr, i) in enumerate(rest): - if n >= 2: - break - if is_attr: - if str(i).startswith("_"): - break - obj = getattr(obj, i) - else: - obj = obj[i] - else: - return obj, first - except (IndexError, KeyError): - pass - return "", first - - -class UnseenFormatter(Formatter): - def get_value(self, key, args, kwds): - if isinstance(key, str): - try: - return kwds[key] - except KeyError: - return "{" + key + "}" - else: - return super().get_value(key, args, kwds) - - -class SimilarCategoryConverter(commands.CategoryChannelConverter): - async def convert(self, ctx, argument): - bot = ctx.bot - guild = ctx.guild - - try: - return await super().convert(ctx, argument) - except commands.ChannelNotFound: - if guild: - categories = {c.name.casefold(): c for c in guild.categories} - else: - categories = { - c.name.casefold(): c - for c in bot.get_all_channels() - if isinstance(c, discord.CategoryChannel) - } - - result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) - if result: - result = categories[result[0]] - - if not isinstance(result, discord.CategoryChannel): - raise commands.ChannelNotFound(argument) - - return result - - -class DummyMessage: - """ - A class mimicking the original :class:discord.Message - where all functions that require an actual message to exist - is replaced with a dummy function - """ - - def __init__(self, message): - if message: - message.attachments = [] - self._message = message - - def __getattr__(self, name: str): - return getattr(self._message, name) - - def __bool__(self): - return bool(self._message) - - async def delete(self, *, delay=None): - return - - async def edit(self, **fields): - return - - async def add_reaction(self, emoji): - return - - async def remove_reaction(self, emoji): - return - - async def clear_reaction(self, emoji): - return - - async def clear_reactions(self): - return - - async def pin(self, *, reason=None): - return - - async def unpin(self, *, reason=None): - return - - async def publish(self): - return - - async def ack(self): - return - - -class PermissionLevel(IntEnum): - OWNER = 5 - ADMINISTRATOR = 4 - ADMIN = 4 - MODERATOR = 3 - MOD = 3 - SUPPORTER = 2 - RESPONDER = 2 - REGULAR = 1 - INVALID = -1 - - -class DMDisabled(IntEnum): - NONE = 0 - NEW_THREADS = 1 - ALL_THREADS = 2 - - -class HostingMethod(IntEnum): - HEROKU = 0 - PM2 = 1 - SYSTEMD = 2 - SCREEN = 3 - DOCKER = 4 - OTHER = 5 +import json +import logging +import os +import re +import sys +import _string + +from difflib import get_close_matches +from enum import IntEnum +from logging import FileHandler, StreamHandler, Handler +from logging.handlers import RotatingFileHandler +from string import Formatter +from typing import Dict, Optional + +import discord +from discord.ext import commands + + +try: + from colorama import Fore, Style +except ImportError: + Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() + + +if ".heroku" in os.environ.get("PYTHONHOME", ""): + # heroku + Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() + + +class ModmailLogger(logging.Logger): + @staticmethod + def _debug_(*msgs): + return f'{Fore.CYAN}{" ".join(msgs)}{Style.RESET_ALL}' + + @staticmethod + def _info_(*msgs): + return f'{Fore.LIGHTMAGENTA_EX}{" ".join(msgs)}{Style.RESET_ALL}' + + @staticmethod + def _error_(*msgs): + return f'{Fore.RED}{" ".join(msgs)}{Style.RESET_ALL}' + + def debug(self, msg, *args, **kwargs): + if self.isEnabledFor(logging.DEBUG): + self._log(logging.DEBUG, self._debug_(msg), args, **kwargs) + + def info(self, msg, *args, **kwargs): + if self.isEnabledFor(logging.INFO): + self._log(logging.INFO, self._info_(msg), args, **kwargs) + + def warning(self, msg, *args, **kwargs): + if self.isEnabledFor(logging.WARNING): + self._log(logging.WARNING, self._error_(msg), args, **kwargs) + + def error(self, msg, *args, **kwargs): + if self.isEnabledFor(logging.ERROR): + self._log(logging.ERROR, self._error_(msg), args, **kwargs) + + def critical(self, msg, *args, **kwargs): + if self.isEnabledFor(logging.CRITICAL): + self._log(logging.CRITICAL, self._error_(msg), args, **kwargs) + + def line(self, level="info"): + if level == "info": + level = logging.INFO + elif level == "debug": + level = logging.DEBUG + else: + level = logging.INFO + if self.isEnabledFor(level): + self._log( + level, + Fore.BLACK + Style.BRIGHT + "-------------------------" + Style.RESET_ALL, + [], + ) + + +class JsonFormatter(logging.Formatter): + """ + Formatter that outputs JSON strings after parsing the LogRecord. + + Parameters + ---------- + fmt_dict : Optional[Dict[str, str]] + {key: logging format attribute} pairs. Defaults to {"message": "message"}. + time_format: str + time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" + msec_format: str + Microsecond formatting. Appended at the end. Default: "%s.%03dZ" + """ + + def __init__( + self, + fmt_dict: Optional[Dict[str, str]] = None, + time_format: str = "%Y-%m-%dT%H:%M:%S", + msec_format: str = "%s.%03dZ", + ): + self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"} + self.default_time_format: str = time_format + self.default_msec_format: str = msec_format + self.datefmt: Optional[str] = None + + def usesTime(self) -> bool: + """ + Overwritten to look for the attribute in the format dict values instead of the fmt string. + """ + return "asctime" in self.fmt_dict.values() + + def formatMessage(self, record) -> Dict[str, str]: + """ + Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. + KeyError is raised if an unknown attribute is provided in the fmt_dict. + """ + return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} + + def format(self, record) -> str: + """ + Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON + instead of a string. + """ + record.message = record.getMessage() + + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + + message_dict = self.formatMessage(record) + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + message_dict["exc_info"] = record.exc_text + + if record.stack_info: + message_dict["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(message_dict, default=str) + + +class FileFormatter(logging.Formatter): + ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + def format(self, record): + record.msg = self.ansi_escape.sub("", record.msg) + return super().format(record) + + +log_stream_formatter = logging.Formatter( + "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" +) + +log_file_formatter = FileFormatter( + "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +json_formatter = JsonFormatter( + { + "level": "levelname", + "message": "message", + "loggerName": "name", + "processName": "processName", + "processID": "process", + "threadName": "threadName", + "threadID": "thread", + "timestamp": "asctime", + } +) + + +def create_log_handler( + filename: Optional[str] = None, + *, + rotating: bool = False, + level: int = logging.DEBUG, + mode: str = "a+", + encoding: str = "utf-8", + format: str = "plain", + maxBytes: int = 28000000, + backupCount: int = 1, + **kwargs, +) -> Handler: + """ + Creates a pre-configured log handler. This function is made for consistency's sake with + pre-defined default values for parameters and formatters to pass to handler class. + Additional keyword arguments also can be specified, just in case. + + Plugin developers should not use this and use `models.getLogger` instead. + + Parameters + ---------- + filename : Optional[Path] + Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, + rather than a `StreamHandler`. Defaults to `None`. + rotating : bool + Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this + argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. + level : int + The root logger level for the handler. Defaults to `logging.DEBUG`. + mode : str + If filename is specified, open the file in this mode. Defaults to 'a+'. + encoding : str + If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, + and thus used when opening the output file. Defaults to 'utf-8'. + format : str + The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, + based on other conditional logic. + maxBytes : int + The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current + log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, + rollover never occurs, so you generally want to set `backupCount` to at least 1. + backupCount : int + Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. + + Returns + ------- + `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` + depending on the `rotating` value. + """ + if filename is None and rotating: + raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") + + if filename is None: + handler = StreamHandler(stream=sys.stdout, **kwargs) + formatter = log_stream_formatter + elif not rotating: + handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) + formatter = log_file_formatter + else: + handler = RotatingFileHandler( + filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs + ) + formatter = log_file_formatter + + if format == "json": + formatter = json_formatter + + handler.setLevel(level) + handler.setFormatter(formatter) + return handler + + +logging.setLoggerClass(ModmailLogger) +log_level = logging.INFO +loggers = set() + +ch = create_log_handler(level=log_level) +ch_debug: Optional[RotatingFileHandler] = None + + +def getLogger(name=None) -> ModmailLogger: + logger = logging.getLogger(name) + logger.setLevel(log_level) + logger.addHandler(ch) + if ch_debug is not None: + logger.addHandler(ch_debug) + loggers.add(logger) + return logger + + +def configure_logging(bot) -> None: + global ch_debug, log_level, ch + + stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"] + if stream_log_format == "json": + ch.setFormatter(json_formatter) + + logger = getLogger(__name__) + level_text = bot.config["log_level"].upper() + logging_levels = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } + logger.line() + + level = logging_levels.get(level_text) + if level is None: + level = bot.config.remove("log_level") + logger.warning("Invalid logging level set: %s.", level_text) + logger.warning("Using default logging level: %s.", level) + level = logging_levels[level] + else: + logger.info("Logging level: %s", level_text) + log_level = level + + logger.info("Log file: %s", bot.log_file_path) + ch_debug = create_log_handler(bot.log_file_path, rotating=True) + + if file_log_format == "json": + ch_debug.setFormatter(json_formatter) + + ch.setLevel(log_level) + + logger.info("Stream log format: %s", stream_log_format) + logger.info("File log format: %s", file_log_format) + + for log in loggers: + log.setLevel(log_level) + log.addHandler(ch_debug) + + # Set up discord.py logging + d_level_text = bot.config["discord_log_level"].upper() + d_level = logging_levels.get(d_level_text) + if d_level is None: + d_level = bot.config.remove("discord_log_level") + logger.warning("Invalid discord logging level set: %s.", d_level_text) + logger.warning("Using default discord logging level: %s.", d_level) + d_level = logging_levels[d_level] + d_logger = logging.getLogger("discord") + d_logger.setLevel(d_level) + + non_verbose_log_level = max(d_level, logging.INFO) + stream_handler = create_log_handler(level=non_verbose_log_level) + if non_verbose_log_level != d_level: + logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level)) + logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) + else: + logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) + d_logger.addHandler(stream_handler) + d_logger.addHandler(ch_debug) + + logger.debug("Successfully configured logging.") + + +class InvalidConfigError(commands.BadArgument): + def __init__(self, msg, *args): + super().__init__(msg, *args) + self.msg = msg + + @property + def embed(self): + # Single reference of Color.red() + return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) + + +class _Default: + pass + + +Default = _Default() + + +class SafeFormatter(Formatter): + def get_field(self, field_name, args, kwargs): + first, rest = _string.formatter_field_name_split(field_name) + + try: + obj = self.get_value(first, args, kwargs) + except (IndexError, KeyError): + return "", first + + # loop through the rest of the field_name, doing + # getattr or getitem as needed + # stops when reaches the depth of 2 or starts with _. + try: + for n, (is_attr, i) in enumerate(rest): + if n >= 2: + break + if is_attr: + if str(i).startswith("_"): + break + obj = getattr(obj, i) + else: + obj = obj[i] + else: + return obj, first + except (IndexError, KeyError): + pass + return "", first + + +class UnseenFormatter(Formatter): + def get_value(self, key, args, kwds): + if isinstance(key, str): + try: + return kwds[key] + except KeyError: + return "{" + key + "}" + else: + return super().get_value(key, args, kwds) + + +class SimilarCategoryConverter(commands.CategoryChannelConverter): + async def convert(self, ctx, argument): + bot = ctx.bot + guild = ctx.guild + + try: + return await super().convert(ctx, argument) + except commands.ChannelNotFound: + if guild: + categories = {c.name.casefold(): c for c in guild.categories} + else: + categories = { + c.name.casefold(): c + for c in bot.get_all_channels() + if isinstance(c, discord.CategoryChannel) + } + + result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) + if result: + result = categories[result[0]] + + if not isinstance(result, discord.CategoryChannel): + raise commands.ChannelNotFound(argument) + + return result + + +class DummyMessage: + """ + A class mimicking the original :class:discord.Message + where all functions that require an actual message to exist + is replaced with a dummy function + """ + + def __init__(self, message): + if message: + message.attachments = [] + self._message = message + + def __getattr__(self, name: str): + return getattr(self._message, name) + + def __bool__(self): + return bool(self._message) + + async def delete(self, *, delay=None): + return + + async def edit(self, **fields): + return + + async def add_reaction(self, emoji): + return + + async def remove_reaction(self, emoji): + return + + async def clear_reaction(self, emoji): + return + + async def clear_reactions(self): + return + + async def pin(self, *, reason=None): + return + + async def unpin(self, *, reason=None): + return + + async def publish(self): + return + + async def ack(self): + return + + +class PermissionLevel(IntEnum): + OWNER = 5 + ADMINISTRATOR = 4 + ADMIN = 4 + MODERATOR = 3 + MOD = 3 + SUPPORTER = 2 + RESPONDER = 2 + REGULAR = 1 + INVALID = -1 + + +class DMDisabled(IntEnum): + NONE = 0 + NEW_THREADS = 1 + ALL_THREADS = 2 + + +class HostingMethod(IntEnum): + HEROKU = 0 + PM2 = 1 + SYSTEMD = 2 + SCREEN = 3 + DOCKER = 4 + OTHER = 5 diff --git a/core/paginator.py b/core/paginator.py index 5a6844f382..aea6488375 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -1,390 +1,390 @@ -import typing - -import discord -from discord import Message, Embed, ButtonStyle, Interaction -from discord.ui import View, Button, Select -from discord.ext import commands - - -class PaginatorSession: - """ - Class that interactively paginates something. - - Parameters - ---------- - ctx : Context - The context of the command. - timeout : float - How long to wait for before the session closes. - pages : List[Any] - A list of entries to paginate. - - Attributes - ---------- - ctx : Context - The context of the command. - timeout : float - How long to wait for before the session closes. - pages : List[Any] - A list of entries to paginate. - running : bool - Whether the paginate session is running. - base : Message - The `Message` of the `Embed`. - current : int - The current page number. - callback_map : Dict[str, method] - A mapping for text to method. - view : PaginatorView - The view that is sent along with the base message. - select_menu : Select - A select menu that will be added to the View. - """ - - def __init__(self, ctx: commands.Context, *pages, **options): - self.ctx = ctx - self.timeout: int = options.get("timeout", 210) - self.running = False - self.base: Message = None - self.current = 0 - self.pages = list(pages) - self.destination = options.get("destination", ctx) - self.view = None - self.select_menu = None - - self.callback_map = { - "<<": self.first_page, - "<": self.previous_page, - ">": self.next_page, - ">>": self.last_page, - } - self._buttons_map = {"<<": None, "<": None, ">": None, ">>": None} - - async def show_page(self, index: int) -> typing.Optional[typing.Dict]: - """ - Show a page by page number. - - Parameters - ---------- - index : int - The index of the page. - """ - if not 0 <= index < len(self.pages): - return - - self.current = index - page = self.pages[index] - result = None - - if self.running: - result = self._show_page(page) - else: - await self.create_base(page) - - self.update_disabled_status() - return result - - def update_disabled_status(self): - if self.current == self.first_page(): - # disable << button - if self._buttons_map["<<"] is not None: - self._buttons_map["<<"].disabled = True - - if self._buttons_map["<"] is not None: - self._buttons_map["<"].disabled = True - else: - if self._buttons_map["<<"] is not None: - self._buttons_map["<<"].disabled = False - - if self._buttons_map["<"] is not None: - self._buttons_map["<"].disabled = False - - if self.current == self.last_page(): - # disable >> button - if self._buttons_map[">>"] is not None: - self._buttons_map[">>"].disabled = True - - if self._buttons_map[">"] is not None: - self._buttons_map[">"].disabled = True - else: - if self._buttons_map[">>"] is not None: - self._buttons_map[">>"].disabled = False - - if self._buttons_map[">"] is not None: - self._buttons_map[">"].disabled = False - - async def create_base(self, item) -> None: - """ - Create a base `Message`. - """ - if len(self.pages) == 1: - self.view = None - self.running = False - else: - self.view = PaginatorView(self, timeout=self.timeout) - self.update_disabled_status() - self.running = True - - await self._create_base(item, self.view) - - async def _create_base(self, item, view: View) -> None: - raise NotImplementedError - - def _show_page(self, page): - raise NotImplementedError - - def first_page(self): - """Returns the index of the first page""" - return 0 - - def next_page(self): - """Returns the index of the next page""" - return min(self.current + 1, self.last_page()) - - def previous_page(self): - """Returns the index of the previous page""" - return max(self.current - 1, self.first_page()) - - def last_page(self): - """Returns the index of the last page""" - return len(self.pages) - 1 - - async def run(self) -> typing.Optional[Message]: - """ - Starts the pagination session. - """ - if not self.running: - await self.show_page(self.current) - - if self.view is not None: - await self.view.wait() - - await self.close(delete=False) - - async def close( - self, delete: bool = True, *, interaction: Interaction = None - ) -> typing.Optional[Message]: - """ - Closes the pagination session. - - Parameters - ---------- - delete : bool, optional - Whether or delete the message upon closure. - Defaults to `True`. - - Returns - ------- - Optional[Message] - If `delete` is `True`. - """ - if self.running: - sent_emoji, _ = await self.ctx.bot.retrieve_emoji() - await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) - - if interaction: - message = interaction.message - else: - message = self.base - - self.running = False - - if self.view is not None: - self.view.stop() - if delete: - await message.delete() - else: - self.view.clear_items() - await message.edit(view=self.view) - - -class PaginatorView(View): - """ - View that is used for pagination. - - Parameters - ---------- - handler : PaginatorSession - The paginator session that spawned this view. - timeout : float - How long to wait for before the session closes. - - Attributes - ---------- - handler : PaginatorSession - The paginator session that spawned this view. - timeout : float - How long to wait for before the session closes. - """ - - def __init__(self, handler: PaginatorSession, *args, **kwargs): - super().__init__(*args, **kwargs) - self.handler = handler - self.clear_items() # clear first so we can control the order - self.fill_items() - - @discord.ui.button(label="Stop", style=ButtonStyle.danger) - async def stop_button(self, interaction: Interaction, button: Button): - await self.handler.close(interaction=interaction) - - def fill_items(self): - if self.handler.select_menu is not None: - self.add_item(self.handler.select_menu) - - for label, callback in self.handler.callback_map.items(): - if len(self.handler.pages) == 2 and label in ("<<", ">>"): - continue - - if label in ("<<", ">>"): - style = ButtonStyle.secondary - else: - style = ButtonStyle.primary - - button = PageButton(self.handler, callback, label=label, style=style) - - self.handler._buttons_map[label] = button - self.add_item(button) - self.add_item(self.stop_button) - - async def interaction_check(self, interaction: Interaction): - """Only allow the message author to interact""" - if interaction.user != self.handler.ctx.author: - await interaction.response.send_message( - "Only the original author can control this!", ephemeral=True - ) - return False - return True - - -class PageButton(Button): - """ - A button that has a callback to jump to the next page - - Parameters - ---------- - handler : PaginatorSession - The paginator session that spawned this view. - page_callback : Callable - A callable that returns an int of the page to go to. - - Attributes - ---------- - handler : PaginatorSession - The paginator session that spawned this view. - page_callback : Callable - A callable that returns an int of the page to go to. - """ - - def __init__(self, handler, page_callback, **kwargs): - super().__init__(**kwargs) - self.handler = handler - self.page_callback = page_callback - - async def callback(self, interaction: Interaction): - kwargs = await self.handler.show_page(self.page_callback()) - await interaction.response.edit_message(**kwargs, view=self.view) - - -class PageSelect(Select): - def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): - self.handler = handler - options = [] - for n, (label, description) in enumerate(pages): - options.append(discord.SelectOption(label=label, description=description, value=str(n))) - - options = options[:25] # max 25 options - super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) - - async def callback(self, interaction: Interaction): - page = int(self.values[0]) - kwargs = await self.handler.show_page(page) - await interaction.response.edit_message(**kwargs, view=self.view) - - -class EmbedPaginatorSession(PaginatorSession): - def __init__(self, ctx: commands.Context, *embeds, **options): - super().__init__(ctx, *embeds, **options) - - if len(self.pages) > 1: - select_options = [] - create_select = True - for i, embed in enumerate(self.pages): - footer_text = f"Page {i + 1} of {len(self.pages)}" - if embed.footer.text: - footer_text = footer_text + " • " + embed.footer.text - - if embed.footer.icon: - icon_url = embed.footer.icon.url - else: - icon_url = None - embed.set_footer(text=footer_text, icon_url=icon_url) - - # select menu - if embed.author.name: - title = embed.author.name[:30].strip() - if len(embed.author.name) > 30: - title += "..." - else: - title = embed.title[:30].strip() - if len(embed.title) > 30: - title += "..." - if not title: - create_select = False - - if embed.description: - description = embed.description[:40].replace("*", "").replace("`", "").strip() - if len(embed.description) > 40: - description += "..." - else: - description = "" - select_options.append((title, description)) - - if create_select: - if len(set(x[0] for x in select_options)) != 1: # must have unique authors - self.select_menu = PageSelect(self, select_options) - - def add_page(self, item: Embed) -> None: - if isinstance(item, Embed): - self.pages.append(item) - else: - raise TypeError("Page must be an Embed object.") - - async def _create_base(self, item: Embed, view: View) -> None: - self.base = await self.destination.send(embed=item, view=view) - - def _show_page(self, page): - return dict(embed=page) - - -class MessagePaginatorSession(PaginatorSession): - def __init__(self, ctx: commands.Context, *messages, embed: Embed = None, **options): - self.embed = embed - self.footer_text = self.embed.footer.text if embed is not None else None - super().__init__(ctx, *messages, **options) - - def add_page(self, item: str) -> None: - if isinstance(item, str): - self.pages.append(item) - else: - raise TypeError("Page must be a str object.") - - def _set_footer(self): - if self.embed is not None: - footer_text = f"Page {self.current+1} of {len(self.pages)}" - if self.footer_text: - footer_text = footer_text + " • " + self.footer_text - - if self.embed.footer.icon: - icon_url = self.embed.footer.icon.url - else: - icon_url = None - - self.embed.set_footer(text=footer_text, icon_url=icon_url) - - async def _create_base(self, item: str, view: View) -> None: - self._set_footer() - self.base = await self.ctx.send(content=item, embed=self.embed, view=view) - - def _show_page(self, page) -> typing.Dict: - self._set_footer() - return dict(content=page, embed=self.embed) +import typing + +import discord +from discord import Message, Embed, ButtonStyle, Interaction +from discord.ui import View, Button, Select +from discord.ext import commands + + +class PaginatorSession: + """ + Class that interactively paginates something. + + Parameters + ---------- + ctx : Context + The context of the command. + timeout : float + How long to wait for before the session closes. + pages : List[Any] + A list of entries to paginate. + + Attributes + ---------- + ctx : Context + The context of the command. + timeout : float + How long to wait for before the session closes. + pages : List[Any] + A list of entries to paginate. + running : bool + Whether the paginate session is running. + base : Message + The `Message` of the `Embed`. + current : int + The current page number. + callback_map : Dict[str, method] + A mapping for text to method. + view : PaginatorView + The view that is sent along with the base message. + select_menu : Select + A select menu that will be added to the View. + """ + + def __init__(self, ctx: commands.Context, *pages, **options): + self.ctx = ctx + self.timeout: int = options.get("timeout", 210) + self.running = False + self.base: Message = None + self.current = 0 + self.pages = list(pages) + self.destination = options.get("destination", ctx) + self.view = None + self.select_menu = None + + self.callback_map = { + "<<": self.first_page, + "<": self.previous_page, + ">": self.next_page, + ">>": self.last_page, + } + self._buttons_map = {"<<": None, "<": None, ">": None, ">>": None} + + async def show_page(self, index: int) -> typing.Optional[typing.Dict]: + """ + Show a page by page number. + + Parameters + ---------- + index : int + The index of the page. + """ + if not 0 <= index < len(self.pages): + return + + self.current = index + page = self.pages[index] + result = None + + if self.running: + result = self._show_page(page) + else: + await self.create_base(page) + + self.update_disabled_status() + return result + + def update_disabled_status(self): + if self.current == self.first_page(): + # disable << button + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = True + + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = True + else: + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = False + + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = False + + if self.current == self.last_page(): + # disable >> button + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = True + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = True + else: + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = False + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = False + + async def create_base(self, item) -> None: + """ + Create a base `Message`. + """ + if len(self.pages) == 1: + self.view = None + self.running = False + else: + self.view = PaginatorView(self, timeout=self.timeout) + self.update_disabled_status() + self.running = True + + await self._create_base(item, self.view) + + async def _create_base(self, item, view: View) -> None: + raise NotImplementedError + + def _show_page(self, page): + raise NotImplementedError + + def first_page(self): + """Returns the index of the first page""" + return 0 + + def next_page(self): + """Returns the index of the next page""" + return min(self.current + 1, self.last_page()) + + def previous_page(self): + """Returns the index of the previous page""" + return max(self.current - 1, self.first_page()) + + def last_page(self): + """Returns the index of the last page""" + return len(self.pages) - 1 + + async def run(self) -> typing.Optional[Message]: + """ + Starts the pagination session. + """ + if not self.running: + await self.show_page(self.current) + + if self.view is not None: + await self.view.wait() + + await self.close(delete=False) + + async def close( + self, delete: bool = True, *, interaction: Interaction = None + ) -> typing.Optional[Message]: + """ + Closes the pagination session. + + Parameters + ---------- + delete : bool, optional + Whether or delete the message upon closure. + Defaults to `True`. + + Returns + ------- + Optional[Message] + If `delete` is `True`. + """ + if self.running: + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) + + if interaction: + message = interaction.message + else: + message = self.base + + self.running = False + + if self.view is not None: + self.view.stop() + if delete: + await message.delete() + else: + self.view.clear_items() + await message.edit(view=self.view) + + +class PaginatorView(View): + """ + View that is used for pagination. + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + """ + + def __init__(self, handler: PaginatorSession, *args, **kwargs): + super().__init__(*args, **kwargs) + self.handler = handler + self.clear_items() # clear first so we can control the order + self.fill_items() + + @discord.ui.button(label="Stop", style=ButtonStyle.danger) + async def stop_button(self, interaction: Interaction, button: Button): + await self.handler.close(interaction=interaction) + + def fill_items(self): + if self.handler.select_menu is not None: + self.add_item(self.handler.select_menu) + + for label, callback in self.handler.callback_map.items(): + if len(self.handler.pages) == 2 and label in ("<<", ">>"): + continue + + if label in ("<<", ">>"): + style = ButtonStyle.secondary + else: + style = ButtonStyle.primary + + button = PageButton(self.handler, callback, label=label, style=style) + + self.handler._buttons_map[label] = button + self.add_item(button) + self.add_item(self.stop_button) + + async def interaction_check(self, interaction: Interaction): + """Only allow the message author to interact""" + if interaction.user != self.handler.ctx.author: + await interaction.response.send_message( + "Only the original author can control this!", ephemeral=True + ) + return False + return True + + +class PageButton(Button): + """ + A button that has a callback to jump to the next page + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + """ + + def __init__(self, handler, page_callback, **kwargs): + super().__init__(**kwargs) + self.handler = handler + self.page_callback = page_callback + + async def callback(self, interaction: Interaction): + kwargs = await self.handler.show_page(self.page_callback()) + await interaction.response.edit_message(**kwargs, view=self.view) + + +class PageSelect(Select): + def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): + self.handler = handler + options = [] + for n, (label, description) in enumerate(pages): + options.append(discord.SelectOption(label=label, description=description, value=str(n))) + + options = options[:25] # max 25 options + super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) + + async def callback(self, interaction: Interaction): + page = int(self.values[0]) + kwargs = await self.handler.show_page(page) + await interaction.response.edit_message(**kwargs, view=self.view) + + +class EmbedPaginatorSession(PaginatorSession): + def __init__(self, ctx: commands.Context, *embeds, **options): + super().__init__(ctx, *embeds, **options) + + if len(self.pages) > 1: + select_options = [] + create_select = True + for i, embed in enumerate(self.pages): + footer_text = f"Page {i + 1} of {len(self.pages)}" + if embed.footer.text: + footer_text = footer_text + " • " + embed.footer.text + + if embed.footer.icon: + icon_url = embed.footer.icon.url if embed.footer.icon else None + else: + icon_url = None + embed.set_footer(text=footer_text, icon_url=icon_url) + + # select menu + if embed.author.name: + title = embed.author.name[:30].strip() + if len(embed.author.name) > 30: + title += "..." + else: + title = embed.title[:30].strip() + if len(embed.title) > 30: + title += "..." + if not title: + create_select = False + + if embed.description: + description = embed.description[:40].replace("*", "").replace("`", "").strip() + if len(embed.description) > 40: + description += "..." + else: + description = "" + select_options.append((title, description)) + + if create_select: + if len(set(x[0] for x in select_options)) != 1: # must have unique authors + self.select_menu = PageSelect(self, select_options) + + def add_page(self, item: Embed) -> None: + if isinstance(item, Embed): + self.pages.append(item) + else: + raise TypeError("Page must be an Embed object.") + + async def _create_base(self, item: Embed, view: View) -> None: + self.base = await self.destination.send(embed=item, view=view) + + def _show_page(self, page): + return dict(embed=page) + + +class MessagePaginatorSession(PaginatorSession): + def __init__(self, ctx: commands.Context, *messages, embed: Embed = None, **options): + self.embed = embed + self.footer_text = self.embed.footer.text if embed is not None else None + super().__init__(ctx, *messages, **options) + + def add_page(self, item: str) -> None: + if isinstance(item, str): + self.pages.append(item) + else: + raise TypeError("Page must be a str object.") + + def _set_footer(self): + if self.embed is not None: + footer_text = f"Page {self.current+1} of {len(self.pages)}" + if self.footer_text: + footer_text = footer_text + " • " + self.footer_text + + if self.embed.footer.icon: + icon_url = self.embed.footer.icon.url if self.embed.footer.icon else None + else: + icon_url = None + + self.embed.set_footer(text=footer_text, icon_url=icon_url) + + async def _create_base(self, item: str, view: View) -> None: + self._set_footer() + self.base = await self.ctx.send(content=item, embed=self.embed, view=view) + + def _show_page(self, page) -> typing.Dict: + self._set_footer() + return dict(content=page, embed=self.embed) diff --git a/core/thread.py b/core/thread.py index ec2070a08e..926f44128a 100644 --- a/core/thread.py +++ b/core/thread.py @@ -66,6 +66,10 @@ def __init__( self.close_task = None self.auto_close_task = None self._cancelled = False + # --- SNOOZE STATE --- + self.snoozed = False # True if thread is snoozed + self.snooze_data = None # Dict with channel/category/position/messages for restoration + self.log_key = None # Ensure log_key always exists def __repr__(self): return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipients={len(self._other_recipients)})' @@ -126,6 +130,183 @@ def cancelled(self, flag: bool): for i in self.wait_tasks: i.cancel() + async def snooze(self, moderator=None, command_used=None): + """ + Save channel/category/position/messages to DB, mark as snoozed, delete channel. + """ + if self.snoozed: + return False # Already snoozed + channel = self.channel + if not isinstance(channel, discord.TextChannel): + return False + # Ensure self.log_key is set before snoozing + if not self.log_key: + # Try to fetch from DB using channel_id + log_entry = await self.bot.api.get_log(self.channel.id) + if log_entry and 'key' in log_entry: + self.log_key = log_entry['key'] + # Fallback: try by recipient id + elif hasattr(self, 'id'): + log_entry = await self.bot.api.get_log(str(self.id)) + if log_entry and 'key' in log_entry: + self.log_key = log_entry['key'] + + self.snooze_data = { + "category_id": channel.category_id, + "position": channel.position, + "name": channel.name, + "topic": channel.topic, + "slowmode_delay": channel.slowmode_delay, + "nsfw": channel.nsfw, + "overwrites": [(role.id, perm._values) for role, perm in channel.overwrites.items()], + "messages": [ + { + "author_id": m.author.id, + "content": m.content, + "attachments": [a.url for a in m.attachments], + "embeds": [e.to_dict() for e in m.embeds], + "created_at": m.created_at.isoformat(), + # Only use 'mod_only' if this is an internal note (note command), safe check for embed author + "type": ( + "mod_only" + if ( + m.embeds + and getattr(m.embeds[0], 'author', None) + and ( + getattr(m.embeds[0].author, 'name', '').startswith('Note') or + getattr(m.embeds[0].author, 'name', '').startswith('Persistent Note') + ) + ) + else None + ), + "author_name": getattr(m.author, "name", None), + } + async for m in channel.history(limit=None, oldest_first=True) + # Only include if not already internal/note, safe check for embed author + if not ( + m.embeds + and getattr(m.embeds[0], 'author', None) + and ( + getattr(m.embeds[0].author, 'name', '').startswith('Note') or + getattr(m.embeds[0].author, 'name', '').startswith('Persistent Note') + ) + ) and getattr(m, "type", None) not in ("internal", "note") + ], + "snoozed_by": getattr(moderator, "name", None) if moderator else None, + "snooze_command": command_used, + "log_key": self.log_key, # Preserve the log_key + } + self.snoozed = True + # Save to DB (robust: try recipient.id, then channel_id) + result = await self.bot.api.logs.update_one( + {"recipient.id": str(self.id)}, + {"$set": {"snoozed": True, "snooze_data": self.snooze_data}}, + ) + if result.modified_count == 0 and self.channel: + result = await self.bot.api.logs.update_one( + {"channel_id": str(self.channel.id)}, + {"$set": {"snoozed": True, "snooze_data": self.snooze_data}}, + ) + import logging + + logging.info(f"[SNOOZE] DB update result: {result.modified_count}") + # Delete channel + await channel.delete(reason="Thread snoozed by moderator") + self._channel = None + return True + + async def restore_from_snooze(self): + """ + Recreate channel in original category/position, replay messages, mark as not snoozed. + """ + if not self.snooze_data or not isinstance(self.snooze_data, dict): + import logging + + logging.warning( + f"[UNSNOOZE] Tried to restore thread {self.id} but snooze_data is None or not a dict." + ) + return False + # Now safe to access self.snooze_data + snoozed_by = self.snooze_data.get("snoozed_by") + snooze_command = self.snooze_data.get("snooze_command") + guild = self.bot.modmail_guild + category = guild.get_channel(self.snooze_data["category_id"]) + overwrites = {} + for role_id, perm_values in self.snooze_data["overwrites"]: + role = guild.get_role(role_id) or guild.get_member(role_id) + if role: + overwrites[role] = discord.PermissionOverwrite(**perm_values) + channel = await guild.create_text_channel( + name=self.snooze_data["name"], + category=category, + topic=self.snooze_data["topic"], + slowmode_delay=self.snooze_data["slowmode_delay"], + overwrites=overwrites, + nsfw=self.snooze_data["nsfw"], + position=self.snooze_data["position"], + reason="Thread unsnoozed/restored", + ) + self._channel = channel + # Strictly restore the log_key from snooze_data (never create a new one) + self.log_key = self.snooze_data.get("log_key") + # Replay messages + for msg in self.snooze_data["messages"]: + author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user(msg["author_id"]) + content = msg["content"] + embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] + attachments = msg.get("attachments", []) + msg_type = msg.get("type") + # Only send if there is content, embeds, or attachments + if not content and not embeds and not attachments: + continue # Skip empty messages + # Format internal/system/mod-only messages as 'username: textcontent' + if msg_type in ("internal", "note", "system", "mod_only"): + username = msg.get("author_name") or (getattr(author, "name", None)) or "Unknown" + formatted = f"{username}: {content}" if content else username + await channel.send(formatted) + else: + await channel.send(content=content or None, embeds=embeds or None) + self.snoozed = False + # Store snooze_data for notification before clearing + snooze_data_for_notify = self.snooze_data + self.snooze_data = None + # Update channel_id in DB and clear snooze_data (robust: try log_key first) + if self.log_key: + result = await self.bot.api.logs.update_one( + {"key": self.log_key}, + {"$set": {"snoozed": False, "channel_id": str(channel.id)}, "$unset": {"snooze_data": ""}}, + ) + else: + result = await self.bot.api.logs.update_one( + {"recipient.id": str(self.id)}, + {"$set": {"snoozed": False, "channel_id": str(channel.id)}, "$unset": {"snooze_data": ""}}, + ) + if result.modified_count == 0: + result = await self.bot.api.logs.update_one( + {"channel_id": str(channel.id)}, + {"$set": {"snoozed": False, "channel_id": str(channel.id)}, "$unset": {"snooze_data": ""}}, + ) + import logging + + logging.info(f"[UNSNOOZE] DB update result: {result.modified_count}") + # Notify in the configured channel + notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" + notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." + if notify_channel == "thread": + await channel.send(notify_text) + else: + ch = self.bot.get_channel(int(notify_channel)) + if ch: + await ch.send(f"Thread for user <@{self.id}> has been unsnoozed and restored.") + # Show who ran the snooze command and the command used + # Use snooze_data_for_notify to avoid accessing self.snooze_data after it is set to None + snoozed_by = snooze_data_for_notify.get("snoozed_by") if snooze_data_for_notify else None + snooze_command = snooze_data_for_notify.get("snooze_command") if snooze_data_for_notify else None + if snoozed_by or snooze_command: + info = f"Snoozed by: {snoozed_by or 'Unknown'} | Command: {snooze_command or '?snooze'}" + await channel.send(info) + return True + @classmethod async def from_channel(cls, manager: "ThreadManager", channel: discord.TextChannel) -> "Thread": # there is a chance it grabs from another recipient's main thread @@ -811,8 +992,9 @@ async def note( thread_creation=thread_creation, ) + # Log as 'internal' type for logviewer visibility self.bot.loop.create_task( - self.bot.api.append_log(message, message_id=msg.id, channel_id=self.channel.id, type_="system") + self.bot.api.append_log(message, message_id=msg.id, channel_id=self.channel.id, type_="internal") ) return msg @@ -943,6 +1125,15 @@ async def send( destination = destination or self.channel + if destination is None: + logger.error("Attempted to send a message to a thread with no channel (destination is None).") + return + try: + await destination.typing() + except discord.NotFound: + logger.warning("Channel not found when trying to send message.") + return + author = message.author member = self.bot.guild.get_member(author.id) if member: @@ -950,7 +1141,27 @@ async def send( else: avatar_url = author.display_avatar.url - embed = discord.Embed(description=message.content) + # Extract content for blank/forwarded messages --- + content = message.content + if not content: + # Try to extract from referenced message (replies) + if hasattr(message, "reference") and message.reference is not None: + try: + ref = message.reference.resolved + if ref and hasattr(ref, "content") and ref.content: + content = f"(Reply to: {ref.author}: {ref.content})" + except Exception: + pass + # Try to extract from first embed's description + if not content and message.embeds: + first_embed = message.embeds[0] + if hasattr(first_embed, "description") and first_embed.description: + content = first_embed.description + # Fallback: show something generic if still blank + if not content: + content = "[This is a forwarded message.]" + + embed = discord.Embed(description=content) if self.bot.config["show_timestamp"]: embed.timestamp = message.created_at @@ -1322,11 +1533,8 @@ async def find( logger.warning("Thread for %s cancelled.", recipient) return thread else: - if not thread.cancelled and ( - not thread.channel or not self.bot.get_channel(thread.channel.id) - ): - logger.warning("Found existing thread for %s but the channel is invalid.", recipient_id) - await thread.close(closer=self.bot.user, silent=True, delete_channel=False) + # If the thread is snoozed (channel is None), return it for restoration + if thread.cancelled: thread = None else: diff --git a/core/time.py b/core/time.py index 71f4ca3c8a..985bf759b9 100644 --- a/core/time.py +++ b/core/time.py @@ -1,361 +1,361 @@ -""" -UserFriendlyTime by Rapptz -Source: -https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py -""" - -from __future__ import annotations - -import datetime -import discord -from typing import TYPE_CHECKING, Any, Optional, Union -import parsedatetime as pdt -from dateutil.relativedelta import relativedelta -from .utils import human_join -from discord.ext import commands -from discord import app_commands -import re - -# Monkey patch mins and secs into the units -units = pdt.pdtLocales["en_US"].units -units["minutes"].append("mins") -units["seconds"].append("secs") - -if TYPE_CHECKING: - from discord.ext.commands import Context - from typing_extensions import Self - - -class plural: - """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" - - def __init__(self, value: int): - self.value: int = value - - def __format__(self, format_spec: str) -> str: - v = self.value - singular, sep, plural = format_spec.partition("|") - plural = plural or f"{singular}s" - if abs(v) != 1: - return f"{v} {plural}" - return f"{v} {singular}" - - -class ShortTime: - compiled = re.compile( - """ - (?:(?P[0-9])(?:years?|y))? # e.g. 2y - (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months - (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w - (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d - (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h - (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m - (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s - """, - re.VERBOSE, - ) - - discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") - - dt: datetime.datetime - - def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): - match = self.compiled.fullmatch(argument) - if match is None or not match.group(0): - match = self.discord_fmt.fullmatch(argument) - if match is not None: - self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) - return - else: - raise commands.BadArgument("invalid time provided") - - data = {k: int(v) for k, v in match.groupdict(default=0).items()} - now = now or datetime.datetime.now(datetime.timezone.utc) - self.dt = now + relativedelta(**data) - - @classmethod - async def convert(cls, ctx: Context, argument: str) -> Self: - return cls(argument, now=ctx.message.created_at) - - -class HumanTime: - calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) - - def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): - now = now or datetime.datetime.utcnow() - dt, status = self.calendar.parseDT(argument, sourceTime=now) - if not status.hasDateOrTime: - raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') - - if not status.hasTime: - # replace it with the current time - dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) - - self.dt: datetime.datetime = dt - self._past: bool = dt < now - - @classmethod - async def convert(cls, ctx: Context, argument: str) -> Self: - return cls(argument, now=ctx.message.created_at) - - -class Time(HumanTime): - def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): - try: - o = ShortTime(argument, now=now) - except Exception: - super().__init__(argument) - else: - self.dt = o.dt - self._past = False - - -class FutureTime(Time): - def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): - super().__init__(argument, now=now) - - if self._past: - raise commands.BadArgument("this time is in the past") - - -class BadTimeTransform(app_commands.AppCommandError): - pass - - -class TimeTransformer(app_commands.Transformer): - async def transform(self, interaction, value: str) -> datetime.datetime: - now = interaction.created_at - try: - short = ShortTime(value, now=now) - except commands.BadArgument: - try: - human = FutureTime(value, now=now) - except commands.BadArgument as e: - raise BadTimeTransform(str(e)) from None - else: - return human.dt - else: - return short.dt - - -# CHANGE: Added now -class FriendlyTimeResult: - dt: datetime.datetime - now: datetime.datetime - arg: str - - __slots__ = ("dt", "arg", "now") - - def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): - self.dt = dt - self.now = now - - if now is None: - self.now = dt - else: - self.now = now - - self.arg = "" - - async def ensure_constraints( - self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str - ) -> None: - if self.dt < now: - raise commands.BadArgument("This time is in the past.") - - # CHANGE - # if not remaining: - # if uft.default is None: - # raise commands.BadArgument("Missing argument after the time.") - # remaining = uft.default - - if uft.converter is not None: - self.arg = await uft.converter.convert(ctx, remaining) - else: - self.arg = remaining - - -class UserFriendlyTime(commands.Converter): - """That way quotes aren't absolutely necessary.""" - - def __init__( - self, - converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, - *, - default: Any = None, - ): - if isinstance(converter, type) and issubclass(converter, commands.Converter): - converter = converter() - - if converter is not None and not isinstance(converter, commands.Converter): - raise TypeError("commands.Converter subclass necessary.") - - self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing - self.default: Any = default - - async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTimeResult: - calendar = HumanTime.calendar - regex = ShortTime.compiled - if now is None: - now = ctx.message.created_at - - match = regex.match(argument) - if match is not None and match.group(0): - data = {k: int(v) for k, v in match.groupdict(default=0).items()} - remaining = argument[match.end() :].strip() - result = FriendlyTimeResult(now + relativedelta(**data), now) - await result.ensure_constraints(ctx, self, now, remaining) - return result - - if match is None or not match.group(0): - match = ShortTime.discord_fmt.match(argument) - if match is not None: - result = FriendlyTimeResult( - datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) - ) - remaining = argument[match.end() :].strip() - await result.ensure_constraints(ctx, self, now, remaining) - return result - - # apparently nlp does not like "from now" - # it likes "from x" in other cases though so let me handle the 'now' case - if argument.endswith("from now"): - argument = argument[:-8].strip() - - if argument[0:2] == "me": - # starts with "me to", "me in", or "me at " - if argument[0:6] in ("me to ", "me in ", "me at "): - argument = argument[6:] - - elements = calendar.nlp(argument, sourceTime=now) - if elements is None or len(elements) == 0: - # CHANGE - result = FriendlyTimeResult(now) - await result.ensure_constraints(ctx, self, now, argument) - return result - - # handle the following cases: - # "date time" foo - # date time foo - # foo date time - - # first the first two cases: - dt, status, begin, end, dt_string = elements[0] - - if not status.hasDateOrTime: - raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') - - if begin not in (0, 1) and end != len(argument): - raise commands.BadArgument( - "Time is either in an inappropriate location, which " - "must be either at the end or beginning of your input, " - "or I just flat out did not understand what you meant. Sorry." - ) - - if not status.hasTime: - # replace it with the current time - dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) - - # if midnight is provided, just default to next day - if status.accuracy == pdt.pdtContext.ACU_HALFDAY: - dt = dt.replace(day=now.day + 1) - - result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) - remaining = "" - - if begin in (0, 1): - if begin == 1: - # check if it's quoted: - if argument[0] != '"': - raise commands.BadArgument("Expected quote before time input...") - - if not (end < len(argument) and argument[end] == '"'): - raise commands.BadArgument("If the time is quoted, you must unquote it.") - - remaining = argument[end + 1 :].lstrip(" ,.!") - else: - remaining = argument[end:].lstrip(" ,.!") - elif len(argument) == end: - remaining = argument[:begin].strip() - - await result.ensure_constraints(ctx, self, now, remaining) - return result - - -def human_timedelta( - dt: datetime.datetime, - *, - source: Optional[datetime.datetime] = None, - accuracy: Optional[int] = 3, - brief: bool = False, - suffix: bool = True, -) -> str: - now = source or datetime.datetime.now(datetime.timezone.utc) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=datetime.timezone.utc) - - if now.tzinfo is None: - now = now.replace(tzinfo=datetime.timezone.utc) - - # Microsecond free zone - now = now.replace(microsecond=0) - dt = dt.replace(microsecond=0) - - # This implementation uses relativedelta instead of the much more obvious - # divmod approach with seconds because the seconds approach is not entirely - # accurate once you go over 1 week in terms of accuracy since you have to - # hardcode a month as 30 or 31 days. - # A query like "11 months" can be interpreted as "!1 months and 6 days" - if dt > now: - delta = relativedelta(dt, now) - output_suffix = "" - else: - delta = relativedelta(now, dt) - output_suffix = " ago" if suffix else "" - - attrs = [ - ("year", "y"), - ("month", "mo"), - ("day", "d"), - ("hour", "h"), - ("minute", "m"), - ("second", "s"), - ] - - output = [] - for attr, brief_attr in attrs: - elem = getattr(delta, attr + "s") - if not elem: - continue - - if attr == "day": - weeks = delta.weeks - if weeks: - elem -= weeks * 7 - if not brief: - output.append(format(plural(weeks), "week")) - else: - output.append(f"{weeks}w") - - if elem <= 0: - continue - - if brief: - output.append(f"{elem}{brief_attr}") - else: - output.append(format(plural(elem), attr)) - - if accuracy is not None: - output = output[:accuracy] - - if len(output) == 0: - return "now" - else: - if not brief: - return human_join(output, final="and") + output_suffix - else: - return " ".join(output) + output_suffix - - -def format_relative(dt: datetime.datetime) -> str: - return discord.utils.format_dt(dt, "R") +""" +UserFriendlyTime by Rapptz +Source: +https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py +""" + +from __future__ import annotations + +import datetime +import discord +from typing import TYPE_CHECKING, Any, Optional, Union +import parsedatetime as pdt +from dateutil.relativedelta import relativedelta +from .utils import human_join +from discord.ext import commands +from discord import app_commands +import re + +# Monkey patch mins and secs into the units +units = pdt.pdtLocales["en_US"].units +units["minutes"].append("mins") +units["seconds"].append("secs") + +if TYPE_CHECKING: + from discord.ext.commands import Context + from typing_extensions import Self + + +class plural: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" + + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, sep, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" + + +class ShortTime: + compiled = re.compile( + """ + (?:(?P[0-9])(?:years?|y))? # e.g. 2y + (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months + (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w + (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d + (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h + (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m + (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s + """, + re.VERBOSE, + ) + + discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") + + dt: datetime.datetime + + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + match = self.compiled.fullmatch(argument) + if match is None or not match.group(0): + match = self.discord_fmt.fullmatch(argument) + if match is not None: + self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) + return + else: + raise commands.BadArgument("invalid time provided") + + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + now = now or datetime.datetime.now(datetime.timezone.utc) + self.dt = now + relativedelta(**data) + + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) + + +class HumanTime: + calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) + + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + now = now or datetime.datetime.utcnow() + dt, status = self.calendar.parseDT(argument, sourceTime=now) + if not status.hasDateOrTime: + raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') + + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) + + self.dt: datetime.datetime = dt + self._past: bool = dt < now + + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) + + +class Time(HumanTime): + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + try: + o = ShortTime(argument, now=now) + except Exception: + super().__init__(argument) + else: + self.dt = o.dt + self._past = False + + +class FutureTime(Time): + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + super().__init__(argument, now=now) + + if self._past: + raise commands.BadArgument("this time is in the past") + + +class BadTimeTransform(app_commands.AppCommandError): + pass + + +class TimeTransformer(app_commands.Transformer): + async def transform(self, interaction, value: str) -> datetime.datetime: + now = interaction.created_at + try: + short = ShortTime(value, now=now) + except commands.BadArgument: + try: + human = FutureTime(value, now=now) + except commands.BadArgument as e: + raise BadTimeTransform(str(e)) from None + else: + return human.dt + else: + return short.dt + + +# CHANGE: Added now +class FriendlyTimeResult: + dt: datetime.datetime + now: datetime.datetime + arg: str + + __slots__ = ("dt", "arg", "now") + + def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): + self.dt = dt + self.now = now + + if now is None: + self.now = dt + else: + self.now = now + + self.arg = "" + + async def ensure_constraints( + self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str + ) -> None: + if self.dt < now: + raise commands.BadArgument("This time is in the past.") + + # CHANGE + # if not remaining: + # if uft.default is None: + # raise commands.BadArgument("Missing argument after the time.") + # remaining = uft.default + + if uft.converter is not None: + self.arg = await uft.converter.convert(ctx, remaining) + else: + self.arg = remaining + + +class UserFriendlyTime(commands.Converter): + """That way quotes aren't absolutely necessary.""" + + def __init__( + self, + converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, + *, + default: Any = None, + ): + if isinstance(converter, type) and issubclass(converter, commands.Converter): + converter = converter() + + if converter is not None and not isinstance(converter, commands.Converter): + raise TypeError("commands.Converter subclass necessary.") + + self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing + self.default: Any = default + + async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTimeResult: + calendar = HumanTime.calendar + regex = ShortTime.compiled + if now is None: + now = ctx.message.created_at + + match = regex.match(argument) + if match is not None and match.group(0): + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + remaining = argument[match.end() :].strip() + result = FriendlyTimeResult(now + relativedelta(**data), now) + await result.ensure_constraints(ctx, self, now, remaining) + return result + + if match is None or not match.group(0): + match = ShortTime.discord_fmt.match(argument) + if match is not None: + result = FriendlyTimeResult( + datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) + ) + remaining = argument[match.end() :].strip() + await result.ensure_constraints(ctx, self, now, remaining) + return result + + # apparently nlp does not like "from now" + # it likes "from x" in other cases though so let me handle the 'now' case + if argument.endswith("from now"): + argument = argument[:-8].strip() + + if argument[0:2] == "me": + # starts with "me to", "me in", or "me at " + if argument[0:6] in ("me to ", "me in ", "me at "): + argument = argument[6:] + + elements = calendar.nlp(argument, sourceTime=now) + if elements is None or len(elements) == 0: + # CHANGE + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + + # handle the following cases: + # "date time" foo + # date time foo + # foo date time + + # first the first two cases: + dt, status, begin, end, dt_string = elements[0] + + if not status.hasDateOrTime: + raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + + if begin not in (0, 1) and end != len(argument): + raise commands.BadArgument( + "Time is either in an inappropriate location, which " + "must be either at the end or beginning of your input, " + "or I just flat out did not understand what you meant. Sorry." + ) + + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) + + # if midnight is provided, just default to next day + if status.accuracy == pdt.pdtContext.ACU_HALFDAY: + dt = dt.replace(day=now.day + 1) + + result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) + remaining = "" + + if begin in (0, 1): + if begin == 1: + # check if it's quoted: + if argument[0] != '"': + raise commands.BadArgument("Expected quote before time input...") + + if not (end < len(argument) and argument[end] == '"'): + raise commands.BadArgument("If the time is quoted, you must unquote it.") + + remaining = argument[end + 1 :].lstrip(" ,.!") + else: + remaining = argument[end:].lstrip(" ,.!") + elif len(argument) == end: + remaining = argument[:begin].strip() + + await result.ensure_constraints(ctx, self, now, remaining) + return result + + +def human_timedelta( + dt: datetime.datetime, + *, + source: Optional[datetime.datetime] = None, + accuracy: Optional[int] = 3, + brief: bool = False, + suffix: bool = True, +) -> str: + now = source or datetime.datetime.now(datetime.timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + + # Microsecond free zone + now = now.replace(microsecond=0) + dt = dt.replace(microsecond=0) + + # This implementation uses relativedelta instead of the much more obvious + # divmod approach with seconds because the seconds approach is not entirely + # accurate once you go over 1 week in terms of accuracy since you have to + # hardcode a month as 30 or 31 days. + # A query like "11 months" can be interpreted as "!1 months and 6 days" + if dt > now: + delta = relativedelta(dt, now) + output_suffix = "" + else: + delta = relativedelta(now, dt) + output_suffix = " ago" if suffix else "" + + attrs = [ + ("year", "y"), + ("month", "mo"), + ("day", "d"), + ("hour", "h"), + ("minute", "m"), + ("second", "s"), + ] + + output = [] + for attr, brief_attr in attrs: + elem = getattr(delta, attr + "s") + if not elem: + continue + + if attr == "day": + weeks = delta.weeks + if weeks: + elem -= weeks * 7 + if not brief: + output.append(format(plural(weeks), "week")) + else: + output.append(f"{weeks}w") + + if elem <= 0: + continue + + if brief: + output.append(f"{elem}{brief_attr}") + else: + output.append(format(plural(elem), attr)) + + if accuracy is not None: + output = output[:accuracy] + + if len(output) == 0: + return "now" + else: + if not brief: + return human_join(output, final="and") + output_suffix + else: + return " ".join(output) + output_suffix + + +def format_relative(dt: datetime.datetime) -> str: + return discord.utils.format_dt(dt, "R") diff --git a/core/utils.py b/core/utils.py index 89d0676eb0..e8b20fb2a9 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,616 +1,616 @@ -import base64 -import functools -import re -import typing -from datetime import datetime, timezone -from difflib import get_close_matches -from distutils.util import strtobool as _stb # pylint: disable=import-error -from itertools import takewhile, zip_longest -from urllib import parse - -import discord -from discord.ext import commands - -from core.models import getLogger - - -__all__ = [ - "strtobool", - "User", - "truncate", - "format_preview", - "is_image_url", - "parse_image_url", - "human_join", - "days", - "cleanup_code", - "parse_channel_topic", - "match_title", - "match_user_id", - "match_other_recipients", - "create_thread_channel", - "create_not_found_embed", - "parse_alias", - "normalize_alias", - "format_description", - "trigger_typing", - "escape_code_block", - "tryint", - "get_top_role", - "get_joint_id", - "extract_block_timestamp", - "return_or_truncate", - "AcceptButton", - "DenyButton", - "ConfirmThreadCreationView", - "DummyParam", -] - - -logger = getLogger(__name__) - - -def strtobool(val): - if isinstance(val, bool): - return val - try: - return _stb(str(val)) - except ValueError: - val = val.lower() - if val == "enable": - return 1 - if val == "disable": - return 0 - raise - - -class User(commands.MemberConverter): - """ - A custom discord.py `Converter` that - supports `Member`, `User`, and string ID's. - """ - - # noinspection PyCallByClass,PyTypeChecker - async def convert(self, ctx, argument): - try: - return await commands.MemberConverter().convert(ctx, argument) - except commands.BadArgument: - pass - try: - return await commands.UserConverter().convert(ctx, argument) - except commands.BadArgument: - pass - match = self._get_id_match(argument) - if match is None: - raise commands.BadArgument('User "{}" not found'.format(argument)) - return discord.Object(int(match.group(1))) - - -def truncate(text: str, max: int = 50) -> str: # pylint: disable=redefined-builtin - """ - Reduces the string to `max` length, by trimming the message into "...". - - Parameters - ---------- - text : str - The text to trim. - max : int, optional - The max length of the text. - Defaults to 50. - - Returns - ------- - str - The truncated text. - """ - text = text.strip() - return text[: max - 3].strip() + "..." if len(text) > max else text - - -def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): - """ - Used to format previews. - - Parameters - ---------- - messages : List[Dict[str, Any]] - A list of messages. - - Returns - ------- - str - A formatted string preview. - """ - messages = messages[:3] - out = "" - for message in messages: - if message.get("type") in {"note", "internal"}: - continue - author = message["author"] - content = str(message["content"]).replace("\n", " ") - - name = author["name"] - discriminator = str(author["discriminator"]) - if discriminator != "0": - name += "#" + discriminator - prefix = "[M]" if author["mod"] else "[R]" - out += truncate(f"`{prefix} {name}:` {content}", max=75) + "\n" - - return out or "No Messages" - - -def is_image_url(url: str, **kwargs) -> str: - """ - Check if the URL is pointing to an image. - - Parameters - ---------- - url : str - The URL to check. - - Returns - ------- - bool - Whether the URL is a valid image URL. - """ - try: - result = parse.urlparse(url) - if result.netloc == "gyazo.com" and result.scheme in ["http", "https"]: - # gyazo support - url = re.sub( - r"(https?://)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+)", - r"\1i.\2.png", - url, - ) - except ValueError: - pass - - return parse_image_url(url, **kwargs) - - -def parse_image_url(url: str, *, convert_size=True) -> str: - """ - Convert the image URL into a sized Discord avatar. - - Parameters - ---------- - url : str - The URL to convert. - - Returns - ------- - str - The converted URL, or '' if the URL isn't in the proper format. - """ - types = [".png", ".jpg", ".gif", ".jpeg", ".webp"] - url = parse.urlsplit(url) - - if any(url.path.lower().endswith(i) for i in types): - if convert_size: - return parse.urlunsplit((*url[:3], "size=128", url[-1])) - else: - return parse.urlunsplit(url) - return "" - - -def human_join(seq: typing.Sequence[str], delim: str = ", ", final: str = "or") -> str: - """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L21-L32""" - size = len(seq) - if size == 0: - return "" - - if size == 1: - return seq[0] - - if size == 2: - return f"{seq[0]} {final} {seq[1]}" - - return delim.join(seq[:-1]) + f" {final} {seq[-1]}" - - -def days(day: typing.Union[str, int]) -> str: - """ - Humanize the number of days. - - Parameters - ---------- - day: Union[int, str] - The number of days passed. - - Returns - ------- - str - A formatted string of the number of days passed. - """ - day = int(day) - if day == 0: - return "**today**" - return f"{day} day ago" if day == 1 else f"{day} days ago" - - -def cleanup_code(content: str) -> str: - """ - Automatically removes code blocks from the code. - - Parameters - ---------- - content : str - The content to be cleaned. - - Returns - ------- - str - The cleaned content. - """ - # remove ```py\n``` - if content.startswith("```") and content.endswith("```"): - return "\n".join(content.split("\n")[1:-1]) - - # remove `foo` - return content.strip("` \n") - - -TOPIC_REGEX = re.compile( - r"(?:\bTitle:\s*(?P.*)\n)?" - r"\bUser ID:\s*(?P<user_id>\d{17,21})\b" - r"(?:\nOther Recipients:\s*(?P<other_ids>\d{17,21}(?:(?:\s*,\s*)\d{17,21})*)\b)?", - flags=re.IGNORECASE | re.DOTALL, -) -UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) - - -def parse_channel_topic(text: str) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: - """ - A helper to parse channel topics and respectivefully returns all the required values - at once. - - Parameters - ---------- - text : str - The text of channel topic. - - Returns - ------- - Tuple[Optional[str], int, List[int]] - A tuple of title, user ID, and other recipients IDs. - """ - title, user_id, other_ids = None, -1, [] - if isinstance(text, str): - match = TOPIC_REGEX.search(text) - else: - match = None - - if match is not None: - groupdict = match.groupdict() - title = groupdict["title"] - - # user ID string is the required one in regex, so if match is found - # the value of this won't be None - user_id = int(groupdict["user_id"]) - - oth_ids = groupdict["other_ids"] - if oth_ids: - other_ids = list(map(int, oth_ids.split(","))) - - return title, user_id, other_ids - - -def match_title(text: str) -> str: - """ - Matches a title in the format of "Title: XXXX" - - Parameters - ---------- - text : str - The text of the user ID. - - Returns - ------- - Optional[str] - The title if found. - """ - return parse_channel_topic(text)[0] - - -def match_user_id(text: str, any_string: bool = False) -> int: - """ - Matches a user ID in the format of "User ID: 12345". - - Parameters - ---------- - text : str - The text of the user ID. - any_string: bool - Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. - Defaults to False. - - Returns - ------- - int - The user ID if found. Otherwise, -1. - """ - user_id = -1 - if any_string: - match = UID_REGEX.search(text) - if match is not None: - user_id = int(match.group(1)) - else: - user_id = parse_channel_topic(text)[1] - - return user_id - - -def match_other_recipients(text: str) -> typing.List[int]: - """ - Matches a title in the format of "Other Recipients: XXXX,XXXX" - - Parameters - ---------- - text : str - The text of the user ID. - - Returns - ------- - List[int] - The list of other recipients IDs. - """ - return parse_channel_topic(text)[2] - - -def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: - # Single reference of Color.red() - embed = discord.Embed( - color=discord.Color.red(), description=f"**{name.capitalize()} `{word}` cannot be found.**" - ) - val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) - if val: - embed.description += "\nHowever, perhaps you meant...\n" + "\n".join(val) - return embed - - -def parse_alias(alias, *, split=True): - def encode_alias(m): - return "\x1aU" + base64.b64encode(m.group(1).encode()).decode() + "\x1aU" - - def decode_alias(m): - return base64.b64decode(m.group(1).encode()).decode() - - alias = re.sub( - r"(?:(?<=^)(?:\s*(?<!\\)(?:\")\s*)|(?<=&&)(?:\s*(?<!\\)(?:\")\s*))(.+?)" - r"(?:(?:\s*(?<!\\)(?:\")\s*)(?=&&)|(?:\s*(?<!\\)(?:\")\s*)(?=$))", - encode_alias, - alias, - ).strip() - - aliases = [] - if not alias: - return aliases - - if split: - iterate = re.split(r"\s*&&\s*", alias) - else: - iterate = [alias] - - for a in iterate: - a = re.sub("\x1aU(.+?)\x1aU", decode_alias, a) - if a[0] == a[-1] == '"': - a = a[1:-1] - aliases.append(a) - - return aliases - - -def normalize_alias(alias, message=""): - aliases = parse_alias(alias) - contents = parse_alias(message, split=False) - - final_aliases = [] - for a, content in zip_longest(aliases, contents): - if a is None: - break - - if content: - final_aliases.append(f"{a} {content}") - else: - final_aliases.append(a) - - return final_aliases - - -def format_description(i, names): - return "\n".join( - ": ".join((str(a + i * 15), b)) - for a, b in enumerate(takewhile(lambda x: x is not None, names), start=1) - ) - - -def trigger_typing(func): - @functools.wraps(func) - async def wrapper(self, ctx: commands.Context, *args, **kwargs): - await ctx.typing() - return await func(self, ctx, *args, **kwargs) - - return wrapper - - -def escape_code_block(text): - return re.sub(r"```", "`\u200b``", text) - - -def tryint(x): - try: - return int(x) - except (ValueError, TypeError): - return x - - -def get_top_role(member: discord.Member, hoisted=True): - roles = sorted(member.roles, key=lambda r: r.position, reverse=True) - for role in roles: - if not hoisted: - return role - if role.hoist: - return role - - -async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): - name = name or bot.format_channel_name(recipient) - errors_raised = errors_raised or [] - - try: - channel = await bot.modmail_guild.create_text_channel( - name=name, - category=category, - overwrites=overwrites, - topic=f"User ID: {recipient.id}", - reason="Creating a thread channel.", - ) - except discord.HTTPException as e: - if (e.text, (category, name)) in errors_raised: - # Just raise the error to prevent infinite recursion after retrying - raise - - errors_raised.append((e.text, (category, name))) - - if "Maximum number of channels in category reached" in e.text: - fallback = None - fallback_id = bot.config["fallback_category_id"] - if fallback_id: - fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) - if fallback and len(fallback.channels) >= 49: - fallback = None - - if not fallback: - fallback = await category.clone(name="Fallback Modmail") - await bot.config.set("fallback_category_id", str(fallback.id)) - await bot.config.update() - - return await create_thread_channel( - bot, recipient, fallback, overwrites, errors_raised=errors_raised - ) - - if "Contains words not allowed" in e.text: - # try again but null-discrim (name could be banned) - return await create_thread_channel( - bot, - recipient, - category, - overwrites, - name=bot.format_channel_name(recipient, force_null=True), - errors_raised=errors_raised, - ) - - raise - - return channel - - -def get_joint_id(message: discord.Message) -> typing.Optional[int]: - """ - Get the joint ID from `discord.Embed().author.url`. - Parameters - ----------- - message : discord.Message - The discord.Message object. - Returns - ------- - int - The joint ID if found. Otherwise, None. - """ - if message.embeds: - try: - url = getattr(message.embeds[0].author, "url", "") - if url: - return int(url.split("#")[-1]) - except ValueError: - pass - return None - - -def extract_block_timestamp(reason, id_): - # etc "blah blah blah... until <t:XX:f>." - now = discord.utils.utcnow() - end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) - attempts = [ - # backwards compat - re.search(r"until ([^`]+?)\.$", reason), - re.search(r"%([^%]+?)%", reason), - ] - after = None - if end_time is None: - for i in attempts: - if i is not None: - end_time = i - break - - if end_time is not None: - # found a deprecated version - try: - after = ( - datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now - ).total_seconds() - except ValueError: - logger.warning( - r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", - id_, - ) - raise - logger.warning( - r"Deprecated time message for user %s, block and unblock again to update.", - id_, - ) - else: - try: - after = ( - datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now - ).total_seconds() - except ValueError: - logger.warning( - r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", - id_, - ) - raise - - return end_time, after - - -def return_or_truncate(text, max_length): - if len(text) <= max_length: - return text - return text[: max_length - 3] + "..." - - -class AcceptButton(discord.ui.Button): - def __init__(self, emoji): - super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) - - async def callback(self, interaction: discord.Interaction): - self.view.value = True - await interaction.response.edit_message(view=None) - self.view.stop() - - -class DenyButton(discord.ui.Button): - def __init__(self, emoji): - super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) - - async def callback(self, interaction: discord.Interaction): - self.view.value = False - await interaction.response.edit_message(view=None) - self.view.stop() - - -class ConfirmThreadCreationView(discord.ui.View): - def __init__(self): - super().__init__(timeout=20) - self.value = None - - -class DummyParam: - """ - A dummy parameter that can be used for MissingRequiredArgument. - """ - - def __init__(self, name): - self.name = name - self.displayed_name = name +import base64 +import functools +import re +import typing +from datetime import datetime, timezone +from difflib import get_close_matches +from distutils.util import strtobool as _stb # pylint: disable=import-error +from itertools import takewhile, zip_longest +from urllib import parse + +import discord +from discord.ext import commands + +from core.models import getLogger + + +__all__ = [ + "strtobool", + "User", + "truncate", + "format_preview", + "is_image_url", + "parse_image_url", + "human_join", + "days", + "cleanup_code", + "parse_channel_topic", + "match_title", + "match_user_id", + "match_other_recipients", + "create_thread_channel", + "create_not_found_embed", + "parse_alias", + "normalize_alias", + "format_description", + "trigger_typing", + "escape_code_block", + "tryint", + "get_top_role", + "get_joint_id", + "extract_block_timestamp", + "return_or_truncate", + "AcceptButton", + "DenyButton", + "ConfirmThreadCreationView", + "DummyParam", +] + + +logger = getLogger(__name__) + + +def strtobool(val): + if isinstance(val, bool): + return val + try: + return _stb(str(val)) + except ValueError: + val = val.lower() + if val == "enable": + return 1 + if val == "disable": + return 0 + raise + + +class User(commands.MemberConverter): + """ + A custom discord.py `Converter` that + supports `Member`, `User`, and string ID's. + """ + + # noinspection PyCallByClass,PyTypeChecker + async def convert(self, ctx, argument): + try: + return await commands.MemberConverter().convert(ctx, argument) + except commands.BadArgument: + pass + try: + return await commands.UserConverter().convert(ctx, argument) + except commands.BadArgument: + pass + match = self._get_id_match(argument) + if match is None: + raise commands.BadArgument('User "{}" not found'.format(argument)) + return discord.Object(int(match.group(1))) + + +def truncate(text: str, max: int = 50) -> str: # pylint: disable=redefined-builtin + """ + Reduces the string to `max` length, by trimming the message into "...". + + Parameters + ---------- + text : str + The text to trim. + max : int, optional + The max length of the text. + Defaults to 50. + + Returns + ------- + str + The truncated text. + """ + text = text.strip() + return text[: max - 3].strip() + "..." if len(text) > max else text + + +def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): + """ + Used to format previews. + + Parameters + ---------- + messages : List[Dict[str, Any]] + A list of messages. + + Returns + ------- + str + A formatted string preview. + """ + messages = messages[:3] + out = "" + for message in messages: + if message.get("type") in {"note", "internal"}: + continue + author = message["author"] + content = str(message["content"]).replace("\n", " ") + + name = author["name"] + discriminator = str(author["discriminator"]) + if discriminator != "0": + name += "#" + discriminator + prefix = "[M]" if author["mod"] else "[R]" + out += truncate(f"`{prefix} {name}:` {content}", max=75) + "\n" + + return out or "No Messages" + + +def is_image_url(url: str, **kwargs) -> str: + """ + Check if the URL is pointing to an image. + + Parameters + ---------- + url : str + The URL to check. + + Returns + ------- + bool + Whether the URL is a valid image URL. + """ + try: + result = parse.urlparse(url) + if result.netloc == "gyazo.com" and result.scheme in ["http", "https"]: + # gyazo support + url = re.sub( + r"(https?://)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+)", + r"\1i.\2.png", + url, + ) + except ValueError: + pass + + return parse_image_url(url, **kwargs) + + +def parse_image_url(url: str, *, convert_size=True) -> str: + """ + Convert the image URL into a sized Discord avatar. + + Parameters + ---------- + url : str + The URL to convert. + + Returns + ------- + str + The converted URL, or '' if the URL isn't in the proper format. + """ + types = [".png", ".jpg", ".gif", ".jpeg", ".webp"] + url = parse.urlsplit(url) + + if any(url.path.lower().endswith(i) for i in types): + if convert_size: + return parse.urlunsplit((*url[:3], "size=128", url[-1])) + else: + return parse.urlunsplit(url) + return "" + + +def human_join(seq: typing.Sequence[str], delim: str = ", ", final: str = "or") -> str: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L21-L32""" + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" + + +def days(day: typing.Union[str, int]) -> str: + """ + Humanize the number of days. + + Parameters + ---------- + day: Union[int, str] + The number of days passed. + + Returns + ------- + str + A formatted string of the number of days passed. + """ + day = int(day) + if day == 0: + return "**today**" + return f"{day} day ago" if day == 1 else f"{day} days ago" + + +def cleanup_code(content: str) -> str: + """ + Automatically removes code blocks from the code. + + Parameters + ---------- + content : str + The content to be cleaned. + + Returns + ------- + str + The cleaned content. + """ + # remove ```py\n``` + if content.startswith("```") and content.endswith("```"): + return "\n".join(content.split("\n")[1:-1]) + + # remove `foo` + return content.strip("` \n") + + +TOPIC_REGEX = re.compile( + r"(?:\bTitle:\s*(?P<title>.*)\n)?" + r"\bUser ID:\s*(?P<user_id>\d{17,21})\b" + r"(?:\nOther Recipients:\s*(?P<other_ids>\d{17,21}(?:(?:\s*,\s*)\d{17,21})*)\b)?", + flags=re.IGNORECASE | re.DOTALL, +) +UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) + + +def parse_channel_topic(text: str) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: + """ + A helper to parse channel topics and respectivefully returns all the required values + at once. + + Parameters + ---------- + text : str + The text of channel topic. + + Returns + ------- + Tuple[Optional[str], int, List[int]] + A tuple of title, user ID, and other recipients IDs. + """ + title, user_id, other_ids = None, -1, [] + if isinstance(text, str): + match = TOPIC_REGEX.search(text) + else: + match = None + + if match is not None: + groupdict = match.groupdict() + title = groupdict["title"] + + # user ID string is the required one in regex, so if match is found + # the value of this won't be None + user_id = int(groupdict["user_id"]) + + oth_ids = groupdict["other_ids"] + if oth_ids: + other_ids = list(map(int, oth_ids.split(","))) + + return title, user_id, other_ids + + +def match_title(text: str) -> str: + """ + Matches a title in the format of "Title: XXXX" + + Parameters + ---------- + text : str + The text of the user ID. + + Returns + ------- + Optional[str] + The title if found. + """ + return parse_channel_topic(text)[0] + + +def match_user_id(text: str, any_string: bool = False) -> int: + """ + Matches a user ID in the format of "User ID: 12345". + + Parameters + ---------- + text : str + The text of the user ID. + any_string: bool + Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. + Defaults to False. + + Returns + ------- + int + The user ID if found. Otherwise, -1. + """ + user_id = -1 + if any_string: + match = UID_REGEX.search(text) + if match is not None: + user_id = int(match.group(1)) + else: + user_id = parse_channel_topic(text)[1] + + return user_id + + +def match_other_recipients(text: str) -> typing.List[int]: + """ + Matches a title in the format of "Other Recipients: XXXX,XXXX" + + Parameters + ---------- + text : str + The text of the user ID. + + Returns + ------- + List[int] + The list of other recipients IDs. + """ + return parse_channel_topic(text)[2] + + +def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: + # Single reference of Color.red() + embed = discord.Embed( + color=discord.Color.red(), description=f"**{name.capitalize()} `{word}` cannot be found.**" + ) + val = get_close_matches(word, possibilities, n=n, cutoff=cutoff) + if val: + embed.description += "\nHowever, perhaps you meant...\n" + "\n".join(val) + return embed + + +def parse_alias(alias, *, split=True): + def encode_alias(m): + return "\x1aU" + base64.b64encode(m.group(1).encode()).decode() + "\x1aU" + + def decode_alias(m): + return base64.b64decode(m.group(1).encode()).decode() + + alias = re.sub( + r"(?:(?<=^)(?:\s*(?<!\\)(?:\")\s*)|(?<=&&)(?:\s*(?<!\\)(?:\")\s*))(.+?)" + r"(?:(?:\s*(?<!\\)(?:\")\s*)(?=&&)|(?:\s*(?<!\\)(?:\")\s*)(?=$))", + encode_alias, + alias, + ).strip() + + aliases = [] + if not alias: + return aliases + + if split: + iterate = re.split(r"\s*&&\s*", alias) + else: + iterate = [alias] + + for a in iterate: + a = re.sub("\x1aU(.+?)\x1aU", decode_alias, a) + if a[0] == a[-1] == '"': + a = a[1:-1] + aliases.append(a) + + return aliases + + +def normalize_alias(alias, message=""): + aliases = parse_alias(alias) + contents = parse_alias(message, split=False) + + final_aliases = [] + for a, content in zip_longest(aliases, contents): + if a is None: + break + + if content: + final_aliases.append(f"{a} {content}") + else: + final_aliases.append(a) + + return final_aliases + + +def format_description(i, names): + return "\n".join( + ": ".join((str(a + i * 15), b)) + for a, b in enumerate(takewhile(lambda x: x is not None, names), start=1) + ) + + +def trigger_typing(func): + @functools.wraps(func) + async def wrapper(self, ctx: commands.Context, *args, **kwargs): + await ctx.typing() + return await func(self, ctx, *args, **kwargs) + + return wrapper + + +def escape_code_block(text): + return re.sub(r"```", "`\u200b``", text) + + +def tryint(x): + try: + return int(x) + except (ValueError, TypeError): + return x + + +def get_top_role(member: discord.Member, hoisted=True): + roles = sorted(member.roles, key=lambda r: r.position, reverse=True) + for role in roles: + if not hoisted: + return role + if role.hoist: + return role + + +async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): + name = name or bot.format_channel_name(recipient) + errors_raised = errors_raised or [] + + try: + channel = await bot.modmail_guild.create_text_channel( + name=name, + category=category, + overwrites=overwrites, + topic=f"User ID: {recipient.id}", + reason="Creating a thread channel.", + ) + except discord.HTTPException as e: + if (e.text, (category, name)) in errors_raised: + # Just raise the error to prevent infinite recursion after retrying + raise + + errors_raised.append((e.text, (category, name))) + + if "Maximum number of channels in category reached" in e.text: + fallback = None + fallback_id = bot.config["fallback_category_id"] + if fallback_id: + fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) + if fallback and len(fallback.channels) >= 49: + fallback = None + + if not fallback: + fallback = await category.clone(name="Fallback Modmail") + await bot.config.set("fallback_category_id", str(fallback.id)) + await bot.config.update() + + return await create_thread_channel( + bot, recipient, fallback, overwrites, errors_raised=errors_raised + ) + + if "Contains words not allowed" in e.text: + # try again but null-discrim (name could be banned) + return await create_thread_channel( + bot, + recipient, + category, + overwrites, + name=bot.format_channel_name(recipient, force_null=True), + errors_raised=errors_raised, + ) + + raise + + return channel + + +def get_joint_id(message: discord.Message) -> typing.Optional[int]: + """ + Get the joint ID from `discord.Embed().author.url`. + Parameters + ----------- + message : discord.Message + The discord.Message object. + Returns + ------- + int + The joint ID if found. Otherwise, None. + """ + if message.embeds: + try: + url = getattr(message.embeds[0].author, "url", "") + if url: + return int(url.split("#")[-1]) + except ValueError: + pass + return None + + +def extract_block_timestamp(reason, id_): + # etc "blah blah blah... until <t:XX:f>." + now = discord.utils.utcnow() + end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) + attempts = [ + # backwards compat + re.search(r"until ([^`]+?)\.$", reason), + re.search(r"%([^%]+?)%", reason), + ] + after = None + if end_time is None: + for i in attempts: + if i is not None: + end_time = i + break + + if end_time is not None: + # found a deprecated version + try: + after = ( + datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise + logger.warning( + r"Deprecated time message for user %s, block and unblock again to update.", + id_, + ) + else: + try: + after = ( + datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise + + return end_time, after + + +def return_or_truncate(text, max_length): + if len(text) <= max_length: + return text + return text[: max_length - 3] + "..." + + +class AcceptButton(discord.ui.Button): + def __init__(self, emoji): + super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) + + async def callback(self, interaction: discord.Interaction): + self.view.value = True + await interaction.response.edit_message(view=None) + self.view.stop() + + +class DenyButton(discord.ui.Button): + def __init__(self, emoji): + super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) + + async def callback(self, interaction: discord.Interaction): + self.view.value = False + await interaction.response.edit_message(view=None) + self.view.stop() + + +class ConfirmThreadCreationView(discord.ui.View): + def __init__(self): + super().__init__(timeout=20) + self.value = None + + +class DummyParam: + """ + A dummy parameter that can be used for MissingRequiredArgument. + """ + + def __init__(self, name): + self.name = name + self.displayed_name = name diff --git a/docker-compose.yml b/docker-compose.yml index fcb0e1b32f..d7f3589e03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,28 @@ -version: "3.7" -services: - bot: - image: ghcr.io/modmail-dev/modmail:master - restart: always - env_file: - - .env - environment: - - CONNECTION_URI=mongodb://mongo - depends_on: - - mongo - logviewer: - image: ghcr.io/modmail-dev/logviewer:master - restart: always - depends_on: - - mongo - environment: - - MONGO_URI=mongodb://mongo - ports: - - 80:8000 - mongo: - image: mongo - restart: always - volumes: - - mongodb:/data/db - -volumes: - mongodb: +version: "3.7" +services: + bot: + image: ghcr.io/modmail-dev/modmail:master + restart: always + env_file: + - .env + environment: + - CONNECTION_URI=mongodb://mongo + depends_on: + - mongo + logviewer: + image: ghcr.io/modmail-dev/logviewer:master + restart: always + depends_on: + - mongo + environment: + - MONGO_URI=mongodb://mongo + ports: + - 80:8000 + mongo: + image: mongo + restart: always + volumes: + - mongodb:/data/db + +volumes: + mongodb: diff --git a/modmail.sh b/modmail.sh index 4f29170f0a..2dbc7218ff 100644 --- a/modmail.sh +++ b/modmail.sh @@ -1,3 +1,3 @@ -#!/bin/sh - +#!/bin/sh + pipenv run python3 bot.py \ No newline at end of file diff --git a/plugins/registry.json b/plugins/registry.json index 4079001a50..d14b6dab66 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1,137 +1,137 @@ -{ - "advanced-menu": { - "repository": "sebkuip/mm-plugins", - "branch": "master", - "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", - "bot_version": "v4.0.0", - "title": "Advanced menu", - "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", - "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" - }, - "announcement": { - "repository": "Jerrie-Aries/modmail-plugins", - "branch": "master", - "description": "Create and post announcements. Supports both plain and embed. Also customisable using buttons and dropdown menus.", - "bot_version": "4.0.0", - "title": "Announcement", - "icon_url": "https://github.com/Jerrie-Aries.png", - "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/announcement.jpg" - }, - "autoreact": { - "repository": "martinbndr/kyb3r-modmail-plugins", - "branch": "master", - "description": "Automatically reacts with emojis in certain channels.", - "bot_version": "4.0.0", - "title": "Autoreact", - "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png", - "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png" - }, - "giveaway": { - "repository": "Jerrie-Aries/modmail-plugins", - "branch": "master", - "description": "Host giveaways on your server with this plugin.", - "bot_version": "4.0.0", - "title": "Giveaway", - "icon_url": "https://github.com/Jerrie-Aries.png", - "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/giveaway.jpg" - }, - "suggest": { - "repository": "realcyguy/modmail-plugins", - "branch": "v4", - "description": "Send suggestions to a selected server! It has accepting, denying, and moderation-ing.", - "bot_version": "4.0.0", - "title": "Suggest stuff.", - "icon_url": "https://i.imgur.com/qtE7AH8.png", - "thumbnail_url": "https://i.imgur.com/qtE7AH8.png" - }, - "reminder": { - "repository": "martinbndr/kyb3r-modmail-plugins", - "branch": "master", - "description": "Let´s you create reminders.", - "bot_version": "4.0.0", - "title": "Reminder", - "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png", - "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png" - }, - "welcomer": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", - "bot_version": "4.0.0", - "title": "New member messages plugin", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "countdowns": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Setup a countdown voice channel in your server!", - "bot_version": "4.0.0", - "title": "Countdowns", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "claim": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Allows supporters to claim thread by sending ?claim in the thread channel", - "bot_version": "4.0.0", - "title": "Claim Thread", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "emote-manager": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Allows managing server emotes via ?emoji", - "bot_version": "4.0.0", - "title": "Emote Manager", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "gen-log": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Outputs a text log of a thread in a specified channel", - "bot_version": "4.0.0", - "title": "Log Generator", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "media-logger": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Re-posts detected media from all visible channels into a specified logging channel", - "bot_version": "4.0.0", - "title": "Media Logger", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "report": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Specify an emoji to react with on messages. Generates a 'report' in specified logging channel upon react.", - "bot_version": "4.0.0", - "title": "Report", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "top-supporters": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Gathers and prints the top supporters of handling threads.", - "bot_version": "4.0.0", - "title": "Top Supporters", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" - }, - "rename": { - "repository": "Nicklaus-s/modmail-plugins", - "branch": "master", - "description": "Set a thread channel name.", - "bot_version": "4.0.0", - "title": "Rename", - "icon_url": "https://i.imgur.com/A1auJ95.png", - "thumbnail_url": "https://i.imgur.com/A1auJ95.png" - } -} +{ + "advanced-menu": { + "repository": "sebkuip/mm-plugins", + "branch": "master", + "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", + "bot_version": "v4.0.0", + "title": "Advanced menu", + "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" + }, + "announcement": { + "repository": "Jerrie-Aries/modmail-plugins", + "branch": "master", + "description": "Create and post announcements. Supports both plain and embed. Also customisable using buttons and dropdown menus.", + "bot_version": "4.0.0", + "title": "Announcement", + "icon_url": "https://github.com/Jerrie-Aries.png", + "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/announcement.jpg" + }, + "autoreact": { + "repository": "martinbndr/kyb3r-modmail-plugins", + "branch": "master", + "description": "Automatically reacts with emojis in certain channels.", + "bot_version": "4.0.0", + "title": "Autoreact", + "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png" + }, + "giveaway": { + "repository": "Jerrie-Aries/modmail-plugins", + "branch": "master", + "description": "Host giveaways on your server with this plugin.", + "bot_version": "4.0.0", + "title": "Giveaway", + "icon_url": "https://github.com/Jerrie-Aries.png", + "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/giveaway.jpg" + }, + "suggest": { + "repository": "realcyguy/modmail-plugins", + "branch": "v4", + "description": "Send suggestions to a selected server! It has accepting, denying, and moderation-ing.", + "bot_version": "4.0.0", + "title": "Suggest stuff.", + "icon_url": "https://i.imgur.com/qtE7AH8.png", + "thumbnail_url": "https://i.imgur.com/qtE7AH8.png" + }, + "reminder": { + "repository": "martinbndr/kyb3r-modmail-plugins", + "branch": "master", + "description": "Let´s you create reminders.", + "bot_version": "4.0.0", + "title": "Reminder", + "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png" + }, + "welcomer": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", + "bot_version": "4.0.0", + "title": "New member messages plugin", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "countdowns": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Setup a countdown voice channel in your server!", + "bot_version": "4.0.0", + "title": "Countdowns", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "claim": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Allows supporters to claim thread by sending ?claim in the thread channel", + "bot_version": "4.0.0", + "title": "Claim Thread", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "emote-manager": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Allows managing server emotes via ?emoji", + "bot_version": "4.0.0", + "title": "Emote Manager", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "gen-log": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Outputs a text log of a thread in a specified channel", + "bot_version": "4.0.0", + "title": "Log Generator", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "media-logger": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Re-posts detected media from all visible channels into a specified logging channel", + "bot_version": "4.0.0", + "title": "Media Logger", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "report": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Specify an emoji to react with on messages. Generates a 'report' in specified logging channel upon react.", + "bot_version": "4.0.0", + "title": "Report", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "top-supporters": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Gathers and prints the top supporters of handling threads.", + "bot_version": "4.0.0", + "title": "Top Supporters", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "rename": { + "repository": "Nicklaus-s/modmail-plugins", + "branch": "master", + "description": "Set a thread channel name.", + "bot_version": "4.0.0", + "title": "Rename", + "icon_url": "https://i.imgur.com/A1auJ95.png", + "thumbnail_url": "https://i.imgur.com/A1auJ95.png" + } +} diff --git a/pyproject.toml b/pyproject.toml index 7e29a4d4ef..3b603022b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,38 @@ -[tool.black] -line-length = "110" -target-version = ['py310'] -include = '\.pyi?$' -extend-exclude = ''' -( - /( - \.eggs - | \.git - | \.venv - | venv - | venv2 - | _build - | build - | dist - | plugins - | temp - )/ -) -''' - -[tool.poetry] -name = 'Modmail' -version = '4.1.2' -description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." -license = 'AGPL-3.0-only' -authors = [ - 'kyb3r <noemail@example.com>', - '4jr <noemail@example.com>', - 'Taki <noemail@example.com>' -] -readme = 'README.md' -repository = 'https://github.com/modmail-dev/modmail' -homepage = 'https://github.com/modmail-dev/modmail' -keywords = ['discord', 'modmail'] - -[tool.pylint.format] -max-line-length = "110" +[tool.black] +line-length = "110" +target-version = ['py310'] +include = '\.pyi?$' +extend-exclude = ''' +( + /( + \.eggs + | \.git + | \.venv + | venv + | venv2 + | _build + | build + | dist + | plugins + | temp + )/ +) +''' + +[tool.poetry] +name = 'Modmail' +version = '4.1.2' +description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." +license = 'AGPL-3.0-only' +authors = [ + 'kyb3r <noemail@example.com>', + '4jr <noemail@example.com>', + 'Taki <noemail@example.com>' +] +readme = 'README.md' +repository = 'https://github.com/modmail-dev/modmail' +homepage = 'https://github.com/modmail-dev/modmail' +keywords = ['discord', 'modmail'] + +[tool.pylint.format] +max-line-length = "110" diff --git a/requirements.txt b/requirements.txt index 2c7bdb7880..812d7e64a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,41 @@ --i https://pypi.org/simple -aiodns==3.1.1 -aiohttp==3.9.0; python_version >= '3.8' -aiosignal==1.3.1; python_version >= '3.7' -async-timeout==4.0.3; python_version < '3.11' -attrs==23.1.0; python_version >= '3.7' -brotli==1.1.0 -cairocffi==1.6.1; python_version >= '3.7' -cairosvg==2.7.1; python_version >= '3.5' -certifi==2023.11.17; python_version >= '3.6' -cffi==1.16.0; python_version >= '3.8' -charset-normalizer==3.3.2; python_full_version >= '3.7.0' -colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' -cssselect2==0.7.0; python_version >= '3.7' -defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -discord.py[speed]==2.3.2; python_full_version >= '3.8.0' -dnspython==2.4.2; python_version >= '3.8' and python_version < '4.0' -emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -frozenlist==1.4.0; python_version >= '3.8' -idna==3.4; python_version >= '3.5' -isodate==0.6.1 -lottie[pdf]==0.7.0; python_version >= '3' -motor==3.3.2; python_version >= '3.7' -multidict==6.0.4; python_version >= '3.7' -natural==0.2.0 -orjson==3.9.10 -packaging==23.2; python_version >= '3.7' -parsedatetime==2.6 -pillow==10.1.0; python_version >= '3.8' -pycares==4.4.0; python_version >= '3.8' -pycparser==2.21 -pymongo[srv]==4.6.0; python_version >= '3.7' -python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -python-dotenv==1.0.0; python_version >= '3.8' -requests==2.31.0; python_version >= '3.7' -six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -tinycss2==1.2.1; python_version >= '3.7' -urllib3==2.1.0; python_version >= '3.8' -uvloop==0.19.0; sys_platform != 'win32' -webencodings==0.5.1 -yarl==1.9.3; python_version >= '3.7' +-i https://pypi.org/simple +aiodns==3.1.1 +aiohttp==3.9.0; python_version >= '3.8' +aiosignal==1.3.1; python_version >= '3.7' +async-timeout==4.0.3; python_version < '3.11' +attrs==23.1.0; python_version >= '3.7' +brotli==1.1.0 +cairocffi==1.6.1; python_version >= '3.7' +cairosvg==2.7.1; python_version >= '3.5' +certifi==2023.11.17; python_version >= '3.6' +cffi==1.16.0; python_version >= '3.8' +charset-normalizer==3.3.2; python_full_version >= '3.7.0' +colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' +cssselect2==0.7.0; python_version >= '3.7' +defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +discord.py[speed]==2.5.2; python_full_version >= '3.8.0' +dnspython==2.4.2; python_version >= '3.8' and python_version < '4.0' +emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +frozenlist==1.4.0; python_version >= '3.8' +idna==3.4; python_version >= '3.5' +isodate==0.6.1 +lottie[pdf]==0.7.0; python_version >= '3' +motor==3.3.2; python_version >= '3.7' +multidict==6.0.4; python_version >= '3.7' +natural==0.2.0 +orjson==3.9.10 +packaging==23.2; python_version >= '3.7' +parsedatetime==2.6 +pillow==10.1.0; python_version >= '3.8' +pycares==4.4.0; python_version >= '3.8' +pycparser==2.21 +pymongo[srv]==4.6.0; python_version >= '3.7' +python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-dotenv==1.0.0; python_version >= '3.8' +requests==2.31.0; python_version >= '3.7' +six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +tinycss2==1.2.1; python_version >= '3.7' +urllib3==2.1.0; python_version >= '3.8' +uvloop==0.19.0; sys_platform != 'win32' +webencodings==0.5.1 +yarl==1.9.3; python_version >= '3.7' diff --git a/runtime.txt b/runtime.txt index 119ff10234..78b04c3ec6 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.10.7 +python-3.10.7