diff --git a/.bandit_baseline.json b/.bandit_baseline.json index 539fe85a88..94fcfd0fc3 100644 --- a/.bandit_baseline.json +++ b/.bandit_baseline.json @@ -1,32 +1,20 @@ { "errors": [], - "generated_at": "2019-10-07T08:19:22Z", + "generated_at": "2020-11-12T15:17:38Z", "metrics": { "./bot.py": { - "CONFIDENCE.HIGH": 0.0, + "CONFIDENCE.HIGH": 1.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, "CONFIDENCE.UNDEFINED": 0.0, "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, + "SEVERITY.LOW": 1.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 933, - "nosec": 0 - }, - "_totals": { - "CONFIDENCE.HIGH": 2.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 2.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 7299, + "loc": 1264, "nosec": 0 }, - "cogs/modmail.py": { + "./cogs/modmail.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -35,10 +23,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 973, + "loc": 1280, "nosec": 0 }, - "cogs/plugins.py": { + "./cogs/plugins.py": { "CONFIDENCE.HIGH": 1.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -47,22 +35,22 @@ "SEVERITY.LOW": 1.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 537, + "loc": 572, "nosec": 0 }, - "cogs/utility.py": { - "CONFIDENCE.HIGH": 1.0, + "./cogs/utility.py": { + "CONFIDENCE.HIGH": 2.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, "CONFIDENCE.UNDEFINED": 0.0, "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, + "SEVERITY.LOW": 1.0, "SEVERITY.MEDIUM": 1.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 1587, + "loc": 1710, "nosec": 0 }, - "core/_color_data.py": { + "./core/_color_data.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -71,10 +59,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 1168, + "loc": 1166, "nosec": 0 }, - "core/changelog.py": { + "./core/changelog.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -83,10 +71,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 154, + "loc": 145, "nosec": 0 }, - "core/checks.py": { + "./core/checks.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -95,10 +83,22 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 75, + "loc": 89, + "nosec": 0 + }, + "./core/clients.py": { + "CONFIDENCE.HIGH": 0.0, + "CONFIDENCE.LOW": 0.0, + "CONFIDENCE.MEDIUM": 1.0, + "CONFIDENCE.UNDEFINED": 0.0, + "SEVERITY.HIGH": 0.0, + "SEVERITY.LOW": 1.0, + "SEVERITY.MEDIUM": 0.0, + "SEVERITY.UNDEFINED": 0.0, + "loc": 585, "nosec": 0 }, - "core/clients.py": { + "./core/config.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -107,10 +107,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 200, + "loc": 327, "nosec": 0 }, - "core/config.py": { + "./core/decorators.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -119,10 +119,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 276, + "loc": 9, "nosec": 0 }, - "core/decorators.py": { + "./core/models.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -131,10 +131,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 7, + "loc": 199, "nosec": 0 }, - "core/models.py": { + "./core/paginator.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -143,10 +143,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 91, + "loc": 209, "nosec": 0 }, - "core/paginator.py": { + "./core/thread.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -155,10 +155,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 214, + "loc": 993, "nosec": 0 }, - "core/thread.py": { + "./core/time.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -167,10 +167,10 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 716, + "loc": 158, "nosec": 0 }, - "core/time.py": { + "./core/utils.py": { "CONFIDENCE.HIGH": 0.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 0.0, @@ -179,65 +179,103 @@ "SEVERITY.LOW": 0.0, "SEVERITY.MEDIUM": 0.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 169, + "loc": 283, "nosec": 0 }, - "core/utils.py": { - "CONFIDENCE.HIGH": 0.0, + "_totals": { + "CONFIDENCE.HIGH": 4.0, "CONFIDENCE.LOW": 0.0, "CONFIDENCE.MEDIUM": 1.0, "CONFIDENCE.UNDEFINED": 0.0, "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, + "SEVERITY.LOW": 4.0, + "SEVERITY.MEDIUM": 1.0, "SEVERITY.UNDEFINED": 0.0, - "loc": 199, + "loc": 8989, "nosec": 0 } }, "results": [ { - "code": "14 from site import USER_SITE\n15 from subprocess import PIPE\n16 \n17 import discord\n", - "filename": "cogs/plugins.py", + "code": "11 from datetime import datetime\n12 from subprocess import PIPE\n13 from types import SimpleNamespace\n", + "filename": "./bot.py", + "issue_confidence": "HIGH", + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with PIPE module.", + "line_number": 12, + "line_range": [ + 12 + ], + "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "13 from site import USER_SITE\n14 from subprocess import PIPE\n15 \n16 import discord\n", + "filename": "./cogs/plugins.py", + "issue_confidence": "HIGH", + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with PIPE module.", + "line_number": 14, + "line_range": [ + 14, + 15 + ], + "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "12 from json import JSONDecodeError, loads\n13 from subprocess import PIPE\n14 from textwrap import indent\n", + "filename": "./cogs/utility.py", "issue_confidence": "HIGH", "issue_severity": "LOW", "issue_text": "Consider possible security implications associated with PIPE module.", - "line_number": 15, + "line_number": 13, "line_range": [ - 15, - 16 + 13 ], "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "1824 try:\n1825 exec(to_compile, env) # pylint: disable=exec-used\n1826 except Exception as exc:\n", - "filename": "cogs/utility.py", + "code": "1985 try:\n1986 exec(to_compile, env) # pylint: disable=exec-used\n1987 except Exception as exc:\n", + "filename": "./cogs/utility.py", "issue_confidence": "HIGH", "issue_severity": "MEDIUM", "issue_text": "Use of exec detected.", - "line_number": 1825, + "line_number": 1986, "line_range": [ - 1825 + 1986 ], "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b102_exec_used.html", "test_id": "B102", "test_name": "exec_used" }, { - "code": "219 for token in shlex.shlex(alias, punctuation_chars=\"&\"):\n220 if token != \"&&\":\n221 buffer += \" \" + token\n", - "filename": "core/utils.py", + "code": "68 \n69 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n70 self.bot = bot\n71 self.session = bot.session\n72 self.headers: dict = None\n73 self.access_token = access_token\n74 self.username = username\n75 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n76 self.url: str = kwargs.pop(\"url\", \"\")\n77 if self.access_token:\n78 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n79 \n80 async def request(\n", + "filename": "./core/clients.py", "issue_confidence": "MEDIUM", "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: '&&'", - "line_number": 220, + "issue_text": "Possible hardcoded password: ''", + "line_number": 69, "line_range": [ - 220 + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b105_hardcoded_password_string.html", - "test_id": "B105", - "test_name": "hardcoded_password_string" + "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", + "test_id": "B107", + "test_name": "hardcoded_password_default" } ] } \ No newline at end of file diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml index 52a538bef7..94b322d968 100644 --- a/.github/workflows/lints.yml +++ b/.github/workflows/lints.yml @@ -22,13 +22,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install bandit pylint black + python -m pip install bandit==1.6.2 pylint black==19.10b0 continue-on-error: true - name: Bandit syntax check - run: bandit ./bot.py cogs/*.py core/*.py -b .bandit_baseline.json + run: bandit -r . -b .bandit_baseline.json - name: Pylint run: pylint ./bot.py cogs/*.py core/*.py --disable=import-error --exit-zero -r y continue-on-error: true - - name: Black and flake8 + - name: Black run: | - black . --diff + black . --diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 5248d772ee..fd5f060d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,54 @@ 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/kyb3r/modmail/issues/319). If you're a plugins developer, note the "BREAKING" section. +however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. + +# v3.7.0-dev22 + +### Added + +- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/kyb3r/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/kyb3r/modmail/issues/215)) +- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/kyb3r/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/kyb3r/modmail/issues/2757)) +- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/kyb3r/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/kyb3r/modmail/issues/2773)) +- Support Gyazo image links in message embeds. ([GH #282](https://github.com/kyb3r/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/kyb3r/modmail/issues/2847)) +- Added a way to block roles. ([GH #2753](https://github.com/kyb3r/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/kyb3r/modmail/issues/2865)) +- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/kyb3r/modmail/issues/2762)) +- Support stickers and reject non-messages. (i.e. pin_add) +- Added support for thread titles, `?title`. ([GH #2838](https://github.com/kyb3r/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/kyb3r/modmail/issues/130), [GH #649](https://github.com/kyb3r/modmail/issues/649)) +- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/kyb3r/modmail/issues/2842), [PR #2878](https://github.com/kyb3r/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/kyb3r/modmail/issues/2856)) +- Command truncated after && in alias. ([GH #2870](https://github.com/kyb3r/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/kyb3r/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 +- Plugins downloading requirements in virtual environments. # v3.6.1 @@ -41,7 +82,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - 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/kyb3r/modmail/issues/2816)) +- Use `--diff` for black CI instead of `--check` ([GH #2816](https://github.com/kyb3r/modmail/issues/2816)) # v3.5.0 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000000..8ff361b702 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +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 seflhosted + +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 frmo 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 01485b1e63..b878d053e9 100644 --- a/Pipfile +++ b/Pipfile @@ -3,6 +3,11 @@ name = "pypi" url = "https://pypi.org/simple" verify_ssl = true +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + [dev-packages] black = "==19.10b0" pylint = "*" @@ -22,7 +27,7 @@ parsedatetime = "==2.6" aiohttp = ">=3.6.0,<3.7.0" python-dotenv = ">=0.10.3" pipenv = "*" -"discord.py" = "==1.5.1" +"discord.py" = {file = "./discord.py-1.5.2.tar.gz"} [scripts] bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index 005ce37f67..0eacd81d2c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,16 @@ { "_meta": { "hash": { - "sha256": "0491618cb8bd6d70e4ab3337c23b72fc3b1d5f9ca0603d8be6b890b661039102" + "sha256": "1f660c7237deeaa50a7098002c0f3c05cce254d6c0ba8bc02993c5400e335a59" }, "pipfile-spec": 6, "requires": {}, "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + }, { "name": "pypi", "url": "https://pypi.org/simple", @@ -45,23 +50,21 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], - "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" + "version": "==20.3.0" }, "certifi": { "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", + "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" ], - "version": "==2020.6.20" + "version": "==2020.11.8" }, "chardet": { "hashes": [ @@ -79,12 +82,7 @@ "version": "==0.4.4" }, "discord.py": { - "hashes": [ - "sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563", - "sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b" - ], - "index": "pypi", - "version": "==1.5.1" + "file": "./discord.py-1.5.2.tar.gz" }, "distlib": { "hashes": [ @@ -120,7 +118,6 @@ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "isodate": { @@ -159,7 +156,6 @@ "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" ], - "markers": "python_version >= '3.5'", "version": "==4.7.6" }, "natural": { @@ -179,11 +175,11 @@ }, "pipenv": { "hashes": [ - "sha256:448ac3a36443db633d52a2359cac15ecbc4f429eab4ddd420697602b721d1c5a", - "sha256:eff0e10eadb330f612edfa5051d3d8e775e9e0e918c3c50361da703bd0daa035" + "sha256:d6ac39d1721517b23aca12cdb4c726dc318ec4d7bdede5c1220bbb81775005c3", + "sha256:dce1fb1a6941f98764c62b00010f52143aed19e2fcd8f100aff4fb3bb1bbbbe3" ], "index": "pypi", - "version": "==2020.8.13" + "version": "==2020.11.4" }, "pymongo": { "hashes": [ @@ -242,7 +238,6 @@ "sha256:ef76535776c0708a85258f6dc51d36a2df12633c735f6d197ed7dfcaa7449b99", "sha256:f6efca006a81e1197b925a7d7b16b8f61980697bb6746587aad8842865233218" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.11.0" }, "python-dateutil": { @@ -255,18 +250,17 @@ }, "python-dotenv": { "hashes": [ - "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d", - "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423" + "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", + "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" ], "index": "pypi", - "version": "==0.14.0" + "version": "==0.15.0" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "uvloop": { @@ -281,6 +275,7 @@ "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362" ], + "index": "pypi", "markers": "sys_platform != 'win32'", "version": "==0.14.0" }, @@ -289,7 +284,6 @@ "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.1.0" }, "virtualenv-clone": { @@ -297,7 +291,6 @@ "sha256:07e74418b7cc64f4fda987bf5bc71ebd59af27a7bc9e8a8ee9fd54b1f2390a27", "sha256:665e48dd54c84b98b71a657acb49104c54e7652bce9c1c4f6c6976ed4c827a29" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.5.4" }, "yarl": { @@ -320,7 +313,6 @@ "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" ], - "markers": "python_version >= '3.5'", "version": "==1.5.1" } }, @@ -337,16 +329,14 @@ "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" ], - "markers": "python_version >= '3.5'", "version": "==2.4.2" }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" + "version": "==20.3.0" }, "bandit": { "hashes": [ @@ -369,7 +359,6 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "colorama": { @@ -393,7 +382,6 @@ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" ], - "markers": "python_version >= '3.4'", "version": "==4.0.5" }, "gitpython": { @@ -401,7 +389,6 @@ "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b", "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8" ], - "markers": "python_version >= '3.4'", "version": "==3.1.11" }, "isort": { @@ -409,7 +396,6 @@ "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" ], - "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==5.6.4" }, "lazy-object-proxy": { @@ -436,7 +422,6 @@ "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.4.3" }, "mccabe": { @@ -448,17 +433,16 @@ }, "pathspec": { "hashes": [ - "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", - "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", + "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" ], - "version": "==0.8.0" + "version": "==0.8.1" }, "pbr": { "hashes": [ "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" ], - "markers": "python_version >= '2.6'", "version": "==5.5.1" }, "pycodestyle": { @@ -466,7 +450,6 @@ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pyflakes": { @@ -474,7 +457,6 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pylint": { @@ -503,42 +485,57 @@ }, "regex": { "hashes": [ - "sha256:0cb23ed0e327c18fb7eac61ebbb3180ebafed5b9b86ca2e15438201e5903b5dd", - "sha256:1a065e7a6a1b4aa851a0efa1a2579eabc765246b8b3a5fd74000aaa3134b8b4e", - "sha256:1a511470db3aa97432ac8c1bf014fcc6c9fbfd0f4b1313024d342549cf86bcd6", - "sha256:1c447b0d108cddc69036b1b3910fac159f2b51fdeec7f13872e059b7bc932be1", - "sha256:2278453c6a76280b38855a263198961938108ea2333ee145c5168c36b8e2b376", - "sha256:240509721a663836b611fa13ca1843079fc52d0b91ef3f92d9bba8da12e768a0", - "sha256:4e21340c07090ddc8c16deebfd82eb9c9e1ec5e62f57bb86194a2595fd7b46e0", - "sha256:570e916a44a361d4e85f355aacd90e9113319c78ce3c2d098d2ddf9631b34505", - "sha256:59d5c6302d22c16d59611a9fd53556554010db1d47e9df5df37be05007bebe75", - "sha256:6a46eba253cedcbe8a6469f881f014f0a98819d99d341461630885139850e281", - "sha256:6f567df0601e9c7434958143aebea47a9c4b45434ea0ae0286a4ec19e9877169", - "sha256:781906e45ef1d10a0ed9ec8ab83a09b5e0d742de70e627b20d61ccb1b1d3964d", - "sha256:8469377a437dbc31e480993399fd1fd15fe26f382dc04c51c9cb73e42965cc06", - "sha256:8cd0d587aaac74194ad3e68029124c06245acaeddaae14cb45844e5c9bebeea4", - "sha256:97a023f97cddf00831ba04886d1596ef10f59b93df7f855856f037190936e868", - "sha256:a973d5a7a324e2a5230ad7c43f5e1383cac51ef4903bf274936a5634b724b531", - "sha256:af360e62a9790e0a96bc9ac845d87bfa0e4ee0ee68547ae8b5a9c1030517dbef", - "sha256:b706c70070eea03411b1761fff3a2675da28d042a1ab7d0863b3efe1faa125c9", - "sha256:bfd7a9fddd11d116a58b62ee6c502fd24cfe22a4792261f258f886aa41c2a899", - "sha256:c30d8766a055c22e39dd7e1a4f98f6266169f2de05db737efe509c2fb9c8a3c8", - "sha256:c53dc8ee3bb7b7e28ee9feb996a0c999137be6c1d3b02cb6b3c4cba4f9e5ed09", - "sha256:c95d514093b80e5309bdca5dd99e51bcf82c44043b57c34594d9d7556bd04d05", - "sha256:d43cf21df524283daa80ecad551c306b7f52881c8d0fe4e3e76a96b626b6d8d8", - "sha256:d62205f00f461fe8b24ade07499454a3b7adf3def1225e258b994e2215fd15c5", - "sha256:e289a857dca3b35d3615c3a6a438622e20d1bf0abcb82c57d866c8d0be3f44c4", - "sha256:e5f6aa56dda92472e9d6f7b1e6331f4e2d51a67caafff4d4c5121cadac03941e", - "sha256:f4b1c65ee86bfbf7d0c3dfd90592a9e3d6e9ecd36c367c884094c050d4c35d04" - ], - "version": "==2020.10.23" + "sha256:03855ee22980c3e4863dc84c42d6d2901133362db5daf4c36b710dd895d78f0a", + "sha256:06b52815d4ad38d6524666e0d50fe9173533c9cc145a5779b89733284e6f688f", + "sha256:11116d424734fe356d8777f89d625f0df783251ada95d6261b4c36ad27a394bb", + "sha256:119e0355dbdd4cf593b17f2fc5dbd4aec2b8899d0057e4957ba92f941f704bf5", + "sha256:127a9e0c0d91af572fbb9e56d00a504dbd4c65e574ddda3d45b55722462210de", + "sha256:1ec66700a10e3c75f1f92cbde36cca0d3aaee4c73dfa26699495a3a30b09093c", + "sha256:227a8d2e5282c2b8346e7f68aa759e0331a0b4a890b55a5cfbb28bd0261b84c0", + "sha256:2564def9ce0710d510b1fc7e5178ce2d20f75571f788b5197b3c8134c366f50c", + "sha256:297116e79074ec2a2f885d22db00ce6e88b15f75162c5e8b38f66ea734e73c64", + "sha256:2dc522e25e57e88b4980d2bdd334825dbf6fa55f28a922fc3bfa60cc09e5ef53", + "sha256:3a5f08039eee9ea195a89e180c5762bfb55258bfb9abb61a20d3abee3b37fd12", + "sha256:3dfca201fa6b326239e1bccb00b915e058707028809b8ecc0cf6819ad233a740", + "sha256:49461446b783945597c4076aea3f49aee4b4ce922bd241e4fcf62a3e7c61794c", + "sha256:4afa350f162551cf402bfa3cd8302165c8e03e689c897d185f16a167328cc6dd", + "sha256:4b5a9bcb56cc146c3932c648603b24514447eafa6ce9295234767bf92f69b504", + "sha256:52e83a5f28acd621ba8e71c2b816f6541af7144b69cc5859d17da76c436a5427", + "sha256:625116aca6c4b57c56ea3d70369cacc4d62fead4930f8329d242e4fe7a58ce4b", + "sha256:654c1635f2313d0843028487db2191530bca45af61ca85d0b16555c399625b0e", + "sha256:8092a5a06ad9a7a247f2a76ace121183dc4e1a84c259cf9c2ce3bbb69fac3582", + "sha256:832339223b9ce56b7b15168e691ae654d345ac1635eeb367ade9ecfe0e66bee0", + "sha256:8ca9dca965bd86ea3631b975d63b0693566d3cc347e55786d5514988b6f5b84c", + "sha256:96f99219dddb33e235a37283306834700b63170d7bb2a1ee17e41c6d589c8eb9", + "sha256:9b6305295b6591e45f069d3553c54d50cc47629eb5c218aac99e0f7fafbf90a1", + "sha256:a62162be05edf64f819925ea88d09d18b09bebf20971b363ce0c24e8b4aa14c0", + "sha256:aacc8623ffe7999a97935eeabbd24b1ae701d08ea8f874a6ff050e93c3e658cf", + "sha256:b45bab9f224de276b7bc916f6306b86283f6aa8afe7ed4133423efb42015a898", + "sha256:b88fa3b8a3469f22b4f13d045d9bd3eda797aa4e406fde0a2644bc92bbdd4bdd", + "sha256:b8a686a6c98872007aa41fdbb2e86dc03b287d951ff4a7f1da77fb7f14113e4d", + "sha256:bd904c0dec29bbd0769887a816657491721d5f545c29e30fd9d7a1a275dc80ab", + "sha256:bf4f896c42c63d1f22039ad57de2644c72587756c0cfb3cc3b7530cfe228277f", + "sha256:c13d311a4c4a8d671f5860317eb5f09591fbe8259676b86a85769423b544451e", + "sha256:c2c6c56ee97485a127555c9595c069201b5161de9d05495fbe2132b5ac104786", + "sha256:c32c91a0f1ac779cbd73e62430de3d3502bbc45ffe5bb6c376015acfa848144b", + "sha256:c3466a84fce42c2016113101018a9981804097bacbab029c2d5b4fcb224b89de", + "sha256:c454ad88e56e80e44f824ef8366bb7e4c3def12999151fd5c0ea76a18fe9aa3e", + "sha256:c8a2b7ccff330ae4c460aff36626f911f918555660cc28163417cb84ffb25789", + "sha256:cb905f3d2e290a8b8f1579d3984f2cfa7c3a29cc7cba608540ceeed18513f520", + "sha256:cfcf28ed4ce9ced47b9b9670a4f0d3d3c0e4d4779ad4dadb1ad468b097f808aa", + "sha256:dd3e6547ecf842a29cf25123fbf8d2461c53c8d37aa20d87ecee130c89b7079b", + "sha256:de7fd57765398d141949946c84f3590a68cf5887dac3fc52388df0639b01eda4", + "sha256:ea37320877d56a7f0a1e6a625d892cf963aa7f570013499f5b8d5ab8402b5625", + "sha256:f1fce1e4929157b2afeb4bb7069204d4370bab9f4fc03ca1fbec8bd601f8c87d", + "sha256:f43109822df2d3faac7aad79613f5f02e4eab0fc8ad7932d2e70e2a83bd49c26" + ], + "version": "==2020.10.28" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -546,7 +543,6 @@ "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.4" }, "stevedore": { @@ -554,15 +550,14 @@ "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" ], - "markers": "python_version >= '3.6'", "version": "==3.2.2" }, "toml": { "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==0.10.1" + "version": "==0.10.2" }, "typed-ast": { "hashes": [ diff --git a/README.md b/README.md index 6173484788..baf7c84c3b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - Bot instances + Bot instances @@ -54,7 +54,7 @@ This bot is free for everyone and always will be. If you like this project and w 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.logviewer.tech/example). +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). ## Features @@ -67,7 +67,7 @@ Our Logviewer will save the threads so you can view previous threads through the * 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](https://logs.logviewer.tech/example) and post it to your log channel. + * When you close a thread, Modmail will generate a [log link](https://logs.modmail.dev/example) 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)). @@ -184,3 +184,9 @@ Plugins requests and support is available in our [Modmail Plugins Server](https: 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/kyb3r/modmail/blob/master/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/kyb3r/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/app.json b/app.json index 41697cf064..326c54273e 100644 --- a/app.json +++ b/app.json @@ -30,6 +30,10 @@ "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": true } } } \ No newline at end of file diff --git a/bot.py b/bot.py index 15ef85429e..3ae6ed03fa 100644 --- a/bot.py +++ b/bot.py @@ -1,26 +1,28 @@ -__version__ = "3.6.2" +__version__ = "3.7.0-dev22" import asyncio +import copy import logging import os import re import sys import typing from datetime import datetime +from subprocess import PIPE from types import SimpleNamespace import discord -from discord.ext import commands, tasks -from discord.ext.commands.view import StringView - import isodate - from aiohttp import ClientSession +from discord.ext import commands, tasks +from discord.ext.commands.view import StringView from emoji import UNICODE_EMOJI - from pkg_resources import parse_version +from core.utils import tryint + + try: # noinspection PyUnresolvedReferences from colorama import init @@ -30,13 +32,20 @@ pass from core import checks -from core.clients import ApiClient, PluginDatabaseClient, MongoDBClient +from core.changelog import Changelog +from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import human_join, normalize_alias -from core.models import PermissionLevel, SafeFormatter, getLogger, configure_logging +from core.models import ( + DMDisabled, + HostingMethod, + PermissionLevel, + SafeFormatter, + configure_logging, + getLogger, +) from core.thread import ThreadManager from core.time import human_timedelta - +from core.utils import human_join, normalize_alias, truncate logger = getLogger(__name__) @@ -58,6 +67,7 @@ def __init__(self): self._session = None self._api = None self.metadata_loop = None + self.autoupdate_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] self._connected = asyncio.Event() @@ -88,6 +98,17 @@ def uptime(self) -> str: 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 + + return HostingMethod.OTHER + def startup(self): logger.line() if os.name != "nt": @@ -244,6 +265,10 @@ def snippets(self) -> typing.Dict[str, str]: 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"] @@ -324,6 +349,10 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: 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"] @@ -454,6 +483,7 @@ async def on_ready(self): log["channel_id"], { "open": False, + "title": None, "closed_at": str(datetime.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { @@ -472,17 +502,24 @@ async def on_ready(self): "Failed to close thread with channel %s, skipping.", log["channel_id"] ) - self.metadata_loop = tasks.Loop( - self.post_metadata, - seconds=0, - minutes=0, - hours=1, - count=None, - reconnect=True, - loop=None, + if self.config.get("data_collection"): + self.metadata_loop = tasks.Loop( + self.post_metadata, + seconds=0, + minutes=0, + hours=1, + count=None, + reconnect=True, + loop=None, + ) + self.metadata_loop.before_loop(self.before_post_metadata) + self.metadata_loop.start() + + self.autoupdate_loop = tasks.Loop( + self.autoupdate, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None ) - self.metadata_loop.before_loop(self.before_post_metadata) - self.metadata_loop.start() + self.autoupdate_loop.before_loop(self.before_autoupdate) + self.autoupdate_loop.start() other_guilds = [ guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild} @@ -578,6 +615,36 @@ def check_guild_age(self, author: discord.Member) -> bool: return False return True + def check_manual_blocked_roles(self, author: discord.Member) -> bool: + for r in author.roles: + if str(r.id) in self.blocked_roles: + + blocked_reason = self.blocked_roles.get(str(r.id)) or "" + now = datetime.utcnow() + + # etc "blah blah blah... until 2019-10-14T21:12:45.559948." + end_time = re.search(r"until ([^`]+?)\.$", blocked_reason) + if end_time is None: + # backwards compat + end_time = re.search(r"%([^%]+?)%", blocked_reason) + if end_time is not None: + logger.warning( + r"Deprecated time message for user %s, block and unblock again to update.", + author.name, + ) + + if end_time is not None: + after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() + 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 + + return True + def check_manual_blocked(self, author: discord.Member) -> bool: if str(author.id) not in self.blocked_users: return True @@ -656,6 +723,9 @@ async def is_blocked( 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 @@ -710,20 +780,23 @@ async def process_dm_modmail(self, message: discord.Message) -> None: return sent_emoji, blocked_emoji = await self.retrieve_emoji() + if message.type != discord.MessageType.default: + 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="Message not sent!", - description=f"You must wait for {delta} before you can contact me again.", + 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"] >= 1: + 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, @@ -738,9 +811,9 @@ async def process_dm_modmail(self, message: discord.Message) -> None: await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) - thread = await self.threads.create(message.author) + thread = await self.threads.create(message.author, message=message) else: - if self.config["dm_disabled"] == 2: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( title=self.config["disabled_current_thread_title"], color=self.error_color, @@ -756,13 +829,15 @@ async def process_dm_modmail(self, message: discord.Message) -> None: await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) - try: - await thread.send(message) - except Exception: - logger.error("Failed to send message:", exc_info=True) - await self.add_reaction(message, blocked_emoji) - else: - await self.add_reaction(message, sent_emoji) + 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: + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) async def get_contexts(self, message, *, cls=commands.Context): """ @@ -809,6 +884,59 @@ async def get_contexts(self, message, *, cls=commands.Context): ctx.command = self.all_commands.get(invoker) return [ctx] + async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): + message.author = self.modmail_guild.me + message.channel = channel + + 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 + + # Check if there is any aliases being called. + if self.config.get("use_regex_autotrigger"): + trigger = next( + filter(lambda x: re.match(x, message.content), self.auto_triggers.keys()) + ) + if trigger: + invoker = re.match(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 automove.", invoker) + + for alias in aliases: + view = StringView(invoked_prefix + alias) + ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx_.thread = thread + discord.utils.find(view.skip_string, await self.get_prefix()) + ctx_.invoked_with = view.get_word().lower() + ctx_.command = self.all_commands.get(ctx_.invoked_with) + ctxs += [ctx_] + + 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. @@ -839,7 +967,7 @@ async def get_context(self, message, *, cls=commands.Context): async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: - value = int(value) + value = str(value) if isinstance(name, PermissionLevel): permissions = self.config["level_permissions"] name = name.name @@ -862,6 +990,20 @@ 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, + timestamp=datetime.utcnow(), + ) + await self.log_channel.send(content=self.config["mention"], embed=em) + await self.process_commands(message) async def process_commands(self, message): @@ -896,10 +1038,19 @@ async def process_commands(self, message): 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"): - await thread.reply(message, anonymous=True) - elif self.config.get("reply_without_command"): - await thread.reply(message) + 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: @@ -1000,10 +1151,33 @@ async def handle_reaction_events(self, payload): logger.warning("Failed to remove reaction: %s", e) async def on_raw_reaction_add(self, payload): - await self.handle_reaction_events(payload) + if self.config["transfer_reactions"]: + await self.handle_reaction_events(payload) + + react_message_id = tryint(self.config.get("react_to_contact_message")) + react_message_emoji = self.config.get("react_to_contact_emoji") + if all((react_message_id, react_message_emoji)): + if payload.message_id == react_message_id: + 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: + channel = self.get_channel(payload.channel_id) + member = channel.guild.get_member(payload.user_id) + message = await channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, member) + + ctx = await self.get_context(message) + ctx.author = member + await ctx.invoke( + self.get_command("contact"), user=member, manual_trigger=False + ) async def on_raw_reaction_remove(self, payload): - await self.handle_reaction_events(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: @@ -1048,10 +1222,17 @@ async def on_member_remove(self, member): return thread = await self.threads.find(recipient=member) if thread: - embed = discord.Embed( - description="The recipient has left the server.", color=self.error_color - ) - await thread.channel.send(embed=embed) + if self.config["close_on_leave"]: + await thread.close( + closer=member.guild.me, + message=self.config["close_on_leave_reason"], + silent=True, + ) + else: + embed = discord.Embed( + description=self.config["close_on_leave_reason"], color=self.error_color + ) + await thread.channel.send(embed=embed) async def on_member_join(self, member): if member.guild != self.guild: @@ -1167,6 +1348,14 @@ async def on_command_error(self, context, exception): 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): @@ -1232,6 +1421,75 @@ async def before_post_metadata(self): if not self.guild: self.metadata_loop.cancel() + async def autoupdate(self): + changelog = await Changelog.from_url(self) + latest = changelog.latest_version + + if self.version < parse_version(latest.version): + if self.hosting_method == HostingMethod.HEROKU: + data = await self.api.update_repository() + + embed = discord.Embed(color=self.main_color) + + commit_data = data["data"] + user = data["user"] + 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} " f"-> v{latest.version}") + + embed.description = latest.description + for name, value in latest.fields.items(): + embed.add_field(name=name, value=value) + + if commit_data: + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", + ) + logger.info("Bot has been updated.") + channel = self.log_channel + await channel.send(embed=embed) + else: + command = "git pull" + proc = await asyncio.create_subprocess_shell(command, stderr=PIPE, stdout=PIPE,) + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if res != "Already up to date.": + logger.info("Bot has been updated.") + channel = self.log_channel + if self.hosting_method == HostingMethod.PM2: + embed = discord.Embed(title="Bot has been updated", color=self.main_color) + await channel.send(embed=embed) + else: + embed = discord.Embed( + title="Bot has been updated and is logging out.", + description="If you do not have an auto-restart setup, please manually start the bot.", + color=self.main_color, + ) + await channel.send(embed=embed) + await self.logout() + + 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_loop.cancel() + + 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_loop.cancel() + def main(): try: diff --git a/cogs/modmail.py b/cogs/modmail.py index 44f0e9dcde..ed9a3dfec7 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -7,13 +7,15 @@ import discord from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType +from discord.role import Role from discord.utils import escape_markdown from dateutil import parser from natural.date import duration from core import checks -from core.models import PermissionLevel, getLogger +from core.models import DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger from core.paginator import EmbedPaginatorSession from core.thread import Thread from core.time import UserFriendlyTime, human_timedelta @@ -295,6 +297,7 @@ async def move(self, ctx, *, arguments): `options` is a string which takes in arguments on how to perform the move. Ex: "silently" """ split_args = arguments.strip('"').split(" ") + category = None # manually parse arguments, consumes as much of args as possible for category for i in range(len(split_args)): @@ -304,7 +307,7 @@ async def move(self, ctx, *, arguments): else: fmt = " ".join(split_args[:-i]) - category = await commands.CategoryChannelConverter().convert(ctx, fmt) + category = await SimilarCategoryConverter().convert(ctx, fmt) except commands.BadArgument: if i == len(split_args) - 1: # last one @@ -313,6 +316,9 @@ async def move(self, ctx, *, arguments): else: break + if not category: + raise commands.ChannelNotFound(arguments) + options = " ".join(arguments.split(" ")[-i:]) thread = ctx.thread @@ -332,6 +338,10 @@ async def move(self, ctx, *, arguments): ) await thread.recipient.send(embed=embed) + if self.bot.config["thread_move_notify_mods"]: + mention = self.bot.config["mention"] + await thread.channel.send(f"{mention}, thread has been moved.") + sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) @@ -641,6 +651,17 @@ def format_log_embeds(self, logs, avatar_url): embeds.append(embed) return embeds + @commands.command(cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def title(self, ctx, *, name: str): + """Sets title for a thread""" + await ctx.thread.set_title(name) + sent_emoji, _ = await self.bot.retrieve_emoji() + await ctx.message.pin() + await self.bot.add_reaction(ctx.message, sent_emoji) + @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) async def logs(self, ctx, *, user: User = None): @@ -831,7 +852,35 @@ async def areply(self, ctx, *, msg: str = ""): async with ctx.typing(): await ctx.thread.reply(ctx.message, anonymous=True) - @commands.command() + @commands.command(aliases=["plainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def preply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, plain=True) + + @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def pareply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message and anonymously. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + + @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() async def note(self, ctx, *, msg: str = ""): @@ -845,6 +894,21 @@ async def note(self, ctx, *, msg: str = ""): msg = await ctx.thread.note(ctx.message) await msg.pin() + @note.command(name="persistent", aliases=["persist"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def note_persistent(self, ctx, *, msg: str = ""): + """ + Take a persistent note about the current user. + """ + ctx.message.content = msg + async with ctx.typing(): + msg = await ctx.thread.note(ctx.message, persistent=True) + await msg.pin() + await self.bot.api.create_note( + recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id + ) + @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @@ -865,7 +929,7 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to edit.", + description="Cannot find a message to edit. Plain messages are not supported.", color=self.bot.error_color, ) ) @@ -874,13 +938,19 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): await self.bot.add_reaction(ctx.message, sent_emoji) @commands.command() + @checks.has_permissions(PermissionLevel.REGULAR) + async def selfcontact(self, ctx): + await ctx.invoke(self.contact, user=ctx.author) + + @commands.command(usage=" [category] [options]") @checks.has_permissions(PermissionLevel.SUPPORTER) async def contact( self, ctx, user: Union[discord.Member, discord.User], *, - category: discord.CategoryChannel = None, + category: Union[SimilarCategoryConverter, str] = None, + manual_trigger=True, ): """ Create a thread with a specified member. @@ -890,13 +960,19 @@ async def contact( `category`, if specified, may be a category ID, mention, or name. `user` may be a user ID, mention, or name. + `options` can be `silent` """ + silent = False + if isinstance(category, str): + if "silent" in category or "silently" in category: + silent = True + category = None if user.bot: embed = discord.Embed( color=self.bot.error_color, description="Cannot start a thread with a bot." ) - return await ctx.send(embed=embed) + return await ctx.send(embed=embed, delete_afer=3) exists = await self.bot.threads.find(recipient=user) if exists: @@ -905,13 +981,28 @@ async def contact( description="A thread for this user already " f"exists in {exists.channel.mention}.", ) - await ctx.channel.send(embed=embed) + await ctx.channel.send(embed=embed, delete_after=3) else: thread = await self.bot.threads.create(user, creator=ctx.author, category=category) - if self.bot.config["dm_disabled"] >= 1: + if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): logger.info("Contacting user %s when Modmail DM is disabled.", user) + if not silent: + if ctx.author.id == user.id: + description = "You have opened a Modmail thread." + else: + description = f"{ctx.author.name} has opened a Modmail thread." + + em = discord.Embed( + title="New Thread", + description=description, + color=self.bot.main_color, + timestamp=datetime.utcnow(), + ) + em.set_footer(icon_url=ctx.author.avatar_url) + await user.send(embed=em) + embed = discord.Embed( title="Created Thread", description=f"Thread started by {ctx.author.mention} for {user.mention}.", @@ -919,10 +1010,12 @@ async def contact( ) await thread.wait_until_ready() await thread.channel.send(embed=embed) - sent_emoji, _ = await self.bot.retrieve_emoji() - await self.bot.add_reaction(ctx.message, sent_emoji) - await asyncio.sleep(3) - await ctx.message.delete() + + if manual_trigger: + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + await asyncio.sleep(5) + await ctx.message.delete() @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) @@ -932,6 +1025,7 @@ async def blocked(self, ctx): embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] + roles = [] users = [] for id_, reason in self.bot.blocked_users.items(): @@ -945,6 +1039,11 @@ async def blocked(self, ctx): except discord.NotFound: users.append((id_, reason)) + for id_, reason in self.bot.blocked_roles.items(): + role = self.bot.guild.get_role(int(id_)) + if role: + roles.append((role.mention, reason)) + if users: embed = embeds[0] @@ -962,7 +1061,29 @@ async def blocked(self, ctx): else: embeds[0].description = "Currently there are no blocked users." + embeds.append( + discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="") + ) + + if roles: + embed = embeds[-1] + + for mention, reason in roles: + line = mention + f" - {reason or 'No Reason Provided'}\n" + if len(embed.description) + len(line) > 2048: + embed = discord.Embed( + title="Blocked Roles (Continued)", + color=self.bot.main_color, + description=line, + ) + embeds.append(embed) + else: + embed.description += line + else: + embeds[-1].description = "Currently there are no blocked roles." + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() @blocked.command(name="whitelist") @@ -1023,7 +1144,13 @@ async def blocked_whitelist(self, ctx, *, user: User = None): @commands.command(usage="[user] [duration] [reason]") @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTime = None): + async def block( + self, + ctx, + user_or_role: Union[User, discord.Role] = None, + *, + after: UserFriendlyTime = None, + ): """ Block a user from using Modmail. @@ -1035,18 +1162,21 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi `duration` may be a simple "human-readable" time text. See `{prefix}help close` for examples. """ - if user is None: + if user_or_role is None: thread = ctx.thread if thread: - user = thread.recipient + user_or_role = thread.recipient elif after is None: raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) else: raise commands.BadArgument(f'User "{after.arg}" not found.') - mention = getattr(user, "mention", f"`{user.id}`") + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") - if str(user.id) in self.bot.blocked_whitelisted_users: + if ( + not isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_whitelisted_users + ): embed = discord.Embed( title="Error", description=f"Cannot block {mention}, user is whitelisted.", @@ -1066,11 +1196,15 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi reason += "." - msg = self.bot.blocked_users.get(str(user.id)) + if isinstance(user_or_role, discord.Role): + msg = self.bot.blocked_roles.get(str(user_or_role.id)) + else: + msg = self.bot.blocked_users.get(str(user_or_role.id)) + if msg is None: msg = "" - if str(user.id) in self.bot.blocked_users and msg: + if msg: old_reason = msg.strip().rstrip(".") embed = discord.Embed( title="Success", @@ -1084,7 +1218,11 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi color=self.bot.main_color, description=f"{mention} is now blocked {reason}", ) - self.bot.blocked_users[str(user.id)] = reason + + if isinstance(user_or_role, discord.Role): + self.bot.blocked_roles[str(user_or_role.id)] = reason + else: + self.bot.blocked_users[str(user_or_role.id)] = reason await self.bot.config.update() return await ctx.send(embed=embed) @@ -1092,7 +1230,7 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi @commands.command() @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - async def unblock(self, ctx, *, user: User = None): + async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): """ Unblock a user from using Modmail. @@ -1101,18 +1239,21 @@ async def unblock(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - if user is None: + if user_or_role is None: thread = ctx.thread if thread: - user = thread.recipient + user_or_role = thread.recipient else: raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) - mention = getattr(user, "mention", f"`{user.id}`") - name = getattr(user, "name", f"`{user.id}`") + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") + name = getattr(user_or_role, "name", f"`{user_or_role.id}`") - if str(user.id) in self.bot.blocked_users: - msg = self.bot.blocked_users.pop(str(user.id)) or "" + if ( + not isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_users + ): + msg = self.bot.blocked_users.pop(str(user_or_role.id)) or "" await self.bot.config.update() if msg.startswith("System Message: "): @@ -1128,7 +1269,7 @@ async def unblock(self, ctx, *, user: User = None): embed.set_footer( text="However, if the original system block reason still applies, " f"{name} will be automatically blocked again. " - f'Use "{self.bot.prefix}blocked whitelist {user.id}" to whitelist the user.' + f'Use "{self.bot.prefix}blocked whitelist {user_or_role.id}" to whitelist the user.' ) else: embed = discord.Embed( @@ -1136,6 +1277,18 @@ async def unblock(self, ctx, *, user: User = None): color=self.bot.main_color, description=f"{mention} is no longer blocked.", ) + elif ( + isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_roles + ): + msg = self.bot.blocked_roles.pop(str(user_or_role.id)) or "" + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"{mention} is no longer blocked.", + ) else: embed = discord.Embed( title="Error", description=f"{mention} is not blocked.", color=self.bot.error_color @@ -1164,7 +1317,7 @@ async def delete(self, ctx, message_id: int = None): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to delete.", + description="Cannot find a message to delete. Plain messages are not supported.", color=self.bot.error_color, ) ) @@ -1297,8 +1450,8 @@ async def enable(self, ctx): color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] != 0: - self.bot.config["dm_disabled"] = 0 + if self.bot.config["dm_disabled"] != DMDisabled.NONE: + self.bot.config["dm_disabled"] = DMDisabled.NONE await self.bot.config.update() return await ctx.send(embed=embed) @@ -1328,8 +1481,8 @@ async def disable_new(self, ctx): description="Modmail will not create any new threads.", color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] < 1: - self.bot.config["dm_disabled"] = 1 + if self.bot.config["dm_disabled"] < DMDisabled.NEW_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.NEW_THREADS await self.bot.config.update() return await ctx.send(embed=embed) @@ -1348,8 +1501,8 @@ async def disable_all(self, ctx): color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] != 2: - self.bot.config["dm_disabled"] = 2 + if self.bot.config["dm_disabled"] != DMDisabled.ALL_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.ALL_THREADS await self.bot.config.update() return await ctx.send(embed=embed) @@ -1361,13 +1514,13 @@ async def isenable(self, ctx): Check if the DM functionalities of Modmail is enabled. """ - if self.bot.config["dm_disabled"] == 1: + if self.bot.config["dm_disabled"] == DMDisabled.NEW_THREADS: embed = discord.Embed( title="New Threads Disabled", description="Modmail is not creating new threads.", color=self.bot.error_color, ) - elif self.bot.config["dm_disabled"] == 2: + elif self.bot.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( title="All DM Disabled", description="Modmail is not accepting any DM messages for new and existing threads.", diff --git a/cogs/plugins.py b/cogs/plugins.py index 3330d407ad..cdea3a4ce8 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -145,6 +145,9 @@ async def initial_load_plugins(self): continue logger.debug("Finished loading all plugins.") + + self.bot.dispatch("plugins_ready") + self._ready_event.set() await self.bot.config.update() @@ -166,6 +169,17 @@ async def download_plugin(self, plugin, force=False): 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 recieved, non-bytes object") + plugin_io = io.BytesIO(raw) if not plugin.cache_path.parent.exists(): plugin.cache_path.parent.mkdir(parents=True) @@ -321,11 +335,11 @@ async def plugins_add(self, ctx, *, plugin_name: str): try: await self.download_plugin(plugin, force=True) - except Exception: + except Exception as e: logger.warning("Unable to download plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description="Failed to download plugin, check logs for error.", + description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", color=self.bot.error_color, ) @@ -340,11 +354,11 @@ async def plugins_add(self, ctx, *, plugin_name: str): try: await self.load_plugin(plugin) - except Exception: + except Exception as e: logger.warning("Unable to load plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description="Failed to download plugin, check logs for error.", + description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", color=self.bot.error_color, ) diff --git a/cogs/utility.py b/cogs/utility.py index b0d96ea094..129551af87 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1,36 +1,57 @@ import asyncio import inspect import os -import traceback import random +import re +import traceback from contextlib import redirect_stdout from datetime import datetime from difflib import get_close_matches -from io import StringIO, BytesIO -from itertools import zip_longest, takewhile +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 types import SimpleNamespace from typing import Union import discord +from aiohttp import ClientResponseError from discord.enums import ActivityType, Status from discord.ext import commands, tasks from discord.ext.commands.view import StringView - -from aiohttp import ClientResponseError from pkg_resources import parse_version -from core import checks +from core import checks, utils from core.changelog import Changelog -from core.models import InvalidConfigError, PermissionLevel, getLogger +from core.models import ( + HostingMethod, + InvalidConfigError, + PermissionLevel, + UnseenFormatter, + getLogger, +) +from core.utils import trigger_typing, truncate from core.paginator import EmbedPaginatorSession, MessagePaginatorSession -from core import utils + logger = getLogger(__name__) class ModmailHelpCommand(commands.HelpCommand): + async def command_callback(self, ctx, *, command=None): + """Ovrwrites 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.clean_prefix @@ -64,6 +85,9 @@ async def format_cog_help(self, cog, *, no_cog=False): ) embed = discord.Embed(description=f"*{description}*", color=bot.main_color) + if not format_: + continue + embed.add_field(name="Commands", value=format_ or "No commands.") continued = " (Continued)" if embeds else "" @@ -231,7 +255,6 @@ def __init__(self, bot): self.bot = bot self._original_help_command = bot.help_command self.bot.help_command = ModmailHelpCommand( - verify_checks=False, command_attrs={ "help": "Shows this help message.", "checks": [checks.has_permissions_predicate(PermissionLevel.REGULAR)], @@ -848,7 +871,7 @@ async def config_help(self, ctx, key: str.lower = None): return await ctx.send(embed=embed) def fmt(val): - return val.format(prefix=self.bot.prefix, bot=self.bot) + return UnseenFormatter().format(val, prefix=self.bot.prefix, bot=self.bot) index = 0 embeds = [] @@ -1684,6 +1707,247 @@ async def oauth_show(self, ctx): 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: + 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}`.", + ) + + 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: + 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}`.", + ) + + 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.match(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=f"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) + @trigger_typing + async def update(self, ctx, *, flag: str = ""): + """ + Update Modmail. + This only works for PM2 or Heroku users who have configured their bot for updates. + 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/kyb3r/modmail/blob/master/bot.py#L25)" + ) + + if self.bot.version >= parse_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: + if self.bot.hosting_method == HostingMethod.HEROKU: + data = await self.bot.api.update_repository() + + 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} " f"-> 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_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,) + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if 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) + await self.bot.logout() + else: + embed = discord.Embed( + title="Already up to date", description=desc, color=self.bot.main_color, + ) + await ctx.send(embed=embed) + @commands.command(hidden=True, name="eval") @checks.has_permissions(PermissionLevel.OWNER) async def eval_(self, ctx, *, body: str): @@ -1762,6 +2026,8 @@ def paginate(text: str): break await ctx.send(f"```py\n{page}\n```") + await self.bot.add_reaction(ctx.message, "\u2705") + def setup(bot): bot.add_cog(Utility(bot)) diff --git a/core/changelog.py b/core/changelog.py index ace825482f..60d0179609 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -93,7 +93,7 @@ def embed(self) -> Embed: ) for name, value in self.fields.items(): - embed.add_field(name=name, value=truncate(value, 1024)) + 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.avatar_url) return embed diff --git a/core/checks.py b/core/checks.py index 46f8424b96..0b7b2acfa3 100644 --- a/core/checks.py +++ b/core/checks.py @@ -1,6 +1,6 @@ from discord.ext import commands -from core.models import PermissionLevel, getLogger +from core.models import HostingMethod, PermissionLevel, getLogger logger = getLogger(__name__) @@ -100,3 +100,23 @@ async def predicate(ctx): 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) diff --git a/core/clients.py b/core/clients.py index 03921d5a17..db4e9936b4 100644 --- a/core/clients.py +++ b/core/clients.py @@ -5,16 +5,215 @@ from typing import Union, Optional 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 getLogger +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/kyb3r/modmail" + MERGE_URL = BASE + "/repos/{username}/modmail/merges" + FORK_URL = REPO + "/forks" + STAR_URL = BASE + "/user/starred/kyb3r/modmail" + + def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): + self.bot = bot + self.session = bot.session + self.headers: 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): + return "master" if not self.bot.version.is_prerelease else "development" + + 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. + """ + 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: + return resp + try: + return await resp.json() + except (JSONDecodeError, ClientResponseError): + return await resp.text() + + def filter_valid(self, data): + """ + 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) -> Optional[dict]: + """ + Update the repository from Modmail main repo. + Parameters + ---------- + sha : Optional[str], optional + The commit SHA to update the repository. + Returns + ------- + Optional[dict] + If the response is a dict. + """ + if not self.username: + raise commands.CommandInvokeError("Username not found.") + + if sha is None: + resp: dict = 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) + if isinstance(resp, dict): + return resp + + async def fork_repository(self) -> None: + """ + Forks Modmail's repository. + """ + await self.request(self.FORK_URL, method="POST") + + 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"}) + + @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 = await self.request("https://api.github.com/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. @@ -142,6 +341,21 @@ async def search_closed_by(self, user_id: Union[int, str]): 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 @@ -401,10 +615,64 @@ async def search_by_text(self, text: str, limit: Optional[int]): {"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": str(message.author.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) -> 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): diff --git a/core/config.py b/core/config.py index f2bb54a5c8..9aab61acf2 100644 --- a/core/config.py +++ b/core/config.py @@ -12,7 +12,7 @@ from discord.ext.commands import BadArgument from core._color_data import ALL_COLORS -from core.models import InvalidConfigError, Default, getLogger +from core.models import DMDisabled, InvalidConfigError, Default, getLogger from core.time import UserFriendlyTimeSync from core.utils import strtobool @@ -39,6 +39,7 @@ class ConfigManager: "thread_cooldown": isodate.Duration(), "reply_without_command": False, "anon_reply_without_command": False, + "plain_reply_without_command": False, # logging "log_channel_id": None, # threads @@ -59,13 +60,20 @@ class ConfigManager: "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": "You must wait for {delta} before you can contact me again.", "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, # moderation "recipient_color": str(discord.Color.gold()), "mod_color": str(discord.Color.green()), @@ -74,6 +82,17 @@ class ConfigManager: "anon_username": None, "anon_avatar_url": None, "anon_tag": "Response", + # react to contact + "react_to_contact_message": None, + "react_to_contact_emoji": "\u2705", + # confirm thread creation + "confirm_thread_creation": False, + "confirm_thread_creation_title": "Confirm thread creation", + "confirm_thread_response": "React to confirm thread creation which will directly contact the moderators", + "confirm_thread_creation_accept": "\u2705", + "confirm_thread_creation_deny": "\U0001F6AB", + # regex + "use_regex_autotrigger": False, } private_keys = { @@ -81,12 +100,11 @@ class ConfigManager: "activity_message": "", "activity_type": None, "status": None, - # dm_disabled 0 = none, 1 = new threads, 2 = all threads - # TODO: use enum - "dm_disabled": 0, + "dm_disabled": DMDisabled.NONE, "oauth_whitelist": [], # moderation "blocked": {}, + "blocked_roles": {}, "blocked_whitelist": [], "command_permissions": {}, "level_permissions": {}, @@ -99,6 +117,7 @@ class ConfigManager: # misc "plugins": [], "aliases": {}, + "auto_triggers": {}, } protected_keys = { @@ -117,8 +136,11 @@ class ConfigManager: "enable_eval": True, # github access token for private repositories "github_token": None, + "disable_autoupdates": False, # Logging "log_level": "INFO", + # data collection + "data_collection": True, } colors = {"mod_color", "recipient_color", "main_color", "error_color"} @@ -130,14 +152,27 @@ class ConfigManager: "mod_typing", "reply_without_command", "anon_reply_without_command", + "plain_reply_without_command", "recipient_thread_close", "thread_auto_close_silently", "thread_move_notify", + "thread_move_notify_mods", + "transfer_reactions", + "close_on_leave", + "alert_on_mention", + "confirm_thread_creation", + "use_regex_autotrigger", "enable_plugins", + "data_collection", "enable_eval", + "disable_autoupdates", } - special_types = {"status", "activity_type"} + enums = { + "dm_disabled": DMDisabled, + "status": discord.Status, + "activity_type": discord.ActivityType, + } defaults = {**public_keys, **private_keys, **protected_keys} all_keys = set(defaults.keys()) @@ -250,25 +285,14 @@ def get(self, key: str, convert=True) -> typing.Any: except ValueError: value = self.remove(key) - elif key in self.special_types: + elif key in self.enums: if value is None: return None - - if key == "status": - try: - # noinspection PyArgumentList - value = discord.Status(value) - except ValueError: - logger.warning("Invalid status %s.", value) - value = self.remove(key) - - elif key == "activity_type": - try: - # noinspection PyArgumentList - value = discord.ActivityType(value) - except ValueError: - logger.warning("Invalid activity %s.", value) - value = self.remove(key) + try: + value = self.enums[key](value) + except ValueError: + logger.warning("Invalid %s %s.", key, value) + value = self.remove(key) return value @@ -327,8 +351,10 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: except ValueError: raise InvalidConfigError("Must be a yes/no value.") - # elif key in self.special_types: - # if key == "status": + elif key in self.enums: + if isinstance(item, self.enums[key]): + # value is an enum type + item = item.value return self.__setitem__(key, item) diff --git a/core/config_help.json b/core/config_help.json index 9778f49f8c..32cc694f32 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -139,7 +139,7 @@ "`{prefix}config set reply_without_command no`" ], "notes": [ - "See also: `anon_reply_without_command`." + "See also: `anon_reply_without_command`, `plain_reply_without_command`." ] }, "anon_reply_without_command": { @@ -150,7 +150,18 @@ "`{prefix}config set anon_reply_without_command no`" ], "notes": [ - "See also: `reply_without_command`." + "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": { @@ -357,7 +368,7 @@ "`{prefix}config set thread_move_title Thread transferred to another channel!" ], "notes": [ - "See also: `thread_move_notify`, `thread_move_response`." + "See also: `thread_move_notify`, `thread_move_notify_mods`, `thread_move_response`." ] }, "thread_move_notify": { @@ -368,7 +379,18 @@ "`{prefix}config set thread_move_notify no`" ], "notes": [ - "See also: `thread_move_title`, `thread_move_response`." + "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": { @@ -382,6 +404,29 @@ "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": "You must wait for {delta} before you can contact me again.", + "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.", @@ -522,6 +567,129 @@ ], "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 logs channel when bot is mentioned", + "examples":[ + "`{prefix}config set alert_on_mention yes`" + ], + "notes": [ + "See also: `mention`" + ] + }, + "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": "React 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 React 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).", @@ -602,5 +770,32 @@ "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." + ] } } diff --git a/core/models.py b/core/models.py index e19086b198..66965a6d88 100644 --- a/core/models.py +++ b/core/models.py @@ -1,6 +1,7 @@ import logging import re import sys +import os from enum import IntEnum from logging.handlers import RotatingFileHandler from string import Formatter @@ -16,6 +17,11 @@ 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 PermissionLevel(IntEnum): OWNER = 5 ADMINISTRATOR = 4 @@ -174,3 +180,99 @@ def get_field(self, field_name, args, kwargs): 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 Formatter.get_value(key, args, kwds) + + +class SimilarCategoryConverter(commands.CategoryChannelConverter): + async def convert(self, ctx, argument): + bot = ctx.bot + guild = ctx.guild + result = None + + try: + return await super().convert(ctx, argument) + except commands.ChannelNotFound: + + def check(c): + return isinstance(c, discord.CategoryChannel) and c.name.lower().startswith( + argument.lower() + ) + + if guild: + result = discord.utils.find(check, guild.categories) + else: + result = discord.utils.find(check, bot.get_all_channels()) + + 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): + 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 DMDisabled(IntEnum): + NONE = 0 + NEW_THREADS = 1 + ALL_THREADS = 2 + + +class HostingMethod(IntEnum): + HEROKU = 0 + PM2 = 1 + OTHER = 2 diff --git a/core/thread.py b/core/thread.py index 9c388389e0..2713afd273 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1,7 +1,10 @@ import asyncio +import copy +import io import re import typing from datetime import datetime, timedelta +import time from types import SimpleNamespace import isodate @@ -9,9 +12,16 @@ import discord from discord.ext.commands import MissingRequiredArgument, CommandError -from core.models import getLogger +from core.models import DMDisabled, DummyMessage, getLogger from core.time import human_timedelta -from core.utils import is_image_url, days, match_user_id, truncate, format_channel_name +from core.utils import ( + is_image_url, + days, + match_title, + match_user_id, + truncate, + format_channel_name, +) logger = getLogger(__name__) @@ -38,19 +48,25 @@ def __init__( self._channel = channel self.genesis_message = None self._ready_event = asyncio.Event() + self.wait_tasks = [] self.close_task = None self.auto_close_task = None + self._cancelled = False def __repr__(self): return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id})' async def wait_until_ready(self) -> None: """Blocks execution until the thread is fully set up.""" - # timeout after 3 seconds + # timeout after 30 seconds + task = asyncio.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25)) + self.wait_tasks.append(task) try: - await asyncio.wait_for(self._ready_event.wait(), timeout=3) + await task except asyncio.TimeoutError: - return + pass + + self.wait_tasks.remove(task) @property def id(self) -> int: @@ -76,7 +92,18 @@ def ready(self, flag: bool): else: self._ready_event.clear() - async def setup(self, *, creator=None, category=None): + @property + def cancelled(self) -> bool: + return self._cancelled + + @cancelled.setter + def cancelled(self, flag: bool): + self._cancelled = flag + if flag: + for i in self.wait_tasks: + i.cancel() + + async def setup(self, *, creator=None, category=None, initial_message=None): """Create the thread channel and other io related initialisation tasks""" self.bot.dispatch("thread_initiate", self) recipient = self.recipient @@ -172,7 +199,56 @@ async def send_recipient_genesis_message(): close_emoji = await self.bot.convert_emoji(close_emoji) await self.bot.add_reaction(msg, close_emoji) - await asyncio.gather(send_genesis_message(), send_recipient_genesis_message()) + async def send_persistent_notes(): + notes = await self.bot.api.find_notes(self.recipient) + ids = {} + + class State: + def store_user(self, user): + return user + + for note in notes: + author = note["author"] + + class Author: + name = author["name"] + id = author["id"] + discriminator = author["discriminator"] + avatar_url = author["avatar_url"] + + data = { + "id": round(time.time() * 1000 - discord.utils.DISCORD_EPOCH) << 22, + "attachments": {}, + "embeds": {}, + "edited_timestamp": None, + "type": None, + "pinned": None, + "mention_everyone": None, + "tts": None, + "content": note["message"], + "author": Author(), + } + message = discord.Message(state=State(), channel=None, data=data) + ids[note["_id"]] = str( + (await self.note(message, persistent=True, thread_creation=True)).id + ) + + await self.bot.api.update_note_ids(ids) + + async def activate_auto_triggers(): + message = DummyMessage(copy.copy(initial_message)) + if message: + try: + return await self.bot.trigger_auto_triggers(message, channel) + except RuntimeError: + pass + + await asyncio.gather( + send_genesis_message(), + send_recipient_genesis_message(), + activate_auto_triggers(), + send_persistent_notes(), + ) self.bot.dispatch("thread_ready", self) def _format_info_embed(self, user, log_url, log_count, color): @@ -311,22 +387,26 @@ async def _close( self.bot.config["notification_squad"].pop(str(self.id), None) # Logging - log_data = await self.bot.api.post_log( - self.channel.id, - { - "open": False, - "closed_at": str(datetime.utcnow()), - "nsfw": self.channel.nsfw, - "close_message": message if not silent else None, - "closer": { - "id": str(closer.id), - "name": closer.name, - "discriminator": closer.discriminator, - "avatar_url": str(closer.avatar_url), - "mod": True, + if self.channel: + log_data = await self.bot.api.post_log( + self.channel.id, + { + "open": False, + "title": match_title(self.channel.topic), + "closed_at": str(datetime.utcnow()), + "nsfw": self.channel.nsfw, + "close_message": message if not silent else None, + "closer": { + "id": str(closer.id), + "name": closer.name, + "discriminator": closer.discriminator, + "avatar_url": str(closer.avatar_url), + "mod": True, + }, }, - }, - ) + ) + else: + log_data = None if isinstance(log_data, dict): prefix = self.bot.config["log_url_prefix"].strip("/") @@ -334,7 +414,9 @@ async def _close( prefix = "" log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{log_data['key']}" - if log_data["messages"]: + if log_data["title"]: + sneak_peak = log_data["title"] + elif log_data["messages"]: content = str(log_data["messages"][0]["content"]) sneak_peak = content.replace("\n", "") else: @@ -372,7 +454,7 @@ async def _close( tasks = [self.bot.config.update()] - if self.bot.log_channel is not None: + if self.bot.log_channel is not None and self.channel is not None: tasks.append(self.bot.log_channel.send(embed=embed)) # Thread closed message @@ -404,6 +486,7 @@ async def _close( tasks.append(self.channel.delete()) await asyncio.gather(*tasks) + self.bot.dispatch("thread_close", self, closer, silent, delete_channel, message, scheduled) async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None: if self.close_task is not None and (not auto_close or all): @@ -486,9 +569,10 @@ async def find_linked_messages( ): raise ValueError("Thread message not found.") - if message1.embeds[0].color.value == self.bot.main_color and message1.embeds[ - 0 - ].author.name.startswith("Note"): + if message1.embeds[0].color.value == self.bot.main_color and ( + message1.embeds[0].author.name.startswith("Note") + or message1.embeds[0].author.name.startswith("Persistent Note") + ): if not note: raise ValueError("Thread message not found.") return message1, None @@ -534,7 +618,7 @@ async def find_linked_messages( return message1, msg except ValueError: continue - raise ValueError("DM message not found.") + raise ValueError("DM message not found. Plain messages are not supported.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: @@ -551,6 +635,8 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed2 = message2.embeds[0] embed2.description = message tasks += [message2.edit(embed=embed2)] + elif message1.embeds[0].author.name.startswith("Persistent Note"): + tasks += [self.bot.api.edit_note(message1.id, message)] await asyncio.gather(*tasks) @@ -566,6 +652,8 @@ async def delete_message( tasks += [message1.delete()] if message2 is not None: tasks += [message2.delete()] + elif message1.embeds[0].author.name.startswith("Persistent Note"): + tasks += [self.bot.api.delete_note(message1.id)] if tasks: await asyncio.gather(*tasks) @@ -605,11 +693,19 @@ async def edit_dm_message(self, message: discord.Message, content: str) -> None: self.bot.api.edit_message(message.id, content), linked_message.edit(embed=embed) ) - async def note(self, message: discord.Message) -> None: + async def note( + self, message: discord.Message, persistent=False, thread_creation=False + ) -> None: if not message.content and not message.attachments: raise MissingRequiredArgument(SimpleNamespace(name="msg")) - msg = await self.send(message, self.channel, note=True) + msg = await self.send( + message, + self.channel, + note=True, + persistent_note=persistent, + thread_creation=thread_creation, + ) self.bot.loop.create_task( self.bot.api.append_log( @@ -619,7 +715,9 @@ async def note(self, message: discord.Message) -> None: return msg - async def reply(self, message: discord.Message, anonymous: bool = False) -> None: + async def reply( + self, message: discord.Message, anonymous: bool = False, plain: bool = False + ) -> None: if not message.content and not message.attachments: raise MissingRequiredArgument(SimpleNamespace(name="msg")) if not any(g.get_member(self.id) for g in self.bot.guilds): @@ -635,7 +733,11 @@ async def reply(self, message: discord.Message, anonymous: bool = False) -> None try: await self.send( - message, destination=self.recipient, from_mod=True, anonymous=anonymous + message, + destination=self.recipient, + from_mod=True, + anonymous=anonymous, + plain=plain, ) except Exception: logger.error("Message delivery failed:", exc_info=True) @@ -653,7 +755,7 @@ async def reply(self, message: discord.Message, anonymous: bool = False) -> None else: # Send the same thing in the thread channel. msg = await self.send( - message, destination=self.channel, from_mod=True, anonymous=anonymous + message, destination=self.channel, from_mod=True, anonymous=anonymous, plain=plain ) tasks.append( @@ -678,6 +780,7 @@ async def reply(self, message: discord.Message, anonymous: bool = False) -> None ) await asyncio.gather(*tasks) + self.bot.dispatch("thread_reply", self, True, message, anonymous, plain) async def send( self, @@ -688,6 +791,9 @@ async def send( from_mod: bool = False, note: bool = False, anonymous: bool = False, + plain: bool = False, + persistent_note: bool = False, + thread_creation: bool = False, ) -> None: self.bot.loop.create_task( @@ -749,12 +855,12 @@ async def send( else: # Special note messages embed.set_author( - name=f"Note ({author.name})", + name=f"{'Persistent' if persistent_note else ''} Note ({author.name})", icon_url=system_avatar_url, url=f"https://discordapp.com/users/{author.id}#{message.id}", ) - ext = [(a.url, a.filename) for a in message.attachments] + ext = [(a.url, a.filename, False) for a in message.attachments] images = [] attachments = [] @@ -765,12 +871,17 @@ async def send( attachments.append(attachment) image_urls = re.findall( - r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", + r"http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", message.content, ) - image_urls = [(url, None) for url in image_urls if is_image_url(url)] + image_urls = [ + (is_image_url(url, convert_size=False), None, False) + for url in image_urls + if is_image_url(url, convert_size=False) + ] images.extend(image_urls) + images.extend((str(i.image_url), f"{i.name} Sticker", True) for i in message.stickers) embedded_image = False @@ -779,11 +890,14 @@ async def send( additional_images = [] additional_count = 1 - for url, filename in images: + for url, filename, is_sticker in images: if not prioritize_uploads or (is_image_url(url) and not embedded_image and filename): embed.set_image(url=url) if filename: - embed.add_field(name="Image", value=f"[{filename}]({url})") + if is_sticker: + embed.add_field(name=filename, value=f"\u200b") + else: + embed.add_field(name="Image", value=f"[{filename}]({url})") embedded_image = True elif filename is not None: if note: @@ -829,7 +943,7 @@ async def send( embed.set_footer(text=f"Message ID: {message.id}") embed.colour = self.bot.recipient_color - if from_mod or note: + if (from_mod or note) and not thread_creation: delete_message = not bool(message.attachments) if delete_message and destination == self.channel: try: @@ -837,7 +951,11 @@ async def send( except Exception as e: logger.warning("Cannot delete message: %s.", e) - if from_mod and self.bot.config["dm_disabled"] == 2 and destination != self.channel: + if ( + from_mod + and self.bot.config["dm_disabled"] == DMDisabled.ALL_THREADS + and destination != self.channel + ): logger.info("Sending a message to %s when DM disabled is set.", self.recipient) try: @@ -851,7 +969,31 @@ async def send( else: mentions = None - msg = await destination.send(mentions, embed=embed) + if plain: + if from_mod and not isinstance(destination, discord.TextChannel): + # Plain to user + if embed.footer.text: + plain_message = f"**({embed.footer.text}) " + else: + plain_message = "**" + plain_message += f"{embed.author.name}:** {embed.description}" + files = [] + for i in embed.fields: + if "Image" in i.name: + async with self.bot.session.get( + i.field[i.field.find("http") : -1] + ) as resp: + stream = io.BytesIO(await resp.read()) + files.append(discord.File(stream)) + + msg = await destination.send(plain_message, files=files) + else: + # Plain to mods + embed.set_footer(text="[PLAIN] " + embed.footer.text) + msg = await destination.send(mentions, embed=embed) + + else: + msg = await destination.send(mentions, embed=embed) if additional_images: self.ready = False @@ -873,6 +1015,10 @@ def get_notifications(self) -> str: return " ".join(mentions) + async def set_title(self, title) -> None: + user_id = match_user_id(self.channel.topic) + await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}") + class ThreadManager: """Class that handles storing, finding and creating Modmail threads.""" @@ -918,15 +1064,20 @@ async def find( thread = self.cache.get(recipient_id) if thread is not None: - await thread.wait_until_ready() - if 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 - ) - self.bot.loop.create_task( - thread.close(closer=self.bot.user, silent=True, delete_channel=False) - ) - thread = None + try: + await thread.wait_until_ready() + except asyncio.CancelledError: + logger.warning("Thread for %s cancelled, abort creating", recipient) + return thread + else: + if 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 + ) + self.bot.loop.create_task( + thread.close(closer=self.bot.user, silent=True, delete_channel=False) + ) + thread = None else: channel = discord.utils.get( self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}" @@ -968,6 +1119,7 @@ async def create( self, recipient: typing.Union[discord.Member, discord.User], *, + message: discord.Message = None, creator: typing.Union[discord.Member, discord.User] = None, category: discord.CategoryChannel = None, ) -> Thread: @@ -976,14 +1128,21 @@ async def create( # checks for existing thread in cache thread = self.cache.get(recipient.id) if thread: - await thread.wait_until_ready() - if thread.channel and self.bot.get_channel(thread.channel.id): - logger.warning("Found an existing thread for %s, abort creating.", recipient) + try: + await thread.wait_until_ready() + except asyncio.CancelledError: + logger.warning("Thread for %s cancelled, abort creating", recipient) return thread - logger.warning("Found an existing thread for %s, closing previous thread.", recipient) - self.bot.loop.create_task( - thread.close(closer=self.bot.user, silent=True, delete_channel=False) - ) + else: + if thread.channel and self.bot.get_channel(thread.channel.id): + logger.warning("Found an existing thread for %s, abort creating.", recipient) + return thread + logger.warning( + "Found an existing thread for %s, closing previous thread.", recipient + ) + self.bot.loop.create_task( + thread.close(closer=self.bot.user, silent=True, delete_channel=False) + ) thread = Thread(self, recipient) @@ -1003,7 +1162,57 @@ async def create( self.bot.config.set("fallback_category_id", category.id) await self.bot.config.update() - self.bot.loop.create_task(thread.setup(creator=creator, category=category)) + if message and self.bot.config["confirm_thread_creation"]: + confirm = await message.channel.send( + embed=discord.Embed( + title=self.bot.config["confirm_thread_creation_title"], + description=self.bot.config["confirm_thread_response"], + color=self.bot.main_color, + ) + ) + accept_emoji = self.bot.config["confirm_thread_creation_accept"] + deny_emoji = self.bot.config["confirm_thread_creation_deny"] + await confirm.add_reaction(accept_emoji) + await asyncio.sleep(0.2) + await confirm.add_reaction(deny_emoji) + try: + r, _ = await self.bot.wait_for( + "reaction_add", + check=lambda r, u: u.id == message.author.id + and r.message.id == confirm.id + and r.message.channel.id == confirm.channel.id + and r.emoji in (accept_emoji, deny_emoji), + timeout=20, + ) + except asyncio.TimeoutError: + thread.cancelled = True + + await confirm.remove_reaction(accept_emoji, self.bot.user) + await asyncio.sleep(0.2) + await confirm.remove_reaction(deny_emoji, self.bot.user) + await message.channel.send( + embed=discord.Embed( + title="Cancelled", description="Timed out", color=self.bot.error_color + ) + ) + del self.cache[recipient.id] + return thread + else: + if r.emoji == deny_emoji: + thread.cancelled = True + + await confirm.remove_reaction(accept_emoji, self.bot.user) + await asyncio.sleep(0.2) + await confirm.remove_reaction(deny_emoji, self.bot.user) + await message.channel.send( + embed=discord.Embed(title="Cancelled", color=self.bot.error_color) + ) + del self.cache[recipient.id] + return thread + + self.bot.loop.create_task( + thread.setup(creator=creator, category=category, initial_message=message) + ) return thread async def find_or_create(self, recipient) -> Thread: diff --git a/core/utils.py b/core/utils.py index a4f14182a5..b6640eaed8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -29,6 +29,7 @@ "trigger_typing", "escape_code_block", "format_channel_name", + "tryint", ] @@ -117,7 +118,7 @@ def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): return out or "No Messages" -def is_image_url(url: str) -> bool: +def is_image_url(url: str, **kwargs) -> str: """ Check if the URL is pointing to an image. @@ -131,10 +132,18 @@ def is_image_url(url: str) -> bool: bool Whether the URL is a valid image URL. """ - return bool(parse_image_url(url)) + if url.startswith("https://gyazo.com") or url.startswith("http://gyazo.com"): + # gyazo support + url = re.sub( + r"(http[s]?:\/\/)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)", + r"\1i.\2.png", + url, + ) + return parse_image_url(url, **kwargs) -def parse_image_url(url: str) -> str: + +def parse_image_url(url: str, *, convert_size=True) -> str: """ Convert the image URL into a sized Discord avatar. @@ -152,7 +161,10 @@ def parse_image_url(url: str) -> str: url = parse.urlsplit(url) if any(url.path.lower().endswith(i) for i in types): - return parse.urlunsplit((*url[:3], "size=128", url[-1])) + if convert_size: + return parse.urlunsplit((*url[:3], "size=128", url[-1])) + else: + return parse.urlunsplit(url) return "" @@ -204,7 +216,27 @@ def cleanup_code(content: str) -> str: return content.strip("` \n") -TOPIC_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) +TOPIC_TITLE_REGEX = re.compile(r"\bTitle: (.*)\n(?:User ID: )\b", flags=re.IGNORECASE | re.DOTALL) +TOPIC_UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) + + +def match_title(text: str) -> int: + """ + Matches a title in the foramt of "Title: XXXX" + + Parameters + ---------- + text : str + The text of the user ID. + + Returns + ------- + Optional[str] + The title if found + """ + match = TOPIC_TITLE_REGEX.search(text) + if match is not None: + return match.group(1) def match_user_id(text: str) -> int: @@ -221,7 +253,7 @@ def match_user_id(text: str) -> int: int The user ID if found. Otherwise, -1. """ - match = TOPIC_REGEX.search(text) + match = TOPIC_UID_REGEX.search(text) if match is not None: return int(match.group(1)) return -1 @@ -238,7 +270,7 @@ def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discor return embed -def parse_alias(alias): +def parse_alias(alias, *, split=True): def encode_alias(m): return "\x1AU" + base64.b64encode(m.group(1).encode()).decode() + "\x1AU" @@ -256,7 +288,12 @@ def decode_alias(m): if not alias: return aliases - for a in re.split(r"\s*&&\s*", alias): + 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] @@ -265,9 +302,9 @@ def decode_alias(m): return aliases -def normalize_alias(alias, message): +def normalize_alias(alias, message=""): aliases = parse_alias(alias) - contents = parse_alias(message) + contents = parse_alias(message, split=False) final_aliases = [] for a, content in zip_longest(aliases, contents): @@ -316,3 +353,10 @@ def format_channel_name(author, guild, exclude_channel=None): counter += 1 return new_name + + +def tryint(x): + try: + return int(x) + except (ValueError, TypeError): + return x diff --git a/discord.py-1.5.2.tar.gz b/discord.py-1.5.2.tar.gz new file mode 100644 index 0000000000..df0732ccca Binary files /dev/null and b/discord.py-1.5.2.tar.gz differ diff --git a/modmail.sh b/modmail.sh index 2dbc7218ff..4f29170f0a 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/pyproject.toml b/pyproject.toml index 0d47eb844a..e618107561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ keywords = ['discord', 'modmail'] [tool.poetry.dependencies] python = "^3.7" -"discord.py" = "=1.5.1" +"discord.py" = "./discord.py-1.5.1.tar.gz" uvloop = {version = ">=0.12.0", markers = "sys_platform != 'win32'"} python-dotenv = ">=0.10.3" parsedatetime = "^2.6" diff --git a/requirements.min.txt b/requirements.min.txt index 9fd4adf1a3..6d434f974d 100644 --- a/requirements.min.txt +++ b/requirements.min.txt @@ -6,7 +6,7 @@ aiohttp==3.6.2 async-timeout==3.0.1 attrs==19.3.0 chardet==3.0.4 -discord.py==1.5.1 +./discord.py-1.5.1.tar.gz dnspython==1.16.0 emoji==0.5.4 future==0.18.2