diff --git a/.bandit_baseline.json b/.bandit_baseline.json index 28a4e47b9c..8a61d4006f 100644 --- a/.bandit_baseline.json +++ b/.bandit_baseline.json @@ -1,295 +1,326 @@ { "errors": [], - "generated_at": "2020-11-26T11:00:36Z", + "generated_at": "2022-09-06T16:19:31Z", "metrics": { "./bot.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1321, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1507, + "nosec": 0, + "skipped_tests": 0 }, "./cogs/modmail.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1273, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1837, + "nosec": 0, + "skipped_tests": 0 }, "./cogs/plugins.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 578, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 597, + "nosec": 0, + "skipped_tests": 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": 1.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1755, - "nosec": 0 + "CONFIDENCE.HIGH": 2, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 1794, + "nosec": 0, + "skipped_tests": 0 }, "./core/_color_data.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, "loc": 1166, - "nosec": 0 + "nosec": 0, + "skipped_tests": 0 }, "./core/changelog.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 155, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 159, + "nosec": 0, + "skipped_tests": 0 }, "./core/checks.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 90, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 105, + "nosec": 0, + "skipped_tests": 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": 587, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 644, + "nosec": 0, + "skipped_tests": 0 }, "./core/config.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 352, - "nosec": 0 - }, - "./core/decorators.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 9, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 388, + "nosec": 0, + "skipped_tests": 0 }, "./core/models.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 202, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 210, + "nosec": 0, + "skipped_tests": 0 }, "./core/paginator.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 209, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 312, + "nosec": 0, + "skipped_tests": 0 }, "./core/thread.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 996, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1184, + "nosec": 0, + "skipped_tests": 0 }, "./core/time.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 158, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 265, + "nosec": 0, + "skipped_tests": 0 }, "./core/utils.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 282, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 396, + "nosec": 0, + "skipped_tests": 0 }, - "./plugins/kyb3r/modmail-plugins/profanity-filter-master/profanity-filter.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 81, - "nosec": 0 + "./plugins/Cordila/cord/jishaku-migration/jishaku.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 2, + "nosec": 0, + "skipped_tests": 0 }, "_totals": { - "CONFIDENCE.HIGH": 5.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 5.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 9214, - "nosec": 0 + "CONFIDENCE.HIGH": 5, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 5, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 10566, + "nosec": 0, + "skipped_tests": 0 } }, "results": [ { - "code": "11 from datetime import datetime\n12 from subprocess import PIPE\n13 from types import SimpleNamespace\n", + "code": "14 from datetime import datetime, timezone\n15 from subprocess import PIPE\n16 from types import SimpleNamespace\n", + "col_offset": 0, "filename": "./bot.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", - "line_number": 12, + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 15, "line_range": [ - 12 + 15 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/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", + "col_offset": 0, "filename": "./cogs/plugins.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", + "issue_text": "Consider possible security implications associated with the subprocess module.", "line_number": 14, "line_range": [ 14, 15 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "13 from json import JSONDecodeError, loads\n14 from subprocess import PIPE\n15 from textwrap import indent\n", + "code": "11 from json import JSONDecodeError, loads\n12 from subprocess import PIPE\n13 from textwrap import indent\n", + "col_offset": 0, "filename": "./cogs/utility.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", - "line_number": 14, + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 12, "line_range": [ - 14 + 12 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "2039 try:\n2040 exec(to_compile, env) # pylint: disable=exec-used\n2041 except Exception as exc:\n", + "code": "2093 try:\n2094 exec(to_compile, env) # pylint: disable=exec-used\n2095 except Exception as exc:\n", + "col_offset": 12, "filename": "./cogs/utility.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "MEDIUM", "issue_text": "Use of exec detected.", - "line_number": 2040, + "line_number": 2094, "line_range": [ - 2040 + 2094 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b102_exec_used.html", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b102_exec_used.html", "test_id": "B102", "test_name": "exec_used" }, { "code": "2 import re\n3 from subprocess import PIPE\n4 from typing import List\n", + "col_offset": 0, "filename": "./core/changelog.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", + "issue_text": "Consider possible security implications associated with the subprocess module.", "line_number": 3, "line_range": [ 3 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "67 \n68 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n69 self.bot = bot\n70 self.session = bot.session\n71 self.headers: dict = None\n72 self.access_token = access_token\n73 self.username = username\n74 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n75 self.url: str = kwargs.pop(\"url\", \"\")\n76 if self.access_token:\n77 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n78 \n79 @property\n80 def BRANCH(self):\n", + "code": "70 \n71 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n72 self.bot = bot\n73 self.session = bot.session\n74 self.headers: Optional[dict] = None\n75 self.access_token = access_token\n76 self.username = username\n77 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n78 self.url: str = kwargs.pop(\"url\", \"\")\n79 if self.access_token:\n80 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n81 \n82 @property\n83 def BRANCH(self) -> str:\n", + "col_offset": 4, "filename": "./core/clients.py", "issue_confidence": "MEDIUM", + "issue_cwe": { + "id": 259, + "link": "https://cwe.mitre.org/data/definitions/259.html" + }, "issue_severity": "LOW", "issue_text": "Possible hardcoded password: ''", - "line_number": 68, + "line_number": 71, "line_range": [ - 68, - 69, - 70, 71, 72, 73, @@ -298,9 +329,12 @@ 76, 77, 78, - 79 + 79, + 80, + 81, + 82 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b107_hardcoded_password_default.html", "test_id": "B107", "test_name": "hardcoded_password_default" } diff --git a/.dockerignore b/.dockerignore index 99fd4a241b..a3de147db4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -138,19 +138,17 @@ temp/ test.py # Other stuff +.dockerignore .env.example +.git/ .gitignore -.lint.py -.pylintrc -.travis.yml +.github/ app.json CHANGELOG.md -CODE_OF_CONDUCT.md -CONTRIBUTING.md -requirements.min.txt +Dockerfile +docker-compose.yml Procfile pyproject.toml README.md -runtime.txt -SPONSORS.json -stack.yml \ No newline at end of file +Pipfile +Pipfile.lock diff --git a/.env.example b/.env.example index 44c91c59c7..14bdf060bf 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ TOKEN=MyBotToken LOG_URL=https://logviewername.herokuapp.com/ GUILD_ID=1234567890 -MODMAIL_GUILD_ID=1234567890 OWNERS=Owner1ID,Owner2ID,Owner3ID CONNECTION_URI=mongodb+srv://mongodburi diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 568e5f2175..6c6694b809 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -30,8 +30,19 @@ Pull requests are the best way to propose changes to the codebase. We actively w ## Any contributions you make will be under the GNU Affero General Public License v3.0 In short, when you submit code changes, your submissions are understood to be under the same [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html) that covers the project. Feel free to contact the maintainers if that's a concern. -## Report bugs using [Github Issues](https://github.com/kyb3r/modmail/issues) -We use GitHub issues to track public bugs. Report a bug by [opening a new Issue](https://github.com/kyb3r/modmail/issues/new); it's that easy! +## Report bugs using [Github Issues](https://github.com/modmail-dev/modmail/issues) +We use GitHub issues to track public bugs. Report a bug by [opening a new Issue](https://github.com/modmail-dev/modmail/issues/new); it's that easy! + +## Find pre-existing issues to tackle +Check out our [unstaged issue tracker](https://github.com/modmail-dev/modmail/issues?q=is%3Aissue+is%3Aopen+-label%3Astaged) and start helping out! + +Ways to help out: +- Help out new members +- Highlight invalid bugs/unsupported use cases +- Code review of pull requests +- Add on new use cases or reproduction steps +- Point out duplicate issues and guide them to the right direction +- Create a pull request to resolve the issue! ## Write bug reports with detail, background, and sample code **Great Bug Reports** tend to have: @@ -43,7 +54,6 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new Issue] - What *actually* happens - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - ## Use a Consistent Coding Style We use [black](https://github.com/python/black) for a unified code style. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 28a29c07b5..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "[BUG] bug report title here" -labels: 'maybe: bug' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Bot Info** -Bot version (check with `@modmail about`): -Host method (Heroku, self-host, etc): - -**To Reproduce** -Steps to reproduce the behavior: -1. Who can reproduce (ex. anyone, owners)? -2. Where can it be reproduced (ex. in thread channels, recipient DM's)? -3. Done what to cause the error? -4. Any recently made changes to your bot? -5. Errored - -**Error Logs** -If your Modmail bot is online, type `@modmail debug hastebin` and include the link here. -If your Modmail bot is not online or the previous command did not generate a link, do the following: - -1. Select your *bot* application at https://dashboard.heroku.com -2. [Restart your bot](https://i.imgur.com/3FcrlKz.png) -3. Reproduce the error to populate the error logs -4. [Copy and paste the logs](https://i.imgur.com/TTrhitm.png) - -**Screenshots** -Add screenshots to help explain your problem. - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..99779ab4f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,55 @@ +name: Bug Report +description: File a bug report +title: "[BUG]: your bug report title" +labels: "maybe: bug" +body: + - type: input + id: bot-info-version + attributes: + label: Bot Version + description: Check it with `@modmail about` + placeholder: eg. v3.9.4 + validations: + required: true + - type: dropdown + id: bot-info-hosting + attributes: + label: How are you hosting Modmail? + description: You can check it with `@modmail about` if you are unsure + options: + - Heroku + - Systemd + - PM2 + - Patreon + - Other + validations: + required: true + - type: input + id: logs + attributes: + label: Error Logs + placeholder: https://hastebin.cc/placeholder + description: + "If your Modmail bot is online, type `@modmail debug hastebin` and include the link here. + + If your Modmail bot is not online or the previous command did not generate a link, do the following: + + 1. Select your *bot* application at https://dashboard.heroku.com + + 2. [Restart your bot](https://i.imgur.com/3FcrlKz.png) + + 3. Reproduce the error to populate the error logs + + 4. [Copy and paste the logs](https://i.imgur.com/TTrhitm.png)" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: "[optional] You may add screenshots to further explain your problem." + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: "[optional] You may provide additional context for us to better understand how this issue occured." diff --git a/.github/ISSUE_TEMPLATE/command-request.md b/.github/ISSUE_TEMPLATE/command-request.md deleted file mode 100644 index d3c1673a6b..0000000000 --- a/.github/ISSUE_TEMPLATE/command-request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Command request -about: Request a new command -title: "your title here" -labels: command-request -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Who will this benefit** -Does this feature apply to a great portion of users? - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e9a2d9fcd4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discord Server + url: https://discord.gg/etJNHCQ + about: Please ask hosting-related questions here before creating an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 7cd7a506cd..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "your title here" -labels: feature-request -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Who will this benefit** -Does this feature apply to a great portion of users? - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..b6a4437f2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature Request +description: Suggest an idea for this project +title: "your feature request title" +labels: "feature request" +body: + - type: textarea + id: problem-relation + attributes: + label: Is your feature request related to a problem? Please elaborate. + description: A clear and concise description of what the problem is. + placeholder: eg. I'm always frustrated when... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: checkboxes + id: complications + attributes: + label: Does your solution involve any of the following? + options: + - label: Logviewer + - label: New config option + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + - type: textarea + id: benefit + attributes: + label: Who will this benefit? + description: Does this feature apply to a great portion of users? + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: "[optional] You may provide additional context or screenshots for us to better understand the need of the feature." diff --git a/.github/pull.yml b/.github/pull.yml index 2fec8bbda0..8a0898a6b7 100644 --- a/.github/pull.yml +++ b/.github/pull.yml @@ -1,8 +1,8 @@ version: "1" rules: - base: master - upstream: kyb3r:master + upstream: modmail-dev:master mergeMethod: hardreset - base: development - upstream: kyb3r:development + upstream: modmail-dev:development mergeMethod: hardreset \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000000..6af8630b38 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,42 @@ + +name: Create and publish a Docker image + +on: + push: + branches: ['master'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml index 94b322d968..b283dff078 100644 --- a/.github/workflows/lints.yml +++ b/.github/workflows/lints.yml @@ -4,30 +4,29 @@ on: [push, pull_request] jobs: code-style: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11'] -# runs-on: ${{ matrix.os }} -# strategy: -# fail-fast: false -# matrix: -# os: [ubuntu-latest, windows-latest, macOS-latest] -# python-version: [3.6, 3.7] + name: Python ${{ matrix.python-version }} on ubuntu-latest - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python 3.7 - uses: actions/setup-python@v1 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: ${{ matrix.python-version }} + architecture: x64 - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install bandit==1.6.2 pylint black==19.10b0 - continue-on-error: true - - name: Bandit syntax check - run: bandit -r . -b .bandit_baseline.json + python -m pip install --upgrade pip pipenv + pipenv install --dev --system + # to refresh: bandit -f json -o .bandit_baseline.json -r . + # - name: Bandit syntax check + # run: bandit -r . -b .bandit_baseline.json - name: Pylint - run: pylint ./bot.py cogs/*.py core/*.py --disable=import-error --exit-zero -r y + run: pylint ./bot.py cogs/*.py core/*.py --exit-zero -r y continue-on-error: true - name: Black run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f1ddc9ef..cb334e4e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,260 @@ 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 plugin developer, note the "BREAKING" section. +however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. + +# v4.1.0 + +Drops support for Python 3.9. Python 3.10 and Python 3.11 are now the only supported versions. + +### Fixed +- GIF stickers no longer cause the bot to crash. +- `?alias make/create` as aliases to `?alias add`. This improves continuity between the bot and its command structure. ([PR #3195](https://github.com/kyb3r/modmail/pull/3195)) +- Loading the blocked list with the `?blocked` command takes a long time when the list is large. ([PR #3242](https://github.com/kyb3r/modmail/pull/3242)) +- Reply not being forwarded from DM. (PR [#3239](https://github.com/modmail-dev/modmail/pull/3239)) +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) +- Fixed a syntactic error in the close message when a thread is closed after a certain duration. ([PR #3233](https://github.com/modmail-dev/Modmail/pull/3233)) +- Removed an extra space in the help command title when the command has no parameters. ([PR #3271](https://github.com/modmail-dev/Modmail/pull/3271)) +- Corrected some incorrect config help descriptions. ([PR #3277](https://github.com/modmail-dev/Modmail/pull/3277)) +- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) +- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) + +### Added +- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) +- `REGISTRY_PLUGINS_ONLY`, environment variable, when set, restricts to only allow adding registry plugins. ([PR #3247](https://github.com/modmail-dev/modmail/pull/3247)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `STREAM_LOG_FORMAT` and `FILE_LOG_FORMAT` environment variable to set the log format of the stream and file handlers respectively. Possible options are `json` and `plain` (default). ([PR #3305](https://github.com/modmail-dev/Modmail/pull/3305)) +- `LOG_EXPIRATION` environment variable to set the expiration time of logs. ([PR #3257](https://github.com/modmail-dev/Modmail/pull/3257)) +- New registry plugins: [`autoreact`](https://github.com/martinbndr/kyb3r-modmail-plugins/tree/master/autoreact) and [`rename`](https://github.com/Nicklaus-s/modmail-plugins/tree/main/rename). +- Improved join/leave message for multiple servers. + +### Changed +- Repo moved to https://github.com/modmail-dev/modmail. +- Channel name no longer shows `-0` if the user has migrated to the new username system. +- `?note` and `?reply` now allows you to send a sticker without any message. +- Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) +- Discord.py internal logging is now enabled by default. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- The confirm-thread-creation dialog now uses buttons instead of reactions. ([PR #3273](https://github.com/modmail-dev/Modmail/pull/3273)) +- `?disable all` no longer overrides `?disable new`. ([PR #3278](https://github.com/modmail-dev/Modmail/pull/3278)) +- Dropped root privileges for Modmail running under Docker. ([PR #3284](https://github.com/modmail-dev/Modmail/pull/3284)) + +### Internal +- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. +- Various dependencies have been updated to their latest versions. + +# v4.0.2 + +### Breaking + +- Presence intent is now by-default OFF. You can turn it on by setting `ENABLE_PRESENCE_INTENT=true` in the environment variables. + +### Fixed + +- Not having a guild icon no longer raises an exception. ([PR #3235](https://github.com/modmail-dev/modmail/pull/3235)) + - When no icon is set, use the default user icon. +- Resolved an issue where `?logs` doesn't work when the thread has no title. ([PR #3201](https://github.com/modmail-dev/modmail/pull/3201)) +- AttributeError raised when failing to forward a reaction. ([GH #3218](https://github.com/modmail-dev/modmail/issues/3218)) + +### Changed + +- Plain messages no longer forces `()` around the respondent text. ([PR #3234](https://github.com/modmail-dev/modmail/pull/3234)) +- Added workflow to automatically build Docker image ([PR #3232](https://github.com/modmail-dev/modmail/pull/3228)) +- Updated installation guide to reflect new preferred hosting methods + +# v4.0.1 + +This is a hotfix release. + +### Improved + +- Error Messages + +### Fixed + +- Thread cooldown + +# v4.0.0 + +### Breaking + +- Modmail now requires [`Message Content` privileged intent](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots). +- Upgraded to discord.py v2.0 ([internal changes](https://discordpy.readthedocs.io/en/latest/migrating.html), [GH #2990](https://github.com/modmail-dev/modmail/issues/2990)). +- Python 3.8 or higher is required. +- Asyncio changes ([gist](https://gist.github.com/Rapptz/6706e1c8f23ac27c98cee4dd985c8120)) +- Plugin registry is purged and all developers have to re-apply due to breaking changes. + +### Added + +- `use_hoisted_top_role` config to use change how default mod tags work, see `v3.10.0#Added` for details. ([PR #3093](https://github.com/modmail-dev/modmail/pull/3093)) +- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/modmail-dev/modmail/issues/3107)) +- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/modmail-dev/modmail/issues/3083)) +- `?fpareply` and `?fpreply` to reply to messages with variables plainly. +- `use_nickname_channel_name` config to use nicknames instead of usernames for channel names. ([GH #3112](https://github.com/modmail-dev/modmail/issues/3112)) +- `use_random_channel_name` config to use random nicknames vaguely tied to user ID. It is unable to be computed in reverse. ([GH #3143](https://github.com/modmail-dev/modmail/issues/3143)) +- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/modmail-dev/modmail/issues/3122)) +- Select menus for certain paginators. +- `Title` field in `?logs`. ([GH #3142](https://github.com/modmail-dev/modmail/issues/3142)) +- Snippets can be used in aliases. ([GH #3108](https://github.com/modmail-dev/modmail/issues/3108), [PR #3124](https://github.com/modmail-dev/modmail/pull/3124)) +- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/modmail-dev/modmail/issues/3173), [PR #3174](https://github.com/modmail-dev/modmail/pull/3174)) + +### Improved + +- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/modmail-dev/modmail/issues/3048)) +- Use discord relative timedeltas. ([GH #3046](https://github.com/modmail-dev/modmail/issues/3046)) +- Use discord native buttons for all paginator sessions. +- `?help` and `?blocked` paginator sessions now have better multi-page UI. +- Autoupdate now automatically updates pipenv dependencies if possible. + +### Fixed + +- Several minor typos. ([PR #3095](https://github.com/modmail-dev/modmail/pull/3095), [PR #3116](https://github.com/modmail-dev/modmail/pull/3116)) +- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/modmail-dev/modmail/pull/3109)) +- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/modmail-dev/modmail/issues/3091), [PR #3092](https://github.com/modmail-dev/modmail/pull/3092)) +- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/modmail-dev/modmail/issues/3104), [PR #3105](https://github.com/modmail-dev/modmail/pull/3105)) +- Stickers now work in Modmail. +- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/modmail-dev/modmail/issues/3088)) +- Attachments now work on plain replies. ([GH #3102](https://github.com/modmail-dev/modmail/issues/3102)) +- Support LOTTIE stickers. ([GH #3119](https://github.com/modmail-dev/modmail/issues/3119)) +- Editing notes now work. ([GH #3094](https://github.com/modmail-dev/modmail/issues/3094)) +- Commands now work in threads. +- Audit log searching now properly works. +- Old data causing `?blocked` to fail. ([GH #3131](https://github.com/modmail-dev/modmail/issues/3131)) +- Delete channel auto close functionality now works. +- Improved error handling for autoupdate. ([PR #3161](https://github.com/modmail-dev/modmail/pull/3161)) +- Skip loading of already-loaded cog. ([PR #3172](https://github.com/modmail-dev/modmail/pull/3172)) +- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/modmail-dev/modmail/issues/3170), [PR #3178](https://github.com/modmail-dev/modmail/pull/3178)) +- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/modmail-dev/modmail/issues/3179)) + +### Internal + +- Improve regex parsing of channel topics. ([GH #3114](https://github.com/modmail-dev/modmail/issues/3114), [PR #3111](https://github.com/modmail-dev/modmail/pull/3111)) +- Add warning if deploying on a developmental version. +- Extensions are now loaded `on_connect`. +- MongoDB v5.0 clients are now supported. ([GH #3126](https://github.com/modmail-dev/modmail/issues/3126)) +- Bump python-dotenv to v0.20.0, support for python 3.10 +- Bump emoji to v1.7.0 +- Bump aiohttp to v3.8.1 +- Bump lottie to v0.6.11 +- Remove deprecated `core/decorators.py` from v3.3.0 + +# v3.10.5 + +### Internal + +- Locked plugin registry version impending v4 release. + +# v3.10.4 + +### Improved + +- Thread genesis message now shows other recipients. + +### Fixed + +- `?snippet add` now properly blocks command names. + +### Internal + +- Set `LOG_DISCORD` environment variable to the logger level and log discord events. + +# v3.10.3 +This is a hotfix for contact command. + +### Fixed + +- Fixed a bug where contacting with no category argument defaults to the top category. + +# v3.10.2 +This is a hotfix for react to contact. + +### Fixed + +- React to contact now works properly. + +# v3.10.1 + +This is a hotfix for the edit command. + +### Fixed + +- `?edit` now works properly. + +# v3.10.0 + +v3.10 adds group conversations while resolving other bugs and QOL changes. It is potentially breaking to some plugins that adds functionality to threads. + +### Breaking + +- `Thread.recipient` (`str`) is now `Thread.recipients` (`List[str]`). +- `Thread.reply` now returns `mod_message, user_message1, user_message2`... It is no longer limited at a size 2 tuple. + +### Added + +- Ability to have group conversations with up to 5 users. ([GH #143](https://github.com/modmail-dev/modmail/issues/143)) +- Snippets are invoked case insensitively. ([GH #3077](https://github.com/modmail-dev/modmail/issues/3077), [PR #3080](https://github.com/modmail-dev/modmail/pull/3080)) +- Default tags now use top hoisted role. ([GH #3014](https://github.com/modmail-dev/modmail/issues/3014)) +- New thread-related config - `thread_show_roles`, `thread_show_account_age`, `thread_show_join_age`, `thread_cancelled`, `thread_creation_contact_title`, `thread_creation_self_contact_response`, `thread_creation_contact_response`. ([GH #3072](https://github.com/modmail-dev/modmail/issues/3072)) +- `use_timestamp_channel_name` config to create thread channels by timestamp. + +### Improved + +- `?contact` now accepts a role or multiple users (creates a group conversation). ([GH #3082](https://github.com/modmail-dev/modmail/issues/3082)) +- Aliases are now supported in autotrigger. ([GH #3081](https://github.com/modmail-dev/modmail/pull/3081)) + +### Fixed + +- Certain situations where the internal thread cache breaks and spams new channels. ([GH #3022](https://github.com/modmail-dev/modmail/issues/3022), [PR #3028](https://github.com/modmail-dev/modmail/pull/3028)) +- Blocked users are now no longer allowed to use `?contact` and react to contact. ([COMMENT #819004157](https://github.com/modmail-dev/modmail/issues/2969#issuecomment-819004157), [PR #3027](https://github.com/modmail-dev/modmail/pull/3027)) +- UnicodeEncodeError will no longer be raised on Windows. ([PR #3043](https://github.com/modmail-dev/modmail/pull/3043)) +- Notifications are no longer duplicated when using both `?notify` and `subscribe`. ([PR #3015](https://github.com/modmail-dev/modmail/pull/3015)) +- `?contact` now works properly with both category and silent. ([GH #3076](https://github.com/modmail-dev/modmail/issues/3076)) +- `close_on_leave_reason` now works properly when `close_on_leave` is enabled. ([GH #3075](https://github.com/modmail-dev/modmail/issues/3075)) +- Invalid arguments are now properly catched and a proper error message is sent. +- Update database after resetting/purging all plugins. ([GH #3011](https://github.com/modmail-dev/modmail/pull/3011)) +- `thread_auto_close` timer now only resets on non-note and replies from mods. ([GH #3030](https://github.com/modmail-dev/modmail/issues/3030)) +- Deleted messages are now deleted on both ends. ([GH #3041](https://github.com/modmail-dev/modmail/issues/3041), [@JerrieAries](https://github.com/modmail-dev/modmail/commit/20b31f8e8b5497943513997fef788d72ae668438)) +- Persistent notes are now properly deleted from the database. ([GH #3013](https://github.com/modmail-dev/modmail/issues/3013)) +- Modmail Bot is now recognized to have `OWNER` permission level. This affects what can be run in autotriggers. + +### Internal + +- Fix return types, type hints and unresolved references ([PR #3009](https://github.com/modmail-dev/modmail/pull/3009)) +- Reload thread cache only when it's the first on_ready trigger. ([GH #3037](https://github.com/modmail-dev/modmail/issues/3037)) +- `format_channel_name` is now extendable to plugins. Modify `Bot.format_channel_name(bot, author, exclude_channel=None, force_null=False):`. ([GH #2982](https://github.com/modmail-dev/modmail/issues/2982)) + +# v3.9.5 + +### Internal + +- Bumped discord.py to v1.7.3, updated all other packages to latest. +- More debug log files are now kept. +- Resolve SSL errors by retrying without SSL # v3.9.4 -## Fixed +### Fixed -- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/kyb3r/modmail/issues/3002), [PR #3003](https://github.com/kyb3r/modmail/pull/3003)) +- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/modmail-dev/modmail/issues/3002), [PR #3003](https://github.com/modmail-dev/modmail/pull/3003)) - There is now a proper message when trying to contact a bot. -## Improved +### Improved -- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/kyb3r/modmail/pull/2993/files)) -- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/kyb3r/modmail/pull/2993/files)) +- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) +- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) -## Internal +### Internal -- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/kyb3r/modmail/pull/3001)) +- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/modmail-dev/modmail/pull/3001)) # v3.9.3 -## Added +### Added - New config: `use_user_id_channel_name`, when set to TRUE, channel names would get created with the recipient's ID instead of their name and discriminator. - This is now an option to better suit the needs of servers in Server Discovery -## Internal +### Internal - Signature of `format_channel_name` in core/util.py changed to: - `format_channel_name(bot, author, exclude_channel=None, force_null=False)` @@ -39,7 +267,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Improved -- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/kyb3r/modmail/issues/2977), [PR #2988](https://github.com/kyb3r/modmail/pull/2988)) +- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/modmail-dev/modmail/issues/2977), [PR #2988](https://github.com/modmail-dev/modmail/pull/2988)) ### Fixed @@ -60,8 +288,8 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Fixed -- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/kyb3r/modmail/issues/2930), [PR #2971](https://github.com/kyb3r/modmail/pull/2971)) -- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/kyb3r/modmail/issues/2969), [PR #2971](https://github.com/kyb3r/modmail/pull/2971)) +- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/modmail-dev/modmail/issues/2930), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/modmail-dev/modmail/issues/2969), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) - Ghost errors are no longer raised when threads are created using non-organic methods. ### Internal @@ -80,13 +308,13 @@ however, insignificant breaking changes do not guarantee a major version bump, s ### Added -- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/kyb3r/modmail/issues/2963), [PR #2964](https://github.com/kyb3r/modmail/pull/2964)) -- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/kyb3r/modmail/pull/2951)) +- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/modmail-dev/modmail/issues/2963), [PR #2964](https://github.com/modmail-dev/modmail/pull/2964)) +- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/modmail-dev/modmail/pull/2951)) ### Fixed - Non-master/development branch deployments no longer cause errors to be raised. -- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/kyb3r/modmail/issues/2961)) +- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/modmail-dev/modmail/issues/2961)) # v3.8.4 @@ -100,28 +328,28 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Fixed -- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/kyb3r/modmail/issues/2934)) +- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/modmail-dev/modmail/issues/2934)) - Fix update notifications. -- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/kyb3r/modmail/issues/2935), [PR #2936](https://github.com/kyb3r/modmail/pull/2936)) +- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/modmail-dev/modmail/issues/2935), [PR #2936](https://github.com/modmail-dev/modmail/pull/2936)) - IDs in `` commands work now. # v3.8.1 ### Fixed -- Additional image uploads now render properly. ([PR #2933](https://github.com/kyb3r/modmail/pull/2933)) -- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/kyb3r/modmail/issues/2931), [PR #2933](https://github.com/kyb3r/modmail/pull/2933)) -- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/kyb3r/modmail/issues/2932)) +- Additional image uploads now render properly. ([PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/modmail-dev/modmail/issues/2931), [PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/modmail-dev/modmail/issues/2932)) # v3.8.0 ### Added -- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/kyb3r/modmail/issues/2896)) +- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/modmail-dev/modmail/issues/2896)) - `?fareply`, anonymously reply with variables. -- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/kyb3r/modmail/issues/2905)) +- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/modmail-dev/modmail/issues/2905)) - `disable_updates` config variable to control if the update command should be disabled or not. -- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/kyb3r/modmail/issues/2907)) +- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/modmail-dev/modmail/issues/2907)) - Support for only the "Server Members" intent. ### Improved @@ -132,17 +360,17 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Fixed -- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/kyb3r/modmail/pull/2902)) +- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/modmail-dev/modmail/pull/2902)) - Level permissions were not checked if command permissions were set. - Regex autotriggers were not working if term was in the middle of strings. - `?blocked` now no longers show blocks that have expired. - Blocked roles will no longer trigger an error during unblock. -- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/kyb3r/modmail/issues/2916)) -- Finding linked messages in replies work now. ([GH #2920](https://github.com/kyb3r/modmail/issues/2920), [Jerrie-Aries](https://github.com/kyb3r/modmail/issues/2920#issuecomment-751530495)) -- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/kyb3r/modmail/issues/2926)) -- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/kyb3r/modmail/issues/2910), [Jerrie-Aries](https://github.com/kyb3r/modmail/issues/2910#issuecomment-753557313)) +- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/modmail-dev/modmail/issues/2916)) +- Finding linked messages in replies work now. ([GH #2920](https://github.com/modmail-dev/modmail/issues/2920), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2920#issuecomment-751530495)) +- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/modmail-dev/modmail/issues/2926)) +- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/modmail-dev/modmail/issues/2910), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2910#issuecomment-753557313)) - Display an error on [Lottie](https://airbnb.io/lottie/#/) stickers, instead of failing the send. -- `?perms get` now shows role/user names. ([PR #2927](https://github.com/kyb3r/modmail/pull/2927)) +- `?perms get` now shows role/user names. ([PR #2927](https://github.com/modmail-dev/modmail/pull/2927)) ### Internal @@ -181,13 +409,13 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Added - Added `update_channel_id` to specify which channel autoupdate notifications were being sent to. -- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/kyb3r/modmail/issues/2885)) +- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/modmail-dev/modmail/issues/2885)) # v3.7.9 ### Fixed -- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/kyb3r/modmail/issues/2892), [PR #2893](https://github.com/kyb3r/modmail/pull/2893)) +- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/modmail-dev/modmail/issues/2892), [PR #2893](https://github.com/modmail-dev/modmail/pull/2893)) ### Improved @@ -197,7 +425,7 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Added -- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/kyb3r/modmail/pull/2887)) +- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/modmail-dev/modmail/pull/2887)) ### Fixed - Permission levels were not respected. @@ -223,7 +451,7 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Fixed - Autoupdate persists despite errors. -- Mention when normal thread created was not working. ([GH #2883](https://github.com/kyb3r/modmail/issues/2883)) +- Mention when normal thread created was not working. ([GH #2883](https://github.com/modmail-dev/modmail/issues/2883)) # v3.7.5 @@ -235,13 +463,13 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Fixed -- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/kyb3r/modmail/issues/2881)) +- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/modmail-dev/modmail/issues/2881)) # v3.7.2 ### Added -- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/kyb3r/modmail/issues/2880)) +- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/modmail-dev/modmail/issues/2880)) ### Fixed @@ -258,31 +486,31 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Added -- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/kyb3r/modmail/issues/2872)) +- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/modmail-dev/modmail/issues/2872)) - Added `react_to_contact_message`, `react_to_contact_emoji` to allow users to create threads by reacting to a message. -- Added `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/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 `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/modmail-dev/modmail/issues/215)) +- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/modmail-dev/modmail/issues/2763)) +- Added `close_on_leave`, `close_on_leave_reason` to automatically close threads upon recipient leaving the server. ([GH #2757](https://github.com/modmail-dev/modmail/issues/2757)) +- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/modmail-dev/modmail/issues/2833)) +- Added `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny` to allow users to confirm that they indeed want to create a new thread. ([GH #2773](https://github.com/modmail-dev/modmail/issues/2773)) +- Support Gyazo image links in message embeds. ([GH #282](https://github.com/modmail-dev/modmail/issues/282)) - Added `silent` argument to `?contact` to restore old behaviour. -- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/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)) +- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/modmail-dev/modmail/issues/2847)) +- Added a way to block roles. ([GH #2753](https://github.com/modmail-dev/modmail/issues/2753)) +- Added `cooldown_thread_title`, `cooldown_thread_response` to customise message sent when user is on a creating thread cooldown. ([GH #2865](https://github.com/modmail-dev/modmail/issues/2865)) +- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/modmail-dev/modmail/issues/2762)) - Support stickers and reject non-messages. (i.e. pin_add) -- Added support for thread titles, `?title`. ([GH #2838](https://github.com/kyb3r/modmail/issues/2838)) +- Added support for thread titles, `?title`. ([GH #2838](https://github.com/modmail-dev/modmail/issues/2838)) - Added `data_collection` to specify if bot metadata should be collected by Modmail developers. -- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/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)) +- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/modmail-dev/modmail/issues/130), [GH #649](https://github.com/modmail-dev/modmail/issues/649)) +- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/modmail-dev/modmail/issues/2842), [PR #2878](https://github.com/modmail-dev/modmail/pull/2878)) - Autoupdates and `?update` which was removed in v3.0.0 ### Fixed - `?contact` now sends members a DM. -- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/kyb3r/modmail/issues/2856)) -- Command truncated after && in alias. ([GH #2870](https://github.com/kyb3r/modmail/issues/2870)) +- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/modmail-dev/modmail/issues/2856)) +- Command truncated after && in alias. ([GH #2870](https://github.com/modmail-dev/modmail/issues/2870)) - `on_plugins_ready` event for plugins works now. ### Improved @@ -291,7 +519,7 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh - `?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)) +- Use enums in config. ([GH #2821](https://github.com/modmail-dev/modmail/issues/2821)) - `on_thread_close` event for plugins. - `on_thread_reply` event for plugins. @@ -314,14 +542,14 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh ### Added - Added `thread_move_title` to specify title of thread moved embed. -- Mark NSFW logs in log message. ([GH #2792](https://github.com/kyb3r/modmail/issues/2792)) -- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/kyb3r/modmail/issues/2828)) -- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/kyb3r/modmail/issues/2796)) +- Mark NSFW logs in log message. ([GH #2792](https://github.com/modmail-dev/modmail/issues/2792)) +- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/modmail-dev/modmail/issues/2828)) +- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/modmail-dev/modmail/issues/2796)) ### Changed - `?move` now consumes rest in category name, which means `?move Long Category Name` works without quotes! -- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/kyb3r/modmail/pull/2845)) +- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/modmail-dev/modmail/pull/2845)) ### Fixed - Unicode errors raised during windows selfhosting @@ -330,7 +558,7 @@ This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 wh - 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/modmail-dev/modmail/issues/2816)) # v3.5.0 @@ -340,7 +568,7 @@ Fixed discord.py issue. ### Added - A confirmation when you manually delete a thread message embed. -- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/kyb3r/modmail/issues/2803)) +- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/modmail-dev/modmail/issues/2803)) - Added `?plugins reset` command to completely reset everything related to plugins. This will fix some problems caused by broken plugins in the file system. - Support private GitHub repos for plugins (thanks to @officialpiyush pr#2767) @@ -649,7 +877,7 @@ Security update! - Removed auto-update functionality and the `?update` command in favor of the [Pull app](https://github.com/apps/pull). -Read more about updating your bot [here](https://github.com/kyb3r/modmail/wiki/updating) +Read more about updating your bot [here](https://github.com/modmail-dev/modmail/wiki/updating) ### Changed - Channel names now can contain Unicode characters. @@ -694,7 +922,7 @@ Added a 🛑 reaction to the paginators to delete the embed. ### Fixed -`?blocked` is now paginated using reactions. This fixes [#249](https://github.com/kyb3r/modmail/issues/249) +`?blocked` is now paginated using reactions. This fixes [#249](https://github.com/modmail-dev/modmail/issues/249) # v2.21.0 @@ -733,7 +961,7 @@ This update contains mostly internal changes. ### What's new? -New `?oauth whitelist` command, which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app, check the logviewer [repo](https://github.com/kyb3r/logviewer). +New `?oauth whitelist` command, which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app, check the logviewer [repo](https://github.com/modmail-dev/logviewer). # v2.19.1 @@ -1018,7 +1246,7 @@ Added image link in title in case discord fails to embed an image. ### What's new? - Plugins: - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit? Plugins are your go-to! - - [Creating Plugins Documentation](https://github.com/kyb3r/modmail/wiki/Plugins). + - [Creating Plugins Documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). # v2.12.5 @@ -1029,7 +1257,7 @@ Added image link in title in case discord fails to embed an image. # v2.12.4 ### What's new? -- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/kyb3r/modmail/blob/master/core/_color_data.py). +- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/modmail-dev/modmail/blob/master/core/_color_data.py). - Named colors can be set the same way as hex. But this can only be done through `config set`, which means database modifications will not work. - For example: `config set main_color yellowish green`. - New config var `main_color` allows you to customize the main Modmail color (as requested by many). Defaults to Discord `blurple`. @@ -1223,7 +1451,7 @@ Thread channels will now default to being private (`@everyone`'s read message pe ### Background - Bots hosted by Heroku restart at least once every 27 hours. - During this period, local caches will be deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. -- [PR #135](https://github.com/kyb3r/modmail/pull/135) +- [PR #135](https://github.com/modmail-dev/modmail/pull/135) ### Changed - Created a new internal config var: `closures`. @@ -1261,7 +1489,7 @@ Fixed a bug in the `?activity` command where it would fail to set the activity o ### What's new? - Added the `?activity` command for setting the activity -- [PR #131](https://github.com/kyb3r/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). +- [PR #131](https://github.com/modmail-dev/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). ### Removed - Removed the deprecated `status` command. @@ -1394,9 +1622,9 @@ Fixed a bug in the `?activity` command where it would fail to set the activity o # v2.0.0 -This release introduces the use of our centralized [API service](https://github.com/kyb3r/webserver) to enable dynamic configuration, auto-updates, and thread logs. +This release introduces the use of our centralized [API service](https://github.com/modmail-dev/webserver) to enable dynamic configuration, auto-updates, and thread logs. To use this release, you must acquire an API token from https://modmail.tk. -Read the updated installation guide [here](https://github.com/kyb3r/modmail/wiki/installation). +Read the updated installation guide [here](https://github.com/modmail-dev/modmail/wiki/installation). ### Changed - Stability improvements through synchronization primitives. diff --git a/Dockerfile b/Dockerfile index 8165e06a53..246d3cf7a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,38 @@ -FROM python:3.7-alpine -ENV USING_DOCKER yes -WORKDIR /modmailbot -COPY . /modmailbot -RUN export PIP_NO_CACHE_DIR=false \ - && apk update \ - && apk add --update --no-cache --virtual .build-deps alpine-sdk \ - && pip install pipenv \ - && pipenv install --deploy --ignore-pipfile \ - && apk del .build-deps -CMD ["pipenv", "run", "bot"] \ No newline at end of file +FROM python:3.11-slim-bookworm as base + +RUN apt-get update && \ + apt-get install --no-install-recommends -y \ + # Install CairoSVG dependencies. + libcairo2 && \ + # Cleanup APT. + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + # Create a non-root user. + useradd --shell /usr/sbin/nologin --create-home -d /opt/modmail modmail + +FROM base as builder + +COPY requirements.txt . + +RUN pip install --root-user-action=ignore --no-cache-dir --upgrade pip wheel && \ + python -m venv /opt/modmail/.venv && \ + . /opt/modmail/.venv/bin/activate && \ + pip install --no-cache-dir --upgrade -r requirements.txt + +FROM base + +# Copy the entire venv. +COPY --from=builder --chown=modmail:modmail /opt/modmail/.venv /opt/modmail/.venv + +# Copy repository files. +WORKDIR /opt/modmail +USER modmail:modmail +COPY --chown=modmail:modmail . . + +# This sets some Python runtime variables and disables the internal auto-update. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH=/opt/modmail/.venv/bin:$PATH \ + USING_DOCKER=yes + +CMD ["python", "bot.py"] diff --git a/Pipfile b/Pipfile index f9621c3493..21205b36f5 100644 --- a/Pipfile +++ b/Pipfile @@ -3,31 +3,28 @@ 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 = "*" -bandit = "==1.6.2" -flake8 = "*" +bandit = ">=1.7.5" +black = "==23.11.0" +pylint = "==3.0.2" +typing-extensions = "==4.8.0" [packages] -colorama = ">=0.4.0" -python-dateutil = ">=2.7.0" -emoji = ">=0.2" -uvloop = {version = ">=0.12.0",sys_platform = "!= 'win32'"} -motor = ">=2.0.0" -natural = "==0.2.0" -isodate = ">=0.6.0" -dnspython = "~=1.16.0" +aiohttp = "==3.9.0" +colorama = "==0.4.6" +"discord.py" = {version = "==2.3.2", extras = ["speed"]} +emoji = "==2.8.0" +isodate = "==0.6.1" +motor = "==3.3.2" +natural = "==0.2.0" # Why is this needed? +packaging = "==23.2" parsedatetime = "==2.6" -aiohttp = ">=3.6.0,<3.7.0" -python-dotenv = ">=0.10.3" -pipenv = "*" -"discord.py" = "==1.6.0" +pymongo = {extras = ["srv"], version = "*"} # Required by motor +python-dateutil = "==2.8.2" +python-dotenv = "==1.0.0" +uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} +lottie = {version = "==0.7.0", extras = ["pdf"]} +requests = "==2.31.0" [scripts] bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index aaa491011e..5f07c7b131 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,16 +1,11 @@ { "_meta": { "hash": { - "sha256": "bfe06c9fe1db25178b01413114093b26f0d32e9a90a031c048ff3ddc7b6644b7" + "sha256": "7fee393ea9ea4c0b923033f0da0fdc590ba3f75c6072812062cdc458b84bf9ae" }, "pipfile-spec": 6, "requires": {}, "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - }, { "name": "pypi", "url": "https://pypi.org/simple", @@ -19,153 +14,620 @@ ] }, "default": { + "aiodns": { + "hashes": [ + "sha256:1073eac48185f7a4150cad7f96a5192d6911f12b4fb894de80a088508c9b3a99", + "sha256:a387b63da4ced6aad35b1dda2d09620ad608a1c7c0fb71efa07ebb4cd511928d" + ], + "version": "==3.1.1" + }, "aiohttp": { "hashes": [ - "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe", - "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599", - "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8", - "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a", - "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37", - "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5", - "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0", - "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c", - "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645", - "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98", - "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d", - "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81", - "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5" + "sha256:05857848da443c8c12110d99285d499b4e84d59918a21132e45c3f0804876994", + "sha256:05a183f1978802588711aed0dea31e697d760ce9055292db9dc1604daa9a8ded", + "sha256:09f23292d29135025e19e8ff4f0a68df078fe4ee013bca0105b2e803989de92d", + "sha256:11ca808f9a6b63485059f5f6e164ef7ec826483c1212a44f268b3653c91237d8", + "sha256:1736d87dad8ef46a8ec9cddd349fa9f7bd3a064c47dd6469c0d6763d3d49a4fc", + "sha256:1df43596b826022b14998f0460926ce261544fedefe0d2f653e1b20f49e96454", + "sha256:23170247ef89ffa842a02bbfdc425028574d9e010611659abeb24d890bc53bb8", + "sha256:2779f5e7c70f7b421915fd47db332c81de365678180a9f3ab404088f87ba5ff9", + "sha256:28185e36a78d247c55e9fbea2332d16aefa14c5276a582ce7a896231c6b1c208", + "sha256:2cbc14a13fb6b42d344e4f27746a4b03a2cb0c1c3c5b932b0d6ad8881aa390e3", + "sha256:2d71abc15ff7047412ef26bf812dfc8d0d1020d664617f4913df2df469f26b76", + "sha256:2d820162c8c2bdbe97d328cd4f417c955ca370027dce593345e437b2e9ffdc4d", + "sha256:317719d7f824eba55857fe0729363af58e27c066c731bc62cd97bc9c3d9c7ea4", + "sha256:35a68cd63ca6aaef5707888f17a70c36efe62b099a4e853d33dc2e9872125be8", + "sha256:3607375053df58ed6f23903aa10cf3112b1240e8c799d243bbad0f7be0666986", + "sha256:366bc870d7ac61726f32a489fbe3d1d8876e87506870be66b01aeb84389e967e", + "sha256:3abf0551874fecf95f93b58f25ef4fc9a250669a2257753f38f8f592db85ddea", + "sha256:3d7f6235c7475658acfc1769d968e07ab585c79f6ca438ddfecaa9a08006aee2", + "sha256:3dd8119752dd30dd7bca7d4bc2a92a59be6a003e4e5c2cf7e248b89751b8f4b7", + "sha256:42fe4fd9f0dfcc7be4248c162d8056f1d51a04c60e53366b0098d1267c4c9da8", + "sha256:45820ddbb276113ead8d4907a7802adb77548087ff5465d5c554f9aa3928ae7d", + "sha256:4790e44f46a4aa07b64504089def5744d3b6780468c4ec3a1a36eb7f2cae9814", + "sha256:4afa8f71dba3a5a2e1e1282a51cba7341ae76585345c43d8f0e624882b622218", + "sha256:4b777c9286b6c6a94f50ddb3a6e730deec327e9e2256cb08b5530db0f7d40fd8", + "sha256:4ee1b4152bc3190cc40ddd6a14715e3004944263ea208229ab4c297712aa3075", + "sha256:51a4cd44788ea0b5e6bb8fa704597af3a30be75503a7ed1098bc5b8ffdf6c982", + "sha256:536b01513d67d10baf6f71c72decdf492fb7433c5f2f133e9a9087379d4b6f31", + "sha256:571760ad7736b34d05597a1fd38cbc7d47f7b65deb722cb8e86fd827404d1f6b", + "sha256:5a2eb5311a37fe105aa35f62f75a078537e1a9e4e1d78c86ec9893a3c97d7a30", + "sha256:5ab16c254e2312efeb799bc3c06897f65a133b38b69682bf75d1f1ee1a9c43a9", + "sha256:65b0a70a25456d329a5e1426702dde67be0fb7a4ead718005ba2ca582d023a94", + "sha256:673343fbc0c1ac44d0d2640addc56e97a052504beacd7ade0dc5e76d3a4c16e8", + "sha256:6777a390e41e78e7c45dab43a4a0196c55c3b8c30eebe017b152939372a83253", + "sha256:6896b8416be9ada4d22cd359d7cb98955576ce863eadad5596b7cdfbf3e17c6c", + "sha256:694df243f394629bcae2d8ed94c589a181e8ba8604159e6e45e7b22e58291113", + "sha256:70e851f596c00f40a2f00a46126c95c2e04e146015af05a9da3e4867cfc55911", + "sha256:7276fe0017664414fdc3618fca411630405f1aaf0cc3be69def650eb50441787", + "sha256:76a86a9989ebf82ee61e06e2bab408aec4ea367dc6da35145c3352b60a112d11", + "sha256:7a94bde005a8f926d0fa38b88092a03dea4b4875a61fbcd9ac6f4351df1b57cd", + "sha256:7ae5f99a32c53731c93ac3075abd3e1e5cfbe72fc3eaac4c27c9dd64ba3b19fe", + "sha256:7e8a3b79b6d186a9c99761fd4a5e8dd575a48d96021f220ac5b5fa856e5dd029", + "sha256:816f4db40555026e4cdda604a1088577c1fb957d02f3f1292e0221353403f192", + "sha256:8303531e2c17b1a494ffaeba48f2da655fe932c4e9a2626c8718403c83e5dd2b", + "sha256:8488519aa05e636c5997719fe543c8daf19f538f4fa044f3ce94bee608817cff", + "sha256:87c8b0a6487e8109427ccf638580865b54e2e3db4a6e0e11c02639231b41fc0f", + "sha256:8c9e5f4d7208cda1a2bb600e29069eecf857e6980d0ccc922ccf9d1372c16f4b", + "sha256:94697c7293199c2a2551e3e3e18438b4cba293e79c6bc2319f5fd652fccb7456", + "sha256:9623cfd9e85b76b83ef88519d98326d4731f8d71869867e47a0b979ffec61c73", + "sha256:98d21092bf2637c5fa724a428a69e8f5955f2182bff61f8036827cf6ce1157bf", + "sha256:99ae01fb13a618b9942376df77a1f50c20a281390dad3c56a6ec2942e266220d", + "sha256:9c196b30f1b1aa3363a69dd69079ae9bec96c2965c4707eaa6914ba099fb7d4f", + "sha256:a00ce44c21612d185c5275c5cba4bab8d7c1590f248638b667ed8a782fa8cd6f", + "sha256:a1b66dbb8a7d5f50e9e2ea3804b01e766308331d0cac76eb30c563ac89c95985", + "sha256:a1d7edf74a36de0e5ca50787e83a77cf352f5504eb0ffa3f07000a911ba353fb", + "sha256:a1e3b3c107ccb0e537f309f719994a55621acd2c8fdf6d5ce5152aed788fb940", + "sha256:a486ddf57ab98b6d19ad36458b9f09e6022de0381674fe00228ca7b741aacb2f", + "sha256:ac9669990e2016d644ba8ae4758688534aabde8dbbc81f9af129c3f5f01ca9cd", + "sha256:b1a2ea8252cacc7fd51df5a56d7a2bb1986ed39be9397b51a08015727dfb69bd", + "sha256:c5b7bf8fe4d39886adc34311a233a2e01bc10eb4e842220235ed1de57541a896", + "sha256:c67a51ea415192c2e53e4e048c78bab82d21955b4281d297f517707dc836bf3d", + "sha256:ca4fddf84ac7d8a7d0866664936f93318ff01ee33e32381a115b19fb5a4d1202", + "sha256:d5b9345ab92ebe6003ae11d8092ce822a0242146e6fa270889b9ba965457ca40", + "sha256:d97c3e286d0ac9af6223bc132dc4bad6540b37c8d6c0a15fe1e70fb34f9ec411", + "sha256:db04d1de548f7a62d1dd7e7cdf7c22893ee168e22701895067a28a8ed51b3735", + "sha256:dcf71c55ec853826cd70eadb2b6ac62ec577416442ca1e0a97ad875a1b3a0305", + "sha256:de3cc86f4ea8b4c34a6e43a7306c40c1275e52bfa9748d869c6b7d54aa6dad80", + "sha256:deac0a32aec29608eb25d730f4bc5a261a65b6c48ded1ed861d2a1852577c932", + "sha256:e18d92c3e9e22553a73e33784fcb0ed484c9874e9a3e96c16a8d6a1e74a0217b", + "sha256:eb6dfd52063186ac97b4caa25764cdbcdb4b10d97f5c5f66b0fa95052e744eb7", + "sha256:f09960b5bb1017d16c0f9e9f7fc42160a5a49fa1e87a175fd4a2b1a1833ea0af", + "sha256:f1e4f254e9c35d8965d377e065c4a8a55d396fe87c8e7e8429bcfdeeb229bfb3", + "sha256:f32c86dc967ab8c719fd229ce71917caad13cc1e8356ee997bf02c5b368799bf", + "sha256:f50b4663c3e0262c3a361faf440761fbef60ccdde5fe8545689a4b3a3c149fb4", + "sha256:f8e05f5163528962ce1d1806fce763ab893b1c5b7ace0a3538cd81a90622f844", + "sha256:f929f4c9b9a00f3e6cc0587abb95ab9c05681f8b14e0fe1daecfa83ea90f8318", + "sha256:f9e09a1c83521d770d170b3801eea19b89f41ccaa61d53026ed111cb6f088887" ], "index": "pypi", - "version": "==3.6.3" + "markers": "python_version >= '3.8'", + "version": "==3.9.0" }, - "appdirs": { + "aiosignal": { "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", + "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" ], - "version": "==1.4.4" + "markers": "python_version >= '3.7'", + "version": "==1.3.1" }, "async-timeout": { "hashes": [ - "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", - "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" ], - "markers": "python_full_version >= '3.5.3'", - "version": "==3.0.1" + "markers": "python_version < '3.11'", + "version": "==4.0.3" }, "attrs": { "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" + "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", + "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" + "markers": "python_version >= '3.7'", + "version": "==23.1.0" + }, + "brotli": { + "hashes": [ + "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208", + "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48", + "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354", + "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a", + "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", + "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c", + "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088", + "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", + "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a", + "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", + "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438", + "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578", + "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b", + "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b", + "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68", + "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d", + "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", + "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", + "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", + "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", + "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0", + "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", + "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", + "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112", + "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", + "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", + "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", + "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95", + "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", + "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914", + "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", + "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a", + "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7", + "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", + "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", + "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f", + "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", + "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", + "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", + "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", + "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", + "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97", + "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d", + "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf", + "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac", + "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", + "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74", + "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60", + "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c", + "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1", + "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", + "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", + "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", + "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", + "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460", + "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751", + "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9", + "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", + "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474", + "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", + "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", + "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", + "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", + "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467", + "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619", + "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", + "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", + "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579", + "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84", + "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b", + "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59", + "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", + "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", + "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", + "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2", + "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3", + "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64", + "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643", + "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", + "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985", + "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596", + "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2", + "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064" + ], + "version": "==1.1.0" + }, + "cairocffi": { + "hashes": [ + "sha256:78e6bbe47357640c453d0be929fa49cd05cce2e1286f3d2a1ca9cbda7efdb8b7", + "sha256:aa78ee52b9069d7475eeac457389b6275aa92111895d78fbaa2202a52dac112e" + ], + "markers": "python_version >= '3.7'", + "version": "==1.6.1" + }, + "cairosvg": { + "hashes": [ + "sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0", + "sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b" + ], + "markers": "python_version >= '3.5'", + "version": "==2.7.1" }, "certifi": { "hashes": [ - "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", - "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.11.17" + }, + "cffi": { + "hashes": [ + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" ], - "version": "==2020.12.5" + "markers": "python_version >= '3.8'", + "version": "==1.16.0" }, - "chardet": { + "charset-normalizer": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" ], - "version": "==3.0.4" + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.2" }, "colorama": { "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], "index": "pypi", - "version": "==0.4.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" }, - "discord.py": { + "cssselect2": { "hashes": [ - "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", - "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" + "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a", + "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" ], - "index": "pypi", - "version": "==1.6.0" + "markers": "python_version >= '3.7'", + "version": "==0.7.0" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" }, - "distlib": { + "discord.py": { + "extras": [ + "speed" + ], "hashes": [ - "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", - "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" + "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c", + "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6" ], - "version": "==0.3.1" + "markers": "python_full_version >= '3.8.0'", + "version": "==2.3.2" }, "dnspython": { "hashes": [ - "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", - "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d" + "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8", + "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984" ], - "index": "pypi", - "version": "==1.16.0" + "markers": "python_version >= '3.8' and python_version < '4.0'", + "version": "==2.4.2" }, "emoji": { "hashes": [ - "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11" + "sha256:8d8b5dec3c507444b58890e598fc895fcec022b3f5acb49497c6ccc5208b8b00", + "sha256:a8468fd836b7ecb6d1eac054c9a591701ce0ccd6c6f7779ad71b66f76664df90" ], "index": "pypi", - "version": "==0.6.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.0" }, - "filelock": { + "frozenlist": { "hashes": [ - "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", - "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" + "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6", + "sha256:008eb8b31b3ea6896da16c38c1b136cb9fec9e249e77f6211d479db79a4eaf01", + "sha256:09163bdf0b2907454042edb19f887c6d33806adc71fbd54afc14908bfdc22251", + "sha256:0c7c1b47859ee2cac3846fde1c1dc0f15da6cec5a0e5c72d101e0f83dcb67ff9", + "sha256:0e5c8764c7829343d919cc2dfc587a8db01c4f70a4ebbc49abde5d4b158b007b", + "sha256:10ff5faaa22786315ef57097a279b833ecab1a0bfb07d604c9cbb1c4cdc2ed87", + "sha256:17ae5cd0f333f94f2e03aaf140bb762c64783935cc764ff9c82dff626089bebf", + "sha256:19488c57c12d4e8095a922f328df3f179c820c212940a498623ed39160bc3c2f", + "sha256:1a0848b52815006ea6596c395f87449f693dc419061cc21e970f139d466dc0a0", + "sha256:1e78fb68cf9c1a6aa4a9a12e960a5c9dfbdb89b3695197aa7064705662515de2", + "sha256:261b9f5d17cac914531331ff1b1d452125bf5daa05faf73b71d935485b0c510b", + "sha256:2b8bcf994563466db019fab287ff390fffbfdb4f905fc77bc1c1d604b1c689cc", + "sha256:38461d02d66de17455072c9ba981d35f1d2a73024bee7790ac2f9e361ef1cd0c", + "sha256:490132667476f6781b4c9458298b0c1cddf237488abd228b0b3650e5ecba7467", + "sha256:491e014f5c43656da08958808588cc6c016847b4360e327a62cb308c791bd2d9", + "sha256:515e1abc578dd3b275d6a5114030b1330ba044ffba03f94091842852f806f1c1", + "sha256:556de4430ce324c836789fa4560ca62d1591d2538b8ceb0b4f68fb7b2384a27a", + "sha256:5833593c25ac59ede40ed4de6d67eb42928cca97f26feea219f21d0ed0959b79", + "sha256:6221d84d463fb110bdd7619b69cb43878a11d51cbb9394ae3105d082d5199167", + "sha256:6918d49b1f90821e93069682c06ffde41829c346c66b721e65a5c62b4bab0300", + "sha256:6c38721585f285203e4b4132a352eb3daa19121a035f3182e08e437cface44bf", + "sha256:71932b597f9895f011f47f17d6428252fc728ba2ae6024e13c3398a087c2cdea", + "sha256:7211ef110a9194b6042449431e08c4d80c0481e5891e58d429df5899690511c2", + "sha256:764226ceef3125e53ea2cb275000e309c0aa5464d43bd72abd661e27fffc26ab", + "sha256:7645a8e814a3ee34a89c4a372011dcd817964ce8cb273c8ed6119d706e9613e3", + "sha256:76d4711f6f6d08551a7e9ef28c722f4a50dd0fc204c56b4bcd95c6cc05ce6fbb", + "sha256:7f4f399d28478d1f604c2ff9119907af9726aed73680e5ed1ca634d377abb087", + "sha256:88f7bc0fcca81f985f78dd0fa68d2c75abf8272b1f5c323ea4a01a4d7a614efc", + "sha256:8d0edd6b1c7fb94922bf569c9b092ee187a83f03fb1a63076e7774b60f9481a8", + "sha256:901289d524fdd571be1c7be054f48b1f88ce8dddcbdf1ec698b27d4b8b9e5d62", + "sha256:93ea75c050c5bb3d98016b4ba2497851eadf0ac154d88a67d7a6816206f6fa7f", + "sha256:981b9ab5a0a3178ff413bca62526bb784249421c24ad7381e39d67981be2c326", + "sha256:9ac08e601308e41eb533f232dbf6b7e4cea762f9f84f6357136eed926c15d12c", + "sha256:a02eb8ab2b8f200179b5f62b59757685ae9987996ae549ccf30f983f40602431", + "sha256:a0c6da9aee33ff0b1a451e867da0c1f47408112b3391dd43133838339e410963", + "sha256:a6c8097e01886188e5be3e6b14e94ab365f384736aa1fca6a0b9e35bd4a30bc7", + "sha256:aa384489fefeb62321b238e64c07ef48398fe80f9e1e6afeff22e140e0850eef", + "sha256:ad2a9eb6d9839ae241701d0918f54c51365a51407fd80f6b8289e2dfca977cc3", + "sha256:b206646d176a007466358aa21d85cd8600a415c67c9bd15403336c331a10d956", + "sha256:b826d97e4276750beca7c8f0f1a4938892697a6bcd8ec8217b3312dad6982781", + "sha256:b89ac9768b82205936771f8d2eb3ce88503b1556324c9f903e7156669f521472", + "sha256:bd7bd3b3830247580de99c99ea2a01416dfc3c34471ca1298bccabf86d0ff4dc", + "sha256:bdf1847068c362f16b353163391210269e4f0569a3c166bc6a9f74ccbfc7e839", + "sha256:c11b0746f5d946fecf750428a95f3e9ebe792c1ee3b1e96eeba145dc631a9672", + "sha256:c5374b80521d3d3f2ec5572e05adc94601985cc526fb276d0c8574a6d749f1b3", + "sha256:ca265542ca427bf97aed183c1676e2a9c66942e822b14dc6e5f42e038f92a503", + "sha256:ce31ae3e19f3c902de379cf1323d90c649425b86de7bbdf82871b8a2a0615f3d", + "sha256:ceb6ec0a10c65540421e20ebd29083c50e6d1143278746a4ef6bcf6153171eb8", + "sha256:d081f13b095d74b67d550de04df1c756831f3b83dc9881c38985834387487f1b", + "sha256:d5655a942f5f5d2c9ed93d72148226d75369b4f6952680211972a33e59b1dfdc", + "sha256:d5a32087d720c608f42caed0ef36d2b3ea61a9d09ee59a5142d6070da9041b8f", + "sha256:d6484756b12f40003c6128bfcc3fa9f0d49a687e171186c2d85ec82e3758c559", + "sha256:dd65632acaf0d47608190a71bfe46b209719bf2beb59507db08ccdbe712f969b", + "sha256:de343e75f40e972bae1ef6090267f8260c1446a1695e77096db6cfa25e759a95", + "sha256:e29cda763f752553fa14c68fb2195150bfab22b352572cb36c43c47bedba70eb", + "sha256:e41f3de4df3e80de75845d3e743b3f1c4c8613c3997a912dbf0229fc61a8b963", + "sha256:e66d2a64d44d50d2543405fb183a21f76b3b5fd16f130f5c99187c3fb4e64919", + "sha256:e74b0506fa5aa5598ac6a975a12aa8928cbb58e1f5ac8360792ef15de1aa848f", + "sha256:f0ed05f5079c708fe74bf9027e95125334b6978bf07fd5ab923e9e55e5fbb9d3", + "sha256:f61e2dc5ad442c52b4887f1fdc112f97caeff4d9e6ebe78879364ac59f1663e1", + "sha256:fec520865f42e5c7f050c2a79038897b1c7d1595e907a9e08e3353293ffc948e" ], - "version": "==3.0.12" + "markers": "python_version >= '3.8'", + "version": "==1.4.0" }, "idna": { "hashes": [ - "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", - "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" ], - "markers": "python_version >= '3.4'", - "version": "==3.1" + "markers": "python_version >= '3.5'", + "version": "==3.4" }, "isodate": { "hashes": [ - "sha256:2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8", - "sha256:aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81" + "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", + "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.6.1" + }, + "lottie": { + "extras": [ + "pdf" + ], + "hashes": [ + "sha256:a3242f8ba37051fbdd7503ecd168203a08e4af26f17be2ecca08a64af1e7d3c1" + ], + "markers": "python_version >= '3'", + "version": "==0.7.0" }, "motor": { "hashes": [ - "sha256:428d94750123d19fcd0a89b8671ff9b4656f205217bad9f44161748c64c5fc80", - "sha256:f1692b760d834707e3477996ce8d407af8cd61c1a2abedbf81c22ef14675e61a" + "sha256:6fe7e6f0c4f430b9e030b9d22549b732f7c2226af3ab71ecc309e4a1b7d19953", + "sha256:d2fc38de15f1c8058f389c1a44a4d4105c0405c48c061cd492a654496f7bc26a" ], "index": "pypi", - "version": "==2.3.0" + "markers": "python_version >= '3.7'", + "version": "==3.3.2" }, "multidict": { "hashes": [ - "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", - "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", - "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", - "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", - "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", - "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", - "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", - "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", - "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", - "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", - "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", - "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", - "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", - "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", - "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", - "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", - "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" + "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", + "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", + "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", + "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", + "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", + "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", + "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", + "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", + "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", + "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", + "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", + "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", + "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", + "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", + "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", + "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", + "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", + "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", + "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", + "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", + "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", + "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", + "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", + "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", + "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", + "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", + "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", + "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", + "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", + "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", + "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", + "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", + "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", + "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", + "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", + "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", + "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", + "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", + "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", + "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", + "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", + "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", + "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", + "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", + "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", + "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", + "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", + "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", + "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", + "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", + "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", + "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", + "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", + "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", + "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", + "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", + "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", + "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", + "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", + "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", + "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", + "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", + "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", + "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", + "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", + "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", + "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", + "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", + "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", + "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", + "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", + "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", + "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", + "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" ], - "markers": "python_version >= '3.5'", - "version": "==4.7.6" + "markers": "python_version >= '3.7'", + "version": "==6.0.4" }, "natural": { "hashes": [ @@ -174,6 +636,70 @@ "index": "pypi", "version": "==0.2.0" }, + "orjson": { + "hashes": [ + "sha256:06ad5543217e0e46fd7ab7ea45d506c76f878b87b1b4e369006bdb01acc05a83", + "sha256:0a73160e823151f33cdc05fe2cea557c5ef12fdf276ce29bb4f1c571c8368a60", + "sha256:1234dc92d011d3554d929b6cf058ac4a24d188d97be5e04355f1b9223e98bbe9", + "sha256:1d0dc4310da8b5f6415949bd5ef937e60aeb0eb6b16f95041b5e43e6200821fb", + "sha256:2a11b4b1a8415f105d989876a19b173f6cdc89ca13855ccc67c18efbd7cbd1f8", + "sha256:2e2ecd1d349e62e3960695214f40939bbfdcaeaaa62ccc638f8e651cf0970e5f", + "sha256:3a2ce5ea4f71681623f04e2b7dadede3c7435dfb5e5e2d1d0ec25b35530e277b", + "sha256:3e892621434392199efb54e69edfff9f699f6cc36dd9553c5bf796058b14b20d", + "sha256:3fb205ab52a2e30354640780ce4587157a9563a68c9beaf52153e1cea9aa0921", + "sha256:4689270c35d4bb3102e103ac43c3f0b76b169760aff8bcf2d401a3e0e58cdb7f", + "sha256:49f8ad582da6e8d2cf663c4ba5bf9f83cc052570a3a767487fec6af839b0e777", + "sha256:4bd176f528a8151a6efc5359b853ba3cc0e82d4cd1fab9c1300c5d957dc8f48c", + "sha256:4cf7837c3b11a2dfb589f8530b3cff2bd0307ace4c301e8997e95c7468c1378e", + "sha256:4fd72fab7bddce46c6826994ce1e7de145ae1e9e106ebb8eb9ce1393ca01444d", + "sha256:5148bab4d71f58948c7c39d12b14a9005b6ab35a0bdf317a8ade9a9e4d9d0bd5", + "sha256:5869e8e130e99687d9e4be835116c4ebd83ca92e52e55810962446d841aba8de", + "sha256:602a8001bdf60e1a7d544be29c82560a7b49319a0b31d62586548835bbe2c862", + "sha256:61804231099214e2f84998316f3238c4c2c4aaec302df12b21a64d72e2a135c7", + "sha256:666c6fdcaac1f13eb982b649e1c311c08d7097cbda24f32612dae43648d8db8d", + "sha256:674eb520f02422546c40401f4efaf8207b5e29e420c17051cddf6c02783ff5ca", + "sha256:7ec960b1b942ee3c69323b8721df2a3ce28ff40e7ca47873ae35bfafeb4555ca", + "sha256:7f433be3b3f4c66016d5a20e5b4444ef833a1f802ced13a2d852c637f69729c1", + "sha256:7f8fb7f5ecf4f6355683ac6881fd64b5bb2b8a60e3ccde6ff799e48791d8f864", + "sha256:81a3a3a72c9811b56adf8bcc829b010163bb2fc308877e50e9910c9357e78521", + "sha256:858379cbb08d84fe7583231077d9a36a1a20eb72f8c9076a45df8b083724ad1d", + "sha256:8b9ba0ccd5a7f4219e67fbbe25e6b4a46ceef783c42af7dbc1da548eb28b6531", + "sha256:92af0d00091e744587221e79f68d617b432425a7e59328ca4c496f774a356071", + "sha256:9ebbdbd6a046c304b1845e96fbcc5559cd296b4dfd3ad2509e33c4d9ce07d6a1", + "sha256:9edd2856611e5050004f4722922b7b1cd6268da34102667bd49d2a2b18bafb81", + "sha256:a353bf1f565ed27ba71a419b2cd3db9d6151da426b61b289b6ba1422a702e643", + "sha256:b5b7d4a44cc0e6ff98da5d56cde794385bdd212a86563ac321ca64d7f80c80d1", + "sha256:b90f340cb6397ec7a854157fac03f0c82b744abdd1c0941a024c3c29d1340aff", + "sha256:c18a4da2f50050a03d1da5317388ef84a16013302a5281d6f64e4a3f406aabc4", + "sha256:c338ed69ad0b8f8f8920c13f529889fe0771abbb46550013e3c3d01e5174deef", + "sha256:c5a02360e73e7208a872bf65a7554c9f15df5fe063dc047f79738998b0506a14", + "sha256:c62b6fa2961a1dcc51ebe88771be5319a93fd89bd247c9ddf732bc250507bc2b", + "sha256:c812312847867b6335cfb264772f2a7e85b3b502d3a6b0586aa35e1858528ab1", + "sha256:c943b35ecdf7123b2d81d225397efddf0bce2e81db2f3ae633ead38e85cd5ade", + "sha256:ce0a29c28dfb8eccd0f16219360530bc3cfdf6bf70ca384dacd36e6c650ef8e8", + "sha256:cf80b550092cc480a0cbd0750e8189247ff45457e5a023305f7ef1bcec811616", + "sha256:cff7570d492bcf4b64cc862a6e2fb77edd5e5748ad715f487628f102815165e9", + "sha256:d2c1e559d96a7f94a4f581e2a32d6d610df5840881a8cba8f25e446f4d792df3", + "sha256:deeb3922a7a804755bbe6b5be9b312e746137a03600f488290318936c1a2d4dc", + "sha256:e28a50b5be854e18d54f75ef1bb13e1abf4bc650ab9d635e4258c58e71eb6ad5", + "sha256:e99c625b8c95d7741fe057585176b1b8783d46ed4b8932cf98ee145c4facf499", + "sha256:ec6f18f96b47299c11203edfbdc34e1b69085070d9a3d1f302810cc23ad36bf3", + "sha256:ed8bc367f725dfc5cabeed1ae079d00369900231fbb5a5280cf0736c30e2adf7", + "sha256:ee5926746232f627a3be1cc175b2cfad24d0170d520361f4ce3fa2fd83f09e1d", + "sha256:f295efcd47b6124b01255d1491f9e46f17ef40d3d7eabf7364099e463fb45f0f", + "sha256:fb0b361d73f6b8eeceba47cd37070b5e6c9de5beaeaa63a1cb35c7e1a73ef088" + ], + "version": "==3.9.10" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, "parsedatetime": { "hashes": [ "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", @@ -182,459 +708,681 @@ "index": "pypi", "version": "==2.6" }, - "pipenv": { + "pillow": { "hashes": [ - "sha256:4ab2f60742184d851ac44b9e1d423afe71dc2ea7a68bde07eb890c8b4ce5a420", - "sha256:8253fe6f9cfb3791a54da8a0571f73c918cb3457dd908684c1800a13a06ec4c1" + "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d", + "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de", + "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616", + "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839", + "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099", + "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a", + "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219", + "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106", + "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b", + "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412", + "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b", + "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7", + "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2", + "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7", + "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14", + "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f", + "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27", + "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57", + "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262", + "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28", + "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610", + "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172", + "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273", + "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e", + "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d", + "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818", + "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f", + "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9", + "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01", + "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7", + "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651", + "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312", + "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80", + "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666", + "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061", + "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b", + "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992", + "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593", + "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4", + "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db", + "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba", + "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd", + "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e", + "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212", + "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb", + "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2", + "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34", + "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256", + "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f", + "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2", + "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38", + "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996", + "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a", + "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793" ], - "index": "pypi", - "version": "==2020.11.15" + "markers": "python_version >= '3.8'", + "version": "==10.1.0" + }, + "pycares": { + "hashes": [ + "sha256:112a4979c695b1c86f6782163d7dec58d57a3b9510536dcf4826550f9053dd9a", + "sha256:1168a48a834813aa80f412be2df4abaf630528a58d15c704857448b20b1675c0", + "sha256:21a5a0468861ec7df7befa69050f952da13db5427ae41ffe4713bc96291d1d95", + "sha256:229a1675eb33bc9afb1fc463e73ee334950ccc485bc83a43f6ae5839fb4d5fa3", + "sha256:22c00bf659a9fa44d7b405cf1cd69b68b9d37537899898d8cbe5dffa4016b273", + "sha256:23aa3993a352491a47fcf17867f61472f32f874df4adcbb486294bd9fbe8abee", + "sha256:24da119850841d16996713d9c3374ca28a21deee056d609fbbed29065d17e1f6", + "sha256:2eeec144bcf6a7b6f2d74d6e70cbba7886a84dd373c886f06cb137a07de4954c", + "sha256:34736a2ffaa9c08ca9c707011a2d7b69074bbf82d645d8138bba771479b2362f", + "sha256:3aebc73e5ad70464f998f77f2da2063aa617cbd8d3e8174dd7c5b4518f967153", + "sha256:3eaa6681c0a3e3f3868c77aca14b7760fed35fdfda2fe587e15c701950e7bc69", + "sha256:4afc2644423f4eef97857a9fd61be9758ce5e336b4b0bd3d591238bb4b8b03e0", + "sha256:52084961262232ec04bd75f5043aed7e5d8d9695e542ff691dfef0110209f2d4", + "sha256:56cf3349fa3a2e67ed387a7974c11d233734636fe19facfcda261b411af14d80", + "sha256:5ed4e04af4012f875b78219d34434a6d08a67175150ac1b79eb70ab585d4ba8c", + "sha256:64965dc19c578a683ea73487a215a8897276224e004d50eeb21f0bc7a0b63c88", + "sha256:6ef64649eba56448f65e26546d85c860709844d2fc22ef14d324fe0b27f761a9", + "sha256:77cf5a2fd5583c670de41a7f4a7b46e5cbabe7180d8029f728571f4d2e864084", + "sha256:7bddc6adba8f699728f7fc1c9ce8cef359817ad78e2ed52b9502cb5f8dc7f741", + "sha256:813d661cbe2e37d87da2d16b7110a6860e93ddb11735c6919c8a3545c7b9c8d8", + "sha256:82bba2ab77eb5addbf9758d514d9bdef3c1bfe7d1649a47bd9a0d55a23ef478b", + "sha256:8bf2eaa83a5987e48fa63302f0fe7ce3275cfda87b34d40fef9ce703fb3ac002", + "sha256:8d186dafccdaa3409194c0f94db93c1a5d191145a275f19da6591f9499b8e7b8", + "sha256:8f64cb58729689d4d0e78f0bfb4c25ce2f851d0274c0273ac751795c04b8798a", + "sha256:902461a92b6a80fd5041a2ec5235680c7cc35e43615639ec2a40e63fca2dfb51", + "sha256:917f08f0b5d9324e9a34211e68d27447c552b50ab967044776bbab7e42a553a2", + "sha256:94d6962db81541eb0396d2f0dfcbb18cdb8c8b251d165efc2d974ae652c547d4", + "sha256:97892cced5794d721fb4ff8765764aa4ea48fe8b2c3820677505b96b83d4ef47", + "sha256:9a0303428d013ccf5c51de59c83f9127aba6200adb7fd4be57eddb432a1edd2a", + "sha256:9dc04c54c6ea615210c1b9e803d0e2d2255f87a3d5d119b6482c8f0dfa15b26b", + "sha256:a0c5368206057884cde18602580083aeaad9b860e2eac14fd253543158ce1e93", + "sha256:ad58e284a658a8a6a84af2e0b62f2f961f303cedfe551854d7bd40c3cbb61912", + "sha256:afb91792f1556f97be7f7acb57dc7756d89c5a87bd8b90363a77dbf9ea653817", + "sha256:b61579cecf1f4d616e5ea31a6e423a16680ab0d3a24a2ffe7bb1d4ee162477ff", + "sha256:b7af06968cbf6851566e806bf3e72825b0e6671832a2cbe840be1d2d65350710", + "sha256:bce8db2fc6f3174bd39b81405210b9b88d7b607d33e56a970c34a0c190da0490", + "sha256:bfb89ca9e3d0a9b5332deeb666b2ede9d3469107742158f4aeda5ce032d003f4", + "sha256:c680fef1b502ee680f8f0b95a41af4ec2c234e50e16c0af5bbda31999d3584bd", + "sha256:c6a8bde63106f162fca736e842a916853cad3c8d9d137e11c9ffa37efa818b02", + "sha256:cb49d5805cd347c404f928c5ae7c35e86ba0c58ffa701dbe905365e77ce7d641", + "sha256:ceb12974367b0a68a05d52f4162b29f575d241bd53de155efe632bf2c943c7f6", + "sha256:d33e2a1120887e89075f7f814ec144f66a6ce06a54f5722ccefc62fbeda83cff", + "sha256:db24c4e7fea4a052c6e869cbf387dd85d53b9736cfe1ef5d8d568d1ca925e977", + "sha256:e3a6f7cfdfd11eb5493d6d632e582408c8f3b429f295f8799c584c108b28db6f", + "sha256:eb66c30eb11e877976b7ead13632082a8621df648c408b8e15cdb91a452dd502", + "sha256:ed2a38e34bec6f2586435f6ff0bc5fe11d14bebd7ed492cf739a424e81681540", + "sha256:f36bdc1562142e3695555d2f4ac0cb69af165eddcefa98efc1c79495b533481f", + "sha256:f47579d508f2f56eddd16ce72045782ad3b1b3b678098699e2b6a1b30733e1c2", + "sha256:f5f646eec041db6ffdbcaf3e0756fb92018f7af3266138c756bb09d2b5baadec", + "sha256:fd644505a8cfd7f6584d33a9066d4e3d47700f050ef1490230c962de5dfb28c6", + "sha256:fff16b09042ba077f7b8aa5868d1d22456f0002574d0ba43462b10a009331677" + ], + "markers": "python_version >= '3.8'", + "version": "==4.4.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" }, "pymongo": { + "extras": [ + "srv" + ], "hashes": [ - "sha256:019ddf7ced8e42cc6c8c608927c799be8097237596c94ffe551f6ef70e55237e", - "sha256:047c325c4a96e7be7d11acf58639bcf71a81ca212d9c6590e3369bc28678647a", - "sha256:047cc2007b280672ddfdf2e7b862aad8d898f481f65bbc9067bfa4e420a019a9", - "sha256:061d59f525831c4051af0b6dbafa62b0b8b168d4ef5b6e3c46d0811b8499d100", - "sha256:082832a59da18efab4d9148cca396451bac99da9757f31767f706e828b5b8500", - "sha256:0a53a751d977ad02f1bd22ddb6288bb4816c4758f44a50225462aeeae9cbf6a0", - "sha256:1222025db539641071a1b67f6950f65a6342a39db5b454bf306abd6954f1ad8a", - "sha256:1580fad512c678b720784e5c9018621b1b3bd37fb5b1633e874738862d6435c7", - "sha256:202ea1d4edc8a5439fc179802d807b49e7e563207fea5610779e56674ac770c6", - "sha256:21d7b48567a1c80f9266e0ab61c1218a31279d911da345679188733e354f81cc", - "sha256:264843ce2af0640994a4331148ef5312989bc004678c457460758766c9b4decc", - "sha256:270a1f6a331eac3a393090af06df68297cb31a8b2df0bdcbd97dc613c5758e78", - "sha256:29a6840c2ac778010547cad5870f3db2e080ad7fad01197b07fff993c08692c8", - "sha256:3646c2286d889618d43e01d9810ac1fc17709d2b4dec61366df5edc8ba228b3e", - "sha256:36b9b98a39565a8f33803c81569442b35e749a72fb1aa7d0bcdb1a33052f8bcc", - "sha256:3ec8f8e106a1476659d8c020228b45614daabdbdb6c6454a843a1d4f77d13339", - "sha256:422069f2cebf58c9dd9e8040b4768f7be4f228c95bc4505e8fa8e7b4f7191ad8", - "sha256:44376a657717de8847d5d71a9305f3595c7e78c91ac77edbb87058d12ede87a6", - "sha256:45728e6aae3023afb5b2829586d1d2bfd9f0d71cfd7d3c924b71a5e9aef617a8", - "sha256:46792b71ab802d9caf1fc9d52e83399ef8e1a36e91eef4d827c06e36b8df2230", - "sha256:4942a5659ae927bb764a123a6409870ca5dd572d83b3bfb71412c9a191bbf792", - "sha256:4be4fe9d18523da98deeb0b554ac76e1dc1562ee879d62572b34dda8593efcc1", - "sha256:523804bd8fcb5255508052b50073a27c701b90a73ea46e29be46dad5fe01bde6", - "sha256:540dafd6f4a0590fc966465c726b80fa7c0804490c39786ef29236fe68c94401", - "sha256:5980509801cbd2942df31714d055d89863684b4de26829c349362e610a48694e", - "sha256:5ad7b96c27acd7e256b33f47cf3d23bd7dd902f9c033ae43f32ffcbc37bebafd", - "sha256:6122470dfa61d4909b75c98012c1577404ba4ab860d0095e0c6980560cb3711f", - "sha256:6175fd105da74a09adb38f93be96e1f64873294c906e5e722cbbc5bd10c44e3b", - "sha256:646d4d30c5aa7c0ddbfe9b990f0f77a88621024a21ad0b792bd9d58caa9611f0", - "sha256:6700e251c6396cc05d7460dc05ef8e19e60a7b53b62c007725b48e123aaa2b1c", - "sha256:6aac7e0e8de92f11a410eb68c24a2decbac6f094e82fd95d22546d0168e7a18b", - "sha256:6e7a6057481a644970e43475292e1c0af095ca39a20fe83781196bd6e6690a38", - "sha256:76579fcf77052b39796fe4a11818d1289dd48cffe15951b3403288fa163c29f6", - "sha256:7e69fa025a1db189443428f345fea5555d16413df6addc056e17bb8c9794b006", - "sha256:7f0c507e1f108790840d6c4b594019ebf595025c324c9f7e9c9b2b15b41f884e", - "sha256:813db97e9955b6b1b50b5cebd18cb148580603bb9b067ea4c5cc656b333bc906", - "sha256:82d5ded5834b6c92380847860eb28dcaf20b847a27cee5811c4aaceef87fd280", - "sha256:82f6e42ba40440a7e0a20bfe12465a3b62d65966a4c7ad1a21b36ffff88de6fe", - "sha256:8d669c720891781e7c82d412cad39f9730ef277e3957b48a3344dae47d3caa03", - "sha256:944ed467feb949e103555863fa934fb84216a096b0004ca364d3ddf9d18e2b9e", - "sha256:96c6aef7ffb0d37206c0342abb82d874fa8cdc344267277ec63f562b94335c22", - "sha256:9be785bd4e1ba0148fb00ca84e4dbfbd1c74df3af3a648559adc60b0782f34de", - "sha256:9d19843568df9d263dc92ae4cc2279879add8a26996473f9155590cac635b321", - "sha256:a118a1df7280ffab7fe0f3eab325868339ff1c4d5b8e0750db0f0a796da8f849", - "sha256:b4294ddf76452459433ecfa6a93258608b5e462c76ef15e4695ed5e2762f009f", - "sha256:b50af6701b4a5288b77fb4db44a363aa9485caf2c3e7a40c0373fd45e34440af", - "sha256:b875bb4b438931dce550e170bfb558597189b8d0160f4ac60f14a21955161699", - "sha256:b95d2c2829b5956bf54d9a22ffec911dea75abf0f0f7e0a8a57423434bfbde91", - "sha256:c046e09e886f4539f8626afba17fa8f2e6552731f9384e2827154e3e3b7fda4e", - "sha256:c1d1992bbdf363b22b5a9543ab7d7c6f27a1498826d50d91319b803ddcf1142e", - "sha256:c2b67881392a9e85aa108e75f62cdbe372d5a3f17ea5f8d3436dcf4662052f14", - "sha256:c6cf288c9e03195d8e12b72a6388b32f18a5e9c2545622417a963e428e1fe496", - "sha256:c812b6e53344e92f10f12235219fb769c491a4a87a02c9c3f93fe632e493bda8", - "sha256:cc421babc687dc52ce0fc19787b2404518ca749d9db59576100946ff886f38ed", - "sha256:ce53c00be204ec4428d3c1f3c478ae89d388efec575544c27f57b61e9fa4a7f2", - "sha256:ce9964c117cbe5cf6269f30a2b334d28675956e988b7dbd0b4f7370924afda2e", - "sha256:d6f82e86896a8db70e8ae8fa4b7556a0f188f1d8a6c53b2ba229889d55a59308", - "sha256:d9d3ae537f61011191b2fd6f8527b9f9f8a848b37d4c85a0f7bb28004c42b546", - "sha256:e565d1e4388765c135052717f15f9e0314f9d172062444c6b3fc0002e93ed04b", - "sha256:ed98683d8f01f1c46ef2d02469e04e9a8fe9a73a9741a4e6e66677a73b59bec8", - "sha256:ef18aa15b1aa18c42933deed5233b3284186e9ed85c25d2704ceff5099a3964c", - "sha256:fa741e9c805567239f845c7e9a016aff797f9bb02ff9bc8ccd2fbd9eafefedd4", - "sha256:fc4946acb6cdada08f60aca103b61334995523da65be5fe816ea8571c9967d46", - "sha256:fcc66d17a3363b7bd6d2655de8706e25a3cd1be2bd1b8e8d8a5c504a6ef893ae" - ], - "version": "==3.11.2" + "sha256:014e7049dd019a6663747ca7dae328943e14f7261f7c1381045dfc26a04fa330", + "sha256:055f5c266e2767a88bb585d01137d9c7f778b0195d3dbf4a487ef0638be9b651", + "sha256:05c30fd35cc97f14f354916b45feea535d59060ef867446b5c3c7f9b609dd5dc", + "sha256:0634994b026336195778e5693583c060418d4ab453eff21530422690a97e1ee8", + "sha256:09c7de516b08c57647176b9fc21d929d628e35bcebc7422220c89ae40b62126a", + "sha256:107a234dc55affc5802acb3b6d83cbb8c87355b38a9457fcd8806bdeb8bce161", + "sha256:10a379fb60f1b2406ae57b8899bacfe20567918c8e9d2d545e1b93628fcf2050", + "sha256:128b1485753106c54af481789cdfea12b90a228afca0b11fb3828309a907e10e", + "sha256:1394c4737b325166a65ae7c145af1ebdb9fb153ebedd37cf91d676313e4a67b8", + "sha256:1c63e3a2e8fb815c4b1f738c284a4579897e37c3cfd95fdb199229a1ccfb638a", + "sha256:1e4ed21029d80c4f62605ab16398fe1ce093fff4b5f22d114055e7d9fbc4adb0", + "sha256:1ec71ac633b126c0775ed4604ca8f56c3540f5c21a1220639f299e7a544b55f9", + "sha256:21812453354b151200034750cd30b0140e82ec2a01fd4357390f67714a1bfbde", + "sha256:256c503a75bd71cf7fb9ebf889e7e222d49c6036a48aad5a619f98a0adf0e0d7", + "sha256:2703a9f8f5767986b4f51c259ff452cc837c5a83c8ed5f5361f6e49933743b2f", + "sha256:288c21ab9531b037f7efa4e467b33176bc73a0c27223c141b822ab4a0e66ff2a", + "sha256:2972dd1f1285866aba027eff2f4a2bbf8aa98563c2ced14cb34ee5602b36afdf", + "sha256:2973f113e079fb98515722cd728e1820282721ec9fd52830e4b73cabdbf1eb28", + "sha256:2ca0ba501898b2ec31e6c3acf90c31910944f01d454ad8e489213a156ccf1bda", + "sha256:2d2be5c9c3488fa8a70f83ed925940f488eac2837a996708d98a0e54a861f212", + "sha256:2f8c04277d879146eacda920476e93d520eff8bec6c022ac108cfa6280d84348", + "sha256:325701ae7b56daa5b0692305b7cb505ca50f80a1288abb32ff420a8a209b01ca", + "sha256:3729b8db02063da50eeb3db88a27670d85953afb9a7f14c213ac9e3dca93034b", + "sha256:3919708594b86d0f5cdc713eb6fccd3f9b9532af09ea7a5d843c933825ef56c4", + "sha256:39a1cd5d383b37285641d5a7a86be85274466ae336a61b51117155936529f9b3", + "sha256:3ec6c20385c5a58e16b1ea60c5e4993ea060540671d7d12664f385f2fb32fe79", + "sha256:47aa128be2e66abd9d1a9b0437c62499d812d291f17b55185cb4aa33a5f710a4", + "sha256:49f2af6cf82509b15093ce3569229e0d53c90ad8ae2eef940652d4cf1f81e045", + "sha256:4a0269811661ba93c472c8a60ea82640e838c2eb148d252720a09b5123f2c2fe", + "sha256:518c90bdd6e842c446d01a766b9136fec5ec6cc94f3b8c3f8b4a332786ee6b64", + "sha256:5717a308a703dda2886a5796a07489c698b442f5e409cf7dc2ac93de8d61d764", + "sha256:5802acc012bbb4bce4dff92973dff76482f30ef35dd4cb8ab5b0e06aa8f08c80", + "sha256:5e63146dbdb1eac207464f6e0cfcdb640c9c5ff0f57b754fa96fe252314a1dc6", + "sha256:6695d7136a435c1305b261a9ddb9b3ecec9863e05aab3935b96038145fd3a977", + "sha256:680fa0fc719e1a3dcb81130858368f51d83667d431924d0bcf249644bce8f303", + "sha256:6b18276f14b4b6d92e707ab6db19b938e112bd2f1dc3f9f1a628df58e4fd3f0d", + "sha256:6bafea6061d63059d8bc2ffc545e2f049221c8a4457d236c5cd6a66678673eab", + "sha256:6d6a1b1361f118e7fefa17ae3114e77f10ee1b228b20d50c47c9f351346180c8", + "sha256:747c84f4e690fbe6999c90ac97246c95d31460d890510e4a3fa61b7d2b87aa34", + "sha256:79f41576b3022c2fe9780ae3e44202b2438128a25284a8ddfa038f0785d87019", + "sha256:7b0e6361754ac596cd16bfc6ed49f69ffcd9b60b7bc4bcd3ea65c6a83475e4ff", + "sha256:7e3b0127b260d4abae7b62203c4c7ef0874c901b55155692353db19de4b18bc4", + "sha256:7fc2bb8a74dcfcdd32f89528e38dcbf70a3a6594963d60dc9595e3b35b66e414", + "sha256:806e094e9e85d8badc978af8c95b69c556077f11844655cb8cd2d1758769e521", + "sha256:81dd1308bd5630d2bb5980f00aa163b986b133f1e9ed66c66ce2a5bc3572e891", + "sha256:82e620842e12e8cb4050d2643a81c8149361cd82c0a920fa5a15dc4ca8a4000f", + "sha256:85f2cdc400ee87f5952ebf2a117488f2525a3fb2e23863a8efe3e4ee9e54e4d1", + "sha256:8ab6bcc8e424e07c1d4ba6df96f7fb963bcb48f590b9456de9ebd03b88084fe8", + "sha256:8adf014f2779992eba3b513e060d06f075f0ab2fb3ad956f413a102312f65cdf", + "sha256:9b0f98481ad5dc4cb430a60bbb8869f05505283b9ae1c62bdb65eb5e020ee8e3", + "sha256:9bea9138b0fc6e2218147e9c6ce1ff76ff8e29dc00bb1b64842bd1ca107aee9f", + "sha256:a09bfb51953930e7e838972ddf646c5d5f984992a66d79da6ba7f6a8d8a890cd", + "sha256:a0be99b599da95b7a90a918dd927b20c434bea5e1c9b3efc6a3c6cd67c23f813", + "sha256:a49aca4d961823b2846b739380c847e8964ff7ae0f0a683992b9d926054f0d6d", + "sha256:a4dc1319d0c162919ee7f4ee6face076becae2abbd351cc14f1fe70af5fb20d9", + "sha256:a8273e1abbcff1d7d29cbbb1ea7e57d38be72f1af3c597c854168508b91516c2", + "sha256:a8f7f9feecae53fa18d6a3ea7c75f9e9a1d4d20e5c3f9ce3fba83f07bcc4eee2", + "sha256:ad4f66fbb893b55f96f03020e67dcab49ffde0177c6565ccf9dec4fdf974eb61", + "sha256:af425f323fce1b07755edd783581e7283557296946212f5b1a934441718e7528", + "sha256:b14dd73f595199f4275bed4fb509277470d9b9059310537e3b3daba12b30c157", + "sha256:b4ad70d7cac4ca0c7b31444a0148bd3af01a2662fa12b1ad6f57cd4a04e21766", + "sha256:b80a4ee19b3442c57c38afa978adca546521a8822d663310b63ae2a7d7b13f3a", + "sha256:ba51129fcc510824b6ca6e2ce1c27e3e4d048b6e35d3ae6f7e517bed1b8b25ce", + "sha256:c011bd5ad03cc096f99ffcfdd18a1817354132c1331bed7a837a25226659845f", + "sha256:cc94f9fea17a5af8cf1a343597711a26b0117c0b812550d99934acb89d526ed2", + "sha256:ccd785fafa1c931deff6a7116e9a0d402d59fabe51644b0d0c268295ff847b25", + "sha256:d16a534da0e39785687b7295e2fcf9a339f4a20689024983d11afaa4657f8507", + "sha256:d3077a31633beef77d057c6523f5de7271ddef7bde5e019285b00c0cc9cac1e3", + "sha256:d603edea1ff7408638b2504905c032193b7dcee7af269802dbb35bc8c3310ed5", + "sha256:db082f728160369d9a6ed2e722438291558fc15ce06d0a7d696a8dad735c236b", + "sha256:ddef295aaf80cefb0c1606f1995899efcb17edc6b327eb6589e234e614b87756", + "sha256:e16ade71c93f6814d095d25cd6d28a90d63511ea396bd96e9ffcb886b278baaa", + "sha256:e3db7d833a7c38c317dc95b54e27f1d27012e031b45a7c24e360b53197d5f6e7", + "sha256:e5e193f89f4f8c1fe273f9a6e6df915092c9f2af6db2d1afb8bd53855025c11f", + "sha256:eb438a8bf6b695bf50d57e6a059ff09652a07968b2041178b3744ea785fcef9b", + "sha256:ebf02c32afa6b67e5861a27183dd98ed88419a94a2ab843cc145fb0bafcc5b28", + "sha256:ecd9e1fa97aa11bf67472220285775fa15e896da108f425e55d23d7540a712ce", + "sha256:ef67fedd863ffffd4adfd46d9d992b0f929c7f61a8307366d664d93517f2c78e", + "sha256:f28ae33dc5a0b9cee06e95fd420e42155d83271ab75964baf747ce959cac5f52", + "sha256:fb1c56d891f9e34303c451998ef62ba52659648bb0d75b03c5e4ac223a3342c2", + "sha256:fe03bf25fae4b95d8afe40004a321df644400fdcba4c8e5e1a19c1085b740888" + ], + "markers": "python_version >= '3.7'", + "version": "==4.6.0" }, "python-dateutil": { "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], "index": "pypi", - "version": "==2.8.1" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" }, "python-dotenv": { "hashes": [ - "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", - "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" + "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", + "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.0.0" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], "index": "pypi", - "version": "==0.15.0" + "markers": "python_version >= '3.7'", + "version": "==2.31.0" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.15.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" }, - "uvloop": { + "tinycss2": { "hashes": [ - "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", - "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", - "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", - "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", - "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", - "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", - "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", - "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", - "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362" + "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847", + "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627" ], - "markers": "sys_platform != 'win32'", - "version": "==0.14.0" + "markers": "python_version >= '3.7'", + "version": "==1.2.1" }, - "virtualenv": { + "urllib3": { "hashes": [ - "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", - "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" + "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", + "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.2" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, - "virtualenv-clone": { + "uvloop": { "hashes": [ - "sha256:07e74418b7cc64f4fda987bf5bc71ebd59af27a7bc9e8a8ee9fd54b1f2390a27", - "sha256:665e48dd54c84b98b71a657acb49104c54e7652bce9c1c4f6c6976ed4c827a29" + "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd", + "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec", + "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b", + "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc", + "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797", + "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5", + "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2", + "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d", + "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be", + "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd", + "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12", + "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17", + "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef", + "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24", + "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428", + "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1", + "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849", + "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593", + "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd", + "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67", + "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6", + "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3", + "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd", + "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8", + "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7", + "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533", + "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957", + "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650", + "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e", + "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7", + "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.5.4" + "markers": "sys_platform != 'win32'", + "version": "==0.19.0" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" }, "yarl": { "hashes": [ - "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", - "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", - "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", - "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", - "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", - "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", - "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", - "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", - "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", - "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", - "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", - "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", - "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", - "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", - "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", - "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", - "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" + "sha256:09c19e5f4404574fcfb736efecf75844ffe8610606f3fccc35a1515b8b6712c4", + "sha256:0ab5baaea8450f4a3e241ef17e3d129b2143e38a685036b075976b9c415ea3eb", + "sha256:0d155a092bf0ebf4a9f6f3b7a650dc5d9a5bbb585ef83a52ed36ba46f55cc39d", + "sha256:126638ab961633f0940a06e1c9d59919003ef212a15869708dcb7305f91a6732", + "sha256:1a0a4f3aaa18580038cfa52a7183c8ffbbe7d727fe581300817efc1e96d1b0e9", + "sha256:1d93461e2cf76c4796355494f15ffcb50a3c198cc2d601ad8d6a96219a10c363", + "sha256:26a1a8443091c7fbc17b84a0d9f38de34b8423b459fb853e6c8cdfab0eacf613", + "sha256:271d63396460b6607b588555ea27a1a02b717ca2e3f2cf53bdde4013d7790929", + "sha256:28a108cb92ce6cf867690a962372996ca332d8cda0210c5ad487fe996e76b8bb", + "sha256:29beac86f33d6c7ab1d79bd0213aa7aed2d2f555386856bb3056d5fdd9dab279", + "sha256:2c757f64afe53a422e45e3e399e1e3cf82b7a2f244796ce80d8ca53e16a49b9f", + "sha256:2dad8166d41ebd1f76ce107cf6a31e39801aee3844a54a90af23278b072f1ccf", + "sha256:2dc72e891672343b99db6d497024bf8b985537ad6c393359dc5227ef653b2f17", + "sha256:2f3c8822bc8fb4a347a192dd6a28a25d7f0ea3262e826d7d4ef9cc99cd06d07e", + "sha256:32435d134414e01d937cd9d6cc56e8413a8d4741dea36af5840c7750f04d16ab", + "sha256:3cfa4dbe17b2e6fca1414e9c3bcc216f6930cb18ea7646e7d0d52792ac196808", + "sha256:3d5434b34100b504aabae75f0622ebb85defffe7b64ad8f52b8b30ec6ef6e4b9", + "sha256:4003f380dac50328c85e85416aca6985536812c082387255c35292cb4b41707e", + "sha256:44e91a669c43f03964f672c5a234ae0d7a4d49c9b85d1baa93dec28afa28ffbd", + "sha256:4a14907b597ec55740f63e52d7fee0e9ee09d5b9d57a4f399a7423268e457b57", + "sha256:4ce77d289f8d40905c054b63f29851ecbfd026ef4ba5c371a158cfe6f623663e", + "sha256:4d6d74a97e898c1c2df80339aa423234ad9ea2052f66366cef1e80448798c13d", + "sha256:51382c72dd5377861b573bd55dcf680df54cea84147c8648b15ac507fbef984d", + "sha256:525cd69eff44833b01f8ef39aa33a9cc53a99ff7f9d76a6ef6a9fb758f54d0ff", + "sha256:53ec65f7eee8655bebb1f6f1607760d123c3c115a324b443df4f916383482a67", + "sha256:5f74b015c99a5eac5ae589de27a1201418a5d9d460e89ccb3366015c6153e60a", + "sha256:6280353940f7e5e2efaaabd686193e61351e966cc02f401761c4d87f48c89ea4", + "sha256:632c7aeb99df718765adf58eacb9acb9cbc555e075da849c1378ef4d18bf536a", + "sha256:6465d36381af057d0fab4e0f24ef0e80ba61f03fe43e6eeccbe0056e74aadc70", + "sha256:66a6dbf6ca7d2db03cc61cafe1ee6be838ce0fbc97781881a22a58a7c5efef42", + "sha256:6d350388ba1129bc867c6af1cd17da2b197dff0d2801036d2d7d83c2d771a682", + "sha256:7217234b10c64b52cc39a8d82550342ae2e45be34f5bff02b890b8c452eb48d7", + "sha256:721ee3fc292f0d069a04016ef2c3a25595d48c5b8ddc6029be46f6158d129c92", + "sha256:72a57b41a0920b9a220125081c1e191b88a4cdec13bf9d0649e382a822705c65", + "sha256:73cc83f918b69110813a7d95024266072d987b903a623ecae673d1e71579d566", + "sha256:778df71c8d0c8c9f1b378624b26431ca80041660d7be7c3f724b2c7a6e65d0d6", + "sha256:79e1df60f7c2b148722fb6cafebffe1acd95fd8b5fd77795f56247edaf326752", + "sha256:7c86d0d0919952d05df880a1889a4f0aeb6868e98961c090e335671dea5c0361", + "sha256:7eaf13af79950142ab2bbb8362f8d8d935be9aaf8df1df89c86c3231e4ff238a", + "sha256:828235a2a169160ee73a2fcfb8a000709edf09d7511fccf203465c3d5acc59e4", + "sha256:8535e111a064f3bdd94c0ed443105934d6f005adad68dd13ce50a488a0ad1bf3", + "sha256:88d2c3cc4b2f46d1ba73d81c51ec0e486f59cc51165ea4f789677f91a303a9a7", + "sha256:8a2538806be846ea25e90c28786136932ec385c7ff3bc1148e45125984783dc6", + "sha256:8dab30b21bd6fb17c3f4684868c7e6a9e8468078db00f599fb1c14e324b10fca", + "sha256:8f18a7832ff85dfcd77871fe677b169b1bc60c021978c90c3bb14f727596e0ae", + "sha256:946db4511b2d815979d733ac6a961f47e20a29c297be0d55b6d4b77ee4b298f6", + "sha256:96758e56dceb8a70f8a5cff1e452daaeff07d1cc9f11e9b0c951330f0a2396a7", + "sha256:9a172c3d5447b7da1680a1a2d6ecdf6f87a319d21d52729f45ec938a7006d5d8", + "sha256:9a5211de242754b5e612557bca701f39f8b1a9408dff73c6db623f22d20f470e", + "sha256:9df9a0d4c5624790a0dea2e02e3b1b3c69aed14bcb8650e19606d9df3719e87d", + "sha256:aa4643635f26052401750bd54db911b6342eb1a9ac3e74f0f8b58a25d61dfe41", + "sha256:aed37db837ecb5962469fad448aaae0f0ee94ffce2062cf2eb9aed13328b5196", + "sha256:af52725c7c39b0ee655befbbab5b9a1b209e01bb39128dce0db226a10014aacc", + "sha256:b0b8c06afcf2bac5a50b37f64efbde978b7f9dc88842ce9729c020dc71fae4ce", + "sha256:b61e64b06c3640feab73fa4ff9cb64bd8182de52e5dc13038e01cfe674ebc321", + "sha256:b7831566595fe88ba17ea80e4b61c0eb599f84c85acaa14bf04dd90319a45b90", + "sha256:b8bc5b87a65a4e64bc83385c05145ea901b613d0d3a434d434b55511b6ab0067", + "sha256:b8d51817cf4b8d545963ec65ff06c1b92e5765aa98831678d0e2240b6e9fd281", + "sha256:b9f9cafaf031c34d95c1528c16b2fa07b710e6056b3c4e2e34e9317072da5d1a", + "sha256:bb72d2a94481e7dc7a0c522673db288f31849800d6ce2435317376a345728225", + "sha256:c25ec06e4241e162f5d1f57c370f4078797ade95c9208bd0c60f484834f09c96", + "sha256:c405d482c320a88ab53dcbd98d6d6f32ada074f2d965d6e9bf2d823158fa97de", + "sha256:c4472fe53ebf541113e533971bd8c32728debc4c6d8cc177f2bff31d011ec17e", + "sha256:c4b1efb11a8acd13246ffb0bee888dd0e8eb057f8bf30112e3e21e421eb82d4a", + "sha256:c5f3faeb8100a43adf3e7925d556801d14b5816a0ac9e75e22948e787feec642", + "sha256:c6f034386e5550b5dc8ded90b5e2ff7db21f0f5c7de37b6efc5dac046eb19c10", + "sha256:c99ddaddb2fbe04953b84d1651149a0d85214780e4d0ee824e610ab549d98d92", + "sha256:ca6b66f69e30f6e180d52f14d91ac854b8119553b524e0e28d5291a724f0f423", + "sha256:cccdc02e46d2bd7cb5f38f8cc3d9db0d24951abd082b2f242c9e9f59c0ab2af3", + "sha256:cd49a908cb6d387fc26acee8b7d9fcc9bbf8e1aca890c0b2fdfd706057546080", + "sha256:cf7a4e8de7f1092829caef66fd90eaf3710bc5efd322a816d5677b7664893c93", + "sha256:cfd77e8e5cafba3fb584e0f4b935a59216f352b73d4987be3af51f43a862c403", + "sha256:d34c4f80956227f2686ddea5b3585e109c2733e2d4ef12eb1b8b4e84f09a2ab6", + "sha256:d61a0ca95503867d4d627517bcfdc28a8468c3f1b0b06c626f30dd759d3999fd", + "sha256:d81657b23e0edb84b37167e98aefb04ae16cbc5352770057893bd222cdc6e45f", + "sha256:d92d897cb4b4bf915fbeb5e604c7911021a8456f0964f3b8ebbe7f9188b9eabb", + "sha256:dd318e6b75ca80bff0b22b302f83a8ee41c62b8ac662ddb49f67ec97e799885d", + "sha256:dd952b9c64f3b21aedd09b8fe958e4931864dba69926d8a90c90d36ac4e28c9a", + "sha256:e0e7e83f31e23c5d00ff618045ddc5e916f9e613d33c5a5823bc0b0a0feb522f", + "sha256:e0f17d1df951336a02afc8270c03c0c6e60d1f9996fcbd43a4ce6be81de0bd9d", + "sha256:e2a16ef5fa2382af83bef4a18c1b3bcb4284c4732906aa69422cf09df9c59f1f", + "sha256:e36021db54b8a0475805acc1d6c4bca5d9f52c3825ad29ae2d398a9d530ddb88", + "sha256:e73db54c967eb75037c178a54445c5a4e7461b5203b27c45ef656a81787c0c1b", + "sha256:e741bd48e6a417bdfbae02e088f60018286d6c141639359fb8df017a3b69415a", + "sha256:f7271d6bd8838c49ba8ae647fc06469137e1c161a7ef97d778b72904d9b68696", + "sha256:fc391e3941045fd0987c77484b2799adffd08e4b6735c4ee5f054366a2e1551d", + "sha256:fc94441bcf9cb8c59f51f23193316afefbf3ff858460cb47b5758bf66a14d130", + "sha256:fe34befb8c765b8ce562f0200afda3578f8abb159c76de3ab354c80b72244c41", + "sha256:fe8080b4f25dfc44a86bedd14bc4f9d469dfc6456e6f3c5d9077e81a5fedfba7", + "sha256:ff34cb09a332832d1cf38acd0f604c068665192c6107a439a92abfd8acf90fe2" ], - "markers": "python_version >= '3.5'", - "version": "==1.5.1" + "markers": "python_version >= '3.7'", + "version": "==1.9.3" } }, "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, "astroid": { "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" - ], - "markers": "python_version >= '3.5'", - "version": "==2.4.2" - }, - "attrs": { - "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" + "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca", + "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.1" }, "bandit": { "hashes": [ - "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", - "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065" + "sha256:75665181dc1e0096369112541a056c59d1c5f66f9bb74a8d686c3c362b83f549", + "sha256:bdfc739baa03b880c2d15d0431b31c658ffc348e907fe197e54e0389dd59e11e" ], "index": "pypi", - "version": "==1.6.2" + "markers": "python_version >= '3.7'", + "version": "==1.7.5" }, "black": { "hashes": [ - "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", - "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" + "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", + "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", + "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", + "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", + "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", + "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", + "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", + "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", + "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", + "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", + "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", + "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", + "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", + "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", + "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", + "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", + "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", + "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" ], "index": "pypi", - "version": "==19.10b0" + "markers": "python_version >= '3.8'", + "version": "==23.11.0" }, "click": { "hashes": [ - "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": { - "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], - "index": "pypi", - "version": "==0.4.4" + "markers": "python_version >= '3.7'", + "version": "==8.1.7" }, - "flake8": { + "dill": { "hashes": [ - "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", - "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" + "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e", + "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03" ], - "index": "pypi", - "version": "==3.8.4" + "markers": "python_version < '3.11'", + "version": "==0.3.7" }, "gitdb": { "hashes": [ - "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", - "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" + "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", + "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" ], - "markers": "python_version >= '3.4'", - "version": "==4.0.5" + "markers": "python_version >= '3.7'", + "version": "==4.0.11" }, "gitpython": { "hashes": [ - "sha256:42dbefd8d9e2576c496ed0059f3103dcef7125b9ce16f9d5f9c834aed44a1dac", - "sha256:867ec3dfb126aac0f8296b19fb63b8c4a399f32b4b6fafe84c4b10af5fa9f7b5" + "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4", + "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a" ], - "markers": "python_version >= '3.4'", - "version": "==3.1.12" + "markers": "python_version >= '3.7'", + "version": "==3.1.40" }, "isort": { "hashes": [ - "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e", - "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==5.7.0" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.3" + "markers": "python_full_version >= '3.8.0'", + "version": "==5.12.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" }, "mccabe": { "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" ], - "version": "==0.6.1" + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" }, "pathspec": { "hashes": [ - "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", - "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" ], - "version": "==0.8.1" + "markers": "python_version >= '3.7'", + "version": "==0.11.2" }, "pbr": { "hashes": [ - "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", - "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" + "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda", + "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9" ], "markers": "python_version >= '2.6'", - "version": "==5.5.1" + "version": "==6.0.0" }, - "pycodestyle": { + "platformdirs": { "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", + "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" + "markers": "python_version >= '3.7'", + "version": "==4.0.0" }, - "pyflakes": { + "pygments": { "hashes": [ - "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", - "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" + "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", + "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.2.0" + "markers": "python_version >= '3.7'", + "version": "==2.17.2" }, "pylint": { "hashes": [ - "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", - "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" + "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", + "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" ], "index": "pypi", - "version": "==2.6.0" + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.2" }, "pyyaml": { "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" - }, - "regex": { - "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" - ], - "version": "==2020.11.13" + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0.1" }, - "six": { + "rich": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", + "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", - "version": "==1.15.0" + "markers": "python_full_version >= '3.7.0'", + "version": "==13.7.0" }, "smmap": { "hashes": [ - "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", - "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" + "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", + "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.4" + "markers": "python_version >= '3.7'", + "version": "==5.0.1" }, "stevedore": { "hashes": [ - "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", - "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" + "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d", + "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c" ], - "markers": "python_version >= '3.6'", - "version": "==3.3.0" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", - "version": "==0.10.2" - }, - "typed-ast": { - "hashes": [ - "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1", - "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d", - "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6", - "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd", - "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37", - "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151", - "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07", - "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440", - "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70", - "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496", - "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea", - "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400", - "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc", - "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606", - "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc", - "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581", - "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412", - "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a", - "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2", - "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787", - "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f", - "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937", - "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64", - "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487", - "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b", - "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41", - "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a", - "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3", - "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166", - "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10" - ], - "version": "==1.4.2" - }, - "wrapt": { - "hashes": [ - "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" - ], - "version": "==1.12.1" + "markers": "python_version >= '3.8'", + "version": "==5.1.0" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "tomlkit": { + "hashes": [ + "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", + "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.12.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", + "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.8.0" } } } diff --git a/README.md b/README.md index 457569b32e..980066ca7e 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,36 @@
- +
- + - + Bot instances - - Support + + Support - Python 3.7 + Python 3.8 - Made with Python 3.7 + Made with Python 3.10 - + MIT License @@ -50,11 +50,13 @@ Modmail is similar to Reddit's Modmail, both in functionality and purpose. It se This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Patreon](https://www.patreon.com/kyber)**, cool benefits included! +For up-to-date setup instructions, please visit our [**documentation**](https://docs.modmail.dev/installation) page. + ## How does it work? When a member sends a direct message to the bot, Modmail will create a channel or "thread" into a designated category. All further DM messages will automatically relay to that channel; any available staff can respond within the channel. -Our Logviewer will save the threads so you can view previous threads through their corresponding log link. Here is an [**example**](https://logs.modmail.dev/example). +Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). ## Features @@ -67,7 +69,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.modmail.dev/example) and post it to your log channel. + * When you close a thread, Modmail will generate a log link and post it to your log channel. * Native Discord dark-mode feel. * Markdown/formatting support. * Login via Discord to protect your logs ([premium Patreon feature](https://patreon.com/kyber)). @@ -84,124 +86,91 @@ This list is ever-growing thanks to active development and our exceptional contr ## Installation -Where can I find the Modmail bot invite link? - -Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following one of the methods listed below (roughly takes 15 minutes of your time). - -### Heroku - -You can host this bot on Heroku. - -Installation via Heroku is possible with your web browser alone. -The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) (which includes a video tutorial!) will guide you through the entire installation process. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for help and support. - -To configure automatic updates: - - Login to [GitHub](https://github.com/) and verify your account. - - [Fork the repo](https://github.com/kyb3r/modmail/fork). - - Install the [Pull app](https://github.com/apps/pull) for your fork. - - Then go to the Deploy tab in your [Heroku account](https://dashboard.heroku.com/apps) of your bot app, select GitHub and connect your fork (usually by typing "Modmail"). - - Turn on auto-deploy for the `master` branch. - -### Hosting for Patreons - -If you don't want to go through the trouble of setting up your very own Modmail bot or wish to support this project, we got a solution for you! We offer the complete installation, hosting, and maintenance of your Modmail with [**Patreon**](https://patreon.com/kyber). Join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for more info! - -### Locally - -Local hosting of Modmail is also possible. First, you will need [`Python 3.7`](https://www.python.org/downloads/release/python-376/). - -Follow the [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) and disregard deploying the Heroku bot application. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for help and support. - -Clone the repo: +There are a number of options for hosting your very own dedicated Modmail bot. -```console -$ git clone https://github.com/kyb3r/modmail -$ cd modmail -``` +Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. -Install dependencies: +### Patreon Hosting -```console -$ pipenv install -``` +If you don't want the trouble of renting and configuring your server to host Modmail, we got a solution for you! We offer hosting and maintenance of your own, private Modmail bot (including a Logviewer) through [**Patreon**](https://patreon.com/kyber). -Rename the `.env.example` to `.env` and fill out the fields. If `.env.example` is nonexistent (hidden), create a text file named `.env` and copy the contents of [`.env.example`](https://raw.githubusercontent.com/kyb3r/modmail/master/.env.example) then modify the values. +## FAQ -Finally, start Modmail. +**Q: Where can I find the Modmail bot invite link?** -```console -$ pipenv run bot -``` +**A:** Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following our [**documentation**](https://docs.modmail.dev/installation) steps or subscribe to [**Patreon**](https://patreon.com/kyber). -#### Docker +**Q: Where can I find out more info about Modmail?** -This repo supplies a Dockerfile for simplified deployment. +**A:** You can find more info about Modmail on our [**documentation**](https://docs.modmail.dev) page. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/cnUpwrnpYb) for help and support. -You can build your own Docker image: - -```console -$ docker build . --tag=modmail -``` - -Or run directly from a pre-built version from https://hub.docker.com/. +## Plugins -- Kyber's: +Modmail supports the use of third-party plugins to extend or add functionalities to the bot. +Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. -```console -$ docker pull kyb3rr/modmail -``` +You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. -And to run your docker image: +To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). -```console -$ docker run --env-file .env kyb3rr/modmail -``` -- `.env` should be the path to your env file; you can also supply a path: `/path/to/.env`. +Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/cnUpwrnpYb). ## Sponsors Special thanks to our sponsors for supporting the project. -Kingdom Gaming Discord: +SirReddit:
- - + +

-SirReddit: +Prime Servers Inc:
- - + +
- - - +
+Real Madrid: +
+
+ + +
+
+Advertise Your Server: +
+ + + +
+
+Help Us • Help Other's: +
+ + + +
+
+Discord Advice Center: +
+ + Become a sponsor on [Patreon](https://patreon.com/kyber). -## Plugins - -Modmail supports the use of third-party plugins to extend or add functionalities to the bot. -Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. - -You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/kyb3r/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. - -To develop your own, check out the [plugins documentation](https://github.com/kyb3r/modmail/wiki/Plugins). - -Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/j5e9p8w). - ## Contributing -Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/kyb3r/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. +Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/modmail-dev/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. If you like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! ## Beta Testing -Our [development](https://github.com/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. +Our [development](https://github.com/modmail-dev/modmail/tree/development) branch is where most of our features are tested before public release. Be warned that there could be bugs in various commands so keep it away from any large servers you manage. If you wish to test the new features and play around with them, feel free to join our [Public Test Server](https://discord.gg/v5hTjKC). Bugs can be raised within that server or in our Github issues (state that you are using the development branch though). diff --git a/SPONSORS.json b/SPONSORS.json index 53034c57b3..491b30eb9f 100644 --- a/SPONSORS.json +++ b/SPONSORS.json @@ -21,7 +21,7 @@ "value": "[**Click Here**](https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1)" }, { - "name": "Discord Server!", + "name": "Discord Server", "value": "[**Click Here**](https://discord.gg/V8ErqHb)" } ] @@ -37,7 +37,7 @@ }, "fields": [ { - "name": "Discord Server!", + "name": "Discord Server", "value": "[**Click here**](https://discord.gg/BanCwptMJV)" } ] @@ -46,7 +46,7 @@ { "embed": { "description": "Quality Hosting at Prices You Deserve!", - "color": 50195251, + "color": 3137203, "footer": { "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", "text": "Prime Servers, Inc." @@ -70,5 +70,96 @@ } ] } + }, + { + "embed": { + "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", + "color": 53380, + "author": { + "name": "Discord Advice Center", + "url": "https://discord.gg/nkMDQfuK", + "icon_url": "https://i.imgur.com/cjVtRw5.jpg" + }, + "image": { + "url": "https://i.imgur.com/1hrjcHd.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/zmwZy5fd9v)" + } + ] + } + }, + { + "embed": { + "footer": { + "text": "Join noch heute!" + }, + "thumbnail": { + "url": "https://i.imgur.com/bp0xfyK.png" + }, + "fields": [ + { + "inline": false, + "name": "Viele Verschiedene Talks", + "value": "Gro\u00dfe Community\nGewinnspiele" + } + ], + "color": 61532, + "description": "Die etwas andere Community", + "url": "https://discord.gg/uncommon", + "title": "uncommon community" + } + }, + { + "embed": { + "author": { + "name": "Help us • Help Others" + }, + "title": "Join Today", + "url": "https://discord.gg/5yQCFzY6HU", + "description": "At Help Us • Help Others, we accept as true with inside the transformative electricity of cooperation and kindness. Each one people has the capability to make a meaningful impact by means of helping and caring for others. Whether you want assistance or want to offer it, this is the right region for you!", + "fields": [ + { + "name": "What we offer:", + "value": "`🎬` - Active community\n`👮` - Active staff around the globe! \n`🛜` - 40+ Advertising channels to grow your socials!\n`💎` - Boosting Perks\n`🎉` - Event's monthly especially bank holiday roles!!\n`🔢` - Unique levelling systems\n`📞` - Multiple voice channels including gaming!\n`🎁` - Exclusive giveaways!" + }, + { + "name": "We Are Hiring", + "value": "`🔵` - Moderators\n`🔵` - Human Resources\n`🔵` - Community Team\n`🔵` - Partnership Manager\n`🔵` - Growth Manager\n`🚀` Much more to come!\n\n\nJoin Today!" + } + ], + "image": { + "url": "https://cdn.discordapp.com/attachments/1218338794416246874/1243635366326567002/AD_animated.gif" + }, + "color": 45300, + "footer": { + "text": "Help Us • Help Others" + } + } + }, + { + "embed": { + "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", + "author": { + "name": "New York Roleplay", + "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + }, + "color": 431075, + "thumbnail": { + "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + } + } + }, + { + "embed": { + "title": "Pixelmark TM PLC", + "description": "Hi there! Welcome to PixelMark PLC! \nI'm so glad you're here. I started PixelMark PLC on May 24, 2023. Our team is dedicated to providing top-quality products and a great shopping experience. But more than that, PixelMark PLC is a community. We're here to share, create, and have fun together. Thanks for joining us on this exciting journey! Our current Goal is to reach 100 Human Members, you can help us achieve that Goal by joining or/and inviting your Friends!\n\nBest regards, \n*Felixpro202110 / Chief Executive Officer (Founder)* \n-----------------------------------------------------------------------\n> https://discord.gg/RVzNVRaFeE\n> https://www.roblox.com/groups/16031525/PixelMark-PLC", + "color": 10634504, + "image": { + "url": "https://imgur.com/iTl1dXm.png" + } + } } ] diff --git a/app.json b/app.json index 66b5c77752..decd58695c 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "name": "Modmail", "description": "An easy to install Modmail bot for Discord - DM to contact mods!", - "repository": "https://github.com/kyb3r/modmail", + "repository": "https://github.com/modmail-dev/modmail", "env": { "TOKEN": { "description": "Your discord bot's token.", @@ -11,10 +11,6 @@ "description": "The id for the server you are hosting this bot for.", "required": true }, - "MODMAIL_GUILD_ID": { - "description": "The ID of the discord server where the threads channels should be created (receiving server). Default to GUILD_ID.", - "required": false - }, "OWNERS": { "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", "required": true @@ -34,6 +30,10 @@ "GITHUB_TOKEN": { "description": "A github personal access token with the repo scope.", "required": false + }, + "REGISTRY_PLUGINS_ONLY": { + "description": "If set to true, only plugins that are in the registry can be loaded.", + "required": false } } -} \ No newline at end of file +} diff --git a/bot.py b/bot.py index 1b9a9f2c1f..bb47c75283 100644 --- a/bot.py +++ b/bot.py @@ -1,27 +1,28 @@ -__version__ = "3.9.4" +__version__ = "4.1.0" import asyncio import copy +import hashlib import logging import os import re -import signal +import string +import struct import sys +import platform import typing -from datetime import datetime +from datetime import datetime, timezone, timedelta from subprocess import PIPE from types import SimpleNamespace import discord import isodate -from aiohttp import ClientSession +from aiohttp import ClientSession, ClientResponseError 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 +from emoji import is_emoji +from packaging.version import Version try: @@ -47,7 +48,7 @@ ) from core.thread import ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, truncate +from core.utils import extract_block_timestamp, normalize_alias, parse_alias, truncate, tryint, human_join logger = getLogger(__name__) @@ -64,31 +65,71 @@ class ModmailBot(commands.Bot): def __init__(self): + self.config = ConfigManager(self) + self.config.populate_cache() + intents = discord.Intents.all() + if not self.config["enable_presence_intent"]: + intents.presences = False + super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` - self._session = None + self.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() - self.start_time = datetime.utcnow() - - self.config = ConfigManager(self) - self.config.populate_cache() + self._connected = None + self.start_time = discord.utils.utcnow() + self._started = False self.threads = ThreadManager(self) - self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") - self._configure_logging() + log_dir = os.path.join(temp_dir, "logs") + if not os.path.exists(log_dir): + os.mkdir(log_dir) + self.log_file_path = os.path.join(log_dir, "modmail.log") + configure_logging(self) self.plugin_db = PluginDatabaseClient(self) # Deprecated self.startup() + def get_guild_icon( + self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None + ) -> str: + if guild is None: + guild = self.guild + if guild.icon is None: + return "https://cdn.discordapp.com/embed/avatars/0.png" + if size is None: + return guild.icon.url + return guild.icon.with_size(size).url + + def _resolve_snippet(self, name: str) -> typing.Optional[str]: + """ + Get actual snippet names from direct aliases to snippets. + + If the provided name is a snippet, it's returned unchanged. + If there is an alias by this name, it is parsed to see if it + refers only to a snippet, in which case that snippet name is + returned. + + If no snippets were found, None is returned. + """ + if name in self.snippets: + return name + + try: + (command,) = parse_alias(self.aliases[name]) + except (KeyError, ValueError): + # There is either no alias by this name present or the + # alias has multiple steps. + pass + else: + if command in self.snippets: + return command + @property def uptime(self) -> str: - now = datetime.utcnow() + now = discord.utils.utcnow() delta = now - self.start_time hours, remainder = divmod(int(delta.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) @@ -122,59 +163,30 @@ def hosting_method(self) -> HostingMethod: def startup(self): logger.line() - if os.name != "nt": - logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") - logger.info("││││ │ │││││├─┤││") - logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") - else: - logger.info("MODMAIL") + logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") + logger.info("││││ │ │││││├─┤││") + logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") logger.info("v%s", __version__) logger.info("Authors: kyb3r, fourjr, Taaku18") logger.line() logger.info("discord.py: v%s", discord.__version__) logger.line() + async def load_extensions(self): for cog in self.loaded_cogs: + if cog in self.extensions: + continue logger.debug("Loading %s.", cog) try: - self.load_extension(cog) + await self.load_extension(cog) logger.debug("Successfully loaded %s.", cog) except Exception: logger.exception("Failed to load %s.", cog) logger.line("debug") - def _configure_logging(self): - level_text = self.config["log_level"].upper() - logging_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - logger.line() - - log_level = logging_levels.get(level_text) - if log_level is None: - log_level = self.config.remove("log_level") - logger.warning("Invalid logging level set: %s.", level_text) - logger.warning("Using default logging level: INFO.") - else: - logger.info("Logging level: %s", level_text) - - logger.info("Log file: %s", self.log_file_name) - configure_logging(self.log_file_name, log_level) - logger.debug("Successfully configured logging.") - @property def version(self): - return parse_version(__version__) - - @property - def session(self) -> ClientSession: - if self._session is None: - self._session = ClientSession(loop=self.loop) - return self._session + return Version(__version__) @property def api(self) -> ApiClient: @@ -195,105 +207,73 @@ async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def run(self): - loop = self.loop + async def runner(): + async with self: + self._connected = asyncio.Event() + self.session = ClientSession(loop=self.loop) - try: - loop.add_signal_handler(signal.SIGINT, lambda: loop.stop()) - loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop()) - except NotImplementedError: - pass + if self.config["enable_presence_intent"]: + logger.info("Starting bot with presence intent.") + else: + logger.info("Starting bot without presence intent.") - async def runner(): - try: - retry_intents = False try: await self.start(self.token) except discord.PrivilegedIntentsRequired: - retry_intents = True - if retry_intents: - await self.http.close() - if self.ws is not None and self.ws.open: - await self.ws.close(code=1000) - self._ready.clear() - intents = discord.Intents.default() - intents.members = True - # Try again with members intent - self._connection._intents = intents - logger.warning( - "Attempting to login with only the server members privileged intent. Some plugins might not work correctly." + logger.critical( + "Privileged intents are not explicitly granted in the discord developers dashboard." ) - await self.start(self.token) - except discord.PrivilegedIntentsRequired: - logger.critical( - "Privileged intents are not explicitly granted in the discord developers dashboard." - ) - except discord.LoginFailure: - logger.critical("Invalid token") - except Exception: - logger.critical("Fatal exception", exc_info=True) - finally: - if not self.is_closed(): - await self.close() - if self._session: - await self._session.close() - - def stop_loop_on_completion(f): - loop.stop() - - def _cancel_tasks(): - if sys.version_info < (3, 8): - task_retriever = asyncio.Task.all_tasks - else: + except discord.LoginFailure: + logger.critical("Invalid token") + except Exception: + logger.critical("Fatal exception", exc_info=True) + finally: + if self.session: + await self.session.close() + if not self.is_closed(): + await self.close() + + async def _cancel_tasks(): + async with self: task_retriever = asyncio.all_tasks + loop = self.loop + tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} - tasks = {t for t in task_retriever(loop=loop) if not t.done()} - - if not tasks: - return + if not tasks: + return - logger.info("Cleaning up after %d tasks.", len(tasks)) - for task in tasks: - task.cancel() - - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - logger.info("All tasks finished cancelling.") - - for task in tasks: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "Unhandled exception during Client.run shutdown.", - "exception": task.exception(), - "task": task, - } - ) + logger.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("All tasks finished cancelling.") + + for task in tasks: + try: + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + except (asyncio.InvalidStateError, asyncio.CancelledError): + pass - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) try: - loop.run_forever() - except KeyboardInterrupt: + asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) + except (KeyboardInterrupt, SystemExit): logger.info("Received signal to terminate bot and event loop.") finally: - future.remove_done_callback(stop_loop_on_completion) logger.info("Cleaning up tasks.") try: - _cancel_tasks() - if sys.version_info >= (3, 6): - loop.run_until_complete(loop.shutdown_asyncgens()) + cancel_tasks_coro = _cancel_tasks() + asyncio.run(cancel_tasks_coro) finally: logger.info("Closing the event loop.") - loop.close() - - if not future.cancelled(): - try: - return future.result() - except KeyboardInterrupt: - # I am unsure why this gets raised here but suppress it anyway - return None @property def bot_owner_ids(self): @@ -328,9 +308,7 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id - logger.warning( - "No log channel set, setting #%s to be the log channel.", channel.name - ) + logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) return channel except IndexError: pass @@ -392,9 +370,7 @@ def auto_triggers(self) -> typing.Dict[str, str]: def token(self) -> str: token = self.config["token"] if token is None: - logger.critical( - "TOKEN must be set, set this as bot token found on the Discord Developer Portal." - ) + logger.critical("TOKEN must be set, set this as bot token found on the Discord Developer Portal.") sys.exit(0) return token @@ -510,11 +486,7 @@ def command_perm(self, command_name: str) -> PermissionLevel: logger.debug("Command %s not found.", command_name) return PermissionLevel.INVALID level = next( - ( - check.permission_level - for check in command.checks - if hasattr(check, "permission_level") - ), + (check.permission_level for check in command.checks if hasattr(check, "permission_level")), None, ) if level is None: @@ -527,11 +499,12 @@ async def on_connect(self): await self.api.validate_database_connection() except Exception: logger.debug("Logging out due to failed database connection.") - return await self.logout() + return await self.close() logger.debug("Connected to gateway.") await self.config.refresh() await self.api.setup_indexes() + await self.load_extensions() self._connected.set() async def on_ready(self): @@ -542,15 +515,21 @@ async def on_ready(self): if self.guild is None: logger.error("Logging out due to invalid GUILD_ID.") - return await self.logout() + return await self.close() + + if self._started: + # Bot has started before + logger.line() + logger.warning("Bot restarted due to internal discord reloading.") + logger.line() + return logger.line() logger.debug("Client ready.") logger.info("Logged in as: %s", self.user) logger.info("Bot ID: %s", self.user.id) owners = ", ".join( - getattr(self.get_user(owner_id), "name", str(owner_id)) - for owner_id in self.bot_owner_ids + getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids ) logger.info("Owners: %s", owners) logger.info("Prefix: %s", self.prefix) @@ -560,6 +539,13 @@ async def on_ready(self): logger.info("Receiving guild ID: %s", self.modmail_guild.id) logger.line() + if "dev" in __version__: + logger.warning( + "You are running a developmental version. This should not be used in production. (v%s)", + __version__, + ) + logger.line() + await self.threads.populate_cache() # closures @@ -568,14 +554,14 @@ async def on_ready(self): logger.line() for recipient_id, items in tuple(closures.items()): - after = (datetime.fromisoformat(items["time"]) - datetime.utcnow()).total_seconds() + after = ( + datetime.fromisoformat(items["time"]).astimezone(timezone.utc) - discord.utils.utcnow() + ).total_seconds() if after <= 0: logger.debug("Closing thread for recipient %s.", recipient_id) after = 0 else: - logger.debug( - "Thread for recipient %s will be closed after %s seconds.", recipient_id, after - ) + logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after) thread = await self.threads.find(recipient_id=int(recipient_id)) @@ -587,7 +573,7 @@ async def on_ready(self): continue await thread.close( - closer=self.get_user(items["closer_id"]), + closer=await self.get_or_fetch_user(items["closer_id"]), after=after, silent=items["silent"], delete_channel=items["delete_channel"], @@ -603,13 +589,13 @@ async def on_ready(self): { "open": False, "title": None, - "closed_at": str(datetime.utcnow()), + "closed_at": str(discord.utils.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { "id": str(self.user.id), "name": self.user.name, "discriminator": self.user.discriminator, - "avatar_url": str(self.user.avatar_url), + "avatar_url": self.user.display_avatar.url, "mod": True, }, }, @@ -617,54 +603,44 @@ async def on_ready(self): if log_data: logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) else: - logger.debug( - "Failed to close thread with channel %s, skipping.", log["channel_id"] - ) - - 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() + logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) - self.autoupdate_loop = tasks.Loop( - self.autoupdate, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None - ) - 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} - ] + other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] if any(other_guilds): logger.warning( "The bot is in more servers other than the main and staff server. " "This may cause data compromise (%s).", - ", ".join(guild.name for guild in other_guilds), + ", ".join(str(guild.name) for guild in other_guilds), ) logger.warning("If the external servers are valid, you may ignore this message.") + self.post_metadata.start() + self.autoupdate.start() + self.log_expiry.start() + self._started = True + async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() - if name not in UNICODE_EMOJI: + if not is_emoji(name): try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument as e: - logger.warning("%s is not a valid emoji. %s.", e) + logger.warning("%s is not a valid emoji: %s", name, e) raise return name - async def retrieve_emoji(self) -> typing.Tuple[str, str]: + async def get_or_fetch_user(self, id: int) -> discord.User: + """ + Retrieve a User based on their ID. + + This tries getting the user from the cache and falls back to making + an API call if they're not found in the cache. + """ + return self.get_user(id) or await self.fetch_user(id) + async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] @@ -688,7 +664,7 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]: def check_account_age(self, author: discord.Member) -> bool: account_age = self.config.get("account_age") - now = datetime.utcnow() + now = discord.utils.utcnow() try: min_account_age = author.created_at + account_age @@ -702,7 +678,7 @@ def check_account_age(self, author: discord.Member) -> bool: logger.debug("Blocked due to account age, user %s.", author.name) if str(author.id) not in self.blocked_users: - new_reason = f"System Message: New Account. Required to wait for {delta}." + new_reason = f"System Message: New Account. User can try again {delta}." self.blocked_users[str(author.id)] = new_reason return False @@ -710,7 +686,7 @@ def check_account_age(self, author: discord.Member) -> bool: def check_guild_age(self, author: discord.Member) -> bool: guild_age = self.config.get("guild_age") - now = datetime.utcnow() + now = discord.utils.utcnow() if not hasattr(author, "joined_at"): logger.warning("Not in guild, cannot verify guild_age, %s.", author.name) @@ -728,7 +704,7 @@ def check_guild_age(self, author: discord.Member) -> bool: logger.debug("Blocked due to guild age, user %s.", author.name) if str(author.id) not in self.blocked_users: - new_reason = f"System Message: Recently Joined. Required to wait for {delta}." + new_reason = f"System Message: Recently Joined. User can try again {delta}." self.blocked_users[str(author.id)] = new_reason return False @@ -738,23 +714,14 @@ def check_manual_blocked_roles(self, author: discord.Member) -> bool: if isinstance(author, discord.Member): for r in author.roles: if str(r.id) in self.blocked_roles: - blocked_reason = self.blocked_roles.get(str(r.id)) or "" - 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 role %s, block and unblock again to update.", - r.name, - ) + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.blocked_roles.pop(str(r.id)) @@ -770,26 +737,19 @@ def check_manual_blocked(self, author: discord.Member) -> bool: return True blocked_reason = self.blocked_users.get(str(author.id)) or "" - now = datetime.utcnow() if blocked_reason.startswith("System Message:"): # Met the limits already, otherwise it would've been caught by the previous checks logger.debug("No longer internally blocked, user %s.", author.name) self.blocked_users.pop(str(author.id)) return True - # 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, - ) + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False 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)) @@ -811,8 +771,7 @@ async def is_blocked( *, channel: discord.TextChannel = None, send_message: bool = False, - ) -> typing.Tuple[bool, str]: - + ) -> bool: member = self.guild.get_member(author.id) if member is None: # try to find in other guilds @@ -859,7 +818,7 @@ async def is_blocked( async def get_thread_cooldown(self, author: discord.Member): thread_cooldown = self.config.get("thread_cooldown") - now = datetime.utcnow() + now = discord.utils.utcnow() if thread_cooldown == isodate.Duration(): return @@ -877,12 +836,12 @@ async def get_thread_cooldown(self, author: discord.Member): return try: - cooldown = datetime.fromisoformat(last_log_closed_at) + thread_cooldown + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + thread_cooldown except ValueError: logger.warning("Error with 'thread_cooldown'.", exc_info=True) - cooldown = datetime.fromisoformat(last_log_closed_at) + self.config.remove( - "thread_cooldown" - ) + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone( + timezone.utc + ) + self.config.remove("thread_cooldown") if cooldown > now: # User messaged before thread cooldown ended @@ -892,11 +851,13 @@ async def get_thread_cooldown(self, author: discord.Member): return @staticmethod - async def add_reaction(msg, reaction: discord.Reaction) -> bool: + async def add_reaction( + msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str] + ) -> bool: if reaction != "disable": try: await msg.add_reaction(reaction) - except (discord.HTTPException, discord.InvalidArgument) as e: + except (discord.HTTPException, TypeError) as e: logger.warning("Failed to add reaction %s: %s.", reaction, e) return False return True @@ -908,7 +869,7 @@ 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: + if message.type not in [discord.MessageType.default, discord.MessageType.reply]: return thread = await self.threads.find(recipient=message.author) @@ -931,11 +892,10 @@ async def process_dm_modmail(self, message: discord.Message) -> None: description=self.config["disabled_new_thread_response"], ) embed.set_footer( - text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon_url - ) - logger.info( - "A new thread was blocked from %s due to disabled Modmail.", message.author + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), ) + logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -949,11 +909,9 @@ async def process_dm_modmail(self, message: discord.Message) -> None: ) embed.set_footer( text=self.config["disabled_current_thread_footer"], - icon_url=self.guild.icon_url, - ) - logger.info( - "A message was blocked from %s due to disabled Modmail.", message.author + icon_url=self.get_guild_icon(guild=message.guild, size=128), ) + logger.info("A message was blocked from %s due to disabled Modmail.", message.author) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -964,9 +922,28 @@ async def process_dm_modmail(self, message: discord.Message) -> None: logger.error("Failed to send message:", exc_info=True) await self.add_reaction(message, blocked_emoji) else: + for user in thread.recipients: + # send to all other recipients + if user != message.author: + try: + await thread.send(message, user) + except Exception: + # silently ignore + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, sent_emoji) self.dispatch("thread_reply", thread, False, message, False, False) + def _get_snippet_command(self) -> commands.Command: + """Get the correct reply command based on the snippet config""" + modifiers = "f" + if self.config["plain_snippets"]: + modifiers += "p" + if self.config["anonymous_snippets"]: + modifiers += "a" + + return self.get_command(f"{modifiers}reply") + async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. @@ -977,7 +954,7 @@ async def get_contexts(self, message, *, cls=commands.Context): ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) thread = await self.threads.find(channel=ctx.channel) - if self._skip_check(message.author.id, self.user.id): + if message.author.id == self.user.id: # type: ignore return [ctx] prefixes = await self.get_prefix() @@ -988,9 +965,18 @@ async def get_contexts(self, message, *, cls=commands.Context): invoker = view.get_word().lower() + # Check if a snippet is being called. + # This needs to be done before checking for aliases since + # snippets can have multiple words. + try: + # Use removeprefix once PY3.9+ + snippet_text = self.snippets[message.content[len(invoked_prefix) :]] + except KeyError: + snippet_text = None + # Check if there is any aliases being called. alias = self.aliases.get(invoker) - if alias is not None: + if alias is not None and snippet_text is None: ctxs = [] aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) if not aliases: @@ -998,18 +984,36 @@ async def get_contexts(self, message, *, cls=commands.Context): self.aliases.pop(invoker) for alias in aliases: - view = StringView(invoked_prefix + alias) + command = None + try: + snippet_text = self.snippets[alias] + except KeyError: + command_invocation_text = alias + else: + command = self._get_snippet_command() + command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" + view = StringView(invoked_prefix + command_invocation_text) ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() - ctx_.command = self.all_commands.get(ctx_.invoked_with) + ctx_.command = command or self.all_commands.get(ctx_.invoked_with) ctxs += [ctx_] return ctxs ctx.thread = thread - ctx.invoked_with = invoker - ctx.command = self.all_commands.get(invoker) + + if snippet_text is not None: + # Process snippets + ctx.command = self._get_snippet_command() + reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") + discord.utils.find(reply_view.skip_string, prefixes) + ctx.invoked_with = reply_view.get_word().lower() + ctx.view = reply_view + else: + ctx.command = self.all_commands.get(invoker) + ctx.invoked_with = invoker + return [ctx] async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): @@ -1025,35 +1029,31 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) invoker = None if self.config.get("use_regex_autotrigger"): - trigger = next( - filter(lambda x: re.search(x, message.content), self.auto_triggers.keys()) - ) + trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) if trigger: invoker = re.search(trigger, message.content).group(0) else: - trigger = next( - filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys()) - ) + trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys())) if trigger: invoker = trigger.lower() alias = self.auto_triggers[trigger] ctxs = [] + if alias is not None: ctxs = [] aliases = normalize_alias(alias) if not aliases: logger.warning("Alias %s is invalid as called in autotrigger.", invoker) - 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_] + message.author = thread.recipient # Allow for get_contexts to work + + for alias in aliases: + message.content = invoked_prefix + alias + ctxs += await self.get_contexts(message) + + message.author = self.modmail_guild.me # Fix message so commands execute properly for ctx in ctxs: if ctx.command: @@ -1074,7 +1074,7 @@ async def get_context(self, message, *, cls=commands.Context): view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) - if self._skip_check(message.author.id, self.user.id): + if message.author.id == self.user.id: return ctx ctx.thread = await self.threads.find(channel=ctx.channel) @@ -1138,7 +1138,7 @@ async def on_message(self, message): color=self.main_color, ) if self.config["show_timestamp"]: - em.timestamp = datetime.utcnow() + em.timestamp = discord.utils.utcnow() if not self.config["silent_alert_on_mention"]: content = self.config["mention"] @@ -1155,23 +1155,10 @@ async def process_commands(self, message): if isinstance(message.channel, discord.DMChannel): return await self.process_dm_modmail(message) - if message.content.startswith(self.prefix): - cmd = message.content[len(self.prefix) :].strip() - - # Process snippets - if cmd in self.snippets: - snippet = self.snippets[cmd] - if self.config["anonymous_snippets"]: - message.content = f"{self.prefix}fareply {snippet}" - else: - message.content = f"{self.prefix}freply {snippet}" - ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: - if not any( - 1 for check in ctx.command.checks if hasattr(check, "permission_level") - ): + if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): logger.debug( "Command %s has no permissions check, adding invalid level.", ctx.command.qualified_name, @@ -1199,9 +1186,7 @@ async def process_commands(self, message): else: await self.api.append_log(message, type_="internal") elif ctx.invoked_with: - exc = commands.CommandNotFound( - 'Command "{}" is not found'.format(ctx.invoked_with) - ) + exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): @@ -1217,16 +1202,17 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(recipient=user) if thread: - await thread.channel.trigger_typing() + await thread.channel.typing() else: if not self.config.get("mod_typing"): return thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: - if await self.is_blocked(thread.recipient): - return - await thread.recipient.trigger_typing() + for user in thread.recipients: + if await self.is_blocked(user): + continue + await user.typing() async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) @@ -1234,25 +1220,36 @@ async def handle_reaction_events(self, payload): return channel = self.get_channel(payload.channel_id) - if not channel: # dm channel not in internal cache - _thread = await self.threads.find(recipient=user) - if not _thread: + thread = None + # dm channel not in internal cache + if not channel: + thread = await self.threads.find(recipient=user) + if not thread: + return + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return + + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: return - channel = await _thread.recipient.create_dm() + # thread must exist before doing this API call try: message = await channel.fetch_message(payload.message_id) except (discord.NotFound, discord.Forbidden): return reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config["close_emoji"]) - - if isinstance(channel, discord.DMChannel): - thread = await self.threads.find(recipient=user) - if not thread: - return + if from_dm: if ( payload.event_type == "REACTION_ADD" and message.embeds @@ -1260,7 +1257,7 @@ async def handle_reaction_events(self, payload): and self.config.get("recipient_thread_close") ): ts = message.embeds[0].timestamp - if thread and ts == thread.channel.created_at: + if ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed # closing thread return await thread.close(closer=user) @@ -1275,79 +1272,76 @@ async def handle_reaction_events(self, payload): if not thread.recipient.dm_channel: await thread.recipient.create_dm() try: - linked_message = await thread.find_linked_message_from_dm( - message, either_direction=True - ) + linked_messages = await thread.find_linked_message_from_dm(message, either_direction=True) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return else: - thread = await self.threads.find(channel=channel) - if not thread: - return try: - _, linked_message = await thread.find_linked_messages( - message.id, either_direction=True + _, *linked_messages = await thread.find_linked_messages( + message1=message, either_direction=True ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return - if self.config["transfer_reactions"] and linked_message is not None: + if self.config["transfer_reactions"] and linked_messages is not [None]: if payload.event_type == "REACTION_ADD": - if await self.add_reaction(linked_message, reaction): - await self.add_reaction(message, reaction) + for msg in linked_messages: + await self.add_reaction(msg, reaction) + await self.add_reaction(message, reaction) else: try: - await linked_message.remove_reaction(reaction, self.user) + for msg in linked_messages: + await msg.remove_reaction(reaction, self.user) await message.remove_reaction(reaction, self.user) - except (discord.HTTPException, discord.InvalidArgument) as e: + except (discord.HTTPException, TypeError) as e: logger.warning("Failed to remove reaction: %s", e) - async def on_raw_reaction_add(self, payload): - await self.handle_reaction_events(payload) - + async def handle_react_to_contact(self, payload): react_message_id = tryint(self.config.get("react_to_contact_message")) react_message_emoji = self.config.get("react_to_contact_emoji") - if 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) - if not member.bot: - message = await channel.fetch_message(payload.message_id) - await message.remove_reaction(payload.emoji, member) - await message.add_reaction(emoji_fmt) # bot adds as well - - if self.config["dm_disabled"] in ( - DMDisabled.NEW_THREADS, - DMDisabled.ALL_THREADS, - ): - embed = discord.Embed( - title=self.config["disabled_new_thread_title"], - color=self.error_color, - description=self.config["disabled_new_thread_response"], - ) - embed.set_footer( - text=self.config["disabled_new_thread_footer"], - icon_url=self.guild.icon_url, - ) - logger.info( - "A new thread using react to contact was blocked from %s due to disabled Modmail.", - member, - ) - return await member.send(embed=embed) + if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: + return + if payload.emoji.is_unicode_emoji(): + emoji_fmt = payload.emoji.name + else: + emoji_fmt = f"<:{payload.emoji.name}:{payload.emoji.id}>" - ctx = await self.get_context(message) - ctx.author = member - await ctx.invoke( - self.get_command("contact"), user=member, manual_trigger=False - ) + if emoji_fmt != react_message_emoji: + return + channel = self.get_channel(payload.channel_id) + member = channel.guild.get_member(payload.user_id) + if member.bot: + return + message = await channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, member) + await message.add_reaction(emoji_fmt) # bot adds as well + + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=channel.guild, size=128), + ) + logger.info( + "A new thread using react to contact was blocked from %s due to disabled Modmail.", + member, + ) + return await member.send(embed=embed) + + ctx = await self.get_context(message) + await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) + + async def on_raw_reaction_add(self, payload): + await asyncio.gather( + self.handle_reaction_events(payload), + self.handle_react_to_contact(payload), + ) async def on_raw_reaction_remove(self, payload): if self.config["transfer_reactions"]: @@ -1373,12 +1367,14 @@ async def on_guild_channel_delete(self, channel): await self.config.update() return - audit_logs = self.modmail_guild.audit_logs( - limit=10, action=discord.AuditLogAction.channel_delete - ) - entry = await audit_logs.find(lambda a: int(a.target.id) == channel.id) + audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) + found_entry = False + async for entry in audit_logs: + if int(entry.target.id) == channel.id: + found_entry = True + break - if entry is None: + if not found_entry: logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) return @@ -1392,30 +1388,44 @@ async def on_guild_channel_delete(self, channel): await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - if self.config["close_on_leave"]: + if member.guild == self.guild and self.config["close_on_leave"]: await thread.close( closer=member.guild.me, message=self.config["close_on_leave_reason"], silent=True, ) else: - embed = discord.Embed( - description=self.config["close_on_leave_reason"], color=self.error_color - ) + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) await thread.channel.send(embed=embed) async def on_member_join(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - embed = discord.Embed( - description="The recipient has joined the server.", color=self.mod_color - ) + if len(self.guilds) > 1: + guild_joined = member.guild + join_message = f"The recipient has joined {guild_joined}." + else: + join_message = "The recipient has joined the server." + embed = discord.Embed(description=join_message, color=self.mod_color) await thread.channel.send(embed=embed) async def on_message_delete(self, message): @@ -1431,13 +1441,20 @@ async def on_message_delete(self, message): if not thread: return try: - message = await thread.find_linked_message_from_dm(message) + message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) except ValueError as e: if str(e) != "Thread channel message not found.": logger.debug("Failed to find linked message to delete: %s", e) return + message = message[0] embed = message.embeds[0] - embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=embed.footer.icon_url) + + if embed.footer.icon: + icon_url = embed.footer.icon.url + else: + icon_url = None + + embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=icon_url) await message.edit(embed=embed) return @@ -1448,26 +1465,13 @@ async def on_message_delete(self, message): if not thread: return - audit_logs = self.modmail_guild.audit_logs( - limit=10, action=discord.AuditLogAction.message_delete - ) - - entry = await audit_logs.find(lambda a: a.target == self.user) - - if entry is None: - return - try: await thread.delete_message(message, note=False) - embed = discord.Embed( - description="Successfully deleted message.", color=self.main_color - ) + embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) except ValueError as e: if str(e) not in {"DM message not found.", "Malformed thread message."}: logger.debug("Failed to find linked message to delete: %s", e) - embed = discord.Embed( - description="Failed to delete message.", color=self.error_color - ) + embed = discord.Embed(description="Failed to delete message.", color=self.error_color) else: return except discord.NotFound: @@ -1495,9 +1499,7 @@ async def on_message_edit(self, before, after): _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(after, blocked_emoji) else: - embed = discord.Embed( - description="Successfully Edited Message", color=self.main_color - ) + embed = discord.Embed(description="Successfully Edited Message", color=self.main_color) embed.set_footer(text=f"Message ID: {after.id}") await after.channel.send(embed=embed) @@ -1505,12 +1507,20 @@ async def on_error(self, event_method, *args, **kwargs): logger.error("Ignoring exception in %s.", event_method) logger.error("Unexpected exception:", exc_info=sys.exc_info()) - async def on_command_error(self, context, exception): - if isinstance(exception, commands.BadArgument): - await context.trigger_typing() - await context.send( - embed=discord.Embed(color=self.error_color, description=str(exception)) - ) + async def on_command_error( + self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False + ) -> None: + if not unhandled_by_cog: + command = context.command + if command and command.has_error_handler(): + return + cog = context.cog + if cog and cog.has_error_handler(): + return + + if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): + await context.typing() + await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): @@ -1531,9 +1541,7 @@ async def on_command_error(self, context, exception): embed=discord.Embed(color=self.error_color, description=check.fail_msg) ) if hasattr(check, "permission_level"): - corrected_permission_level = self.command_perm( - context.command.qualified_name - ) + corrected_permission_level = self.command_perm(context.command.qualified_name) logger.warning( "User %s does not have permission to use this command: `%s` (%s).", context.author.name, @@ -1542,35 +1550,33 @@ async def on_command_error(self, context, exception): ) logger.warning("CheckFailure: %s", exception) elif isinstance(exception, commands.DisabledCommand): - logger.info( - "DisabledCommand: %s is trying to run eval but it's disabled", context.author.name - ) + logger.info("DisabledCommand: %s is trying to run eval but it's disabled", context.author.name) else: logger.error("Unexpected exception:", exc_info=exception) + @tasks.loop(hours=1) async def post_metadata(self): info = await self.application_info() + delta = discord.utils.utcnow() - self.start_time data = { "bot_id": self.user.id, "bot_name": str(self.user), - "avatar_url": str(self.user.avatar_url), + "avatar_url": self.user.display_avatar.url, "guild_id": self.guild_id, "guild_name": self.guild.name, "member_count": len(self.guild.members), - "uptime": (datetime.utcnow() - self.start_time).total_seconds(), + "uptime": delta.total_seconds(), "latency": f"{self.ws.latency * 1000:.4f}", "version": str(self.version), "selfhosted": True, - "last_updated": str(datetime.utcnow()), + "last_updated": str(discord.utils.utcnow()), } if info.team is not None: data.update( { - "owner_name": info.team.owner.name - if info.team.owner is not None - else "No Owner", + "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", "owner_id": info.team.owner_id, "team": True, } @@ -1581,58 +1587,77 @@ async def post_metadata(self): async with self.session.post("https://api.modmail.dev/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") + @post_metadata.before_loop async def before_post_metadata(self): await self.wait_for_connected() + if not self.config.get("data_collection") or not self.guild: + self.post_metadata.cancel() + return + logger.debug("Starting metadata loop.") logger.line("debug") - if not self.guild: - self.metadata_loop.cancel() + @tasks.loop(hours=1) 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: + if self.version < Version(latest.version): + error = None + data = {} + try: + # update fork if gh_token exists data = await self.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + if self.hosting_method == HostingMethod.HEROKU: + if error is not None: + logger.error(f"Autoupdate failed! Status: {error.status}.") + logger.error(f"Error message: {error.message}") + self.autoupdate.cancel() + return - embed = discord.Embed(color=self.main_color) + commit_data = data.get("data") + if not commit_data: + return + + logger.info("Bot has been updated.") + + if not self.config["update_notifications"]: + return - commit_data = data["data"] + embed = discord.Embed(color=self.main_color) + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] user = data["user"] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", + ) embed.set_author( name=user["username"] + " - Updating Bot", icon_url=user["avatar_url"], url=user["url"], ) - embed.set_footer(text=f"Updating Modmail v{self.version} " f"-> v{latest.version}") + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") embed.description = latest.description for name, value in latest.fields.items(): embed.add_field(name=name, value=value) - 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 - if self.config["update_notifications"]: - await channel.send(embed=embed) + channel = self.update_channel + await channel.send(embed=embed) else: - try: - # update fork if gh_token exists - await self.api.update_repository() - except InvalidConfigError: - pass - command = "git pull" - proc = await asyncio.create_subprocess_shell(command, stderr=PIPE, stdout=PIPE,) + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) err = await proc.stderr.read() err = err.decode("utf-8").rstrip() res = await proc.stdout.read() @@ -1640,64 +1665,156 @@ async def autoupdate(self): if err and not res: logger.warning(f"Autoupdate failed: {err}") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() return elif res != "Already up to date.": + if os.getenv("PIPENV_ACTIVE"): + # Update pipenv if possible + await asyncio.create_subprocess_shell( + "pipenv sync", + stderr=PIPE, + stdout=PIPE, + ) + message = "" + else: + message = "\n\nDo manually update dependencies if your bot has crashed." + logger.info("Bot has been updated.") channel = self.update_channel if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): embed = discord.Embed(title="Bot has been updated", color=self.main_color) embed.set_footer( - text=f"Updating Modmail v{self.version} " f"-> v{latest.version}" + text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" ) if self.config["update_notifications"]: await channel.send(embed=embed) else: embed = discord.Embed( title="Bot has been updated and is logging out.", - description="If you do not have an auto-restart setup, please manually start the bot.", + description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", color=self.main_color, ) - embed.set_footer( - text=f"Updating Modmail v{self.version} " f"-> v{latest.version}" - ) + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") if self.config["update_notifications"]: await channel.send(embed=embed) - await self.logout() + return await self.close() + @autoupdate.before_loop async def before_autoupdate(self): await self.wait_for_connected() logger.debug("Starting autoupdate loop") if self.config.get("disable_autoupdates"): logger.warning("Autoupdates disabled.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return if self.hosting_method == HostingMethod.DOCKER: logger.warning("Autoupdates disabled as using Docker.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: logger.warning("GitHub access token not found.") logger.warning("Autoupdates disabled.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return + + @tasks.loop(hours=1, reconnect=False) + async def log_expiry(self): + log_expire_after = self.config.get("log_expiration") + if log_expire_after == isodate.Duration(): + return self.log_expiry.stop() + + now = discord.utils.utcnow() + expiration_datetime = now - log_expire_after + # WARNING: comparison is done lexicographically, not by date. + # This is fine as long as the date is in zero-padded ISO format, which it should be. + expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) + + logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") + + def format_channel_name(self, author, exclude_channel=None, force_null=False): + """Sanitises a username for use with text channel names + + Placed in main bot class to be extendable to plugins""" + guild = self.modmail_guild + + if force_null: + name = new_name = "null" + else: + if self.config["use_random_channel_name"]: + to_hash = self.token.split(".")[-1] + str(author.id) + digest = hashlib.md5(to_hash.encode("utf8"), usedforsecurity=False) + name = new_name = digest.hexdigest()[-8:] + elif self.config["use_user_id_channel_name"]: + name = new_name = str(author.id) + elif self.config["use_timestamp_channel_name"]: + name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") + else: + if self.config["use_nickname_channel_name"]: + author_member = self.guild.get_member(author.id) + name = author_member.display_name.lower() + else: + name = author.name.lower() + + if force_null: + name = "null" + + name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" + if author.discriminator != "0": + name += f"-{author.discriminator}" + new_name = name + + counter = 1 + existed = set(c.name for c in guild.text_channels if c != exclude_channel) + while new_name in existed: + new_name = f"{name}_{counter}" # multiple channels with same name + counter += 1 + + return new_name def main(): try: # noinspection PyUnresolvedReferences - import uvloop + import uvloop # type: ignore logger.debug("Setting up with uvloop.") uvloop.install() except ImportError: pass + try: + import cairosvg # noqa: F401 + except OSError: + if os.name == "nt": + if struct.calcsize("P") * 8 != 64: + logger.error( + "Unable to import cairosvg, ensure your Python is a 64-bit version: https://www.python.org/downloads/" + ) + else: + logger.error( + "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" + ) + else: + if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): + logger.error( + "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + else: + logger.error( + "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + sys.exit(0) + # check discord version - if discord.__version__ != "1.6.0": + discord_version = "2.3.2" + if discord.__version__ != discord_version: logger.error( - "Dependencies are not updated, run pipenv install. discord.py version expected 1.6.0, received %s", + "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", + discord_version, discord.__version__, ) sys.exit(0) diff --git a/cogs/modmail.py b/cogs/modmail.py index 87b9d91e57..ee27806177 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,18 +1,17 @@ import asyncio import re -from datetime import datetime +from datetime import datetime, timezone from itertools import zip_longest -from typing import Optional, Union -from types import SimpleNamespace +from typing import Optional, Union, List, Tuple, Literal import discord from discord.ext import commands +from discord.ext.commands.view import StringView 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 DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger @@ -42,9 +41,7 @@ async def setup(self, ctx): """ if ctx.guild != self.bot.modmail_guild: - return await ctx.send( - f"You can only setup in the Modmail guild: {self.bot.modmail_guild}." - ) + return await ctx.send(f"You can only setup in the Modmail guild: {self.bot.modmail_guild}.") if self.bot.main_category is not None: logger.debug("Can't re-setup server, main_category is found.") @@ -79,15 +76,11 @@ async def setup(self, ctx): logger.info("Granting %s access to Modmail category.", key.name) overwrites[key] = discord.PermissionOverwrite(read_messages=True) - category = await self.bot.modmail_guild.create_category( - name="Modmail", overwrites=overwrites - ) + category = await self.bot.modmail_guild.create_category(name="Modmail", overwrites=overwrites) await category.edit(position=0) - log_channel = await self.bot.modmail_guild.create_text_channel( - name="bot-logs", category=category - ) + log_channel = await self.bot.modmail_guild.create_text_channel(name="bot-logs", category=category) embed = discord.Embed( title="Friendly Reminder", @@ -100,7 +93,7 @@ async def setup(self, ctx): embed.add_field( name="Thanks for using our bot!", value="If you like what you see, consider giving the " - "[repo a star](https://github.com/kyb3r/modmail) :star: and if you are " + "[repo a star](https://github.com/modmail-dev/modmail) :star: and if you are " "feeling extra generous, buy us coffee on [Patreon](https://patreon.com/kyber) :heart:!", ) @@ -150,12 +143,14 @@ async def snippet(self, ctx, *, name: str.lower = None): """ if name is not None: - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: + val = self.bot.snippets[snippet_name] embed = discord.Embed( - title=f'Snippet - "{name}":', description=val, color=self.bot.main_color + title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color ) return await ctx.send(embed=embed) @@ -164,7 +159,7 @@ async def snippet(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any snippets at the moment." ) embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) return await ctx.send(embed=embed) embeds = [] @@ -172,7 +167,7 @@ async def snippet(self, ctx, *, name: str.lower = None): for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): description = format_description(i, names) embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) embeds.append(embed) session = EmbedPaginatorSession(ctx, *embeds) @@ -184,20 +179,20 @@ async def snippet_raw(self, ctx, *, name: str.lower): """ View the raw content of a snippet. """ - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: - val = truncate(escape_code_block(val), 2048 - 7) + val = truncate(escape_code_block(self.bot.snippets[snippet_name]), 2048 - 7) embed = discord.Embed( - title=f'Raw snippet - "{name}":', + title=f'Raw snippet - "{snippet_name}":', description=f"```\n{val}```", color=self.bot.main_color, ) return await ctx.send(embed=embed) - @snippet.command(name="add") + @snippet.command(name="add", aliases=["create", "make"]) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content): """ @@ -212,7 +207,14 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte {prefix}snippet add "two word" this is a two word snippet. ``` """ - if name in self.bot.snippets: + if self.bot.get_command(name): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + elif name in self.bot.snippets: embed = discord.Embed( title="Error", color=self.bot.error_color, @@ -246,16 +248,103 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte ) return await ctx.send(embed=embed) + def _fix_aliases(self, snippet_being_deleted: str) -> Tuple[List[str]]: + """ + Remove references to the snippet being deleted from aliases. + + Direct aliases to snippets are deleted, and aliases having + other steps are edited. + + A tuple of dictionaries are returned. The first dictionary + contains a mapping of alias names which were deleted to their + original value, and the second dictionary contains a mapping + of alias names which were edited to their original value. + """ + deleted = {} + edited = {} + + # Using a copy since we might need to delete aliases + for alias, val in self.bot.aliases.copy().items(): + values = parse_alias(val) + + save_aliases = [] + + for val in values: + view = StringView(val) + linked_command = view.get_word().lower() + message = view.read_rest() + + if linked_command == snippet_being_deleted: + continue + + is_valid_snippet = snippet_being_deleted in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_valid_snippet: + alias_command = self.bot.aliases[linked_command] + save_aliases.extend(normalize_alias(alias_command, message)) + else: + save_aliases.append(val) + + if not save_aliases: + original_value = self.bot.aliases.pop(alias) + deleted[alias] = original_value + else: + original_alias = self.bot.aliases[alias] + new_alias = " && ".join(f'"{a}"' for a in save_aliases) + + if original_alias != new_alias: + self.bot.aliases[alias] = new_alias + edited[alias] = original_alias + + return deleted, edited + @snippet.command(name="remove", aliases=["del", "delete"]) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_remove(self, ctx, *, name: str.lower): """Remove a snippet.""" - if name in self.bot.snippets: + deleted_aliases, edited_aliases = self._fix_aliases(name) + + deleted_aliases_string = ",".join(f"`{alias}`" for alias in deleted_aliases) + if len(deleted_aliases) == 1: + deleted_aliases_output = f"The `{deleted_aliases_string}` direct alias has been removed." + elif deleted_aliases: + deleted_aliases_output = ( + f"The following direct aliases have been removed: {deleted_aliases_string}." + ) + else: + deleted_aliases_output = None + + if len(edited_aliases) == 1: + alias, val = edited_aliases.popitem() + edited_aliases_output = ( + f"Steps pointing to this snippet have been removed from the `{alias}` alias" + f" (previous value: `{val}`).`" + ) + elif edited_aliases: + alias_list = "\n".join( + [ + f"- `{alias_name}` (previous value: `{val}`)" + for alias_name, val in edited_aliases.items() + ] + ) + edited_aliases_output = ( + f"Steps pointing to this snippet have been removed from the following aliases:" + f"\n\n{alias_list}" + ) + else: + edited_aliases_output = None + + description = f"Snippet `{name}` is now deleted." + if deleted_aliases_output: + description += f"\n\n{deleted_aliases_output}" + if edited_aliases_output: + description += f"\n\n{edited_aliases_output}" + embed = discord.Embed( title="Removed snippet", color=self.bot.main_color, - description=f"Snippet `{name}` is now deleted.", + description=description, ) self.bot.snippets.pop(name) await self.bot.config.update() @@ -328,7 +417,9 @@ async def move(self, ctx, *, arguments): silent_words = ["silent", "silently"] silent = any(word in silent_words for word in options.split()) - await thread.channel.edit(category=category, sync_permissions=True) + await thread.channel.move( + category=category, end=True, sync_permissions=True, reason=f"{ctx.author} moved this thread." + ) if self.bot.config["thread_move_notify"] and not silent: embed = discord.Embed( @@ -352,11 +443,9 @@ async def move(self, ctx, *, arguments): async def send_scheduled_close_message(self, ctx, after, silent=False): human_delta = human_timedelta(after.dt) - silent = "*silently* " if silent else "" - embed = discord.Embed( title="Scheduled close", - description=f"This thread will close {silent}in {human_delta}.", + description=f"This thread will{' silently' if silent else ''} close in {human_delta}.", color=self.bot.error_color, ) @@ -371,7 +460,13 @@ async def send_scheduled_close_message(self, ctx, after, silent=False): @commands.command(usage="[after] [close message]") @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def close(self, ctx, *, after: UserFriendlyTime = None): + async def close( + self, + ctx, + option: Optional[Literal["silent", "silently", "cancel"]] = "", + *, + after: UserFriendlyTime = None, + ): """ Close the current thread. @@ -385,7 +480,7 @@ async def close(self, ctx, *, after: UserFriendlyTime = None): Silently close a thread (no message) - `{prefix}close silently` - - `{prefix}close in 10m silently` + - `{prefix}close silently in 10m` Stop a thread from closing: - `{prefix}close cancel` @@ -393,15 +488,11 @@ async def close(self, ctx, *, after: UserFriendlyTime = None): thread = ctx.thread - now = datetime.utcnow() - - close_after = (after.dt - now).total_seconds() if after else 0 - message = after.arg if after else None - silent = str(message).lower() in {"silent", "silently"} - cancel = str(message).lower() == "cancel" + close_after = (after.dt - after.now).total_seconds() if after else 0 + silent = any(x == option for x in {"silent", "silently"}) + cancel = option == "cancel" if cancel: - if thread.close_task is not None or thread.auto_close_task is not None: await thread.cancel_closure(all=True) embed = discord.Embed( @@ -415,7 +506,11 @@ async def close(self, ctx, *, after: UserFriendlyTime = None): return await ctx.send(embed=embed) - if after and after.dt > now: + message = after.arg if after else None + if self.bot.config["require_close_reason"] and message is None: + raise commands.BadArgument("Provide a reason for closing the thread.") + + if after and after.dt > after.now: await self.send_scheduled_close_message(ctx, after, silent) await thread.close(closer=ctx.author, after=close_after, message=message, silent=silent) @@ -434,9 +529,7 @@ def parse_user_or_role(ctx, user_or_role): @commands.command(aliases=["alert"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def notify( - self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None - ): + async def notify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ Notify a user or role when the next thread message received. @@ -474,9 +567,7 @@ async def notify( @commands.command(aliases=["unalert"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def unnotify( - self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None - ): + async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ Un-notify a user, role, or yourself from a thread. @@ -511,9 +602,7 @@ async def unnotify( @commands.command(aliases=["sub"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def subscribe( - self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None - ): + async def subscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ Notify a user, role, or yourself for every thread message received. @@ -537,7 +626,7 @@ async def subscribe( if mention in mentions: embed = discord.Embed( color=self.bot.error_color, - description=f"{mention} is not subscribed to this thread.", + description=f"{mention} is already subscribed to this thread.", ) else: mentions.append(mention) @@ -551,9 +640,7 @@ async def subscribe( @commands.command(aliases=["unsub"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def unsubscribe( - self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None - ): + async def unsubscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ Unsubscribe a user, role, or yourself from a thread. @@ -633,20 +720,23 @@ def format_log_embeds(self, logs, avatar_url): title = f"Total Results Found ({len(logs)})" for entry in logs: - created_at = parser.parse(entry["created_at"]) + created_at = parser.parse(entry["created_at"]).astimezone(timezone.utc) prefix = self.bot.config["log_url_prefix"].strip("/") if prefix == "NONE": prefix = "" - log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" + log_url = ( + f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" + ) - username = entry["recipient"]["name"] + "#" - username += entry["recipient"]["discriminator"] + username = entry["recipient"]["name"] + if entry["recipient"]["discriminator"] != "0": + username += "#" + entry["recipient"]["discriminator"] embed = discord.Embed(color=self.bot.main_color, timestamp=created_at) embed.set_author(name=f"{title} - {username}", icon_url=avatar_url, url=log_url) embed.url = log_url - embed.add_field(name="Created", value=duration(created_at, now=datetime.utcnow())) + embed.add_field(name="Created", value=human_timedelta(created_at)) closer = entry.get("closer") if closer is None: closer_msg = "Unknown" @@ -657,6 +747,9 @@ def format_log_embeds(self, logs, avatar_url): if entry["recipient"]["id"] != entry["creator"]["id"]: embed.add_field(name="Created by", value=f"<@{entry['creator']['id']}>") + if entry.get("title"): + embed.add_field(name="Title", value=entry["title"], inline=False) + embed.add_field(name="Preview", value=format_preview(entry["messages"]), inline=False) if closer is not None: @@ -681,6 +774,380 @@ async def title(self, ctx, *, name: str): await ctx.message.pin() await self.bot.add_reaction(ctx.message, sent_emoji) + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Adds a user to a modmail thread + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + # u is a discord.Member + curr_thread = await self.bot.threads.find(recipient=u) + if curr_thread == ctx.thread: + users.remove(u) + continue + + if curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is already in a thread: {curr_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="All users are already in the thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if len(users + ctx.thread.recipients) > 5: + em = discord.Embed( + title="Error", + description="Only 5 users are allowed in a group conversation", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + description = self.bot.formatter.format( + self.bot.config["private_added_to_group_response"], moderator=ctx.author + ) + em = discord.Embed( + title=self.bot.config["private_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_added_to_group_response"], + moderator=ctx.author, + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.add_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Removes a user from a modmail thread + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + # u is a discord.Member + curr_thread = await self.bot.threads.find(recipient=u) + if ctx.thread != curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is not in this thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + elif ctx.thread.recipient == u: + em = discord.Embed( + title="Error", + description=f"{u.mention} is the main recipient of the thread and cannot be removed.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="No valid users to remove.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + description = self.bot.formatter.format( + self.bot.config["private_removed_from_group_response"], moderator=ctx.author + ) + em = discord.Embed( + title=self.bot.config["private_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_removed_from_group_response"], + moderator=ctx.author, + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.remove_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Adds a user to a modmail thread anonymously + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + curr_thread = await self.bot.threads.find(recipient=u) + if curr_thread == ctx.thread: + users.remove(u) + continue + + if curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is already in a thread: {curr_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="All users are already in the thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + em = discord.Embed( + title=self.bot.config["private_added_to_group_title"], + description=self.bot.config["private_added_to_group_description_anon"], + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + tag = self.bot.config["mod_tag"] + if tag is None: + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + name = self.bot.config["anon_username"] + if name is None: + name = tag + avatar_url = self.bot.config["anon_avatar_url"] + if avatar_url is None: + avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) + em.set_footer(text=name, icon_url=avatar_url) + + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_added_to_group_description_anon"], + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.add_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Removes a user from a modmail thread anonymously + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + curr_thread = await self.bot.threads.find(recipient=u) + if ctx.thread != curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is not in this thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + elif ctx.thread.recipient == u: + em = discord.Embed( + title="Error", + description=f"{u.mention} is the main recipient of the thread and cannot be removed.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + em = discord.Embed( + title=self.bot.config["private_removed_from_group_title"], + description=self.bot.config["private_removed_from_group_description_anon"], + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + tag = self.bot.config["mod_tag"] + if tag is None: + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + name = self.bot.config["anon_username"] + if name is None: + name = tag + avatar_url = self.bot.config["anon_avatar_url"] + if avatar_url is None: + avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) + em.set_footer(text=name, icon_url=avatar_url) + + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_removed_from_group_description_anon"], + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.remove_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + 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): @@ -692,13 +1159,13 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.trigger_typing() + await ctx.typing() if not user: thread = ctx.thread if not thread: - raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) - user = thread.recipient or await self.bot.fetch_user(thread.id) + raise commands.MissingRequiredArgument(DummyParam("user")) + user = thread.recipient or await self.bot.get_or_fetch_user(thread.id) default_avatar = "https://cdn.discordapp.com/embed/avatars/0.png" icon_url = getattr(user, "avatar_url", default_avatar) @@ -731,7 +1198,7 @@ async def logs_closed_by(self, ctx, *, user: User = None): user = user if user is not None else ctx.author entries = await self.bot.api.search_closed_by(user.id) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) if not embeds: embed = discord.Embed( @@ -743,6 +1210,28 @@ async def logs_closed_by(self, ctx, *, user: User = None): session = EmbedPaginatorSession(ctx, *embeds) await session.run() + @logs.command(name="key", aliases=["id"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_key(self, ctx, key: str): + """ + Get the log link for the specified log key. + """ + icon_url = ctx.author.avatar.url + + logs = await self.bot.api.find_log_entry(key) + + if not logs: + embed = discord.Embed( + color=self.bot.error_color, + description=f"Log entry `{key}` not found.", + ) + return await ctx.send(embed=embed) + + embeds = self.format_log_embeds(logs, avatar_url=icon_url) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + @logs.command(name="delete", aliases=["wipe"]) @checks.has_permissions(PermissionLevel.OWNER) async def logs_delete(self, ctx, key_or_link: str): @@ -781,7 +1270,7 @@ async def logs_responded(self, ctx, *, user: User = None): entries = await self.bot.api.get_responded_logs(user.id) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) if not embeds: embed = discord.Embed( @@ -802,11 +1291,11 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): Provide a `limit` to specify the maximum number of logs the bot should find. """ - await ctx.trigger_typing() + await ctx.typing() entries = await self.bot.api.search_by_text(query, limit) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) if not embeds: embed = discord.Embed( @@ -828,7 +1317,9 @@ async def reply(self, ctx, *, msg: str = ""): 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) @@ -876,6 +1367,50 @@ async def fareply(self, ctx, *, msg: str = ""): async with ctx.typing(): await ctx.thread.reply(ctx.message, anonymous=True) + @commands.command(aliases=["formatplainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpreply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + ) + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, plain=True) + + @commands.command(aliases=["formatplainanonreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpareply(self, ctx, *, msg: str = ""): + """ + Anonymously reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + ) + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + @commands.command(aliases=["anonreply", "anonymousreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @@ -946,9 +1481,7 @@ async def note_persistent(self, ctx, *, msg: str = ""): 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 - ) + await self.bot.api.create_note(recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id) @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -982,16 +1515,18 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): @checks.has_permissions(PermissionLevel.REGULAR) async def selfcontact(self, ctx): """Creates a thread with yourself""" - await ctx.invoke(self.contact, user=ctx.author) + await ctx.invoke(self.contact, users=[ctx.author]) @commands.command(usage=" [category] [options]") @checks.has_permissions(PermissionLevel.SUPPORTER) async def contact( self, ctx, - user: Union[discord.Member, discord.User], + users: commands.Greedy[ + Union[Literal["silent", "silently"], discord.Member, discord.User, discord.Role] + ], *, - category: Union[SimilarCategoryConverter, str] = None, + category: SimilarCategoryConverter = None, manual_trigger=True, ): """ @@ -1001,70 +1536,130 @@ async def contact( will be created in that specified category. `category`, if specified, may be a category ID, mention, or name. - `user` may be a user ID, mention, or name. - `options` can be `silent` + `users` may be a user ID, mention, or name. If multiple users are specified, a group thread will start. + A maximum of 5 users are allowed. + `options` can be `silent` or `silently`. """ - silent = False + silent = any(x in users for x in ("silent", "silently")) + if silent: + try: + users.remove("silent") + except ValueError: + pass + + try: + users.remove("silently") + except ValueError: + pass + if isinstance(category, str): - if "silent" in category or "silently" in category: - silent = True - category = None + category = category.split() - 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, delete_after=3) + category = " ".join(category) + if category: + try: + category = await SimilarCategoryConverter().convert( + ctx, category + ) # attempt to find a category again + except commands.BadArgument: + category = None + + if isinstance(category, str): + category = None + + errors = [] + for u in list(users): + if isinstance(u, discord.Role): + users += u.members + users.remove(u) + + for u in list(users): + exists = await self.bot.threads.find(recipient=u) + if exists: + errors.append(f"A thread for {u} already exists.") + if exists.channel: + errors[-1] += f" in {exists.channel.mention}" + errors[-1] += "." + users.remove(u) + elif u.bot: + errors.append(f"{u} is a bot, cannot add to thread.") + users.remove(u) + elif await self.bot.is_blocked(u): + ref = f"{u.mention} is" if ctx.author != u else "You are" + errors.append(f"{ref} currently blocked from contacting {self.bot.user.name}.") + users.remove(u) + + if len(users) > 5: + errors.append("Group conversations only support 5 users.") + users = [] + + if errors or not users: + if not users: + # no users left + title = "Thread not created" + else: + title = None - exists = await self.bot.threads.find(recipient=user) - if exists: - embed = discord.Embed( - color=self.bot.error_color, - description="A thread for this user already " - f"exists in {exists.channel.mention}.", - ) - await ctx.channel.send(embed=embed, delete_after=3) + if manual_trigger: # not react to contact + embed = discord.Embed(title=title, color=self.bot.error_color, description="\n".join(errors)) + await ctx.send(embed=embed, delete_after=10) - else: - thread = await self.bot.threads.create( - recipient=user, - creator=ctx.author, - category=category, - manual_trigger=manual_trigger, - ) - if thread.cancelled: + if not users: + # end return - if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): - logger.info("Contacting user %s when Modmail DM is disabled.", user) + creator = ctx.author if manual_trigger else users[0] - if not silent and not self.bot.config.get("thread_contact_silently"): - if ctx.author.id == user.id: - description = "You have opened a Modmail thread." - else: - description = f"{ctx.author.name} has opened a Modmail thread." + thread = await self.bot.threads.create( + recipient=users[0], + creator=creator, + category=category, + manual_trigger=manual_trigger, + ) - em = discord.Embed( - title="New Thread", description=description, color=self.bot.main_color, + if thread.cancelled: + return + + if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + logger.info("Contacting user %s when Modmail DM is disabled.", users[0]) + + if not silent and not self.bot.config.get("thread_contact_silently"): + if creator.id == users[0].id: + description = self.bot.config["thread_creation_self_contact_response"] + else: + description = self.bot.formatter.format( + self.bot.config["thread_creation_contact_response"], creator=creator ) - if self.bot.config["show_timestamp"]: - em.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}.", + em = discord.Embed( + title=self.bot.config["thread_creation_contact_title"], + description=description, color=self.bot.main_color, ) - await thread.wait_until_ready() - await thread.channel.send(embed=embed) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{creator}", icon_url=creator.display_avatar.url) + + for u in users: + await u.send(embed=em) + + embed = discord.Embed( + title="Created Thread", + description=f"Thread started by {creator.mention} for {', '.join(u.mention for u in users)}.", + color=self.bot.main_color, + ) + await thread.wait_until_ready() + + if users[1:]: + await thread.add_users(users[1:]) + + await thread.channel.send(embed=embed) - 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() + 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) @@ -1072,8 +1667,6 @@ async def contact( async def blocked(self, ctx): """Retrieve a list of blocked users.""" - embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] - roles = [] users = [] now = ctx.message.created_at @@ -1081,51 +1674,29 @@ async def blocked(self, ctx): blocked_users = list(self.bot.blocked_users.items()) for id_, reason in blocked_users: # parse "reason" and check if block is expired - # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for user %s, block and unblock again to update.", - id_, - ) + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.bot.blocked_users.pop(str(id_)) logger.debug("No longer blocked, user %s.", id_) continue - - user = self.bot.get_user(int(id_)) - if user: - users.append((user.mention, reason)) - else: - try: - user = await self.bot.fetch_user(id_) - users.append((user.mention, reason)) - except discord.NotFound: - users.append((id_, reason)) + users.append((f"<@{id_}>", reason)) blocked_roles = list(self.bot.blocked_roles.items()) for id_, reason in blocked_roles: # parse "reason" and check if block is expired # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for role %s, block and unblock again to update.", - id_, - ) + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.bot.blocked_roles.pop(str(id_)) @@ -1136,45 +1707,54 @@ async def blocked(self, ctx): if role: roles.append((role.mention, reason)) + user_embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] + if users: - embed = embeds[0] + embed = user_embeds[0] for mention, reason in users: line = mention + f" - {reason or 'No Reason Provided'}\n" if len(embed.description) + len(line) > 2048: embed = discord.Embed( - title="Blocked Users (Continued)", + title="Blocked Users", color=self.bot.main_color, description=line, ) - embeds.append(embed) + user_embeds.append(embed) else: embed.description += line else: - embeds[0].description = "Currently there are no blocked users." + user_embeds[0].description = "Currently there are no blocked users." - embeds.append( - discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="") - ) + if len(user_embeds) > 1: + for n, em in enumerate(user_embeds): + em.title = f"{em.title} [{n + 1}]" + + role_embeds = [discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="")] if roles: - embed = embeds[-1] + embed = role_embeds[-1] for mention, reason in roles: line = mention + f" - {reason or 'No Reason Provided'}\n" if len(embed.description) + len(line) > 2048: + role_embeds[-1].set_author() embed = discord.Embed( - title="Blocked Roles (Continued)", + title="Blocked Roles", color=self.bot.main_color, description=line, ) - embeds.append(embed) + role_embeds.append(embed) else: embed.description += line else: - embeds[-1].description = "Currently there are no blocked roles." + role_embeds[-1].description = "Currently there are no blocked roles." - session = EmbedPaginatorSession(ctx, *embeds) + if len(role_embeds) > 1: + for n, em in enumerate(role_embeds): + em.title = f"{em.title} [{n + 1}]" + + session = EmbedPaginatorSession(ctx, *user_embeds, *role_embeds) await session.run() @@ -1259,7 +1839,7 @@ async def block( if thread: user_or_role = thread.recipient elif after is None: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user or role")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) else: raise commands.BadArgument(f'User or role "{after.arg}" not found.') @@ -1276,15 +1856,18 @@ async def block( ) return await ctx.send(embed=embed) - reason = f"by {escape_markdown(ctx.author.name)}#{ctx.author.discriminator}" + reason = f"by {escape_markdown(str(ctx.author))}" if after is not None: if "%" in reason: raise commands.BadArgument('The reason contains illegal character "%".') + if after.arg: - reason += f" for `{after.arg}`" + fmt_dt = discord.utils.format_dt(after.dt, "R") if after.dt > after.now: - reason += f" until {after.dt.isoformat()}" + fmt_dt = discord.utils.format_dt(after.dt, "f") + + reason += f" until {fmt_dt}" reason += "." @@ -1336,15 +1919,12 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): if thread: user_or_role = thread.recipient else: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") name = getattr(user_or_role, "name", f"`{user_or_role.id}`") - if ( - not isinstance(user_or_role, discord.Role) - and str(user_or_role.id) in self.bot.blocked_users - ): + 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() @@ -1369,10 +1949,7 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = 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 - ): + 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() @@ -1453,24 +2030,24 @@ async def repair(self, ctx): and message.embeds[0].color.value == self.bot.main_color and message.embeds[0].footer.text ): - user_id = match_user_id(message.embeds[0].footer.text) + user_id = match_user_id(message.embeds[0].footer.text, any_string=True) + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = await self.bot.get_or_fetch_user(uid) + if user_id != -1: recipient = self.bot.get_user(user_id) if recipient is None: self.bot.threads.cache[user_id] = thread = Thread( - self.bot.threads, user_id, ctx.channel + self.bot.threads, user_id, ctx.channel, other_recipients ) else: self.bot.threads.cache[user_id] = thread = Thread( - self.bot.threads, recipient, ctx.channel + self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True - logger.info( - "Setting current channel's topic to User ID and created new thread." - ) - await ctx.channel.edit( - reason="Fix broken Modmail thread", topic=f"User ID: {user_id}" - ) + logger.info("Setting current channel's topic to User ID and created new thread.") + await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}") return await self.bot.add_reaction(ctx.message, sent_emoji) else: @@ -1478,18 +2055,18 @@ async def repair(self, ctx): # match username from channel name # username-1234, username-1234_1, username-1234_2 - m = re.match(r"^(.+)-(\d{4})(?:_\d+)?$", ctx.channel.name) + m = re.match(r"^(.+?)(?:-(\d{4}))?(?:_\d+)?$", ctx.channel.name) if m is not None: users = set( filter( lambda member: member.name == m.group(1) - and member.discriminator == m.group(2), + and (member.discriminator == "0" or member.discriminator == m.group(2)), ctx.guild.members, ) ) if len(users) == 1: user = users.pop() - name = format_channel_name(self.bot, user, exclude_channel=ctx.channel) + name = self.bot.format_channel_name(user, exclude_channel=ctx.channel) recipient = self.bot.get_user(user.id) if user.id in self.bot.threads.cache: thread = self.bot.threads.cache[user.id] @@ -1507,13 +2084,18 @@ async def repair(self, ctx): await thread.channel.send(embed=embed) except discord.HTTPException: pass + + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = await self.bot.get_or_fetch_user(uid) + if recipient is None: self.bot.threads.cache[user.id] = thread = Thread( - self.bot.threads, user_id, ctx.channel + self.bot.threads, user_id, ctx.channel, other_recipients ) else: self.bot.threads.cache[user.id] = thread = Thread( - self.bot.threads, recipient, ctx.channel + self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True logger.info("Setting current channel's topic to User ID and created new thread.") @@ -1571,7 +2153,7 @@ 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"] < DMDisabled.NEW_THREADS: + if self.bot.config["dm_disabled"] != DMDisabled.NEW_THREADS: self.bot.config["dm_disabled"] = DMDisabled.NEW_THREADS await self.bot.config.update() @@ -1626,5 +2208,5 @@ async def isenable(self, ctx): return await ctx.send(embed=embed) -def setup(bot): - bot.add_cog(Modmail(bot)) +async def setup(bot): + await bot.add_cog(Modmail(bot)) diff --git a/cogs/plugins.py b/cogs/plugins.py index 94a3f425c0..fc6e09f05d 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -16,7 +16,7 @@ import discord from discord.ext import commands -from pkg_resources import parse_version +from packaging.version import Version from core import checks from core.models import PermissionLevel, getLogger @@ -114,7 +114,7 @@ class Plugins(commands.Cog): These addons could have a range of features from moderation to simply making your life as a moderator easier! Learn how to create a plugin yourself here: - https://github.com/kyb3r/modmail/wiki/Plugins + https://github.com/modmail-dev/modmail/wiki/Plugins """ def __init__(self, bot): @@ -123,21 +123,19 @@ def __init__(self, bot): self.loaded_plugins = set() self._ready_event = asyncio.Event() - self.bot.loop.create_task(self.populate_registry()) - + async def cog_load(self): + await self.populate_registry() if self.bot.config.get("enable_plugins"): - self.bot.loop.create_task(self.initial_load_plugins()) + await self.initial_load_plugins() else: logger.info("Plugins not loaded since ENABLE_PLUGINS=false.") async def populate_registry(self): - url = "https://raw.githubusercontent.com/kyb3r/modmail/master/plugins/registry.json" + url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" async with self.bot.session.get(url) as resp: self.registry = json.loads(await resp.text()) async def initial_load_plugins(self): - await self.bot.wait_for_connected() - for plugin_name in list(self.bot.config["plugins"]): try: plugin = Plugin.from_string(plugin_name, strict=True) @@ -251,18 +249,14 @@ async def load_plugin(self, plugin): if stderr: logger.debug("[stderr]\n%s.", stderr.decode()) - logger.error( - "Failed to download requirements for %s.", plugin.ext_string, exc_info=True - ) - raise InvalidPluginError( - f"Unable to download requirements: ```\n{stderr.decode()}\n```" - ) + logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) + raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") if os.path.exists(USER_SITE): sys.path.insert(0, USER_SITE) try: - self.bot.load_extension(plugin.ext_string) + await self.bot.load_extension(plugin.ext_string) logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) self.loaded_plugins.add(plugin) @@ -270,8 +264,18 @@ async def load_plugin(self, plugin): logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc - async def parse_user_input(self, ctx, plugin_name, check_version=False): + async def unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + + async def parse_user_input(self, ctx, plugin_name, check_version=False): if not self.bot.config["enable_plugins"]: embed = discord.Embed( description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", @@ -296,7 +300,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): if check_version: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed = discord.Embed( description="Your bot's version is too low. " f"This plugin requires version `{required_version}`.", @@ -308,6 +312,14 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): plugin = Plugin(user, repo, plugin_name, branch) else: + if self.bot.config.get("registry_plugins_only"): + embed = discord.Embed( + description="This plugin is not in the registry. To install this plugin, " + "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return try: plugin = Plugin.from_string(plugin_name) except InvalidPluginError: @@ -347,9 +359,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): return if str(plugin) in self.bot.config["plugins"]: - embed = discord.Embed( - description="This plugin is already installed.", color=self.bot.error_color - ) + embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) return await ctx.send(embed=embed) if plugin.name in self.bot.cogs: @@ -378,7 +388,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to download plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -388,7 +398,6 @@ async def plugins_add(self, ctx, *, plugin_name: str): await self.bot.config.update() if self.bot.config.get("enable_plugins"): - invalidate_caches() try: @@ -397,7 +406,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to load plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -433,14 +442,12 @@ async def plugins_remove(self, ctx, *, plugin_name: str): return if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed( - description="Plugin is not installed.", color=self.bot.error_color - ) + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) return await ctx.send(embed=embed) if self.bot.config.get("enable_plugins"): try: - self.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) self.loaded_plugins.remove(plugin) except (commands.ExtensionNotLoaded, KeyError): logger.warning("Plugin was never loaded.") @@ -472,9 +479,7 @@ async def update_plugin(self, ctx, plugin_name): return if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed( - description="Plugin is not installed.", color=self.bot.error_color - ) + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) return await ctx.send(embed=embed) async with ctx.typing(): @@ -484,9 +489,10 @@ async def update_plugin(self, ctx, plugin_name): await self.download_plugin(plugin, force=True) if self.bot.config.get("enable_plugins"): try: - self.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) except commands.ExtensionError: logger.warning("Plugin unload fail.", exc_info=True) + try: await self.load_plugin(plugin) except Exception: @@ -494,12 +500,12 @@ async def update_plugin(self, ctx, plugin_name): description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", color=self.bot.error_color, ) - self.bot.config["plugins"].remove(plugin_name) - logger.debug("Failed to update %s. Removed plugin from config.", plugin_name) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) return await ctx.send(embed=embed) @plugins.command(name="update") @@ -533,12 +539,23 @@ async def plugins_reset(self, ctx): for ext in list(self.bot.extensions): if not ext.startswith("plugins."): continue + logger.error("Unloading plugin: %s.", ext) try: - logger.error("Unloading plugin: %s.", ext) - self.bot.unload_extension(ext) + plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) + if plugin: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + else: + await self.bot.unload_extension(ext) except Exception: logger.error("Failed to unload plugin: %s.", ext) + + for module in list(sys.modules.keys()): + if module.startswith("plugins."): + del sys.modules[module] + self.bot.config["plugins"].clear() + await self.bot.config.update() cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" if cache_path.exists(): @@ -598,9 +615,7 @@ async def plugins_loaded(self, ctx): embeds = [] for page in pages: - embed = discord.Embed( - title="Loaded plugins:", description=page, color=self.bot.main_color - ) + embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) embeds.append(embed) paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() @@ -641,9 +656,7 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N matches = get_close_matches(plugin_name, self.registry.keys()) if matches: - embed.add_field( - name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches) - ) + embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) return await ctx.send(embed=embed) @@ -661,13 +674,9 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N title=details["repository"], ) - embed.add_field( - name="Installation", value=f"```{self.bot.prefix}plugins add {name}```" - ) + embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") - embed.set_author( - name=details["title"], icon_url=details.get("icon_url"), url=plugin.link - ) + embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) if details.get("thumbnail_url"): embed.set_thumbnail(url=details.get("thumbnail_url")) @@ -679,7 +688,7 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N embed.set_footer(text="This plugin is currently loaded.") else: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed.set_footer( text="Your bot is unable to install this plugin, " f"minimum required version is v{required_version}." @@ -740,12 +749,12 @@ async def plugins_registry_compact(self, ctx): for page in pages: embed = discord.Embed(color=self.bot.main_color, description=page) - embed.set_author(name="Plugin Registry", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Plugin Registry", icon_url=self.bot.user.display_avatar.url) embeds.append(embed) paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() -def setup(bot): - bot.add_cog(Plugins(bot)) +async def setup(bot): + await bot.add_cog(Plugins(bot)) diff --git a/cogs/utility.py b/cogs/utility.py index 7b39631dcd..b892ffc01b 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -5,22 +5,21 @@ import re import traceback from contextlib import redirect_stdout -from datetime import datetime from difflib import get_close_matches from io import BytesIO, StringIO from itertools import takewhile, zip_longest from json import JSONDecodeError, loads from subprocess import PIPE from textwrap import indent -from 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 pkg_resources import parse_version + +from aiohttp import ClientResponseError +from packaging.version import Version from core import checks, utils from core.changelog import Changelog @@ -31,7 +30,7 @@ UnseenFormatter, getLogger, ) -from core.utils import trigger_typing, truncate +from core.utils import trigger_typing, truncate, DummyParam from core.paginator import EmbedPaginatorSession, MessagePaginatorSession @@ -40,7 +39,7 @@ class ModmailHelpCommand(commands.HelpCommand): async def command_callback(self, ctx, *, command=None): - """Ovrwrites original command_callback to ensure `help` without any arguments + """Overwrites original command_callback to ensure `help` without any arguments returns with checks, `help all` returns without checks""" if command is None: self.verify_checks = True @@ -54,7 +53,7 @@ async def command_callback(self, ctx, *, command=None): async def format_cog_help(self, cog, *, no_cog=False): bot = self.context.bot - prefix = self.clean_prefix + prefix = self.context.clean_prefix formats = [""] for cmd in await self.filter_commands( @@ -90,19 +89,23 @@ async def format_cog_help(self, cog, *, no_cog=False): embed.add_field(name="Commands", value=format_ or "No commands.") - continued = " (Continued)" if embeds else "" name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" - embed.set_author(name=name + continued, icon_url=bot.user.avatar_url) + embed.set_author(name=name, icon_url=bot.user.display_avatar.url) embed.set_footer( text=f'Type "{prefix}{self.command_attrs["name"]} command" ' "for more info on a specific command." ) embeds.append(embed) + + if len(embeds) > 1: + for n, em in enumerate(embeds): + em.set_author(name=f"{em.author.name} [{n + 1}]", icon_url=em.author.icon_url) + return embeds def process_help_msg(self, help_: str): - return help_.format(prefix=self.clean_prefix) if help_ else "No help message." + return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." async def send_bot_help(self, mapping): embeds = [] @@ -139,7 +142,7 @@ async def _get_help_embed(self, topic): perm_level = "NONE" embed = discord.Embed( - title=f"`{self.get_command_signature(topic)}`", + title=f"`{self.get_command_signature(topic).strip()}`", color=self.context.bot.main_color, description=self.process_help_msg(topic.help), ) @@ -174,7 +177,7 @@ async def send_group_help(self, group): embed.add_field(name="Sub Command(s)", value=format_[:1024], inline=False) embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} command" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} command" ' "for more info on a command." ) @@ -184,10 +187,19 @@ async def send_error_message(self, error): command = self.context.kwargs.get("command") val = self.context.bot.snippets.get(command) if val is not None: - embed = discord.Embed( - title=f"{command} is a snippet.", color=self.context.bot.main_color - ) - embed.add_field(name=f"`{command}` will send:", value=val) + embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) + embed.add_field(name=f"`{command}` will send:", value=val, inline=False) + + snippet_aliases = [] + for alias in self.context.bot.aliases: + if self.context.bot._resolve_snippet(alias) == command: + snippet_aliases.append(f"`{alias}`") + + if snippet_aliases: + embed.add_field( + name="Aliases to this snippet:", value=",".join(snippet_aliases), inline=False + ) + return await self.get_destination().send(embed=embed) val = self.context.bot.aliases.get(command) @@ -206,9 +218,7 @@ async def send_error_message(self, error): await self.context.bot.config.update() else: if len(values) == 1: - embed = discord.Embed( - title=f"{command} is an alias.", color=self.context.bot.main_color - ) + embed = discord.Embed(title=f"{command} is an alias.", color=self.context.bot.main_color) embed.add_field(name=f"`{command}` points to:", value=values[0]) else: embed = discord.Embed( @@ -220,7 +230,7 @@ async def send_error_message(self, error): embed.add_field(name=f"Step {i}:", value=val) embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} alias" ' "for more details on aliases." ) return await self.get_destination().send(embed=embed) @@ -242,7 +252,7 @@ async def send_error_message(self, error): else: embed.title = "Cannot find command or category" embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]}" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]}" ' "for a list of all available commands." ) await self.get_destination().send(embed=embed) @@ -261,11 +271,13 @@ def __init__(self, bot): }, ) self.bot.help_command.cog = self - self.loop_presence.start() # pylint: disable=no-member if not self.bot.config.get("enable_eval"): self.eval_.enabled = False logger.info("Eval disabled. enable_eval=False") + async def cog_load(self): + self.loop_presence.start() # pylint: disable=no-member + def cog_unload(self): self.bot.help_command = self._original_help_command @@ -307,13 +319,13 @@ async def changelog(self, ctx, version: str.lower = ""): @utils.trigger_typing async def about(self, ctx): """Shows information about this bot.""" - embed = discord.Embed(color=self.bot.main_color, timestamp=datetime.utcnow()) + embed = discord.Embed(color=self.bot.main_color, timestamp=discord.utils.utcnow()) embed.set_author( name="Modmail - About", - icon_url=self.bot.user.avatar_url, + icon_url=self.bot.user.display_avatar.url, url="https://discord.gg/F34cRU8", ) - embed.set_thumbnail(url=self.bot.user.avatar_url) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) desc = "This is an open source Discord bot that serves as a means for " desc += "members to easily communicate with server administrators in " @@ -330,21 +342,17 @@ async def about(self, ctx): latest = changelog.latest_version if self.bot.version.is_prerelease: - stable = next( - filter(lambda v: not parse_version(v.version).is_prerelease, changelog.versions) - ) - footer = ( - f"You are on the prerelease version • the latest version is v{stable.version}." - ) - elif self.bot.version < parse_version(latest.version): + stable = next(filter(lambda v: not Version(v.version).is_prerelease, changelog.versions)) + footer = f"You are on the prerelease version • the latest version is v{stable.version}." + elif self.bot.version < Version(latest.version): footer = f"A newer version is available v{latest.version}." else: footer = "You are up to date with the latest version." embed.add_field( name="Want Modmail in Your Server?", - value="Follow the installation guide on [GitHub](https://github.com/kyb3r/modmail/) " - "and join our [Discord server](https://discord.gg/F34cRU8)!", + value="Follow the installation guide on [GitHub](https://github.com/modmail-dev/modmail/) " + "and join our [Discord server](https://discord.gg/cnUpwrnpYb)!", inline=False, ) @@ -356,18 +364,25 @@ async def about(self, ctx): inline=False, ) + embed.add_field( + name="Project Sponsors", + value=f"Checkout the people who supported Modmail with command `{self.bot.prefix}sponsors`!", + inline=False, + ) + embed.set_footer(text=footer) await ctx.send(embed=embed) - @commands.command() + @commands.command(aliases=["sponsor"]) @checks.has_permissions(PermissionLevel.REGULAR) @utils.trigger_typing async def sponsors(self, ctx): - """Shows a list of sponsors.""" - resp = await self.bot.session.get( - "https://raw.githubusercontent.com/kyb3r/modmail/master/SPONSORS.json" - ) - data = loads(await resp.text()) + """Shows the sponsors of this project.""" + + async with self.bot.session.get( + "https://raw.githubusercontent.com/modmail-dev/modmail/master/SPONSORS.json" + ) as resp: + data = loads(await resp.text()) embeds = [] @@ -386,14 +401,7 @@ async def sponsors(self, ctx): async def debug(self, ctx): """Shows the recent application logs of the bot.""" - log_file_name = self.bot.token.split(".")[0] - - with open( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log" - ), - "r+", - ) as f: + with open(self.bot.log_file_path, "r+", encoding="utf-8") as f: logs = f.read().strip() if not logs: @@ -402,7 +410,7 @@ async def debug(self, ctx): title="Debug Logs:", description="You don't have any logs at the moment.", ) - embed.set_footer(text="Go to Heroku to see your logs.") + embed.set_footer(text="Go to your console to see your logs.") return await ctx.send(embed=embed) messages = [] @@ -419,7 +427,7 @@ async def debug(self, ctx): msg = "```Haskell\n" msg += line if len(msg) + 3 > 2000: - msg = msg[:1993] + "[...]```" + msg = msg[:1992] + "[...]```" messages.append(msg) msg = "```Haskell\n" @@ -441,14 +449,8 @@ async def debug_hastebin(self, ctx): """Posts application-logs to Hastebin.""" haste_url = os.environ.get("HASTE_URL", "https://hastebin.cc") - log_file_name = self.bot.token.split(".")[0] - - with open( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log" - ), - "rb+", - ) as f: + + with open(self.bot.log_file_path, "rb+") as f: logs = BytesIO(f.read().strip()) try: @@ -470,7 +472,7 @@ async def debug_hastebin(self, ctx): color=self.bot.main_color, description="Something's wrong. We're unable to upload your logs to hastebin.", ) - embed.set_footer(text="Go to Heroku to see your logs.") + embed.set_footer(text="Go to your console to see your logs.") await ctx.send(embed=embed) @debug.command(name="clear", aliases=["wipe"]) @@ -479,19 +481,10 @@ async def debug_hastebin(self, ctx): async def debug_clear(self, ctx): """Clears the locally cached logs.""" - log_file_name = self.bot.token.split(".")[0] - - with open( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), f"../temp/{log_file_name}.log" - ), - "w", - ): + with open(self.bot.log_file_path, "w"): pass await ctx.send( - embed=discord.Embed( - color=self.bot.main_color, description="Cached logs are now cleared." - ) + embed=discord.Embed(color=self.bot.main_color, description="Cached logs are now cleared.") ) @commands.command(aliases=["presence"]) @@ -529,16 +522,14 @@ async def activity(self, ctx, activity_type: str.lower, *, message: str = ""): return await ctx.send(embed=embed) if not message: - raise commands.MissingRequiredArgument(SimpleNamespace(name="message")) + raise commands.MissingRequiredArgument(DummyParam("message")) try: activity_type = ActivityType[activity_type] except KeyError: - raise commands.MissingRequiredArgument(SimpleNamespace(name="activity")) + raise commands.MissingRequiredArgument(DummyParam("activity")) - activity, _ = await self.set_presence( - activity_type=activity_type, activity_message=message - ) + activity, _ = await self.set_presence(activity_type=activity_type, activity_message=message) self.bot.config["activity_type"] = activity.type.value self.bot.config["activity_message"] = activity.name @@ -581,7 +572,7 @@ async def status(self, ctx, *, status_type: str.lower): try: status = Status[status_type] except KeyError: - raise commands.MissingRequiredArgument(SimpleNamespace(name="status")) + raise commands.MissingRequiredArgument(DummyParam("status")) _, status = await self.set_presence(status=status) @@ -593,7 +584,6 @@ async def status(self, ctx, *, status_type: str.lower): return await ctx.send(embed=embed) async def set_presence(self, *, status=None, activity_type=None, activity_message=None): - if status is None: status = self.bot.config.get("status") @@ -603,9 +593,7 @@ async def set_presence(self, *, status=None, activity_type=None, activity_messag url = None activity_message = (activity_message or self.bot.config["activity_message"]).strip() if activity_type is not None and not activity_message: - logger.warning( - 'No activity message found whilst activity is provided, defaults to "Modmail".' - ) + logger.warning('No activity message found whilst activity is provided, defaults to "Modmail".') activity_message = "Modmail" if activity_type == ActivityType.listening: @@ -706,12 +694,14 @@ async def mention(self, ctx, *user_or_role: Union[discord.Role, discord.Member, option = user_or_role[0].lower() if option == "disable": embed = discord.Embed( - description=f"Disabled mention on thread creation.", color=self.bot.main_color, + description="Disabled mention on thread creation.", + color=self.bot.main_color, ) self.bot.config["mention"] = None else: embed = discord.Embed( - description="`mention` is reset to default.", color=self.bot.main_color, + description="`mention` is reset to default.", + color=self.bot.main_color, ) self.bot.config.remove("mention") await self.bot.config.update() @@ -747,9 +737,7 @@ async def prefix(self, ctx, *, prefix=None): """ current = self.bot.prefix - embed = discord.Embed( - title="Current prefix", color=self.bot.main_color, description=f"{current}" - ) + embed = discord.Embed(title="Current prefix", color=self.bot.main_color, description=f"{current}") if prefix is None: await ctx.send(embed=embed) @@ -786,9 +774,7 @@ async def config_options(self, ctx): """Return a list of valid configuration names you can change.""" embeds = [] for names in zip_longest(*(iter(sorted(self.bot.config.public_keys)),) * 15): - description = "\n".join( - f"`{name}`" for name in takewhile(lambda x: x is not None, names) - ) + description = "\n".join(f"`{name}`" for name in takewhile(lambda x: x is not None, names)) embed = discord.Embed( title="Available configuration keys:", color=self.bot.main_color, @@ -808,7 +794,7 @@ async def config_set(self, ctx, key: str.lower, *, value: str): if key in keys: try: - self.bot.config.set(key, value) + await self.bot.config.set(key, value) await self.bot.config.update() embed = discord.Embed( title="Success", @@ -862,7 +848,7 @@ async def config_get(self, ctx, *, key: str.lower = None): if key in keys: desc = f"`{key}` is set to `{self.bot.config[key]}`" embed = discord.Embed(color=self.bot.main_color, description=desc) - embed.set_author(name="Config variable", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Config variable", icon_url=self.bot.user.display_avatar.url) else: embed = discord.Embed( @@ -879,7 +865,7 @@ async def config_get(self, ctx, *, key: str.lower = None): color=self.bot.main_color, description="Here is a list of currently set configuration variable(s).", ) - embed.set_author(name="Current config(s):", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Current config(s):", icon_url=self.bot.user.display_avatar.url) config = self.bot.config.filter_default(self.bot.config) for name, value in config.items(): @@ -906,9 +892,7 @@ async def config_help(self, ctx, key: str.lower = None): description=f"`{key}` is an invalid key.", ) if closest: - embed.add_field( - name=f"Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest) - ) + embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{x}`" for x in closest)) return await ctx.send(embed=embed) config_help = self.bot.config.config_help @@ -929,9 +913,7 @@ def fmt(val): for i, (current_key, info) in enumerate(config_help.items()): if current_key == key: index = i - embed = discord.Embed( - title=f"Configuration description on {current_key}:", color=self.bot.main_color - ) + embed = discord.Embed(title=f"{current_key}", color=self.bot.main_color) embed.add_field(name="Default:", value=fmt(info["default"]), inline=False) embed.add_field(name="Information:", value=fmt(info["description"]), inline=False) if info["examples"]: @@ -1022,7 +1004,7 @@ async def alias(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any aliases at the moment." ) embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.') - embed.set_author(name="Aliases", icon_url=ctx.guild.icon_url) + embed.set_author(name="Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) return await ctx.send(embed=embed) embeds = [] @@ -1030,7 +1012,9 @@ async def alias(self, ctx, *, name: str.lower = None): for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): description = utils.format_description(i, names) embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) + embed.set_author( + name="Command Aliases", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128) + ) embeds.append(embed) session = EmbedPaginatorSession(ctx, *embeds) @@ -1087,7 +1071,9 @@ async def make_alias(self, name, value, action): linked_command = view.get_word().lower() message = view.read_rest() - if not self.bot.get_command(linked_command): + is_snippet = val in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_snippet: alias_command = self.bot.aliases.get(linked_command) if alias_command is not None: save_aliases.extend(utils.normalize_alias(alias_command, message)) @@ -1115,7 +1101,7 @@ async def make_alias(self, name, value, action): await self.bot.config.update() return embed - @alias.command(name="add") + @alias.command(name="add", aliases=["create", "make"]) @checks.has_permissions(PermissionLevel.MODERATOR) async def alias_add(self, ctx, name: str.lower, *, value): """ @@ -1401,9 +1387,7 @@ async def permissions_remove( Do not ping `@everyone` for granting permission to everyone, use "everyone" or "all" instead. """ - if type_ not in {"command", "level", "override"} or ( - type_ != "override" and user_or_role is None - ): + if type_ not in {"command", "level", "override"} or (type_ != "override" and user_or_role is None): return await ctx.send_help(ctx.command) if type_ == "override": @@ -1566,15 +1550,9 @@ async def permissions_get( levels.append(level.name) mention = getattr(user_or_role, "name", getattr(user_or_role, "id", user_or_role)) - desc_cmd = ( - ", ".join(map(lambda x: f"`{x}`", cmds)) - if cmds - else "No permission entries found." - ) + desc_cmd = ", ".join(map(lambda x: f"`{x}`", cmds)) if cmds else "No permission entries found." desc_level = ( - ", ".join(map(lambda x: f"`{x}`", levels)) - if levels - else "No permission entries found." + ", ".join(map(lambda x: f"`{x}`", levels)) if levels else "No permission entries found." ) embeds = [ @@ -1599,9 +1577,7 @@ async def permissions_get( for command in self.bot.walk_commands(): if command not in done: done.add(command) - level = self.bot.config["override_command_level"].get( - command.qualified_name - ) + level = self.bot.config["override_command_level"].get(command.qualified_name) if level is not None: overrides[command.qualified_name] = level @@ -1620,11 +1596,10 @@ async def permissions_get( ": ".join((f"`{name}`", level)) for name, level in takewhile(lambda x: x is not None, items) ) - embed = discord.Embed( - color=self.bot.main_color, description=description - ) + embed = discord.Embed(color=self.bot.main_color, description=description) embed.set_author( - name="Permission Overrides", icon_url=ctx.guild.icon_url + name="Permission Overrides", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), ) embeds.append(embed) @@ -1727,9 +1702,7 @@ async def oauth_whitelist(self, ctx, target: Union[discord.Role, utils.User]): if not hasattr(target, "mention"): target = self.bot.get_user(target.id) or self.bot.modmail_guild.get_role(target.id) - embed.description = ( - f"{'Un-w' if removed else 'W'}hitelisted {target.mention} to view logs." - ) + embed.description = f"{'Un-w' if removed else 'W'}hitelisted {target.mention} to view logs." await ctx.send(embed=embed) @@ -1780,10 +1753,15 @@ async def autotrigger_add(self, ctx, keyword, *, command): split_cmd = command.split(" ") for n in range(1, len(split_cmd) + 1): if self.bot.get_command(" ".join(split_cmd[0:n])): - print(self.bot.get_command(" ".join(split_cmd[0:n]))) valid = True break + if not valid and self.bot.aliases: + for n in range(1, len(split_cmd) + 1): + if self.bot.aliases.get(" ".join(split_cmd[0:n])): + valid = True + break + if valid: self.bot.auto_triggers[keyword] = command await self.bot.config.update() @@ -1797,7 +1775,7 @@ async def autotrigger_add(self, ctx, keyword, *, command): embed = discord.Embed( title="Error", color=self.bot.error_color, - description="Invalid command. Note that autotriggers do not work with aliases.", + description="Invalid command. Please provide a valid command or alias.", ) await ctx.send(embed=embed) @@ -1807,9 +1785,7 @@ async def autotrigger_add(self, ctx, keyword, *, command): 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" - ) + embed = utils.create_not_found_embed(keyword, self.bot.auto_triggers.keys(), "Autotrigger") else: # command validation valid = False @@ -1819,6 +1795,12 @@ async def autotrigger_edit(self, ctx, keyword, *, command): valid = True break + if not valid and self.bot.aliases: + for n in range(1, len(split_cmd) + 1): + if self.bot.aliases.get(" ".join(split_cmd[0:n])): + valid = True + break + if valid: self.bot.auto_triggers[keyword] = command await self.bot.config.update() @@ -1832,7 +1814,7 @@ async def autotrigger_edit(self, ctx, keyword, *, command): embed = discord.Embed( title="Error", color=self.bot.error_color, - description="Invalid command. Note that autotriggers do not work with aliases.", + description="Invalid command. Please provide a valid command or alias.", ) await ctx.send(embed=embed) @@ -1884,7 +1866,7 @@ async def autotrigger_test(self, ctx, *, text): embed = discord.Embed( title="Keyword Not Found", color=self.bot.error_color, - description=f"No autotrigger keyword found.", + description="No autotrigger keyword found.", ) return await ctx.send(embed=embed) @@ -1895,7 +1877,11 @@ async def autotrigger_list(self, ctx): 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,) + embed = discord.Embed( + title=keyword, + color=self.bot.main_color, + description=command, + ) embeds.append(embed) if not embeds: @@ -1918,17 +1904,13 @@ async def github(self, ctx): data = await self.bot.api.get_user_info() if data: - embed = discord.Embed( - title="GitHub", description="Current User", color=self.bot.main_color - ) + 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) - ) + await ctx.send(embed=discord.Embed(title="Invalid Github Token", color=self.bot.error_color)) @commands.command() @checks.has_permissions(PermissionLevel.OWNER) @@ -1938,7 +1920,7 @@ async def github(self, ctx): async def update(self, ctx, *, flag: str = ""): """ Update Modmail. - To stay up-to-date with the latest commit rom GitHub, specify "force" as the flag. + To stay up-to-date with the latest commit from GitHub, specify "force" as the flag. """ changelog = await Changelog.from_url(self.bot) @@ -1946,34 +1928,51 @@ async def update(self, ctx, *, flag: str = ""): desc = ( f"The latest version is [`{self.bot.version}`]" - "(https://github.com/kyb3r/modmail/blob/master/bot.py#L25)" + "(https://github.com/modmail-dev/modmail/blob/master/bot.py#L1)" ) - 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 - ) + if self.bot.version >= Version(latest.version) and flag.lower() != "force": + embed = discord.Embed(title="Already up to date", description=desc, color=self.bot.main_color) data = await self.bot.api.get_user_info() if data: user = data["user"] - embed.set_author( - name=user["username"], icon_url=user["avatar_url"], url=user["url"] - ) + 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: + error = None + data = {} + try: + # update fork if gh_token exists data = await self.bot.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + + if self.bot.hosting_method == HostingMethod.HEROKU: + if error is not None: + embed = discord.Embed( + title="Update failed", + description=f"Error status: {error.status}.\nError message: {error.message}", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + if not data: + # invalid gh_token + embed = discord.Embed( + title="Update failed", + description="Invalid Github token.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) commit_data = data["data"] user = data["user"] - if commit_data and commit_data.get("html_url"): embed = discord.Embed(color=self.bot.main_color) - embed.set_footer( - text=f"Updating Modmail v{self.bot.version} " f"-> v{latest.version}" - ) + embed.set_footer(text=f"Updating Modmail v{self.bot.version} -> v{latest.version}") embed.set_author( name=user["username"] + " - Updating bot", @@ -1991,42 +1990,36 @@ async def update(self, ctx, *, flag: str = ""): else: embed = discord.Embed( title="Already up to date", - description="No further updates required", + description="No further updates required.", color=self.bot.main_color, ) embed.set_footer(text="Force update") - embed.set_author( - name=user["username"], icon_url=user["avatar_url"], url=user["url"] - ) + embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: - # update fork if gh_token exists - try: - await self.bot.api.update_repository() - except InvalidConfigError: - pass - command = "git pull" - - proc = await asyncio.create_subprocess_shell(command, stderr=PIPE, stdout=PIPE,) + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) err = await proc.stderr.read() err = err.decode("utf-8").rstrip() res = await proc.stdout.read() res = res.decode("utf-8").rstrip() if err and not res: - embed = discord.Embed( - title="Update failed", description=err, color=self.bot.error_color - ) + embed = discord.Embed(title="Update failed", description=err, color=self.bot.error_color) await ctx.send(embed=embed) elif res != "Already up to date.": logger.info("Bot has been updated.") - embed = discord.Embed(title="Bot has been updated", color=self.bot.main_color,) - embed.set_footer( - text=f"Updating Modmail v{self.bot.version} " f"-> v{latest.version}" + embed = 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)) @@ -2037,10 +2030,12 @@ async def update(self, ctx, *, flag: str = ""): ) await ctx.send(embed=embed) - await self.bot.logout() + return await self.bot.close() else: embed = discord.Embed( - title="Already up to date", description=desc, color=self.bot.main_color, + title="Already up to date", + description=desc, + color=self.bot.main_color, ) embed.set_footer(text="Force update") await ctx.send(embed=embed) @@ -2126,5 +2121,5 @@ def paginate(text: str): await self.bot.add_reaction(ctx.message, "\u2705") -def setup(bot): - bot.add_cog(Utility(bot)) +async def setup(bot): + await bot.add_cog(Utility(bot)) diff --git a/core/changelog.py b/core/changelog.py index 878a19b26c..06f141fce1 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -53,7 +53,7 @@ def __init__(self, bot, branch: str, version: str, lines: str): self.version = version.lstrip("vV") self.lines = lines.strip() self.fields = {} - self.changelog_url = f"https://github.com/kyb3r/modmail/blob/{branch}/CHANGELOG.md" + self.changelog_url = f"https://github.com/modmail-dev/modmail/blob/{branch}/CHANGELOG.md" self.description = "" self.parse() @@ -65,9 +65,7 @@ def parse(self) -> None: Parse the lines and split them into `description` and `fields`. """ self.description = re.match(self.DESCRIPTION_REGEX, self.lines, re.DOTALL) - self.description = ( - self.description.group(1).strip() if self.description is not None else "" - ) + self.description = self.description.group(1).strip() if self.description is not None else "" matches = re.finditer(self.ACTION_REGEX, self.lines, re.DOTALL) for m in matches: @@ -91,13 +89,16 @@ def embed(self) -> Embed: """ embed = Embed(color=self.bot.main_color, description=self.description) embed.set_author( - name=f"v{self.version} - Changelog", icon_url=self.bot.user.avatar_url, url=self.url, + name=f"v{self.version} - Changelog", + icon_url=self.bot.user.display_avatar.url, + url=self.url, ) for name, value in self.fields.items(): embed.add_field(name=name, value=truncate(value, 1024), inline=False) embed.set_footer(text=f"Current version: v{self.bot.version}") - embed.set_thumbnail(url=self.bot.user.avatar_url) + + embed.set_thumbnail(url=self.bot.user.display_avatar.url) return embed @@ -171,7 +172,9 @@ async def from_url(cls, bot, url: str = "") -> "Changelog": """ # get branch via git cli if available proc = await asyncio.create_subprocess_shell( - "git branch --show-current", stderr=PIPE, stdout=PIPE, + "git branch --show-current", + stderr=PIPE, + stdout=PIPE, ) err = await proc.stderr.read() err = err.decode("utf-8").rstrip() @@ -183,7 +186,7 @@ async def from_url(cls, bot, url: str = "") -> "Changelog": if branch not in ("master", "development"): branch = "master" - url = url or f"https://raw.githubusercontent.com/kyb3r/modmail/{branch}/CHANGELOG.md" + url = url or f"https://raw.githubusercontent.com/modmail-dev/modmail/{branch}/CHANGELOG.md" async with await bot.session.get(url) as resp: return cls(bot, branch, await resp.text()) diff --git a/core/checks.py b/core/checks.py index 74b7dc38cd..15dcb098da 100644 --- a/core/checks.py +++ b/core/checks.py @@ -5,7 +5,9 @@ logger = getLogger(__name__) -def has_permissions_predicate(permission_level: PermissionLevel = PermissionLevel.REGULAR,): +def has_permissions_predicate( + permission_level: PermissionLevel = PermissionLevel.REGULAR, +): async def predicate(ctx): return await check_permissions(ctx, ctx.command.qualified_name) @@ -29,7 +31,7 @@ def has_permissions(permission_level: PermissionLevel = PermissionLevel.REGULAR) :: @has_permissions(PermissionLevel.OWNER) async def setup(ctx): - print("Success") + await ctx.send('Success') """ return commands.check(has_permissions_predicate(permission_level)) @@ -37,7 +39,7 @@ async def setup(ctx): async def check_permissions(ctx, command_name) -> bool: """Logic for checking permissions for a command for a user""" - if await ctx.bot.is_owner(ctx.author): + if await ctx.bot.is_owner(ctx.author) or ctx.author.id == ctx.bot.user.id: # Bot owner(s) (and creator) has absolute power over the bot return True diff --git a/core/clients.py b/core/clients.py index db4e9936b4..61c39fdd4b 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,9 +1,9 @@ import secrets import sys -from datetime import datetime from json import JSONDecodeError -from typing import Union, Optional +from typing import Any, Dict, Union, Optional +import discord from discord import Member, DMChannel, TextChannel, Message from discord.ext import commands @@ -19,6 +19,7 @@ class GitHub: """ The client for interacting with GitHub API. + Parameters ---------- bot : Bot @@ -31,6 +32,7 @@ class GitHub: URL to the avatar in GitHub. url : str, optional URL to the GitHub profile. + Attributes ---------- bot : Bot @@ -43,6 +45,7 @@ class GitHub: URL to the avatar in GitHub. url : str URL to the GitHub profile. + Class Attributes ---------------- BASE : str @@ -60,15 +63,15 @@ class GitHub: """ BASE = "https://api.github.com" - REPO = BASE + "/repos/kyb3r/modmail" + REPO = BASE + "/repos/modmail-dev/modmail" MERGE_URL = BASE + "/repos/{username}/modmail/merges" FORK_URL = REPO + "/forks" - STAR_URL = BASE + "/user/starred/kyb3r/modmail" + STAR_URL = BASE + "/user/starred/modmail-dev/modmail" def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): self.bot = bot self.session = bot.session - self.headers: dict = None + self.headers: Optional[dict] = None self.access_token = access_token self.username = username self.avatar_url: str = kwargs.pop("avatar_url", "") @@ -77,7 +80,7 @@ def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): self.headers = {"Authorization": "token " + str(access_token)} @property - def BRANCH(self): + def BRANCH(self) -> str: return "master" if not self.bot.version.is_prerelease else "development" async def request( @@ -85,11 +88,13 @@ async def request( url: str, method: str = "GET", payload: dict = None, - return_response: bool = False, headers: dict = None, - ) -> Union[ClientResponse, dict, str]: + return_response: bool = False, + read_before_return: bool = False, + ) -> Union[ClientResponse, Dict[str, Any], str]: """ Makes a HTTP request. + Parameters ---------- url : str @@ -98,16 +103,20 @@ async def request( 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`. + return_response : bool + Whether the `ClientResponse` object should be returned. + read_before_return : bool + Whether to perform `.read()` method before returning the `ClientResponse` object. + Only valid if `return_response` is set to `True`. + Returns ------- ClientResponse or Dict[str, Any] or List[Any] or str `ClientResponse` if `return_response` is `True`. - `dict` if the returned data is a json object. - `list` if the returned data is a json list. + `Dict[str, Any]` if the returned data is a json object. + `List[Any]` if the returned data is a json list. `str` if the returned data is not a valid json data, the raw response. """ @@ -117,19 +126,32 @@ async def request( headers = self.headers async with self.session.request(method, url, headers=headers, json=payload) as resp: if return_response: + if read_before_return: + await resp.read() return resp - try: - return await resp.json() - except (JSONDecodeError, ClientResponseError): - return await resp.text() - def filter_valid(self, data): + return await self._get_response_data(resp) + + @staticmethod + async def _get_response_data(response: ClientResponse) -> Union[Dict[str, Any], str]: + """ + Internal method to convert the response data to `dict` if the data is a + json object, or to `str` (raw response) if the data is not a valid json. + """ + try: + return await response.json() + except (JSONDecodeError, ClientResponseError): + return await response.text() + + def filter_valid(self, data) -> Dict[str, Any]: """ Filters configuration keys that are accepted. + Parameters ---------- data : Dict[str, Any] The data that needs to be cleaned. + Returns ------- Dict[str, Any] @@ -138,42 +160,79 @@ def filter_valid(self, data): 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]: + async def update_repository(self, sha: str = None) -> Dict[str, Any]: """ Update the repository from Modmail main repo. + Parameters ---------- - sha : Optional[str], optional - The commit SHA to update the repository. + sha : Optional[str] + The commit SHA to update the repository. If `None`, the latest + commit SHA will be fetched. + Returns ------- - Optional[dict] - If the response is a dict. + Dict[str, Any] + A dictionary that contains response data. """ 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) + resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) sha = resp["object"]["sha"] payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} merge_url = self.MERGE_URL.format(username=self.username) - resp = await self.request(merge_url, method="POST", payload=payload) - if isinstance(resp, dict): - return resp + resp = await self.request( + merge_url, + method="POST", + payload=payload, + return_response=True, + read_before_return=True, + ) + + repo_url = self.BASE + f"/repos/{self.username}/modmail" + status_map = { + 201: "Successful response.", + 204: "Already merged.", + 403: "Forbidden.", + 404: f"Repository '{repo_url}' not found.", + 409: "There is a merge conflict.", + 422: "Validation failed.", + } + # source https://docs.github.com/en/rest/branches/branches#merge-a-branch + + status = resp.status + data = await self._get_response_data(resp) + if status in (201, 204): + return data + + args = (resp.request_info, resp.history) + try: + # try to get the response error message if any + message = data.get("message") + except AttributeError: + message = None + kwargs = { + "status": status, + "message": message if message else status_map.get(status), + } + # just raise + raise ClientResponseError(*args, **kwargs) async def fork_repository(self) -> None: """ Forks Modmail's repository. """ - await self.request(self.FORK_URL, method="POST") + await self.request(self.FORK_URL, method="POST", return_response=True) async def has_starred(self) -> bool: """ Checks if shared Modmail. + Returns ------- bool @@ -187,23 +246,30 @@ async def star_repository(self) -> None: """ Stars Modmail's repository. """ - await self.request(self.STAR_URL, method="PUT", headers={"Content-Length": "0"}) + await self.request( + self.STAR_URL, + method="PUT", + headers={"Content-Length": "0"}, + return_response=True, + ) @classmethod async def login(cls, bot) -> "GitHub": """ Logs in to GitHub with configuration variable information. + Parameters ---------- bot : Bot The Modmail bot. + Returns ------- GitHub The newly created `GitHub` object. """ self = cls(bot, bot.config.get("github_token")) - resp: dict = await self.request("https://api.github.com/user") + resp: Dict[str, Any] = await self.request(self.BASE + "/user") if resp.get("login"): self.username = resp["login"] self.avatar_url = resp["avatar_url"] @@ -290,6 +356,9 @@ async def validate_database_connection(self): async def get_user_logs(self, user_id: Union[str, int]) -> list: return NotImplemented + async def find_log_entry(self, key: str) -> list: + return NotImplemented + async def get_latest_user_logs(self, user_id: Union[str, int]): return NotImplemented @@ -305,9 +374,7 @@ async def get_log(self, channel_id: Union[str, int]) -> dict: async def get_log_link(self, channel_id: Union[str, int]) -> str: return NotImplemented - async def create_log_entry( - self, recipient: Member, channel: TextChannel, creator: Member - ) -> str: + async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: return NotImplemented async def delete_log_entry(self, key: str) -> bool: @@ -319,7 +386,7 @@ async def get_config(self) -> dict: async def update_config(self, data: dict): return NotImplemented - async def edit_message(self, message_id: Union[int, str], new_content: str) -> None: + async def edit_message(self, message_id: Union[int, str], new_content: str): return NotImplemented async def append_log( @@ -359,6 +426,12 @@ async def edit_note(self, message_id: Union[int, str], message: str): def get_plugin_partition(self, cog): return NotImplemented + async def update_repository(self) -> dict: + return NotImplemented + + async def get_user_info(self) -> Optional[dict]: + return NotImplemented + class MongoDBClient(ApiClient): def __init__(self, bot): @@ -379,9 +452,9 @@ def __init__(self, bot): except ConfigurationError as e: logger.critical( "Your MongoDB CONNECTION_URI might be copied wrong, try re-copying from the source again. " - "Otherwise noted in the following message:" + "Otherwise noted in the following message:\n%s", + e, ) - logger.critical(e) sys.exit(0) super().__init__(bot, db) @@ -407,14 +480,28 @@ async def setup_indexes(self): ) logger.debug("Successfully configured and verified database indexes.") - async def validate_database_connection(self): + async def validate_database_connection(self, *, ssl_retry=True): try: await self.db.command("buildinfo") except Exception as exc: logger.critical("Something went wrong while connecting to the database.") message = f"{type(exc).__name__}: {str(exc)}" logger.critical(message) - + if "CERTIFICATE_VERIFY_FAILED" in message and ssl_retry: + mongo_uri = self.bot.config["connection_uri"] + if mongo_uri is None: + mongo_uri = self.bot.config["mongo_uri"] + for _ in range(3): + logger.warning( + "FAILED TO VERIFY SSL CERTIFICATE, ATTEMPTING TO START WITHOUT SSL (UNSAFE)." + ) + logger.warning( + "To fix this warning, check there's no proxies blocking SSL cert verification, " + 'run "Certificate.command" on MacOS, ' + 'and check certifi is up to date "pip3 install --upgrade certifi".' + ) + self.db = AsyncIOMotorClient(mongo_uri, tlsAllowInvalidCertificates=True).modmail_bot + return await self.validate_database_connection(ssl_retry=False) if "ServerSelectionTimeoutError" in message: logger.critical( "This may have been caused by not whitelisting " @@ -445,6 +532,13 @@ async def get_user_logs(self, user_id: Union[str, int]) -> list: return await self.logs.find(query, projection).to_list(None) + async def find_log_entry(self, key: str) -> list: + query = {"key": key} + projection = {"messages": {"$slice": 5}} + logger.debug(f"Retrieving log ID {key}.") + + return await self.logs.find(query, projection).to_list(None) + async def get_latest_user_logs(self, user_id: Union[str, int]): query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} projection = {"messages": {"$slice": 5}} @@ -479,13 +573,9 @@ async def get_log_link(self, channel_id: Union[str, int]) -> str: prefix = self.bot.config["log_url_prefix"].strip("/") if prefix == "NONE": prefix = "" - return ( - f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" - ) + return f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{doc['key']}" - async def create_log_entry( - self, recipient: Member, channel: TextChannel, creator: Member - ) -> str: + async def create_log_entry(self, recipient: Member, channel: TextChannel, creator: Member) -> str: key = secrets.token_hex(6) await self.logs.insert_one( @@ -493,7 +583,7 @@ async def create_log_entry( "_id": key, "key": key, "open": True, - "created_at": str(datetime.utcnow()), + "created_at": str(discord.utils.utcnow()), "closed_at": None, "channel_id": str(channel.id), "guild_id": str(self.bot.guild_id), @@ -502,14 +592,14 @@ async def create_log_entry( "id": str(recipient.id), "name": recipient.name, "discriminator": recipient.discriminator, - "avatar_url": str(recipient.avatar_url), + "avatar_url": recipient.display_avatar.url, "mod": False, }, "creator": { "id": str(creator.id), "name": creator.name, "discriminator": creator.discriminator, - "avatar_url": str(creator.avatar_url), + "avatar_url": creator.display_avatar.url, "mod": isinstance(creator, Member), }, "closer": None, @@ -536,9 +626,7 @@ async def get_config(self) -> dict: async def update_config(self, data: dict): toset = self.bot.config.filter_valid(data) - unset = self.bot.config.filter_valid( - {k: 1 for k in self.bot.config.all_keys if k not in data} - ) + unset = self.bot.config.filter_valid({k: 1 for k in self.bot.config.all_keys if k not in data}) if toset and unset: return await self.db.config.update_one( @@ -573,7 +661,7 @@ async def append_log( "id": str(message.author.id), "name": message.author.name, "discriminator": message.author.discriminator, - "avatar_url": str(message.author.avatar_url), + "avatar_url": message.author.display_avatar.url, "mod": not isinstance(message.channel, DMChannel), }, "content": message.content, @@ -623,7 +711,7 @@ async def create_note(self, recipient: Member, message: Message, message_id: Uni "id": str(message.author.id), "name": message.author.name, "discriminator": message.author.discriminator, - "avatar_url": str(message.author.avatar_url), + "avatar_url": message.author.display_avatar.url, }, "message": message.content, "message_id": str(message_id), @@ -635,17 +723,13 @@ async def find_notes(self, recipient: Member): 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}} - ) + 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}} - ) + 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__ @@ -656,10 +740,14 @@ async def update_repository(self) -> dict: data = await user.update_repository() return { "data": data, - "user": {"username": user.username, "avatar_url": user.avatar_url, "url": user.url,}, + "user": { + "username": user.username, + "avatar_url": user.avatar_url, + "url": user.url, + }, } - async def get_user_info(self) -> dict: + async def get_user_info(self) -> Optional[dict]: try: user = await GitHub.login(self.bot) except InvalidConfigError: diff --git a/core/config.py b/core/config.py index 30f364622d..5c6b0dd09d 100644 --- a/core/config.py +++ b/core/config.py @@ -13,7 +13,7 @@ from core._color_data import ALL_COLORS from core.models import DMDisabled, InvalidConfigError, Default, getLogger -from core.time import UserFriendlyTimeSync +from core.time import UserFriendlyTime from core.utils import strtobool logger = getLogger(__name__) @@ -21,7 +21,6 @@ class ConfigManager: - public_keys = { # activity "twitch_url": "https://www.twitch.tv/discordmodmail/", @@ -37,6 +36,7 @@ class ConfigManager: "account_age": isodate.Duration(), "guild_age": isodate.Duration(), "thread_cooldown": isodate.Duration(), + "log_expiration": isodate.Duration(), "reply_without_command": False, "anon_reply_without_command": False, "plain_reply_without_command": False, @@ -51,7 +51,14 @@ class ConfigManager: "blocked_emoji": "\N{NO ENTRY SIGN}", "close_emoji": "\N{LOCK}", "use_user_id_channel_name": False, + "use_timestamp_channel_name": False, + "use_nickname_channel_name": False, + "use_random_channel_name": False, "recipient_thread_close": False, + "thread_show_roles": True, + "thread_show_account_age": True, + "thread_show_join_age": True, + "thread_cancelled": "Cancelled", "thread_auto_close_silently": False, "thread_auto_close": isodate.Duration(), "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", @@ -59,6 +66,9 @@ class ConfigManager: "thread_creation_footer": "Your message has been sent", "thread_contact_silently": False, "thread_self_closable_creation_footer": "Click the lock to close the thread", + "thread_creation_contact_title": "New Thread", + "thread_creation_self_contact_response": "You have opened a Modmail thread.", + "thread_creation_contact_response": "{creator.name} has opened a Modmail thread.", "thread_creation_title": "Thread Created", "thread_close_footer": "Replying will create a new thread", "thread_close_title": "Thread Closed", @@ -69,7 +79,7 @@ class ConfigManager: "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.", + "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", "disabled_new_thread_title": "Not Delivered", "disabled_new_thread_response": "We are not accepting new threads.", "disabled_new_thread_footer": "Please try again later...", @@ -83,6 +93,22 @@ class ConfigManager: "silent_alert_on_mention": False, "show_timestamp": True, "anonymous_snippets": False, + "plain_snippets": False, + "require_close_reason": False, + "show_log_url_button": False, + # group conversations + "private_added_to_group_title": "New Thread (Group)", + "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", + "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.", + "public_added_to_group_title": "New User", + "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.", + "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.", + "private_removed_from_group_title": "Removed From Thread (Group)", + "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.", + "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.", + "public_removed_from_group_title": "User Removed", + "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.", + "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.", # moderation "recipient_color": str(discord.Color.gold()), "mod_color": str(discord.Color.green()), @@ -97,11 +123,12 @@ class ConfigManager: # 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_response": "Click the button to confirm thread creation which will directly contact the moderators.", "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}", "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", # regex "use_regex_autotrigger": False, + "use_hoisted_top_role": True, } private_keys = { @@ -139,6 +166,8 @@ class ConfigManager: "database_type": "mongodb", "connection_uri": None, # replace mongo uri in the future "owners": None, + "enable_presence_intent": False, + "registry_plugins_only": False, # bot "token": None, "enable_plugins": True, @@ -149,21 +178,28 @@ class ConfigManager: "disable_updates": False, # Logging "log_level": "INFO", + "stream_log_format": "plain", + "file_log_format": "plain", + "discord_log_level": "INFO", # data collection "data_collection": True, } colors = {"mod_color", "recipient_color", "main_color", "error_color"} - time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown"} + time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown", "log_expiration"} booleans = { "use_user_id_channel_name", + "use_timestamp_channel_name", + "use_nickname_channel_name", + "use_random_channel_name", "user_typing", "mod_typing", "reply_without_command", "anon_reply_without_command", "plain_reply_without_command", + "show_log_url_button", "recipient_thread_close", "thread_auto_close_silently", "thread_move_notify", @@ -183,6 +219,15 @@ class ConfigManager: "update_notifications", "thread_contact_silently", "anonymous_snippets", + "plain_snippets", + "require_close_reason", + "recipient_thread_close", + "thread_show_roles", + "thread_show_account_age", + "thread_show_join_age", + "use_hoisted_top_role", + "enable_presence_intent", + "registry_plugins_only", } enums = { @@ -210,28 +255,18 @@ def populate_cache(self) -> dict: # populate from env var and .env file data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys}) - config_json = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json" - ) + config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") if os.path.exists(config_json): logger.debug("Loading envs from config.json.") with open(config_json, "r", encoding="utf-8") as f: # Config json should override env vars try: - data.update( - { - k.lower(): v - for k, v in json.load(f).items() - if k.lower() in self.all_keys - } - ) + data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys}) except json.JSONDecodeError: logger.critical("Failed to load config.json env values.", exc_info=True) self._cache = data - config_help_json = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "config_help.json" - ) + config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json") with open(config_help_json, "r", encoding="utf-8") as f: self.config_help = dict(sorted(json.load(f).items())) @@ -269,7 +304,7 @@ def __getitem__(self, key: str) -> typing.Any: def __delitem__(self, key: str) -> None: return self.remove(key) - def get(self, key: str, convert=True) -> typing.Any: + def get(self, key: str, *, convert: bool = True) -> typing.Any: key = key.lower() if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') @@ -338,7 +373,7 @@ def get(self, key: str, convert=True) -> typing.Any: return value - def set(self, key: str, item: typing.Any, convert=True) -> None: + async def set(self, key: str, item: typing.Any, convert=True) -> None: if not convert: return self.__setitem__(key, item) @@ -372,8 +407,8 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: isodate.parse_duration(item) except isodate.ISO8601Error: try: - converter = UserFriendlyTimeSync() - time = converter.convert(None, item) + converter = UserFriendlyTime() + time = await converter.convert(None, item, now=discord.utils.utcnow()) if time.arg: raise ValueError except BadArgument as exc: @@ -384,7 +419,8 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: "Unrecognized time, please use ISO-8601 duration format " 'string or a simpler "human readable" time.' ) - item = isodate.duration_isoformat(time.dt - converter.now) + now = discord.utils.utcnow() + item = isodate.duration_isoformat(time.dt - now) return self.__setitem__(key, item) if key in self.booleans: diff --git a/core/config_help.json b/core/config_help.json index ff6be5cba1..d301763fe4 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -105,7 +105,48 @@ "`{prefix}config set use_user_id_channel_name no`" ], "notes": [ - "This config is suitable for servers in Server Discovery to comply with channel name restrictions." + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." + ] + }, + "use_timestamp_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's account creation date instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_timestamp_channel_name yes`", + "`{prefix}config set use_timestamp_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_user_id_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_user_id_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." + ] + }, + "use_nickname_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's nickname instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_nickname_channel_name yes`", + "`{prefix}config set use_nickname_channel_name no`" + ], + "notes": [ + "This config is NOT suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_random_channel_name`." + ] + }, + "use_random_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with random characters tied to their user ID.", + "examples": [ + "`{prefix}config set use_random_channel_name yes`", + "`{prefix}config set use_random_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_nickname_channel_name`, or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_nickname_channel_name`." ] }, "mod_typing": { @@ -212,7 +253,7 @@ "default": "Yes", "description": "This is the channel where update notifications are sent to.", "examples": [ - "`{prefix}config set update_notifications no" + "`{prefix}config set update_notifications no`" ], "notes": [ "This has no effect unless `disable_autoupdates` is set to no.", @@ -266,6 +307,36 @@ "See also: `close_emoji`." ] }, + "thread_show_roles": { + "default": "Yes", + "description": "Shows roles on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_roles no`" + ], + "notes": [ + "See also: `thread_show_account_age`, `thread_show_join_age`." + ] + }, + "thread_show_account_age": { + "default": "Yes", + "description": "Shows account age on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_account_age no`" + ], + "notes": [ + "See also: `thread_show_roles`, `thread_show_join_age`." + ] + }, + "thread_show_join_age": { + "default": "Yes", + "description": "Shows join age on first message sent in thread channels to mods", + "examples":[ + "`{prefix}config set thread_show_join_age no`" + ], + "notes": [ + "See also: `thread_show_account_age`, `thread_show_roles`." + ] + }, "thread_auto_close_silently": { "default": "No", "description": "Setting this configuration will close silently when the thread auto-closes.", @@ -302,6 +373,25 @@ "To disable thread cooldown, do `{prefix}config del thread_cooldown`." ] }, + "log_expiration": { + "default": "Never", + "description": "The duration closed threads will be stored within the database before deletion. Logs that have been closed for longer than this duration will be deleted automatically.", + "examples": [ + "`{prefix}config set log_expiration P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set log_expiration 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable log expiration, do `{prefix}config del log_expiration`." + ] + }, + "thread_cancelled": { + "default": "\"Cancelled\"", + "description": "This is the message to display when a thread times out and creation is cancelled.", + "examples": [ + "`{prefix}config set thread_cancelled Gone.`" + ], + "notes": [] + }, "thread_auto_close_response": { "default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"", "description": "This is the message to display when the thread when the thread auto-closes.", @@ -359,6 +449,39 @@ "See also: `thread_creation_title`, `thread_creation_response`, `thread_creation_footer`." ] }, + "thread_creation_contact_title": { + "default": "\"New Thread\"", + "description": "This is the message embed title sent to recipients when contacted.", + "examples": [ + "`{prefix}config set thread_creation_contact_title New Message!`" + ], + "notes": [ + "See also: `thread_creation_self_contact_response`, `thread_creation_contact_response`." + ] + }, + "thread_creation_self_contact_response": { + "default": "\"You have opened a Modmail thread.\"", + "description": "This is the message embed description sent to recipients when self-contacted.", + "examples": [ + "`{prefix}config set thread_creation_self_contact_response You contacted yourself.`" + ], + "notes": [ + "`thread_creation_contact_response` is used when contacted by another user.", + "See also: `thread_creation_contact_title`, `thread_creation_contact_response`." + ] + }, + "thread_creation_contact_response": { + "default": "\"{{creator.name}} has opened a Modmail thread.\"", + "description": "This is the message embed description sent to recipients when contacted by a mod.", + "examples": [ + "`{prefix}config set thread_creation_contact_response New thread opened.`" + ], + "notes": [ + "You may use the `{{creator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that created the thread.", + "`thread_creation_self_contact_response` is used when contacted by self.", + "See also: `thread_creation_contact_title`, `thread_creation_self_contact_response`." + ] + }, "thread_creation_title": { "default": "\"Thread Created\"", "description": "This is the message embed title sent to the recipient upon the creation of a new thread.", @@ -421,7 +544,7 @@ "default": "Thread Moved", "description": "The title of the message embed when a thread is moved.", "examples": [ - "`{prefix}config set thread_move_title Thread transferred to another channel!" + "`{prefix}config set thread_move_title Thread transferred to another channel!`" ], "notes": [ "See also: `thread_move_notify`, `thread_move_notify_mods`, `thread_move_response`." @@ -472,7 +595,7 @@ ] }, "cooldown_thread_response": { - "default": "You must wait for {delta} before you can contact me again.", + "default": "Your cooldown ends {delta}. Try contacting me then.", "description": "The description of the message embed when the user has a cooldown before creating a new thread.", "examples": [ "`{prefix}config set cooldown_thread_response Be patient! You are on cooldown, wait {delta} more.`" @@ -708,7 +831,177 @@ "`{prefix}config set anonymous_snippets yes`" ], "notes": [ - "See also: `anon_avatar_url`, `anon_tag`." + "See also: `anon_avatar_url`, `anon_tag`, `plain_snippets`." + ] + }, + "plain_snippets": { + "default": "No", + "description": "Sends snippets with a plain interface.", + "examples":[ + "`{prefix}config set plain_snippets yes`" + ], + "notes": [ + "See also: `anonymous_snippets`." + ] + }, + "require_close_reason": { + "default" : "No", + "description": "Require a reason to close threads.", + "examples": [ + "`{prefix}config set require_close_reason yes`" + ], + "notes": [] + }, + "show_log_url_button": { + "default" : "No", + "description": "Shows the button to open the Log URL.", + "examples": [ + "`{prefix}config set show_log_url_button yes`" + ], + "notes": [] + }, + "private_added_to_group_title": { + "default": "New Thread (Group)", + "description": "This is the message embed title sent to the recipient that is just added to a thread.", + "examples": [ + "`{prefix}config set private_added_to_group_title Welcome to this new group thread!`" + ], + "notes": [ + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_description`, `public_added_to_group_title`" + ] + }, + "private_added_to_group_response": { + "default": "\"{{moderator.name}} has added you to a Modmail thread.\"", + "description": "This is the message embed content sent to the recipient that is just added to a thread.", + "examples": [ + "`{prefix}config set private_added_to_group_response Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonadduser is used, `private_added_to_group_description_anon` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_title`, `public_added_to_group_description`" + ] + }, + "private_added_to_group_description_anon": { + "default": "A moderator has added you to a Modmail thread.", + "description": "This is the message embed content sent to the recipient that is just added to a thread when adduser is used anonymously.", + "examples": [ + "`{prefix}config set private_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "When adduser (no anon) is used, `private_added_to_group_description` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_added_to_group_title`, `public_added_to_group_description_anon`" + ] + }, + "public_added_to_group_title": { + "default": "New User", + "description": "This is the message embed title sent to all other recipients when someone is added to the thread.", + "examples": [ + "`{prefix}config set public_added_to_group_title Welcome to our new user!`" + ], + "notes": [ + "The private_ variant is used when sending to the new user.", + "See also: `private_added_to_group_title`, `private_added_to_group_title`" + ] + }, + "public_added_to_group_response": { + "default": "\"{{moderator.name}} has added {{users}} to the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is added to the thread.", + "examples": [ + "`{prefix}config set public_added_to_group_description Welcome {users}!`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonadduser is used, `public_added_to_group_description_anon` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_added_to_group_title`, `private_added_to_group_description`" + ] + }, + "public_added_to_group_description_anon": { + "default": "\"A moderator has added {{users}} to the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is added to the thread when adduser is used anonymously.", + "examples": [ + "`{prefix}config set public_added_to_group_description_anon Any message sent here will be sent to all other thread recipients.`" + ], + "notes": [ + "When adduser (no anon) is used, `public_added_to_group_description` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_added_to_group_title`, `private_added_to_group_description_anon`" + ] + }, + "private_removed_from_group_title": { + "default": "Removed From Thread (Group)", + "description": "This is the message embed title sent to the recipient that is just removed from a thread.", + "examples": [ + "`{prefix}config set private_removed_from_group_title Welcome to this new group thread!`" + ], + "notes": [ + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_description`, `public_removed_from_group_title`" + ] + }, + "private_removed_from_group_response": { + "default": "\"{{moderator.name}} has removed you from the Modmail thread.\"", + "description": "This is the message embed content sent to the recipient that is just removed from a thread.", + "examples": [ + "`{prefix}config set private_removed_from_group_response Bye`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonremoveuser is used, `private_removed_from_group_description_anon` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_title`, `public_removed_from_group_description`" + ] + }, + "private_removed_from_group_description_anon": { + "default": "A moderator has removed you from the Modmail thread.", + "description": "This is the message embed content sent to the recipient that is just removed from a thread when removeuser is used anonymously.", + "examples": [ + "`{prefix}config set private_removed_from_group_description_anon You are permenantly removed from this thread.`" + ], + "notes": [ + "When adduser (no anon) is used, `private_removed_from_group_description` is used instead.", + "The public_ variant is used when sending to other thread recipients.", + "See also: `private_removed_from_group_title`, `public_removed_from_group_description_anon`" + ] + }, + "public_removed_from_group_title": { + "default": "User Removed", + "description": "This is the message embed title sent to all other recipients when someone is removed from the thread.", + "examples": [ + "`{prefix}config set public_removed_from_group_title User is now gone!`" + ], + "notes": [ + "The private_ variant is used when sending to the new user.", + "See also: `private_removed_from_group_title`, `private_removed_from_group_title`" + ] + }, + "public_removed_from_group_response": { + "default": "\"{{moderator.name}} has removed {{users}} from the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is removed from the thread.", + "examples": [ + "`{prefix}config set public_removed_from_group_description Goodbye {users}!`" + ], + "notes": [ + "You may use the `{{moderator}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that added the user.", + "When anonremoveuser is used, `public_removed_from_group_description_anon` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_removed_from_group_title`, `private_removed_from_group_description`" + ] + }, + "public_removed_from_group_description_anon": { + "default": "\"A moderator has removed {{users}} from the Modmail thread.\"", + "description": "This is the message embed content sent to all other recipients when someone is removed from the thread when removeuser is used anonymously.", + "examples": [ + "`{prefix}config set public_removed_from_group_description_anon Goodbye {users}!`" + ], + "notes": [ + "When adduser (no anon) is used, `public_removed_from_group_description` is used instead.", + "The private_ variant is used when sending to the new user.", + "See also: `public_removed_from_group_title`, `private_removed_from_group_description_anon`" ] }, "confirm_thread_creation": { @@ -732,10 +1025,10 @@ ] }, "confirm_thread_response": { - "default": "React to confirm thread creation which will directly contact the moderators", + "default": "Click the button to confirm thread creation which will directly contact the moderators.", "description": "Description for the embed message sent to users to confirm a thread creation", "examples":[ - "`{prefix}config set confirm_thread_response React to confirm`" + "`{prefix}config set confirm_thread_response Click to confirm`" ], "notes": [ "See also: `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny`" @@ -847,6 +1140,33 @@ "This configuration can only to be set through `.env` file or environment (config) variables." ] }, + "stream_log_format": { + "default": "plain", + "description": "The logging format when through a stream, can be 'plain' or 'json'", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "file_log_format": { + "default": "plain", + "description": "The logging format when logging to a file, can be 'plain' or 'json'", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, + "discord_log_level": { + "default": "INFO", + "description": "The `discord.py` library logging level for logging to stdout.", + "examples": [ + ], + "notes": [ + "This configuration can only to be set through `.env` file or environment (config) variables." + ] + }, "enable_plugins": { "default": "Yes", "description": "Whether plugins should be enabled and loaded into Modmail.", @@ -891,5 +1211,16 @@ "notes": [ "This configuration can only to be set through `.env` file or environment (config) variables." ] + }, + "use_hoisted_top_role": { + "default": "Yes", + "description": "Controls if only hoisted roles are evaluated when finding top role.", + "examples": [ + ], + "notes": [ + "Top role is displayed in embeds when replying or adding/removing users to a thread in the case mod_tag and anon_username are not set.", + "If this configuration is enabled, only roles that are hoisted (displayed seperately in member list) will be used. If a user has no hoisted roles, it will return 'None'.", + "If you would like to display the top role of a user regardless of if it's hoisted or not, disable `use_hoisted_top_role`." + ] } } diff --git a/core/decorators.py b/core/decorators.py deleted file mode 100644 index 0107a6b2d6..0000000000 --- a/core/decorators.py +++ /dev/null @@ -1,12 +0,0 @@ -import warnings - -from core.utils import trigger_typing as _trigger_typing - - -def trigger_typing(func): - warnings.warn( - "trigger_typing has been moved to core.utils.trigger_typing, this will be removed.", - DeprecationWarning, - stacklevel=2, - ) - return _trigger_typing(func) diff --git a/core/models.py b/core/models.py index 0238e051d4..611db375f0 100644 --- a/core/models.py +++ b/core/models.py @@ -1,15 +1,20 @@ +import json import logging +import os import re import sys -import os +import _string + +from difflib import get_close_matches from enum import IntEnum +from logging import FileHandler, StreamHandler, Handler from logging.handlers import RotatingFileHandler from string import Formatter +from typing import Dict, Optional import discord from discord.ext import commands -import _string try: from colorama import Fore, Style @@ -22,29 +27,6 @@ Fore = Style = type("Dummy", (object,), {"__getattr__": lambda self, item: ""})() -class PermissionLevel(IntEnum): - OWNER = 5 - ADMINISTRATOR = 4 - ADMIN = 4 - MODERATOR = 3 - MOD = 3 - SUPPORTER = 2 - RESPONDER = 2 - REGULAR = 1 - INVALID = -1 - - -class InvalidConfigError(commands.BadArgument): - def __init__(self, msg, *args): - super().__init__(msg, *args) - self.msg = msg - - @property - def embed(self): - # Single reference of Color.red() - return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) - - class ModmailLogger(logging.Logger): @staticmethod def _debug_(*msgs): @@ -93,18 +75,180 @@ def line(self, level="info"): ) -logging.setLoggerClass(ModmailLogger) -log_level = logging.INFO -loggers = set() +class JsonFormatter(logging.Formatter): + """ + Formatter that outputs JSON strings after parsing the LogRecord. + + Parameters + ---------- + fmt_dict : Optional[Dict[str, str]] + {key: logging format attribute} pairs. Defaults to {"message": "message"}. + time_format: str + time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" + msec_format: str + Microsecond formatting. Appended at the end. Default: "%s.%03dZ" + """ + + def __init__( + self, + fmt_dict: Optional[Dict[str, str]] = None, + time_format: str = "%Y-%m-%dT%H:%M:%S", + msec_format: str = "%s.%03dZ", + ): + self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"} + self.default_time_format: str = time_format + self.default_msec_format: str = msec_format + self.datefmt: Optional[str] = None + + def usesTime(self) -> bool: + """ + Overwritten to look for the attribute in the format dict values instead of the fmt string. + """ + return "asctime" in self.fmt_dict.values() + + def formatMessage(self, record) -> Dict[str, str]: + """ + Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. + KeyError is raised if an unknown attribute is provided in the fmt_dict. + """ + return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} + + def format(self, record) -> str: + """ + Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON + instead of a string. + """ + record.message = record.getMessage() + + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + + message_dict = self.formatMessage(record) + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + message_dict["exc_info"] = record.exc_text + + if record.stack_info: + message_dict["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(message_dict, default=str) + -ch = logging.StreamHandler(stream=sys.stdout) -ch.setLevel(log_level) -formatter = logging.Formatter( +class FileFormatter(logging.Formatter): + ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + def format(self, record): + record.msg = self.ansi_escape.sub("", record.msg) + return super().format(record) + + +log_stream_formatter = logging.Formatter( "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S" ) -ch.setFormatter(formatter) -ch_debug = None +log_file_formatter = FileFormatter( + "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) + +json_formatter = JsonFormatter( + { + "level": "levelname", + "message": "message", + "loggerName": "name", + "processName": "processName", + "processID": "process", + "threadName": "threadName", + "threadID": "thread", + "timestamp": "asctime", + } +) + + +def create_log_handler( + filename: Optional[str] = None, + *, + rotating: bool = False, + level: int = logging.DEBUG, + mode: str = "a+", + encoding: str = "utf-8", + format: str = "plain", + maxBytes: int = 28000000, + backupCount: int = 1, + **kwargs, +) -> Handler: + """ + Creates a pre-configured log handler. This function is made for consistency's sake with + pre-defined default values for parameters and formatters to pass to handler class. + Additional keyword arguments also can be specified, just in case. + + Plugin developers should not use this and use `models.getLogger` instead. + + Parameters + ---------- + filename : Optional[Path] + Specifies that a `FileHandler` or `RotatingFileHandler` be created, using the specified filename, + rather than a `StreamHandler`. Defaults to `None`. + rotating : bool + Whether the file handler should be the `RotatingFileHandler`. Defaults to `False`. Note, this + argument only compatible if the `filename` is specified, otherwise `ValueError` will be raised. + level : int + The root logger level for the handler. Defaults to `logging.DEBUG`. + mode : str + If filename is specified, open the file in this mode. Defaults to 'a+'. + encoding : str + If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created, + and thus used when opening the output file. Defaults to 'utf-8'. + format : str + The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created, + based on other conditional logic. + maxBytes : int + The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current + log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero, + rollover never occurs, so you generally want to set `backupCount` to at least 1. + backupCount : int + Max number of backup files. Defaults to 1. If this is set to zero, rollover will never occur. + + Returns + ------- + `StreamHandler` when `filename` is `None`, otherwise `FileHandler` or `RotatingFileHandler` + depending on the `rotating` value. + """ + if filename is None and rotating: + raise ValueError("`filename` must be set to instantiate a `RotatingFileHandler`.") + + if filename is None: + handler = StreamHandler(stream=sys.stdout, **kwargs) + formatter = log_stream_formatter + elif not rotating: + handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs) + formatter = log_file_formatter + else: + handler = RotatingFileHandler( + filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs + ) + formatter = log_file_formatter + + if format == "json": + formatter = json_formatter + + handler.setLevel(level) + handler.setFormatter(formatter) + return handler + + +logging.setLoggerClass(ModmailLogger) +log_level = logging.INFO +loggers = set() + +ch = create_log_handler(level=log_level) +ch_debug: Optional[RotatingFileHandler] = None def getLogger(name=None) -> ModmailLogger: @@ -117,33 +261,82 @@ def getLogger(name=None) -> ModmailLogger: return logger -class FileFormatter(logging.Formatter): - ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") +def configure_logging(bot) -> None: + global ch_debug, log_level, ch + + stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"] + if stream_log_format == "json": + ch.setFormatter(json_formatter) + + logger = getLogger(__name__) + level_text = bot.config["log_level"].upper() + logging_levels = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + } + logger.line() + + level = logging_levels.get(level_text) + if level is None: + level = bot.config.remove("log_level") + logger.warning("Invalid logging level set: %s.", level_text) + logger.warning("Using default logging level: %s.", level) + level = logging_levels[level] + else: + logger.info("Logging level: %s", level_text) + log_level = level + + logger.info("Log file: %s", bot.log_file_path) + ch_debug = create_log_handler(bot.log_file_path, rotating=True) + + if file_log_format == "json": + ch_debug.setFormatter(json_formatter) - def format(self, record): - record.msg = self.ansi_escape.sub("", record.msg) - return super().format(record) - - -def configure_logging(name, level=None): - global ch_debug, log_level - ch_debug = RotatingFileHandler(name, mode="a+", maxBytes=48000, backupCount=1) + ch.setLevel(log_level) - formatter_debug = FileFormatter( - "%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ch_debug.setFormatter(formatter_debug) - ch_debug.setLevel(logging.DEBUG) + logger.info("Stream log format: %s", stream_log_format) + logger.info("File log format: %s", file_log_format) + + for log in loggers: + log.setLevel(log_level) + log.addHandler(ch_debug) + + # Set up discord.py logging + d_level_text = bot.config["discord_log_level"].upper() + d_level = logging_levels.get(d_level_text) + if d_level is None: + d_level = bot.config.remove("discord_log_level") + logger.warning("Invalid discord logging level set: %s.", d_level_text) + logger.warning("Using default discord logging level: %s.", d_level) + d_level = logging_levels[d_level] + d_logger = logging.getLogger("discord") + d_logger.setLevel(d_level) + + non_verbose_log_level = max(d_level, logging.INFO) + stream_handler = create_log_handler(level=non_verbose_log_level) + if non_verbose_log_level != d_level: + logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level)) + logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level)) + else: + logger.info("Discord logging level: %s.", logging.getLevelName(d_level)) + d_logger.addHandler(stream_handler) + d_logger.addHandler(ch_debug) + + logger.debug("Successfully configured logging.") - if level is not None: - log_level = level - ch.setLevel(log_level) +class InvalidConfigError(commands.BadArgument): + def __init__(self, msg, *args): + super().__init__(msg, *args) + self.msg = msg - for logger in loggers: - logger.setLevel(log_level) - logger.addHandler(ch_debug) + @property + def embed(self): + # Single reference of Color.red() + return discord.Embed(title="Error", description=self.msg, color=discord.Color.red()) class _Default: @@ -201,16 +394,18 @@ async def convert(self, ctx, argument): 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) + categories = {c.name.casefold(): c for c in guild.categories} else: - result = discord.utils.find(check, bot.get_all_channels()) + categories = { + c.name.casefold(): c + for c in bot.get_all_channels() + if isinstance(c, discord.CategoryChannel) + } + + result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) + if result: + result = categories[result[0]] if not isinstance(result, discord.CategoryChannel): raise commands.ChannelNotFound(argument) @@ -267,6 +462,18 @@ async def ack(self): return +class PermissionLevel(IntEnum): + OWNER = 5 + ADMINISTRATOR = 4 + ADMIN = 4 + MODERATOR = 3 + MOD = 3 + SUPPORTER = 2 + RESPONDER = 2 + REGULAR = 1 + INVALID = -1 + + class DMDisabled(IntEnum): NONE = 0 NEW_THREADS = 1 diff --git a/core/paginator.py b/core/paginator.py index 7ba1c98b60..5a6844f382 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -1,8 +1,8 @@ import typing -import asyncio -from discord import User, Reaction, Message, Embed -from discord import HTTPException, InvalidArgument +import discord +from discord import Message, Embed, ButtonStyle, Interaction +from discord.ui import View, Button, Select from discord.ext import commands @@ -33,8 +33,12 @@ class PaginatorSession: The `Message` of the `Embed`. current : int The current page number. - reaction_map : Dict[str, method] - A mapping for reaction to method. + callback_map : Dict[str, method] + A mapping for text to method. + view : PaginatorView + The view that is sent along with the base message. + select_menu : Select + A select menu that will be added to the View. """ def __init__(self, ctx: commands.Context, *pages, **options): @@ -45,40 +49,18 @@ def __init__(self, ctx: commands.Context, *pages, **options): self.current = 0 self.pages = list(pages) self.destination = options.get("destination", ctx) - self.reaction_map = { - "⏮": self.first_page, - "◀": self.previous_page, - "▶": self.next_page, - "⏭": self.last_page, - "🛑": self.close, + self.view = None + self.select_menu = None + + self.callback_map = { + "<<": self.first_page, + "<": self.previous_page, + ">": self.next_page, + ">>": self.last_page, } + self._buttons_map = {"<<": None, "<": None, ">": None, ">>": None} - def add_page(self, item) -> None: - """ - Add a page. - """ - raise NotImplementedError - - async def create_base(self, item) -> None: - """ - Create a base `Message`. - """ - await self._create_base(item) - - if len(self.pages) == 1: - self.running = False - return - - self.running = True - for reaction in self.reaction_map: - if len(self.pages) == 2 and reaction in "⏮⏭": - continue - await self.ctx.bot.add_reaction(self.base, reaction) - - async def _create_base(self, item) -> None: - raise NotImplementedError - - async def show_page(self, index: int) -> None: + async def show_page(self, index: int) -> typing.Optional[typing.Dict]: """ Show a page by page number. @@ -92,74 +74,96 @@ async def show_page(self, index: int) -> None: self.current = index page = self.pages[index] + result = None if self.running: - await self._show_page(page) + result = self._show_page(page) else: await self.create_base(page) - async def _show_page(self, page): - raise NotImplementedError + self.update_disabled_status() + return result - def react_check(self, reaction: Reaction, user: User) -> bool: - """ + def update_disabled_status(self): + if self.current == self.first_page(): + # disable << button + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = True - Parameters - ---------- - reaction : Reaction - The `Reaction` object of the reaction. - user : User - The `User` or `Member` object of who sent the reaction. + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = True + else: + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = False - Returns - ------- - bool + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = False + + if self.current == self.last_page(): + # disable >> button + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = True + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = True + else: + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = False + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = False + + async def create_base(self, item) -> None: """ - return ( - reaction.message.id == self.base.id - and user.id == self.ctx.author.id - and reaction.emoji in self.reaction_map.keys() - ) + Create a base `Message`. + """ + if len(self.pages) == 1: + self.view = None + self.running = False + else: + self.view = PaginatorView(self, timeout=self.timeout) + self.update_disabled_status() + self.running = True + + await self._create_base(item, self.view) + + async def _create_base(self, item, view: View) -> None: + raise NotImplementedError + + def _show_page(self, page): + raise NotImplementedError + + def first_page(self): + """Returns the index of the first page""" + return 0 + + def next_page(self): + """Returns the index of the next page""" + return min(self.current + 1, self.last_page()) + + def previous_page(self): + """Returns the index of the previous page""" + return max(self.current - 1, self.first_page()) + + def last_page(self): + """Returns the index of the last page""" + return len(self.pages) - 1 async def run(self) -> typing.Optional[Message]: """ Starts the pagination session. - - Returns - ------- - Optional[Message] - If it's closed before running ends. """ if not self.running: await self.show_page(self.current) - while self.running: - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", check=self.react_check, timeout=self.timeout - ) - except asyncio.TimeoutError: - return await self.close(delete=False) - else: - action = self.reaction_map.get(reaction.emoji) - await action() - try: - await self.base.remove_reaction(reaction, user) - except (HTTPException, InvalidArgument): - pass - - async def previous_page(self) -> None: - """ - Go to the previous page. - """ - await self.show_page(self.current - 1) - async def next_page(self) -> None: - """ - Go to the next page. - """ - await self.show_page(self.current + 1) + if self.view is not None: + await self.view.wait() + + await self.close(delete=False) - async def close(self, delete: bool = True) -> typing.Optional[Message]: + async def close( + self, delete: bool = True, *, interaction: Interaction = None + ) -> typing.Optional[Message]: """ Closes the pagination session. @@ -174,30 +178,127 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: Optional[Message] If `delete` is `True`. """ - self.running = False + if self.running: + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) - sent_emoji, _ = await self.ctx.bot.retrieve_emoji() - await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) + if interaction: + message = interaction.message + else: + message = self.base - if delete: - return await self.base.delete() + self.running = False - try: - await self.base.clear_reactions() - except HTTPException: - pass + if self.view is not None: + self.view.stop() + if delete: + await message.delete() + else: + self.view.clear_items() + await message.edit(view=self.view) - async def first_page(self) -> None: - """ - Go to the first page. - """ - await self.show_page(0) - async def last_page(self) -> None: - """ - Go to the last page. - """ - await self.show_page(len(self.pages) - 1) +class PaginatorView(View): + """ + View that is used for pagination. + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + """ + + def __init__(self, handler: PaginatorSession, *args, **kwargs): + super().__init__(*args, **kwargs) + self.handler = handler + self.clear_items() # clear first so we can control the order + self.fill_items() + + @discord.ui.button(label="Stop", style=ButtonStyle.danger) + async def stop_button(self, interaction: Interaction, button: Button): + await self.handler.close(interaction=interaction) + + def fill_items(self): + if self.handler.select_menu is not None: + self.add_item(self.handler.select_menu) + + for label, callback in self.handler.callback_map.items(): + if len(self.handler.pages) == 2 and label in ("<<", ">>"): + continue + + if label in ("<<", ">>"): + style = ButtonStyle.secondary + else: + style = ButtonStyle.primary + + button = PageButton(self.handler, callback, label=label, style=style) + + self.handler._buttons_map[label] = button + self.add_item(button) + self.add_item(self.stop_button) + + async def interaction_check(self, interaction: Interaction): + """Only allow the message author to interact""" + if interaction.user != self.handler.ctx.author: + await interaction.response.send_message( + "Only the original author can control this!", ephemeral=True + ) + return False + return True + + +class PageButton(Button): + """ + A button that has a callback to jump to the next page + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + """ + + def __init__(self, handler, page_callback, **kwargs): + super().__init__(**kwargs) + self.handler = handler + self.page_callback = page_callback + + async def callback(self, interaction: Interaction): + kwargs = await self.handler.show_page(self.page_callback()) + await interaction.response.edit_message(**kwargs, view=self.view) + + +class PageSelect(Select): + def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): + self.handler = handler + options = [] + for n, (label, description) in enumerate(pages): + options.append(discord.SelectOption(label=label, description=description, value=str(n))) + + options = options[:25] # max 25 options + super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) + + async def callback(self, interaction: Interaction): + page = int(self.values[0]) + kwargs = await self.handler.show_page(page) + await interaction.response.edit_message(**kwargs, view=self.view) class EmbedPaginatorSession(PaginatorSession): @@ -205,11 +306,42 @@ def __init__(self, ctx: commands.Context, *embeds, **options): super().__init__(ctx, *embeds, **options) if len(self.pages) > 1: + select_options = [] + create_select = True for i, embed in enumerate(self.pages): footer_text = f"Page {i + 1} of {len(self.pages)}" if embed.footer.text: footer_text = footer_text + " • " + embed.footer.text - embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url) + + if embed.footer.icon: + icon_url = embed.footer.icon.url + else: + icon_url = None + embed.set_footer(text=footer_text, icon_url=icon_url) + + # select menu + if embed.author.name: + title = embed.author.name[:30].strip() + if len(embed.author.name) > 30: + title += "..." + else: + title = embed.title[:30].strip() + if len(embed.title) > 30: + title += "..." + if not title: + create_select = False + + if embed.description: + description = embed.description[:40].replace("*", "").replace("`", "").strip() + if len(embed.description) > 40: + description += "..." + else: + description = "" + select_options.append((title, description)) + + if create_select: + if len(set(x[0] for x in select_options)) != 1: # must have unique authors + self.select_menu = PageSelect(self, select_options) def add_page(self, item: Embed) -> None: if isinstance(item, Embed): @@ -217,11 +349,11 @@ def add_page(self, item: Embed) -> None: else: raise TypeError("Page must be an Embed object.") - async def _create_base(self, item: Embed) -> None: - self.base = await self.destination.send(embed=item) + async def _create_base(self, item: Embed, view: View) -> None: + self.base = await self.destination.send(embed=item, view=view) - async def _show_page(self, page): - await self.base.edit(embed=page) + def _show_page(self, page): + return dict(embed=page) class MessagePaginatorSession(PaginatorSession): @@ -241,12 +373,18 @@ def _set_footer(self): footer_text = f"Page {self.current+1} of {len(self.pages)}" if self.footer_text: footer_text = footer_text + " • " + self.footer_text - self.embed.set_footer(text=footer_text, icon_url=self.embed.footer.icon_url) - async def _create_base(self, item: str) -> None: + if self.embed.footer.icon: + icon_url = self.embed.footer.icon.url + else: + icon_url = None + + self.embed.set_footer(text=footer_text, icon_url=icon_url) + + async def _create_base(self, item: str, view: View) -> None: self._set_footer() - self.base = await self.ctx.send(content=item, embed=self.embed) + self.base = await self.ctx.send(content=item, embed=self.embed, view=view) - async def _show_page(self, page) -> None: + def _show_page(self, page) -> typing.Dict: self._set_footer() - await self.base.edit(content=page, embed=self.embed) + return dict(content=page, embed=self.embed) diff --git a/core/thread.py b/core/thread.py index 530e063e54..646c98a604 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1,26 +1,38 @@ import asyncio +import base64 import copy +import functools import io import re import time +import traceback import typing -from datetime import datetime, timedelta +import warnings +from datetime import timedelta from types import SimpleNamespace import isodate import discord from discord.ext.commands import MissingRequiredArgument, CommandError +from lottie.importers import importers as l_importers +from lottie.exporters import exporters as l_exporters from core.models import DMDisabled, DummyMessage, getLogger from core.time import human_timedelta from core.utils import ( is_image_url, - days, + parse_channel_topic, match_title, match_user_id, truncate, - format_channel_name, + get_top_role, + create_thread_channel, + get_joint_id, + AcceptButton, + DenyButton, + ConfirmThreadCreationView, + DummyParam, ) logger = getLogger(__name__) @@ -34,6 +46,7 @@ def __init__( manager: "ThreadManager", recipient: typing.Union[discord.Member, discord.User, int], channel: typing.Union[discord.DMChannel, discord.TextChannel] = None, + other_recipients: typing.List[typing.Union[discord.Member, discord.User]] = None, ): self.manager = manager self.bot = manager.bot @@ -45,8 +58,9 @@ def __init__( raise CommandError("Recipient cannot be a bot.") self._id = recipient.id self._recipient = recipient + self._other_recipients = other_recipients or [] self._channel = channel - self.genesis_message = None + self._genesis_message = None self._ready_event = asyncio.Event() self.wait_tasks = [] self.close_task = None @@ -54,12 +68,17 @@ def __init__( self._cancelled = False def __repr__(self): - return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id})' + return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipients={len(self._other_recipients)})' + + def __eq__(self, other): + if isinstance(other, Thread): + return self.id == other.id + return super().__eq__(other) async def wait_until_ready(self) -> None: """Blocks execution until the thread is fully set up.""" # timeout after 30 seconds - task = asyncio.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25)) + task = self.bot.loop.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25)) self.wait_tasks.append(task) try: await task @@ -80,6 +99,10 @@ def channel(self) -> typing.Union[discord.TextChannel, discord.DMChannel]: def recipient(self) -> typing.Optional[typing.Union[discord.User, discord.Member]]: return self._recipient + @property + def recipients(self) -> typing.List[typing.Union[discord.User, discord.Member]]: + return [self._recipient] + self._other_recipients + @property def ready(self) -> bool: return self._ready_event.is_set() @@ -103,49 +126,64 @@ def cancelled(self, flag: bool): for i in self.wait_tasks: i.cancel() + @classmethod + async def from_channel(cls, manager: "ThreadManager", channel: discord.TextChannel) -> "Thread": + # there is a chance it grabs from another recipient's main thread + _, recipient_id, other_ids = parse_channel_topic(channel.topic) + + if recipient_id in manager.cache: + thread = manager.cache[recipient_id] + else: + recipient = await manager.bot.get_or_fetch_user(recipient_id) + + other_recipients = [] + for uid in other_ids: + try: + other_recipient = await manager.bot.get_or_fetch_user(uid) + except discord.NotFound: + continue + other_recipients.append(other_recipient) + + thread = cls(manager, recipient or recipient_id, channel, other_recipients) + + return thread + + async def get_genesis_message(self) -> discord.Message: + if self._genesis_message is None: + async for m in self.channel.history(limit=5, oldest_first=True): + if m.author == self.bot.user: + if m.embeds and m.embeds[0].fields and m.embeds[0].fields[0].name == "Roles": + self._genesis_message = m + + return self._genesis_message + 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, creator, category, initial_message) recipient = self.recipient # in case it creates a channel outside of category - overwrites = { - self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False) - } + overwrites = {self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False)} category = category or self.bot.main_category if category is not None: - overwrites = None + overwrites = {} try: - channel = await self.bot.modmail_guild.create_text_channel( - name=format_channel_name(self.bot, recipient), - category=category, - overwrites=overwrites, - reason="Creating a thread channel.", - ) - except discord.HTTPException as e: - # try again but null-discrim (name could be banned) - try: - channel = await self.bot.modmail_guild.create_text_channel( - name=format_channel_name(self.bot, recipient, force_null=True), - category=category, - overwrites=overwrites, - reason="Creating a thread channel.", - ) - except discord.HTTPException as e: # Failed to create due to missing perms. - logger.critical("An error occurred while creating a thread.", exc_info=True) - self.manager.cache.pop(self.id) + channel = await create_thread_channel(self.bot, recipient, category, overwrites) + except discord.HTTPException as e: # Failed to create due to missing perms. + logger.critical("An error occurred while creating a thread.", exc_info=True) + self.manager.cache.pop(self.id) - embed = discord.Embed(color=self.bot.error_color) - embed.title = "Error while trying to create a thread." - embed.description = str(e) - embed.add_field(name="Recipient", value=recipient.mention) + embed = discord.Embed(color=self.bot.error_color) + embed.title = "Error while trying to create a thread." + embed.description = str(e) + embed.add_field(name="Recipient", value=recipient.mention) - if self.bot.log_channel is not None: - await self.bot.log_channel.send(embed=embed) - return + if self.bot.log_channel is not None: + await self.bot.log_channel.send(embed=embed) + return self._channel = channel @@ -161,7 +199,6 @@ async def setup(self, *, creator=None, category=None, initial_message=None): log_url = log_count = None # ensure core functionality still works - await channel.edit(topic=f"User ID: {recipient.id}") self.ready = True if creator is not None and creator != recipient: @@ -170,18 +207,16 @@ async def setup(self, *, creator=None, category=None, initial_message=None): mention = self.bot.config["mention"] async def send_genesis_message(): - info_embed = self._format_info_embed( - recipient, log_url, log_count, self.bot.main_color - ) + info_embed = self._format_info_embed(recipient, log_url, log_count, self.bot.main_color) try: msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) - self.genesis_message = msg + self._genesis_message = msg except Exception: logger.error("Failed unexpectedly:", exc_info=True) async def send_recipient_genesis_message(): - # Once thread is ready, tell the recipient. + # Once thread is ready, tell the recipient (don't send if using contact on others) thread_creation_response = self.bot.config["thread_creation_response"] embed = discord.Embed( @@ -197,7 +232,9 @@ async def send_recipient_genesis_message(): else: footer = self.bot.config["thread_creation_footer"] - embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) + embed.set_footer( + text=footer, icon_url=self.bot.get_guild_icon(guild=self.bot.modmail_guild, size=128) + ) embed.title = self.bot.config["thread_creation_title"] if creator is None or creator == recipient: @@ -223,7 +260,7 @@ class Author: name = author["name"] id = author["id"] discriminator = author["discriminator"] - avatar_url = author["avatar_url"] + display_avatar = SimpleNamespace(url=author["avatar_url"]) data = { "id": round(time.time() * 1000 - discord.utils.DISCORD_EPOCH) << 22, @@ -237,16 +274,15 @@ class Author: "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 - ) + message = discord.Message(state=State(), channel=self.channel, 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(): if initial_message: message = DummyMessage(copy.copy(initial_message)) + try: return await self.bot.trigger_auto_triggers(message, channel) except RuntimeError: @@ -264,12 +300,12 @@ def _format_info_embed(self, user, log_url, log_count, color): """Get information about a member of a server supports users from the guild or not.""" member = self.bot.guild.get_member(user.id) - time = datetime.utcnow() + time = discord.utils.utcnow() # key = log_url.split('/')[-1] role_names = "" - if member is not None: + if member is not None and self.bot.config["thread_show_roles"]: sep_server = self.bot.using_multiple_server_setup separator = ", " if sep_server else " " @@ -291,27 +327,24 @@ def _format_info_embed(self, user, log_url, log_count, color): role_names = separator.join(roles) - created = str((time - user.created_at).days) - embed = discord.Embed( - color=color, description=f"{user.mention} was created {days(created)}", timestamp=time - ) + user_info = [] + if self.bot.config["thread_show_account_age"]: + created = discord.utils.format_dt(user.created_at, "R") + user_info.append(f" was created {created}") - # if not role_names: - # embed.add_field(name='Mention', value=user.mention) - # embed.add_field(name='Registered', value=created + days(created)) + embed = discord.Embed(color=color, description=user.mention, timestamp=time) if user.dm_channel: footer = f"User ID: {user.id} • DM ID: {user.dm_channel.id}" else: footer = f"User ID: {user.id}" - embed.set_author(name=str(user), icon_url=user.avatar_url, url=log_url) - # embed.set_thumbnail(url=avi) - if member is not None: - joined = str((time - member.joined_at).days) - # embed.add_field(name='Joined', value=joined + days(joined)) - embed.description += f", joined {days(joined)}" + embed.set_author(name=str(user), icon_url=member.display_avatar.url, url=log_url) + + if self.bot.config["thread_show_join_age"]: + joined = discord.utils.format_dt(member.joined_at, "R") + user_info.append(f"joined {joined}") if member.nick: embed.add_field(name="Nickname", value=member.nick, inline=True) @@ -319,27 +352,27 @@ def _format_info_embed(self, user, log_url, log_count, color): embed.add_field(name="Roles", value=role_names, inline=True) embed.set_footer(text=footer) else: + embed.set_author(name=str(user), icon_url=user.display_avatar.url, url=log_url) embed.set_footer(text=f"{footer} • (not in main server)") + embed.description += ", ".join(user_info) + if log_count is not None: - # embed.add_field(name="Past logs", value=f"{log_count}") + connector = "with" if user_info else "has" thread = "thread" if log_count == 1 else "threads" - embed.description += f" with **{log_count or 'no'}** past {thread}." + embed.description += f" {connector} **{log_count or 'no'}** past {thread}." else: embed.description += "." mutual_guilds = [g for g in self.bot.guilds if user in g.members] if member is None or len(mutual_guilds) > 1: - embed.add_field( - name="Mutual Server(s)", value=", ".join(g.name for g in mutual_guilds) - ) + embed.add_field(name="Mutual Server(s)", value=", ".join(g.name for g in mutual_guilds)) return embed - def _close_after(self, closer, silent, delete_channel, message): - return self.bot.loop.create_task( - self._close(closer, silent, delete_channel, message, True) - ) + async def _close_after(self, after, closer, silent, delete_channel, message): + await asyncio.sleep(after) + return self.bot.loop.create_task(self._close(closer, silent, delete_channel, message, True)) async def close( self, @@ -359,7 +392,7 @@ async def close( if after > 0: # TODO: Add somewhere to clean up broken closures # (when channel is already deleted) - now = datetime.utcnow() + now = discord.utils.utcnow() items = { # 'initiation_time': now.isoformat(), "time": (now + timedelta(seconds=after)).isoformat(), @@ -372,9 +405,7 @@ async def close( self.bot.config["closures"][str(self.id)] = items await self.bot.config.update() - task = self.bot.loop.call_later( - after, self._close_after, closer, silent, delete_channel, message - ) + task = asyncio.create_task(self._close_after(after, closer, silent, delete_channel, message)) if auto_close: self.auto_close_task = task @@ -383,9 +414,7 @@ async def close( else: await self._close(closer, silent, delete_channel, message) - async def _close( - self, closer, silent=False, delete_channel=True, message=None, scheduled=False - ): + async def _close(self, closer, silent=False, delete_channel=True, message=None, scheduled=False): try: self.manager.cache.pop(self.id) except KeyError as e: @@ -406,14 +435,14 @@ async def _close( { "open": False, "title": match_title(self.channel.topic), - "closed_at": str(datetime.utcnow()), + "closed_at": str(discord.utils.utcnow()), "nsfw": self.channel.nsfw, - "close_message": message if not silent else None, + "close_message": message, "closer": { "id": str(closer.id), "name": closer.name, "discriminator": closer.discriminator, - "avatar_url": str(closer.avatar_url), + "avatar_url": closer.display_avatar.url, "mod": True, }, }, @@ -425,7 +454,9 @@ async def _close( prefix = self.bot.config["log_url_prefix"].strip("/") if prefix == "NONE": prefix = "" - log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{log_data['key']}" + log_url = ( + f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{log_data['key']}" + ) if log_data["title"]: sneak_peak = log_data["title"] @@ -462,21 +493,27 @@ async def _close( event = "Thread Closed as Scheduled" if scheduled else "Thread Closed" # embed.set_author(name=f"Event: {event}", url=log_url) - embed.set_footer(text=f"{event} by {_closer}", icon_url=closer.avatar_url) - embed.timestamp = datetime.utcnow() + embed.set_footer(text=f"{event} by {_closer}", icon_url=closer.display_avatar.url) + embed.timestamp = discord.utils.utcnow() tasks = [self.bot.config.update()] if self.bot.log_channel is not None and self.channel is not None: - tasks.append(self.bot.log_channel.send(embed=embed)) + if self.bot.config["show_log_url_button"]: + view = discord.ui.View() + view.add_item(discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url)) + else: + view = None + tasks.append(self.bot.log_channel.send(embed=embed, view=view)) # Thread closed message embed = discord.Embed( - title=self.bot.config["thread_close_title"], color=self.bot.error_color, + title=self.bot.config["thread_close_title"], + color=self.bot.error_color, ) if self.bot.config["show_timestamp"]: - embed.timestamp = datetime.utcnow() + embed.timestamp = discord.utils.utcnow() if not message: if self.id == closer.id: @@ -490,10 +527,19 @@ async def _close( embed.description = message footer = self.bot.config["thread_close_footer"] - embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) + embed.set_footer(text=footer, icon_url=self.bot.get_guild_icon(guild=self.bot.guild, size=128)) + + if not silent: + for user in self.recipients: + if not message: + if user.id == closer.id: + message = self.bot.config["thread_self_close_response"] + else: + message = self.bot.config["thread_close_response"] + embed.description = message - if not silent and self.recipient is not None: - tasks.append(self.recipient.send(embed=embed)) + if user is not None: + tasks.append(user.send(embed=embed)) if delete_channel: tasks.append(self.channel.delete()) @@ -527,13 +573,11 @@ async def _restart_close_timer(self): # Set timeout seconds seconds = timeout.total_seconds() # seconds = 20 # Uncomment to debug with just 20 seconds - reset_time = datetime.utcnow() + timedelta(seconds=seconds) - human_time = human_timedelta(dt=reset_time) + reset_time = discord.utils.utcnow() + timedelta(seconds=seconds) + human_time = discord.utils.format_dt(reset_time) if self.bot.config.get("thread_auto_close_silently"): - return await self.close( - closer=self.bot.user, silent=True, after=int(seconds), auto_close=True - ) + return await self.close(closer=self.bot.user, silent=True, after=int(seconds), auto_close=True) # Grab message close_message = self.bot.formatter.format( @@ -549,9 +593,7 @@ async def _restart_close_timer(self): time_marker_regex, ) - await self.close( - closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True - ) + await self.close(closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True) async def find_linked_messages( self, @@ -559,13 +601,9 @@ async def find_linked_messages( either_direction: bool = False, message1: discord.Message = None, note: bool = True, - ) -> typing.Tuple[discord.Message, typing.Optional[discord.Message]]: + ) -> typing.Tuple[discord.Message, typing.List[typing.Optional[discord.Message]]]: if message1 is not None: - if ( - not message1.embeds - or not message1.embeds[0].author.url - or message1.author != self.bot.user - ): + if not message1.embeds or not message1.embeds[0].author.url or message1.author != self.bot.user: raise ValueError("Malformed thread message.") elif message_id is not None: @@ -602,10 +640,7 @@ async def find_linked_messages( and message1.embeds[0].color and ( message1.embeds[0].color.value == self.bot.mod_color - or ( - either_direction - and message1.embeds[0].color.value == self.bot.recipient_color - ) + or (either_direction and message1.embeds[0].color.value == self.bot.recipient_color) ) and message1.embeds[0].author.url.split("#")[-1].isdigit() and message1.author == self.bot.user @@ -619,23 +654,30 @@ async def find_linked_messages( except ValueError: raise ValueError("Malformed thread message.") - async for msg in self.recipient.history(): - if either_direction: - if msg.id == joint_id: - return message1, msg + messages = [message1] + for user in self.recipients: + async for msg in user.history(): + if either_direction: + if msg.id == joint_id: + return message1, msg - if not (msg.embeds and msg.embeds[0].author.url): - continue - try: - if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: - return message1, msg - except ValueError: - continue - raise ValueError("DM message not found. Plain messages are not supported.") + if not (msg.embeds and msg.embeds[0].author.url): + continue + try: + if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + messages.append(msg) + break + except ValueError: + continue + + if len(messages) > 1: + return messages + + raise ValueError("DM message not found.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: - message1, message2 = await self.find_linked_messages(message_id) + message1, *message2 = await self.find_linked_messages(message_id) except ValueError: logger.warning("Failed to edit message.", exc_info=True) raise @@ -644,12 +686,14 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1.description = message tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)] - if message2 is not None: - embed2 = message2.embeds[0] - embed2.description = message - tasks += [message2.edit(embed=embed2)] - elif message1.embeds[0].author.name.startswith("Persistent Note"): + if message1.embeds[0].author.name.startswith("Persistent Note"): tasks += [self.bot.api.edit_note(message1.id, message)] + else: + for m2 in message2: + if m2 is not None: + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks) @@ -657,68 +701,107 @@ async def delete_message( self, message: typing.Union[int, discord.Message] = None, note: bool = True ) -> None: if isinstance(message, discord.Message): - message1, message2 = await self.find_linked_messages(message1=message, note=note) + message1, *message2 = await self.find_linked_messages(message1=message, note=note) else: - message1, message2 = await self.find_linked_messages(message, note=note) + message1, *message2 = await self.find_linked_messages(message, note=note) tasks = [] + if not isinstance(message, discord.Message): tasks += [message1.delete()] - elif message2 is not None: - tasks += [message2.delete()] - elif message1.embeds[0].author.name.startswith("Persistent Note"): + + for m2 in message2: + if m2 is not None: + tasks += [m2.delete()] + + if message1.embeds[0].author.name.startswith("Persistent Note"): tasks += [self.bot.api.delete_note(message1.id)] + if tasks: await asyncio.gather(*tasks) - async def find_linked_message_from_dm(self, message, either_direction=False): - if either_direction and message.embeds and message.embeds[0].author.url: - compare_url = message.embeds[0].author.url - compare_id = compare_url.split("#")[-1] - else: - compare_url = None - compare_id = None + async def find_linked_message_from_dm( + self, message, either_direction=False, get_thread_channel=False + ) -> typing.List[discord.Message]: + joint_id = None + if either_direction: + joint_id = get_joint_id(message) + # could be None too, if that's the case we'll reassign this variable from + # thread message we fetch in the next step + linked_messages = [] if self.channel is not None: - async for linked_message in self.channel.history(): - if not linked_message.embeds: - continue - url = linked_message.embeds[0].author.url - if not url: + async for msg in self.channel.history(): + if not msg.embeds: continue - if url == compare_url: - return linked_message - msg_id = url.split("#")[-1] - if not msg_id.isdigit(): + msg_joint_id = get_joint_id(msg) + if msg_joint_id is None: continue - msg_id = int(msg_id) - if int(msg_id) == message.id: - return linked_message - if compare_id is not None and compare_id.isdigit(): - if int(msg_id) == int(compare_id): - return linked_message + if msg_joint_id == message.id: + linked_messages.append(msg) + break + if joint_id is not None and msg_joint_id == joint_id: + linked_messages.append(msg) + break + else: + raise ValueError("Thread channel message not found.") + else: raise ValueError("Thread channel message not found.") + if get_thread_channel: + # end early as we only want the main message from thread channel + return linked_messages + + if joint_id is None: + joint_id = get_joint_id(linked_messages[0]) + if joint_id is None: + # still None, supress this and return the thread message + logger.error("Malformed thread message.") + return linked_messages + + for user in self.recipients: + if user.dm_channel == message.channel: + continue + async for other_msg in user.history(): + if either_direction: + if other_msg.id == joint_id: + linked_messages.append(other_msg) + break + + if not other_msg.embeds: + continue + + other_joint_id = get_joint_id(other_msg) + if other_joint_id is not None and other_joint_id == joint_id: + linked_messages.append(other_msg) + break + else: + logger.error("Linked message from recipient %s not found.", user) + + return linked_messages + async def edit_dm_message(self, message: discord.Message, content: str) -> None: try: - linked_message = await self.find_linked_message_from_dm(message) + linked_messages = await self.find_linked_message_from_dm(message) except ValueError: logger.warning("Failed to edit message.", exc_info=True) raise - embed = linked_message.embeds[0] - embed.add_field(name="**Edited, former message:**", value=embed.description) - embed.description = content - await asyncio.gather( - self.bot.api.edit_message(message.id, content), linked_message.edit(embed=embed) - ) + + for msg in linked_messages: + embed = msg.embeds[0] + if isinstance(msg.channel, discord.TextChannel): + # just for thread channel, we put the old message in embed field + embed.add_field(name="**Edited, former message:**", value=embed.description) + embed.description = content + await asyncio.gather(self.bot.api.edit_message(message.id, content), msg.edit(embed=embed)) 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")) + ) -> discord.Message: + if not message.content and not message.attachments and not message.stickers: + raise MissingRequiredArgument(DummyParam("msg")) msg = await self.send( message, @@ -729,18 +812,17 @@ async def note( ) self.bot.loop.create_task( - self.bot.api.append_log( - message, message_id=msg.id, channel_id=self.channel.id, type_="system" - ) + self.bot.api.append_log(message, message_id=msg.id, channel_id=self.channel.id, type_="system") ) return msg 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")) + ) -> typing.Tuple[typing.List[discord.Message], discord.Message]: + """Returns List[user_dm_msg] and thread_channel_msg""" + if not message.content and not message.attachments and not message.stickers: + raise MissingRequiredArgument(DummyParam("msg")) if not any(g.get_member(self.id) for g in self.bot.guilds): return await message.channel.send( embed=discord.Embed( @@ -750,18 +832,25 @@ async def reply( ) ) + user_msg_tasks = [] tasks = [] - try: - user_msg = await self.send( - message, - destination=self.recipient, - from_mod=True, - anonymous=anonymous, - plain=plain, + for user in self.recipients: + user_msg_tasks.append( + self.send( + message, + destination=user, + from_mod=True, + anonymous=anonymous, + plain=plain, + ) ) + + try: + user_msg = await asyncio.gather(*user_msg_tasks) except Exception as e: logger.error("Message delivery failed:", exc_info=True) + user_msg = None if isinstance(e, discord.Forbidden): description = ( "Your message could not be delivered as " @@ -775,9 +864,10 @@ async def reply( "to an unknown error. Check `?debug` for " "more information" ) - tasks.append( - message.channel.send( - embed=discord.Embed(color=self.bot.error_color, description=description,) + msg = await message.channel.send( + embed=discord.Embed( + color=self.bot.error_color, + description=description, ) ) else: @@ -824,10 +914,8 @@ async def send( persistent_note: bool = False, thread_creation: bool = False, ) -> None: - - self.bot.loop.create_task( - self._restart_close_timer() - ) # Start or restart thread auto close + if not note and from_mod: + self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close if self.close_task is not None: # cancel closing if a thread message is sent. @@ -850,6 +938,11 @@ async def send( destination = destination or self.channel author = message.author + member = self.bot.guild.get_member(author.id) + if member: + avatar_url = member.display_avatar.url + else: + avatar_url = author.display_avatar.url embed = discord.Embed(description=message.content) if self.bot.config["show_timestamp"]: @@ -862,13 +955,13 @@ async def send( # Anonymously sending to the user. tag = self.bot.config["mod_tag"] if tag is None: - tag = str(author.top_role) + tag = str(get_top_role(author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: name = tag avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: - avatar_url = self.bot.guild.icon_url + avatar_url = self.bot.get_guild_icon(guild=self.bot.guild, size=128) embed.set_author( name=name, icon_url=avatar_url, @@ -877,7 +970,7 @@ async def send( else: # Normal message name = str(author) - avatar_url = author.avatar_url + avatar_url = avatar_url embed.set_author( name=name, icon_url=avatar_url, @@ -912,14 +1005,57 @@ async def send( if is_image_url(url, convert_size=False) ] images.extend(image_urls) - images.extend( - ( - str(i.image_url) if isinstance(i.image_url, discord.Asset) else i.image_url, - f"{i.name} Sticker", - True, - ) - for i in message.stickers - ) + + def lottie_to_png(data): + importer = l_importers.get("lottie") + exporter = l_exporters.get("png") + with io.BytesIO() as stream: + stream.write(data) + stream.seek(0) + an = importer.process(stream) + + with io.BytesIO() as stream: + exporter.process(an, stream) + stream.seek(0) + return stream.read() + + for i in message.stickers: + if i.format in ( + discord.StickerFormatType.png, + discord.StickerFormatType.apng, + discord.StickerFormatType.gif, + ): + images.append( + (f"https://media.discordapp.net/stickers/{i.id}.{i.format.file_extension}", i.name, True) + ) + elif i.format == discord.StickerFormatType.lottie: + # save the json lottie representation + try: + async with self.bot.session.get(i.url) as resp: + data = await resp.read() + + # convert to a png + img_data = await self.bot.loop.run_in_executor( + None, functools.partial(lottie_to_png, data) + ) + b64_data = base64.b64encode(img_data).decode() + + # upload to imgur + async with self.bot.session.post( + "https://api.imgur.com/3/image", + headers={"Authorization": "Client-ID 50e96145ac5e085"}, + data={"image": b64_data}, + ) as resp: + result = await resp.json() + url = result["data"]["link"] + + except Exception: + traceback.print_exc() + images.append((None, i.name, True)) + else: + images.append((url, i.name, True)) + else: + images.append((None, i.name, True)) embedded_image = False @@ -937,10 +1073,10 @@ async def send( if filename: if is_sticker: if url is None: - description = "Unable to retrieve sticker image" + description = f"{filename}: Unable to retrieve sticker image" else: - description = "\u200b" - embed.add_field(name=filename, value=description) + description = f"[{filename}]({url})" + embed.add_field(name="Sticker", value=description) else: embed.add_field(name="Image", value=f"[{filename}]({url})") embedded_image = True @@ -967,9 +1103,7 @@ async def send( file_upload_count = 1 for url, filename, _ in attachments: - embed.add_field( - name=f"File upload ({file_upload_count})", value=f"[{filename}]({url})" - ) + embed.add_field(name=f"File upload ({file_upload_count})", value=f"[{filename}]({url})") file_upload_count += 1 if from_mod: @@ -981,7 +1115,7 @@ async def send( elif not anonymous: mod_tag = self.bot.config["mod_tag"] if mod_tag is None: - mod_tag = str(message.author.top_role) + mod_tag = str(get_top_role(message.author, self.bot.config["use_hoisted_top_role"])) embed.set_footer(text=mod_tag) # Normal messages else: embed.set_footer(text=self.bot.config["anon_tag"]) @@ -1007,32 +1141,32 @@ async def send( logger.info("Sending a message to %s when DM disabled is set.", self.recipient) try: - await destination.trigger_typing() + await destination.typing() except discord.NotFound: logger.warning("Channel not found.") raise if not from_mod and not note: - mentions = self.get_notifications() + mentions = await self.get_notifications() else: mentions = None if plain: if from_mod and not isinstance(destination, discord.TextChannel): # Plain to user + with warnings.catch_warnings(): + # Catch coroutines not awaited warning + warnings.simplefilter("ignore") + additional_images = [] + if embed.footer.text: - plain_message = f"**({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)) + for i in message.attachments: + files.append(await i.to_file()) msg = await destination.send(plain_message, files=files) else: @@ -1050,7 +1184,7 @@ async def send( return msg - def get_notifications(self) -> str: + async def get_notifications(self) -> str: key = str(self.id) mentions = [] @@ -1061,11 +1195,75 @@ def get_notifications(self) -> str: self.bot.config["notification_squad"].pop(key) self.bot.loop.create_task(self.bot.config.update()) - return " ".join(mentions) + return " ".join(set(mentions)) + + async def set_title(self, title: str) -> None: + topic = f"Title: {title}\n" - 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}") + topic += f"User ID: {user_id}" + + if self._other_recipients: + ids = ",".join(str(i.id) for i in self._other_recipients) + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + + async def _update_users_genesis(self): + genesis_message = await self.get_genesis_message() + embed = genesis_message.embeds[0] + value = " ".join(x.mention for x in self._other_recipients) + index = None + for n, field in enumerate(embed.fields): + if field.name == "Other Recipients": + index = n + break + + if index is None and value: + embed.add_field(name="Other Recipients", value=value, inline=False) + else: + if value: + embed.set_field_at(index, name="Other Recipients", value=value, inline=False) + else: + embed.remove_field(index) + + await genesis_message.edit(embed=embed) + + async def add_users(self, users: typing.List[typing.Union[discord.Member, discord.User]]) -> None: + topic = "" + title, _, _ = parse_channel_topic(self.channel.topic) + if title is not None: + topic += f"Title: {title}\n" + + topic += f"User ID: {self._id}" + + self._other_recipients += users + self._other_recipients = list(set(self._other_recipients)) + + ids = ",".join(str(i.id) for i in self._other_recipients) + + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + await self._update_users_genesis() + + async def remove_users(self, users: typing.List[typing.Union[discord.Member, discord.User]]) -> None: + topic = "" + title, user_id, _ = parse_channel_topic(self.channel.topic) + if title is not None: + topic += f"Title: {title}\n" + + topic += f"User ID: {user_id}" + + for u in users: + self._other_recipients.remove(u) + + if self._other_recipients: + ids = ",".join(str(i.id) for i in self._other_recipients) + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + await self._update_users_genesis() class ThreadManager: @@ -1096,7 +1294,7 @@ async def find( recipient_id: int = None, ) -> typing.Optional[Thread]: """Finds a thread from cache or from discord channel topics.""" - if recipient is None and channel is not None: + if recipient is None and channel is not None and isinstance(channel, discord.TextChannel): thread = await self._find_from_channel(channel) if thread is None: user_id, thread = next( @@ -1115,27 +1313,40 @@ async def find( try: await thread.wait_until_ready() except asyncio.CancelledError: - logger.warning("Thread for %s cancelled, abort creating", recipient) + logger.warning("Thread for %s cancelled.", 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) - ) + if not thread.cancelled and ( + not thread.channel or not self.bot.get_channel(thread.channel.id) + ): + logger.warning("Found existing thread for %s but the channel is invalid.", recipient_id) + await thread.close(closer=self.bot.user, silent=True, delete_channel=False) thread = None else: - channel = discord.utils.get( - self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}" + + def check(topic): + _, user_id, other_ids = parse_channel_topic(topic) + return recipient_id == user_id or recipient_id in other_ids + + channel = discord.utils.find( + lambda x: (check(x.topic)) if x.topic else False, + self.bot.modmail_guild.text_channels, ) + if channel: - thread = Thread(self, recipient or recipient_id, channel) + thread = await Thread.from_channel(self, channel) if thread.recipient: - # only save if data is valid - self.cache[recipient_id] = thread + # only save if data is valid. + # also the recipient_id here could belong to other recipient, + # it would be wrong if we set it as the dict key, + # so we use the thread id instead + self.cache[thread.id] = thread thread.ready = True + + if thread and recipient_id not in [x.id for x in thread.recipients]: + self.cache.pop(recipient_id) + thread = None + return thread async def _find_from_channel(self, channel): @@ -1145,10 +1356,11 @@ async def _find_from_channel(self, channel): searching channel history for genesis embed and extracts user_id from that. """ - user_id = -1 - if channel.topic: - user_id = match_user_id(channel.topic) + if not channel.topic: + return None + + _, user_id, other_ids = parse_channel_topic(channel.topic) if user_id == -1: return None @@ -1157,14 +1369,22 @@ async def _find_from_channel(self, channel): return self.cache[user_id] try: - recipient = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id) + recipient = await self.bot.get_or_fetch_user(user_id) except discord.NotFound: recipient = None + other_recipients = [] + for uid in other_ids: + try: + other_recipient = await self.bot.get_or_fetch_user(uid) + except discord.NotFound: + continue + other_recipients.append(other_recipient) + if recipient is None: - thread = Thread(self, user_id, channel) + thread = Thread(self, user_id, channel, other_recipients) else: - self.cache[user_id] = thread = Thread(self, recipient, channel) + self.cache[user_id] = thread = Thread(self, recipient, channel, other_recipients) thread.ready = True return thread @@ -1186,15 +1406,13 @@ async def create( try: await thread.wait_until_ready() except asyncio.CancelledError: - logger.warning("Thread for %s cancelled, abort creating", recipient) + logger.warning("Thread for %s cancelled, abort creating.", recipient) return thread 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 - ) + 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) ) @@ -1203,75 +1421,49 @@ async def create( self.cache[recipient.id] = thread - # Schedule thread setup for later - cat = self.bot.main_category - if category is None and len(cat.channels) >= 49: - fallback_id = self.bot.config["fallback_category_id"] - if fallback_id: - fallback = discord.utils.get(cat.guild.categories, id=int(fallback_id)) - if fallback and len(fallback.channels) < 49: - category = fallback - - if not category: - category = await cat.clone(name="Fallback Modmail") - self.bot.config.set("fallback_category_id", str(category.id)) - await self.bot.config.update() - if (message or not manual_trigger) and self.bot.config["confirm_thread_creation"]: if not manual_trigger: destination = recipient else: destination = message.channel + view = ConfirmThreadCreationView() + view.add_item(AcceptButton(self.bot.config["confirm_thread_creation_accept"])) + view.add_item(DenyButton(self.bot.config["confirm_thread_creation_deny"])) confirm = await destination.send( embed=discord.Embed( title=self.bot.config["confirm_thread_creation_title"], description=self.bot.config["confirm_thread_response"], color=self.bot.main_color, - ) + ), + view=view, ) - 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 == recipient.id - and r.message.id == confirm.id - and r.message.channel.id == confirm.channel.id - and str(r.emoji) in (accept_emoji, deny_emoji), - timeout=20, + await view.wait() + if view.value is None: + thread.cancelled = True + self.bot.loop.create_task( + destination.send( + embed=discord.Embed( + title=self.bot.config["thread_cancelled"], + description="Timed out", + color=self.bot.error_color, + ) + ) ) - except asyncio.TimeoutError: + await confirm.edit(view=None) + if view.value is False: 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 destination.send( - embed=discord.Embed( - title="Cancelled", description="Timed out", color=self.bot.error_color + self.bot.loop.create_task( + destination.send( + embed=discord.Embed( + title=self.bot.config["thread_cancelled"], color=self.bot.error_color + ) ) ) + if thread.cancelled: del self.cache[recipient.id] return thread - else: - if str(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 destination.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) - ) + 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/time.py b/core/time.py index 331e26349f..c56c7264e2 100644 --- a/core/time.py +++ b/core/time.py @@ -3,219 +3,358 @@ Source: https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py """ -import re -from datetime import datetime - -from discord.ext.commands import BadArgument, Converter +from __future__ import annotations +import datetime +import discord +from typing import TYPE_CHECKING, Any, Optional, Union import parsedatetime as pdt from dateutil.relativedelta import relativedelta +from .utils import human_join +from discord.ext import commands +from discord import app_commands +import re + +# Monkey patch mins and secs into the units +units = pdt.pdtLocales["en_US"].units +units["minutes"].append("mins") +units["seconds"].append("secs") -from core.models import getLogger +if TYPE_CHECKING: + from discord.ext.commands import Context + from typing_extensions import Self -logger = getLogger(__name__) + +class plural: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" + + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, sep, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" class ShortTime: compiled = re.compile( - r""" - (?:(?P[0-9])(?:years?|y))? # e.g. 2y - (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 9mo - (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w - (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d - (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h - (?:(?P[0-9]{1,5})(?:min(?:ute)?s?|m))? # e.g. 10m - (?:(?P[0-9]{1,5})(?:sec(?:ond)?s?|s))? # e.g. 15s - """, + """ + (?:(?P[0-9])(?:years?|y))? # e.g. 2y + (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months + (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w + (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d + (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h + (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m + (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s + """, re.VERBOSE, ) - def __init__(self, argument): + discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") + + dt: datetime.datetime + + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): match = self.compiled.fullmatch(argument) if match is None or not match.group(0): - raise BadArgument("Invalid time provided.") - - data = {k: int(v) for k, v in match.groupdict(default="0").items()} - now = datetime.utcnow() + match = self.discord_fmt.fullmatch(argument) + if match is not None: + self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) + return + else: + raise commands.BadArgument("invalid time provided") + + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + now = now or datetime.datetime.now(datetime.timezone.utc) self.dt = now + relativedelta(**data) - -# Monkey patch mins and secs into the units -units = pdt.pdtLocales["en_US"].units -units["minutes"].append("mins") -units["seconds"].append("secs") + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) class HumanTime: calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) - def __init__(self, argument): - now = datetime.utcnow() + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + now = now or datetime.datetime.utcnow() dt, status = self.calendar.parseDT(argument, sourceTime=now) if not status.hasDateOrTime: - raise BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') if not status.hasTime: # replace it with the current time - dt = dt.replace( - hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond - ) + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) - self.dt = dt - self._past = dt < now + self.dt: datetime.datetime = dt + self._past: bool = dt < now + + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) class Time(HumanTime): - def __init__(self, argument): + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): try: - short_time = ShortTime(argument) + o = ShortTime(argument, now=now) except Exception: super().__init__(argument) else: - self.dt = short_time.dt + self.dt = o.dt self._past = False class FutureTime(Time): - def __init__(self, argument): - super().__init__(argument) + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + super().__init__(argument, now=now) if self._past: - raise BadArgument("The time is in the past.") + raise commands.BadArgument("this time is in the past") -class UserFriendlyTimeSync(Converter): - """That way quotes aren't absolutely necessary.""" +class BadTimeTransform(app_commands.AppCommandError): + pass - def __init__(self): - self.raw: str = None - self.dt: datetime = None - self.arg = None - self.now: datetime = None - def check_constraints(self, now, remaining): - if self.dt < now: - raise BadArgument("This time is in the past.") +class TimeTransformer(app_commands.Transformer): + async def transform(self, interaction, value: str) -> datetime.datetime: + now = interaction.created_at + try: + short = ShortTime(value, now=now) + except commands.BadArgument: + try: + human = FutureTime(value, now=now) + except commands.BadArgument as e: + raise BadTimeTransform(str(e)) from None + else: + return human.dt + else: + return short.dt - self.arg = remaining - return self - def convert(self, ctx, argument): - self.raw = argument - remaining = "" - try: - calendar = HumanTime.calendar - regex = ShortTime.compiled - self.dt = self.now = datetime.utcnow() +# CHANGE: Added now +class FriendlyTimeResult: + dt: datetime.datetime + now: datetime.datetime + arg: str - match = regex.match(argument) - if match is not None and match.group(0): - data = {k: int(v) for k, v in match.groupdict(default="0").items()} - remaining = argument[match.end() :].strip() - self.dt = self.now + relativedelta(**data) - return self.check_constraints(self.now, remaining) - - # apparently nlp does not like "from now" - # it likes "from x" in other cases though - # so let me handle the 'now' case - if argument.endswith(" from now"): - argument = argument[:-9].strip() - # handles "in xxx hours" - if argument.startswith("in "): - argument = argument[3:].strip() - - elements = calendar.nlp(argument, sourceTime=self.now) - if elements is None or not elements: - return self.check_constraints(self.now, argument) - - # handle the following cases: - # "date time" foo - # date time foo - # foo date time - - # first the first two cases: - dt, status, begin, end, _ = elements[0] - - if not status.hasDateOrTime: - return self.check_constraints(self.now, argument) - - if begin not in (0, 1) and end != len(argument): - raise BadArgument( - "Time is either in an inappropriate location, which must " - "be either at the end or beginning of your input, or I " - "just flat out did not understand what you meant. Sorry." - ) + __slots__ = ("dt", "arg", "now") - if not status.hasTime: - # replace it with the current time - dt = dt.replace( - hour=self.now.hour, - minute=self.now.minute, - second=self.now.second, - microsecond=self.now.microsecond, - ) + def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): + self.dt = dt + self.now = now - # if midnight is provided, just default to next day - if status.accuracy == pdt.pdtContext.ACU_HALFDAY: - dt = dt.replace(day=self.now.day + 1) + if now is None: + self.now = dt + else: + self.now = now - self.dt = dt + self.arg = "" - if begin in (0, 1): - if begin == 1: - # check if it's quoted: - if argument[0] != '"': - raise BadArgument("Expected quote before time input...") + async def ensure_constraints( + self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str + ) -> None: + if self.dt < now: + raise commands.BadArgument("This time is in the past.") - if not (end < len(argument) and argument[end] == '"'): - raise BadArgument("If the time is quoted, you must unquote it.") + # CHANGE + # if not remaining: + # if uft.default is None: + # raise commands.BadArgument("Missing argument after the time.") + # remaining = uft.default - remaining = argument[end + 1 :].lstrip(" ,.!") - else: - remaining = argument[end:].lstrip(" ,.!") - elif len(argument) == end: - remaining = argument[:begin].strip() + if uft.converter is not None: + self.arg = await uft.converter.convert(ctx, remaining) + else: + self.arg = remaining - return self.check_constraints(self.now, remaining) - except Exception: - logger.exception("Something went wrong while parsing the time.") - raise +class UserFriendlyTime(commands.Converter): + """That way quotes aren't absolutely necessary.""" + + def __init__( + self, + converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, + *, + default: Any = None, + ): + if isinstance(converter, type) and issubclass(converter, commands.Converter): + converter = converter() + + if converter is not None and not isinstance(converter, commands.Converter): + raise TypeError("commands.Converter subclass necessary.") + + self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing + self.default: Any = default + + async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTimeResult: + calendar = HumanTime.calendar + regex = ShortTime.compiled + if now is None: + now = ctx.message.created_at + + match = regex.match(argument) + if match is not None and match.group(0): + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + remaining = argument[match.end() :].strip() + result = FriendlyTimeResult(now + relativedelta(**data), now) + await result.ensure_constraints(ctx, self, now, remaining) + return result + + if match is None or not match.group(0): + match = ShortTime.discord_fmt.match(argument) + if match is not None: + result = FriendlyTimeResult( + datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) + ) + remaining = argument[match.end() :].strip() + await result.ensure_constraints(ctx, self, now, remaining) + return result + + # apparently nlp does not like "from now" + # it likes "from x" in other cases though so let me handle the 'now' case + if argument.endswith("from now"): + argument = argument[:-8].strip() + + if argument[0:2] == "me": + # starts with "me to", "me in", or "me at " + if argument[0:6] in ("me to ", "me in ", "me at "): + argument = argument[6:] + + elements = calendar.nlp(argument, sourceTime=now) + if elements is None or len(elements) == 0: + # CHANGE + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + + # handle the following cases: + # "date time" foo + # date time foo + # foo date time + + # first the first two cases: + dt, status, begin, end, dt_string = elements[0] + + if not status.hasDateOrTime: + raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + + if begin not in (0, 1) and end != len(argument): + raise commands.BadArgument( + "Time is either in an inappropriate location, which " + "must be either at the end or beginning of your input, " + "or I just flat out did not understand what you meant. Sorry." + ) + + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) -class UserFriendlyTime(UserFriendlyTimeSync): - async def convert(self, ctx, argument): - return super().convert(ctx, argument) + # if midnight is provided, just default to next day + if status.accuracy == pdt.pdtContext.ACU_HALFDAY: + dt = dt.replace(day=now.day + 1) + result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) + remaining = "" -def human_timedelta(dt, *, source=None): - now = source or datetime.utcnow() + if begin in (0, 1): + if begin == 1: + # check if it's quoted: + if argument[0] != '"': + raise commands.BadArgument("Expected quote before time input...") + + if not (end < len(argument) and argument[end] == '"'): + raise commands.BadArgument("If the time is quoted, you must unquote it.") + + remaining = argument[end + 1 :].lstrip(" ,.!") + else: + remaining = argument[end:].lstrip(" ,.!") + elif len(argument) == end: + remaining = argument[:begin].strip() + + await result.ensure_constraints(ctx, self, now, remaining) + return result + + +def human_timedelta( + dt: datetime.datetime, + *, + source: Optional[datetime.datetime] = None, + accuracy: Optional[int] = 3, + brief: bool = False, + suffix: bool = True, +) -> str: + now = source or datetime.datetime.now(datetime.timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + + # Microsecond free zone + now = now.replace(microsecond=0) + dt = dt.replace(microsecond=0) + + # This implementation uses relativedelta instead of the much more obvious + # divmod approach with seconds because the seconds approach is not entirely + # accurate once you go over 1 week in terms of accuracy since you have to + # hardcode a month as 30 or 31 days. + # A query like "11 months" can be interpreted as "!1 months and 6 days" if dt > now: delta = relativedelta(dt, now) - suffix = "" + output_suffix = "" else: delta = relativedelta(now, dt) - suffix = " ago" - - if delta.microseconds and delta.seconds: - delta = delta + relativedelta(seconds=+1) + output_suffix = " ago" if suffix else "" - attrs = ["years", "months", "days", "hours", "minutes", "seconds"] + attrs = [ + ("year", "y"), + ("month", "mo"), + ("day", "d"), + ("hour", "h"), + ("minute", "m"), + ("second", "s"), + ] output = [] - for attr in attrs: - elem = getattr(delta, attr) + for attr, brief_attr in attrs: + elem = getattr(delta, attr + "s") if not elem: continue - if elem > 1: - output.append(f"{elem} {attr}") + if attr == "day": + weeks = delta.weeks + if weeks: + elem -= weeks * 7 + if not brief: + output.append(format(plural(weeks), "week")) + else: + output.append(f"{weeks}w") + + if elem <= 0: + continue + + if brief: + output.append(f"{elem}{brief_attr}") else: - output.append(f"{elem} {attr[:-1]}") + output.append(format(plural(elem), attr)) - if not output: + if accuracy is not None: + output = output[:accuracy] + + if len(output) == 0: return "now" - if len(output) == 1: - return output[0] + suffix - if len(output) == 2: - return f"{output[0]} and {output[1]}{suffix}" - return f"{output[0]}, {output[1]} and {output[2]}{suffix}" + else: + if not brief: + return human_join(output, final="and") + output_suffix + else: + return " ".join(output) + output_suffix + + +def format_relative(dt: datetime.datetime) -> str: + return discord.utils.format_dt(dt, "R") diff --git a/core/utils.py b/core/utils.py index 2dfb319a8e..9f9f572f5a 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,8 +1,8 @@ import base64 import functools import re -import string import typing +from datetime import datetime, timezone from difflib import get_close_matches from distutils.util import strtobool as _stb # pylint: disable=import-error from itertools import takewhile, zip_longest @@ -11,6 +11,9 @@ import discord from discord.ext import commands +from core.models import getLogger + + __all__ = [ "strtobool", "User", @@ -21,19 +24,31 @@ "human_join", "days", "cleanup_code", + "parse_channel_topic", + "match_title", "match_user_id", + "match_other_recipients", + "create_thread_channel", "create_not_found_embed", "parse_alias", "normalize_alias", "format_description", "trigger_typing", "escape_code_block", - "format_channel_name", "tryint", - "match_title", + "get_top_role", + "get_joint_id", + "extract_block_timestamp", + "AcceptButton", + "DenyButton", + "ConfirmThreadCreationView", + "DummyParam", ] +logger = getLogger(__name__) + + def strtobool(val): if isinstance(val, bool): return val @@ -112,7 +127,11 @@ def format_preview(messages: typing.List[typing.Dict[str, typing.Any]]): continue author = message["author"] content = str(message["content"]).replace("\n", " ") - name = author["name"] + "#" + str(author["discriminator"]) + + name = author["name"] + discriminator = str(author["discriminator"]) + if discriminator != "0": + name += "#" + discriminator prefix = "[M]" if author["mod"] else "[R]" out += truncate(f"`{prefix} {name}:` {content}", max=75) + "\n" @@ -133,13 +152,17 @@ def is_image_url(url: str, **kwargs) -> str: bool Whether the URL is a valid image 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, - ) + try: + result = parse.urlparse(url) + if result.netloc == "gyazo.com" and result.scheme in ["http", "https"]: + # gyazo support + url = re.sub( + r"(https?://)((?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+)", + r"\1i.\2.png", + url, + ) + except ValueError: + pass return parse_image_url(url, **kwargs) @@ -169,10 +192,19 @@ def parse_image_url(url: str, *, convert_size=True) -> str: return "" -def human_join(strings): - if len(strings) <= 2: - return " or ".join(strings) - return ", ".join(strings[: len(strings) - 1]) + " or " + strings[-1] +def human_join(seq: typing.Sequence[str], delim: str = ", ", final: str = "or") -> str: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L21-L32""" + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" def days(day: typing.Union[str, int]) -> str: @@ -217,11 +249,52 @@ def cleanup_code(content: str) -> str: return content.strip("` \n") -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) +TOPIC_REGEX = re.compile( + r"(?:\bTitle:\s*(?P.*)\n)?" + r"\bUser ID:\s*(?P<user_id>\d{17,21})\b" + r"(?:\nOther Recipients:\s*(?P<other_ids>\d{17,21}(?:(?:\s*,\s*)\d{17,21})*)\b)?", + flags=re.IGNORECASE | re.DOTALL, +) +UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) -def match_title(text: str) -> int: +def parse_channel_topic(text: str) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: + """ + A helper to parse channel topics and respectivefully returns all the required values + at once. + + Parameters + ---------- + text : str + The text of channel topic. + + Returns + ------- + Tuple[Optional[str], int, List[int]] + A tuple of title, user ID, and other recipients IDs. + """ + title, user_id, other_ids = None, -1, [] + if isinstance(text, str): + match = TOPIC_REGEX.search(text) + else: + match = None + + if match is not None: + groupdict = match.groupdict() + title = groupdict["title"] + + # user ID string is the required one in regex, so if match is found + # the value of this won't be None + user_id = int(groupdict["user_id"]) + + oth_ids = groupdict["other_ids"] + if oth_ids: + other_ids = list(map(int, oth_ids.split(","))) + + return title, user_id, other_ids + + +def match_title(text: str) -> str: """ Matches a title in the format of "Title: XXXX" @@ -233,14 +306,12 @@ def match_title(text: str) -> int: Returns ------- Optional[str] - The title if found + The title if found. """ - match = TOPIC_TITLE_REGEX.search(text) - if match is not None: - return match.group(1) + return parse_channel_topic(text)[0] -def match_user_id(text: str) -> int: +def match_user_id(text: str, any_string: bool = False) -> int: """ Matches a user ID in the format of "User ID: 12345". @@ -248,16 +319,41 @@ def match_user_id(text: str) -> int: ---------- text : str The text of the user ID. + any_string: bool + Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. + Defaults to False. Returns ------- int The user ID if found. Otherwise, -1. """ - match = TOPIC_UID_REGEX.search(text) - if match is not None: - return int(match.group(1)) - return -1 + user_id = -1 + if any_string: + match = UID_REGEX.search(text) + if match is not None: + user_id = int(match.group(1)) + else: + user_id = parse_channel_topic(text)[1] + + return user_id + + +def match_other_recipients(text: str) -> typing.List[int]: + """ + Matches a title in the format of "Other Recipients: XXXX,XXXX" + + Parameters + ---------- + text : str + The text of the user ID. + + Returns + ------- + List[int] + The list of other recipients IDs. + """ + return parse_channel_topic(text)[2] def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: @@ -330,7 +426,7 @@ def format_description(i, names): def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - await ctx.trigger_typing() + await ctx.typing() return await func(self, ctx, *args, **kwargs) return wrapper @@ -340,36 +436,174 @@ def escape_code_block(text): return re.sub(r"```", "`\u200b``", text) -def format_channel_name(bot, author, exclude_channel=None, force_null=False): - """Sanitises a username for use with text channel names""" - guild = bot.modmail_guild +def tryint(x): + try: + return int(x) + except (ValueError, TypeError): + return x + + +def get_top_role(member: discord.Member, hoisted=True): + roles = sorted(member.roles, key=lambda r: r.position, reverse=True) + for role in roles: + if not hoisted: + return role + if role.hoist: + return role + - if force_null: - name = new_name = "null" +async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): + name = name or bot.format_channel_name(recipient) + errors_raised = errors_raised or [] + + try: + channel = await bot.modmail_guild.create_text_channel( + name=name, + category=category, + overwrites=overwrites, + topic=f"User ID: {recipient.id}", + reason="Creating a thread channel.", + ) + except discord.HTTPException as e: + if (e.text, (category, name)) in errors_raised: + # Just raise the error to prevent infinite recursion after retrying + raise + + errors_raised.append((e.text, (category, name))) + + if "Maximum number of channels in category reached" in e.text: + fallback = None + fallback_id = bot.config["fallback_category_id"] + if fallback_id: + fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) + if fallback and len(fallback.channels) >= 49: + fallback = None + + if not fallback: + fallback = await category.clone(name="Fallback Modmail") + await bot.config.set("fallback_category_id", str(fallback.id)) + await bot.config.update() + + return await create_thread_channel( + bot, recipient, fallback, overwrites, errors_raised=errors_raised + ) + + if "Contains words not allowed" in e.text: + # try again but null-discrim (name could be banned) + return await create_thread_channel( + bot, + recipient, + category, + overwrites, + name=bot.format_channel_name(recipient, force_null=True), + errors_raised=errors_raised, + ) + + raise + + return channel + + +def get_joint_id(message: discord.Message) -> typing.Optional[int]: + """ + Get the joint ID from `discord.Embed().author.url`. + Parameters + ----------- + message : discord.Message + The discord.Message object. + Returns + ------- + int + The joint ID if found. Otherwise, None. + """ + if message.embeds: + try: + url = getattr(message.embeds[0].author, "url", "") + if url: + return int(url.split("#")[-1]) + except ValueError: + pass + return None + + +def extract_block_timestamp(reason, id_): + # etc "blah blah blah... until <t:XX:f>." + now = discord.utils.utcnow() + end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) + attempts = [ + # backwards compat + re.search(r"until ([^`]+?)\.$", reason), + re.search(r"%([^%]+?)%", reason), + ] + after = None + if end_time is None: + for i in attempts: + if i is not None: + end_time = i + break + + if end_time is not None: + # found a deprecated version + try: + after = ( + datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise + logger.warning( + r"Deprecated time message for user %s, block and unblock again to update.", + id_, + ) else: - if bot.config["use_user_id_channel_name"]: - name = new_name = str(author.id) - else: - name = author.name.lower() - if force_null: - name = "null" + try: + after = ( + datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise - name = new_name = ( - "".join(l for l in name if l not in string.punctuation and l.isprintable()) - or "null" - ) + f"-{author.discriminator}" + return end_time, after - counter = 1 - existed = set(c.name for c in guild.text_channels if c != exclude_channel) - while new_name in existed: - new_name = f"{name}_{counter}" # multiple channels with same name - counter += 1 - return new_name +class AcceptButton(discord.ui.Button): + def __init__(self, emoji): + super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) + async def callback(self, interaction: discord.Interaction): + self.view.value = True + await interaction.response.edit_message(view=None) + self.view.stop() -def tryint(x): - try: - return int(x) - except (ValueError, TypeError): - return x + +class DenyButton(discord.ui.Button): + def __init__(self, emoji): + super().__init__(style=discord.ButtonStyle.gray, emoji=emoji) + + async def callback(self, interaction: discord.Interaction): + self.view.value = False + await interaction.response.edit_message(view=None) + self.view.stop() + + +class ConfirmThreadCreationView(discord.ui.View): + def __init__(self): + super().__init__(timeout=20) + self.value = None + + +class DummyParam: + """ + A dummy parameter that can be used for MissingRequiredArgument. + """ + + def __init__(self, name): + self.name = name + self.displayed_name = name diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..fcb0e1b32f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.7" +services: + bot: + image: ghcr.io/modmail-dev/modmail:master + restart: always + env_file: + - .env + environment: + - CONNECTION_URI=mongodb://mongo + depends_on: + - mongo + logviewer: + image: ghcr.io/modmail-dev/logviewer:master + restart: always + depends_on: + - mongo + environment: + - MONGO_URI=mongodb://mongo + ports: + - 80:8000 + mongo: + image: mongo + restart: always + volumes: + - mongodb:/data/db + +volumes: + mongodb: diff --git a/plugins/registry.json b/plugins/registry.json index 579c3e3eb0..4079001a50 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1,242 +1,137 @@ { - "profanity-filter": { - "repository": "kyb3r/modmail-plugins", + "advanced-menu": { + "repository": "sebkuip/mm-plugins", "branch": "master", - "description": "Checks for profanity in a message using machine learning and deletes it.", - "bot_version": "2.20.1", - "title": "Profanity Filter Plugin", - "icon_url": "https://i.imgur.com/951szZ3.jpg", - "thumbnail_url": "https://i.imgur.com/951szZ3.jpg" - }, - "dragory-migrate": { - "repository": "kyb3r/modmail-plugins", - "branch": "master", - "description": "Migrate your logs from Dragory's modmail bot to this one with a simple command. Added at the request of users.", - "bot_version": "2.20.1", - "title": "Dragory Logs Migration", - "icon_url": "https://cdn1.iconfinder.com/data/icons/web-hosting-2-4/52/200-512.png", - "thumbnail_url": "https://cdn1.iconfinder.com/data/icons/web-hosting-2-4/52/200-512.png" - }, - "music": { - "repository": "Taaku18/modmail-plugins", - "branch": "master", - "description": "Play wonderfull jams through your modmail!", - "bot_version": "2.20.1", - "title": "music", - "icon_url": "https://i.imgur.com/JmJPX5W.gif", - "thumbnail_url": "https://i.imgur.com/jrYL7F8.gif" - }, - "media-only": { - "repository": "lorenzo132/modmail-plugins", - "branch": "master", - "description": "Make a channel mediaonly, only the following mediatypes will be accepted `.png` / `.gif` / `.jpg` / `.mp4`/ `.jpeg`", - "bot_version": "2.20.1", - "title": "Media-only", - "icon_url": "https://i.imgur.com/ussAoIi.png", - "thumbnail_url": "https://i.imgur.com/ussAoIi.png" - }, - "anti-steal-close": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Don't let anyone steal ya close.", - "title": "Anti Steal Close", - "icon_url": "https://i.imgur.com/LovxyV3.png", - "thumbnail_url": "https://i.imgur.com/LovxyV3.png" + "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", + "bot_version": "v4.0.0", + "title": "Advanced menu", + "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" }, "announcement": { - "repository": "officialpiyush/modmail-plugins", + "repository": "Jerrie-Aries/modmail-plugins", "branch": "master", - "description": "Easily make announcements in your server!", - "bot_version": "2.20.1", - "title": "Announcement Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" + "description": "Create and post announcements. Supports both plain and embed. Also customisable using buttons and dropdown menus.", + "bot_version": "4.0.0", + "title": "Announcement", + "icon_url": "https://github.com/Jerrie-Aries.png", + "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/announcement.jpg" }, - "dm-on-join": { - "repository": "officialpiyush/modmail-plugins", + "autoreact": { + "repository": "martinbndr/kyb3r-modmail-plugins", "branch": "master", - "description": "DM New Users when they join", - "bot_version": "2.20.1", - "title": "DM-on-join Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" + "description": "Automatically reacts with emojis in certain channels.", + "bot_version": "4.0.0", + "title": "Autoreact", + "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/autoreact/logo.png" }, "giveaway": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Host giveaways on your server", - "bot_version": "2.20.1", - "title": "\uD83C\uDF89 Giveaway Plugin \uD83C\uDF89", - "icon_url": "https://i.imgur.com/qk85xdi.png", - "thumbnail_url": "https://i.imgur.com/gUHB91v.png" - }, - "hastebin": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Easily Upload Text To hastebin!", - "bot_version": "2.20.1", - "title": "Hastebin Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "leave-server": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Don't want your bot in a server? Did someone invite it without your permission? If so, this plugin is useful for you!", - "bot_version": "2.20.1", - "title": "Leave-server Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "welcomer": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", - "bot_version": "2.20.1", - "title": "New member messages plugin", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" - }, - "tags": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Tag Management For Your Server", - "bot_version": "2.20.1", - "title": "Tags Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "backupdb": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Backup you're current Modmail DB with a single command!\n\n**Requires `BACKUP_MONGO_URI` in either config.json or environment variables**", - "bot_version": "2.20.1", - "title": "Backup Database (backupdb)", - "icon_url": "https://images.ionadev.ml/b/nKAlOC4.jpg", - "thumbnail_url": "https://images.ionadev.ml/b/nKAlOC4.jpg" - }, - "colors": { - "repository": "Taaku18/modmail-plugins", - "branch": "master", - "description": "Conversions between hex, RGB, and color names.", - "bot_version": "2.20.1", - "title": "Colors!!", - "icon_url": "https://cdn1.iconfinder.com/data/icons/weather-19/32/rainbow-512.png", - "thumbnail_url": "https://i.imgur.com/fSxnc9W.jpg" - }, - "fun": { - "repository": "TheKinG2149/modmail-plugins", - "branch": "master", - "description": "Some fun commands like 8ball, dadjokes", - "bot_version": "2.24.1", - "title": "Fun", - "icon_url": "https://cdn.discordapp.com/attachments/584692239893135362/591588754142265354/43880032.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/584692239893135362/591588754142265354/43880032.png" - }, - "stats": { - "repository": "KarateWumpus/modmail-plugins", - "branch": "master", - "description": "Get useful stats directly in an embed about either the Modmail bot, a user or the server.", - "bot_version": "2.24.1", - "title": "Get Stats", - "icon_url": "https://image.flaticon.com/icons/png/512/117/117761.png", - "thumbnail_url": "http://www.pngmart.com/files/7/Statistics-PNG-Clipart.png" - }, - "moderation": { - "repository": "Vincysuper07/modmail-plugins", - "branch": "main", - "description": "Moderate your server with Modmail, bring the Mod to Modmail!", - "bot_version": "3.6.2", - "title": "Moderate your server", - "icon_url": "https://cdn.discordapp.com/attachments/759829573654544454/773535811143598110/ad2e4d6e7b90ca6005a5038e22b099cc.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/759829573654544454/773535811143598110/ad2e4d6e7b90ca6005a5038e22b099cc.png" - }, - "serverstats": { - "repository": "dazvise/modmail-plugins", + "repository": "Jerrie-Aries/modmail-plugins", "branch": "master", - "description": "Voice channels containing interesting and accurate statistics about your server such as Member Count.", - "bot_version": "2.20.1", - "title": "Server Stats", - "icon_url": "https://i.gyazo.com/fadb70740e83f2448b23ffe192a1f32d.png", - "thumbnail_url": "https://i.gyazo.com/fadb70740e83f2448b23ffe192a1f32d.png" + "description": "Host giveaways on your server with this plugin.", + "bot_version": "4.0.0", + "title": "Giveaway", + "icon_url": "https://github.com/Jerrie-Aries.png", + "thumbnail_url": "https://raw.githubusercontent.com/Jerrie-Aries/modmail-plugins/master/.static/giveaway.jpg" }, "suggest": { "repository": "realcyguy/modmail-plugins", - "branch": "master", - "description": "Send suggestions to a selected server! It even has moderation...", - "bot_version": "3.4.1", + "branch": "v4", + "description": "Send suggestions to a selected server! It has accepting, denying, and moderation-ing.", + "bot_version": "4.0.0", "title": "Suggest stuff.", "icon_url": "https://i.imgur.com/qtE7AH8.png", "thumbnail_url": "https://i.imgur.com/qtE7AH8.png" }, - "githubstats": { - "repository": "mischievousdev/modmail-plugins", - "branch": "master", - "description": "Github statistics in discord", - "bot_version": "2.20.1", - "title": "Github Stats", - "icon_url": "https://raw.githubusercontent.com/mischievousdev/modmail-plugins/master/download%20(9).jpeg", - "thumbnail_url": "https://raw.githubusercontent.com/mischievousdev/modmail-plugins/master/download%20(9).jpeg" - }, - "slowmode": { - "repository": "teen1/modmail-plugins", + "reminder": { + "repository": "martinbndr/kyb3r-modmail-plugins", "branch": "master", - "description": "Configure slow mode for your channels with Modmail!", - "bot_version": "2.20.1", - "title": "Slow Mode", - "icon_url": "https://cdn.discordapp.com/attachments/717029057635549274/717033838966210601/Slow_mode_-_icon.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/717029057635549274/717029110907666482/Slow_mode_plugin_-_thumbnail.png" + "description": "Let´s you create reminders.", + "bot_version": "4.0.0", + "title": "Reminder", + "icon_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png", + "thumbnail_url": "https://raw.githubusercontent.com/martinbndr/kyb3r-modmail-plugins/master/reminder/logo.png" }, - "publish": { - "repository": "codeinteger6/modmail-plugins", - "branch": "master", - "description": "Publish messages sent in announcement channels.", - "bot_version": "3.5.0", - "title": "Publish", - "icon_url": "https://user-images.githubusercontent.com/44692189/89184422-96de3600-d5ba-11ea-98ea-d096aa385ad5.png", - "thumbnail_url": "https://user-images.githubusercontent.com/44692189/89184422-96de3600-d5ba-11ea-98ea-d096aa385ad5.png" - }, - "translate": { - "repository": "WebKide/modmail-plugins", - "branch": "master", - "description": "(∩`-´)⊃━☆゚.*・。゚ translate text from one language to another (defaults to English)\n\nGet full list of available languages at: https://github.com/WebKide/modmail-plugins/blob/master/translate/langs.json\n\nThis command conflicts with Translator-plugin", - "bot_version": "3.5.0", - "title": "Translate", - "icon_url": "https://i.imgur.com/yeHFKgl.png", - "thumbnail_url": "https://i.imgur.com/yeHFKgl.png" + "welcomer": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", + "bot_version": "4.0.0", + "title": "New member messages plugin", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" }, "countdowns": { "repository": "fourjr/modmail-plugins", - "branch": "master", + "branch": "v4", "description": "Setup a countdown voice channel in your server!", - "bot_version": "3.6.2", + "bot_version": "4.0.0", "title": "Countdowns", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" - }, - "action": { - "repository": "6days9weeks/modmail-plugins", - "branch": "master", - "description": "Have fun with others by hugging them or giving them pats~!!", - "title": "Action", - "icon_url": "https://media.discordapp.net/attachments/720733784970100776/820933433579798528/689105042212388965.png", - "thumbnail_url": "https://data.whicdn.com/images/58526601/original.gif" - }, - "menu": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Adds reaction-based menus into thread creates. Check out `?configmenu`", - "title": "Menus", - "bot_version": "3.9.0", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" }, "claim": { "repository": "fourjr/modmail-plugins", - "branch": "master", + "branch": "v4", "description": "Allows supporters to claim thread by sending ?claim in the thread channel", + "bot_version": "4.0.0", "title": "Claim Thread", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "emote-manager": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Allows managing server emotes via ?emoji", + "bot_version": "4.0.0", + "title": "Emote Manager", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "gen-log": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Outputs a text log of a thread in a specified channel", + "bot_version": "4.0.0", + "title": "Log Generator", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "media-logger": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Re-posts detected media from all visible channels into a specified logging channel", + "bot_version": "4.0.0", + "title": "Media Logger", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "report": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Specify an emoji to react with on messages. Generates a 'report' in specified logging channel upon react.", + "bot_version": "4.0.0", + "title": "Report", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "top-supporters": { + "repository": "fourjr/modmail-plugins", + "branch": "v4", + "description": "Gathers and prints the top supporters of handling threads.", + "bot_version": "4.0.0", + "title": "Top Supporters", + "icon_url": "https://i.imgur.com/Mo60CdK.png", + "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + }, + "rename": { + "repository": "Nicklaus-s/modmail-plugins", + "branch": "master", + "description": "Set a thread channel name.", + "bot_version": "4.0.0", + "title": "Rename", + "icon_url": "https://i.imgur.com/A1auJ95.png", + "thumbnail_url": "https://i.imgur.com/A1auJ95.png" } } diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 4aa8248546..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,455 +0,0 @@ -[[package]] -category = "main" -description = "Async http client/server framework (asyncio)" -name = "aiohttp" -optional = false -python-versions = ">=3.5.3" -version = "3.6.2" - -[package.dependencies] -async-timeout = ">=3.0,<4.0" -attrs = ">=17.3.0" -chardet = ">=2.0,<4.0" -multidict = ">=4.5,<5.0" -yarl = ">=1.0,<2.0" - -[[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" -optional = false -python-versions = "*" -version = "1.4.4" - -[[package]] -category = "dev" -description = "An abstract syntax tree for Python with inference support." -name = "astroid" -optional = false -python-versions = ">=3.5" -version = "2.4.1" - -[package.dependencies] -lazy-object-proxy = ">=1.4.0,<1.5.0" -six = ">=1.12,<2.0" -wrapt = ">=1.11,<2.0" - -[package.dependencies.typed-ast] -python = "<3.8" -version = ">=1.4.0,<1.5" - -[[package]] -category = "main" -description = "Timeout context manager for asyncio programs" -name = "async-timeout" -optional = false -python-versions = ">=3.5.3" -version = "3.0.1" - -[[package]] -category = "main" -description = "Classes Without Boilerplate" -name = "attrs" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" - -[[package]] -category = "dev" -description = "Security oriented static analyser for python code." -name = "bandit" -optional = false -python-versions = "*" -version = "1.6.2" - -[package.dependencies] -GitPython = ">=1.0.1" -PyYAML = ">=3.13" -colorama = ">=0.3.9" -six = ">=1.10.0" -stevedore = ">=1.20.0" - -[[package]] -category = "dev" -description = "The uncompromising code formatter." -name = "black" -optional = false -python-versions = ">=3.6" -version = "19.10b0" - -[package.dependencies] -appdirs = "*" -attrs = ">=18.1.0" -click = ">=6.5" -pathspec = ">=0.6,<1" -regex = "*" -toml = ">=0.9.4" -typed-ast = ">=1.4.0" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = false -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "dev" -description = "Composable command line interface toolkit" -name = "click" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.2" - -[[package]] -category = "main" -description = "Cross-platform colored terminal text." -name = "colorama" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" - -[[package]] -category = "main" -description = "A Python wrapper for the Discord API" -name = "discord.py" -optional = false -python-versions = ">=3.5.3" -version = "1.3.3" - -[package.dependencies] -aiohttp = ">=3.6.0,<3.7.0" -websockets = ">=6.0,<7.0 || >7.0,<8.0 || >8.0,<8.0.1 || >8.0.1,<9.0" - -[[package]] -category = "main" -description = "DNS toolkit" -name = "dnspython" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.16.0" - -[[package]] -category = "main" -description = "Emoji for Python" -name = "emoji" -optional = false -python-versions = "*" -version = "0.5.4" - -[[package]] -category = "main" -description = "Backport of the concurrent.futures package from Python 3.2" -name = "futures" -optional = true -python-versions = "*" -version = "3.1.1" - -[[package]] -category = "dev" -description = "Git Object Database" -name = "gitdb" -optional = false -python-versions = ">=3.4" -version = "4.0.5" - -[package.dependencies] -smmap = ">=3.0.1,<4" - -[[package]] -category = "dev" -description = "Python Git Library" -name = "gitpython" -optional = false -python-versions = ">=3.4" -version = "3.1.3" - -[package.dependencies] -gitdb = ">=4.0.1,<5" - -[[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" - -[[package]] -category = "main" -description = "An ISO 8601 date/time/duration parser and formatter" -name = "isodate" -optional = false -python-versions = "*" -version = "0.6.0" - -[package.dependencies] -six = "*" - -[[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." -name = "isort" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "4.3.21" - -[[package]] -category = "dev" -description = "A fast and thorough lazy object proxy." -name = "lazy-object-proxy" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.3" - -[[package]] -category = "dev" -description = "McCabe checker, plugin for flake8" -name = "mccabe" -optional = false -python-versions = "*" -version = "0.6.1" - -[[package]] -category = "main" -description = "Non-blocking MongoDB driver for Tornado or asyncio" -name = "motor" -optional = true -python-versions = "*" -version = "2.1.0" - -[package.dependencies] -futures = "*" -pymongo = ">=3.10,<4" - -[[package]] -category = "main" -description = "multidict implementation" -name = "multidict" -optional = false -python-versions = ">=3.5" -version = "4.7.6" - -[[package]] -category = "main" -description = "Convert data to their natural (human-readable) format" -name = "natural" -optional = false -python-versions = "*" -version = "0.2.0" - -[package.dependencies] -six = "*" - -[[package]] -category = "main" -description = "Parse human-readable date/time text." -name = "parsedatetime" -optional = false -python-versions = "*" -version = "2.6" - -[[package]] -category = "dev" -description = "Utility library for gitignore style pattern matching of file paths." -name = "pathspec" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.8.0" - -[[package]] -category = "dev" -description = "Python Build Reasonableness" -name = "pbr" -optional = false -python-versions = "*" -version = "5.4.5" - -[[package]] -category = "dev" -description = "python code static checker" -name = "pylint" -optional = false -python-versions = ">=3.5.*" -version = "2.5.2" - -[package.dependencies] -astroid = ">=2.4.0,<=2.5" -colorama = "*" -isort = ">=4.2.5,<5" -mccabe = ">=0.6,<0.7" -toml = ">=0.7.1" - -[[package]] -category = "main" -description = "Python driver for MongoDB <http://www.mongodb.org>" -name = "pymongo" -optional = true -python-versions = "*" -version = "3.10.1" - -[[package]] -category = "main" -description = "Extensions to the standard Python datetime module" -name = "python-dateutil" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -version = "2.8.1" - -[package.dependencies] -six = ">=1.5" - -[[package]] -category = "main" -description = "Add .env support to your django/flask apps in development and deployments" -name = "python-dotenv" -optional = false -python-versions = "*" -version = "0.10.5" - -[[package]] -category = "dev" -description = "YAML parser and emitter for Python" -name = "pyyaml" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.3.1" - -[[package]] -category = "dev" -description = "Alternative regular expression module, to replace re." -name = "regex" -optional = false -python-versions = "*" -version = "2020.6.7" - -[[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" -name = "six" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" - -[[package]] -category = "dev" -description = "A pure Python implementation of a sliding window memory map manager" -name = "smmap" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.0.4" - -[[package]] -category = "dev" -description = "Manage dynamic plugins for Python applications" -name = "stevedore" -optional = false -python-versions = ">=3.6" -version = "2.0.0" - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - -[[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" -optional = false -python-versions = "*" -version = "0.10.1" - -[[package]] -category = "dev" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" -optional = false -python-versions = "*" -version = "1.4.1" - -[[package]] -category = "main" -description = "Fast implementation of asyncio event loop on top of libuv" -name = "uvloop" -optional = false -python-versions = "*" -version = "0.14.0" - -[[package]] -category = "main" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -name = "websockets" -optional = false -python-versions = ">=3.6.1" -version = "8.1" - -[[package]] -category = "dev" -description = "Module for decorators, wrappers and monkey patching." -name = "wrapt" -optional = false -python-versions = "*" -version = "1.12.1" - -[[package]] -category = "main" -description = "Yet another URL library" -name = "yarl" -optional = false -python-versions = ">=3.5" -version = "1.4.2" - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[extras] -mongodb = ["motor"] - -[metadata] -content-hash = "68d5c15e62c4bf5f65fd3b0a9a2586b15557f724c3de5b756534bacd52cbfe40" -python-versions = "^3.7" - -[metadata.hashes] -aiohttp = ["1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e", "259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326", "2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a", "32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654", "344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a", "460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4", "4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17", "50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec", "6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd", "65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48", "ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59", "b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"] -appdirs = ["7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", "a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"] -astroid = ["4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1", "d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"] -async-timeout = ["0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"] -attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] -bandit = ["336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", "41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"] -black = ["1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", "c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"] -chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] -click = ["d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"] -colorama = ["7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"] -"discord.py" = ["406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580", "ad00e34c72d2faa8db2157b651d05f3c415d7d05078e7e41dc9e8dc240051beb"] -dnspython = ["36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", "f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"] -emoji = ["60652d3a2dcee5b8af8acb097c31776fb6d808027aeb7221830f72cdafefc174"] -futures = ["3a44f286998ae64f0cc083682fcfec16c406134a81a589a5de445d7bb7c2751b", "51ecb45f0add83c806c68e4b06106f90db260585b25ef2abfcda0bd95c0132fd", "c4884a65654a7c45435063e14ae85280eb1f111d94e542396717ba9828c4337f"] -gitdb = ["91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"] -gitpython = ["e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a", "ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"] -idna = ["7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"] -isodate = ["2e364a3d5759479cdb2d37cce6b9376ea504db2ff90252a2e5b7cc89cc9ff2d8", "aa4d33c06640f5352aca96e4b81afd8ab3b47337cc12089822d6f322ac772c81"] -isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] -lazy-object-proxy = ["0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", "194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", "1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", "4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", "48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", "5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", "59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", "8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", "9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", "9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", "97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", "9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", "a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", "a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", "ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", "cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", "d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", "d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", "eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", "efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"] -mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] -motor = ["599719bc6dcddc3b9ea4e09659fb0073d5fadcc24735999b2902f48cef33f909", "756c587985d166166e644ccd36fb8b586fb987eb42fc0fc60cce9a3d76d809b4", "97b4fc0a00a84df30f866d18693c503eef46c7642f75218a2c44d74d835be38a"] -multidict = ["1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", "275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", "3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", "4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", "5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", "51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", "5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", "6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", "7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", "9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", "c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", "d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", "e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", "f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", "fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", "fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", "feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"] -natural = ["18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2"] -parsedatetime = ["4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", "cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b"] -pathspec = ["7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", "da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"] -pbr = ["07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", "579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8"] -pylint = ["b95e31850f3af163c2283ed40432f053acbc8fc6eba6a069cb518d9dbf71848c", "dd506acce0427e9e08fb87274bcaa953d38b50a58207170dbf5b36cf3e16957b"] -pymongo = ["01b4e10027aef5bb9ecefbc26f5df3368ce34aef81df43850f701e716e3fe16d", "0fc5aa1b1acf7f61af46fe0414e6a4d0c234b339db4c03a63da48599acf1cbfc", "1396eb7151e0558b1f817e4b9d7697d5599e5c40d839a9f7270bd90af994ad82", "18e84a3ec5e73adcb4187b8e5541b2ad61d716026ed9863267e650300d8bea33", "19adf2848b80cb349b9891cc854581bbf24c338be9a3260e73159bdeb2264464", "20ee0475aa2ba437b0a14806f125d696f90a8433d820fb558fdd6f052acde103", "26798795097bdeb571f13942beef7e0b60125397811c75b7aa9214d89880dd1d", "26e707a4eb851ec27bb969b5f1413b9b2eac28fe34271fa72329100317ea7c73", "2a3c7ad01553b27ec553688a1e6445e7f40355fb37d925c11fcb50b504e367f8", "2f07b27dbf303ea53f4147a7922ce91a26b34a0011131471d8aaf73151fdee9a", "316f0cf543013d0c085e15a2c8abe0db70f93c9722c0f99b6f3318ff69477d70", "31d11a600eea0c60de22c8bdcb58cda63c762891facdcb74248c36713240987f", "334ef3ffd0df87ea83a0054454336159f8ad9c1b389e19c0032d9cb8410660e6", "358ba4693c01022d507b96a980ded855a32dbdccc3c9331d0667be5e967f30ed", "3a6568bc53103df260f5c7d2da36dffc5202b9a36c85540bba1836a774943794", "444bf2f44264578c4085bb04493bfed0e5c1b4fe7c2704504d769f955cc78fe4", "47a00b22c52ee59dffc2aad02d0bbfb20c26ec5b8de8900492bf13ad6901cf35", "4c067db43b331fc709080d441cb2e157114fec60749667d12186cc3fc8e7a951", "4c092310f804a5d45a1bcaa4191d6d016c457b6ed3982a622c35f729ff1c7f6b", "53b711b33134e292ef8499835a3df10909c58df53a2a0308f598c432e9a62892", "568d6bee70652d8a5af1cd3eec48b4ca1696fb1773b80719ebbd2925b72cb8f6", "56fa55032782b7f8e0bf6956420d11e2d4e9860598dfe9c504edec53af0fc372", "5a2c492680c61b440272341294172fa3b3751797b1ab983533a770e4fb0a67ac", "61235cc39b5b2f593086d1d38f3fc130b2d125bd8fc8621d35bc5b6bdeb92bd2", "619ac9aaf681434b4d4718d1b31aa2f0fce64f2b3f8435688fcbdc0c818b6c54", "6238ac1f483494011abde5286282afdfacd8926659e222ba9b74c67008d3a58c", "63752a72ca4d4e1386278bd43d14232f51718b409e7ac86bcf8810826b531113", "6fdc5ccb43864065d40dd838437952e9e3da9821b7eac605ba46ada77f846bdf", "7abc3a6825a346fa4621a6f63e3b662bbb9e0f6ffc32d30a459d695f20fb1a8b", "7aef381bb9ae8a3821abd7f9d4d93978dbd99072b48522e181baeffcd95b56ae", "80df3caf251fe61a3f0c9614adc6e2bfcffd1cd3345280896766712fb4b4d6d7", "95f970f34b59987dee6f360d2e7d30e181d58957b85dff929eee4423739bd151", "993257f6ca3cde55332af1f62af3e04ca89ce63c08b56a387cdd46136c72f2fa", "9c0a57390549affc2b5dda24a38de03a5c7cbc58750cd161ff5d106c3c6eec80", "a0794e987d55d2f719cc95fcf980fc62d12b80e287e6a761c4be14c60bd9fecc", "a3b98121e68bf370dd8ea09df67e916f93ea95b52fc010902312168c4d1aff5d", "a60756d55f0887023b3899e6c2923ba5f0042fb11b1d17810b4e07395404f33e", "a676bd2fbc2309092b9bbb0083d35718b5420af3a42135ebb1e4c3633f56604d", "a732838c78554c1257ff2492f5c8c4c7312d0aecd7f732149e255f3749edd5ee", "ad3dc88dfe61f0f1f9b99c6bc833ea2f45203a937a18f0d2faa57c6952656012", "ae65d65fde4135ef423a2608587c9ef585a3551fc2e4e431e7c7e527047581be", "b070a4f064a9edb70f921bfdc270725cff7a78c22036dd37a767c51393fb956f", "b6da85949aa91e9f8c521681344bd2e163de894a5492337fba8b05c409225a4f", "bbf47110765b2a999803a7de457567389253f8670f7daafb98e059c899ce9764", "bd9c1e6f92b4888ae3ef7ae23262c513b962f09f3fb3b48581dde5df7d7a860a", "c06b3f998d2d7160db58db69adfb807d2ec307e883e2f17f6b87a1ef6c723f11", "c318fb70542be16d3d4063cde6010b1e4d328993a793529c15a619251f517c39", "c4aef42e5fa4c9d5a99f751fb79caa880dac7eaf8a65121549318b984676a1b7", "c9ca545e93a9c2a3bdaa2e6e21f7a43267ff0813e8055adf2b591c13164c0c57", "da2c3220eb55c4239dd8b982e213da0b79023cac59fe54ca09365f2bc7e4ad32", "dd8055da300535eefd446b30995c0813cc4394873c9509323762a93e97c04c03", "e2b46e092ea54b732d98c476720386ff2ccd126de1e52076b470b117bff7e409", "e334c4f39a2863a239d38b5829e442a87f241a92da9941861ee6ec5d6380b7fe", "e5c54f04ca42bbb5153aec5d4f2e3d9f81e316945220ac318abd4083308143f5", "f4d06764a06b137e48db6d569dc95614d9d225c89842c885669ee8abc9f28c7a", "f96333f9d2517c752c20a35ff95de5fc2763ac8cdb1653df0f6f45d281620606"] -python-dateutil = ["73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"] -python-dotenv = ["440c7c23d53b7d352f9c94d6f70860242c2f071cf5c029dd661ccb22d64ae42b", "f254bfd0c970d64ccbb6c9ebef3667ab301a71473569c991253a481f1c98dddc"] -pyyaml = ["06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", "4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", "73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", "7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", "95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", "cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"] -regex = ["150125da109fccdcc8fec3b0b386b2a5d6ca7cff076f8b622486d1ca868b0c10", "163bc0805e46acfa098dfc8c0b07f371577d505f603e48afc425ff475cdac3a5", "20c513893ff80bdbe4b4ce11ea2e93d49481f05b270595d82af69ffc402010a6", "21fc17cb868c4264f0813f992f46f9ae6fc8c309d4741091de4153bd1f6a6176", "2c928bc8e0c453d73dffa3193a6e37ee752ea36df0dd4601e21024d98274dfad", "2d9beca70e36f9c60d679e108c5fe49f3d4da79d13a13f91e5e759443bd954f9", "5735f26cacdb50b3d6d35ebf8fdeb504bd8b381e2d079d2d9f12ce534fc14ecd", "6edc5c190248d3b612f2cca45448cf8ebc3621d41afcd1c5708853cbb1dbb3b3", "7606dba82435429641efe4fbc580574942f89cf2b9c5c1f8bc1eab2bacbf7e8b", "8d1ee3796795e609ef7a3a5a35eaf4728038d986aa12c06b3fd1b92ee81911f4", "8d9bb2d90e23c51aacbc58c1a11320f49b335cd67a91986cdbebcc3e843e4de8", "97d414c41f19fd2362e493810caa8445c05e0a2d63a14081c972aad66284a8d2", "9e37502817225ee99d91d8418f5119e98c380b00e772d06915690c05290f32ee", "af7209b2fcc79ee2b0ad4ea080d70bb748450ec4f282cc9e864861e469b1072e", "c0849b0864ff451f04c8afb5fc28e9ed592262e03debdd227cf0f53e04a55dcd", "c4ac9215650688e78dea29b46adbdafb7b85058eebe92ef6ea848e14466c915f", "dcda6d4e1bbfc939b177c237aee41c9678eaaf71df482688f8986e8251e12345", "dd8501b8d9ea1aba53c4bc7d47bc72933f9b4213d534cf400f16c1431f51c8ba", "ec0e509ed1877ff1cbc6f0864689bb60384a303502c4d72d9a635f8a4676fd3f", "f6c8c3f56fef719180464855346e6e80971b86dfd9e5a0e356664b5baca53072", "ffd4f80602490a309064cf2b203e220d581c51660e01055c64bf5da450485ee6"] -six = ["30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"] -smmap = ["54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", "9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"] -stevedore = ["001e90cd704be6470d46cc9076434e2d0d566c1379187e7013eb296d3a6032d9", "471c920412265cc809540ae6fb01f3f02aba89c79bbc7091372f4745a50f9691"] -toml = ["926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", "bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"] -typed-ast = ["0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", "0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", "249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", "24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", "269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", "4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", "498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", "4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", "6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", "715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", "73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", "8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", "8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", "aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", "bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", "c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", "d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", "d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", "d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", "fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", "fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"] -uvloop = ["08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", "123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", "4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", "4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", "afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", "b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", "bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", "e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", "f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"] -websockets = ["0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", "1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", "20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", "295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", "2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", "3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", "3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", "3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", "4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", "5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", "5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", "751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", "7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", "965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", "9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", "9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", "c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", "c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", "ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", "d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", "e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", "f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"] -wrapt = ["b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"] -yarl = ["0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", "0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", "2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", "25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", "26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", "308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", "3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", "58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", "5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", "6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", "944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", "a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", "a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", "c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", "c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", "d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", "e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"] diff --git a/pyproject.toml b/pyproject.toml index b74dfad111..cc43b4a54c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [tool.black] -line-length = 99 -target-version = ['py37'] +line-length = "110" +target-version = ['py310'] include = '\.pyi?$' -exclude = ''' +extend-exclude = ''' ( /( \.eggs @@ -21,7 +21,7 @@ exclude = ''' [tool.poetry] name = 'Modmail' -version = '3.9.3' +version = '4.1.0' description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." license = 'AGPL-3.0-only' authors = [ @@ -30,29 +30,9 @@ authors = [ 'Taki <noemail@example.com>' ] readme = 'README.md' -repository = 'https://github.com/kyb3r/modmail' -homepage = 'https://github.com/kyb3r/modmail' +repository = 'https://github.com/modmail-dev/modmail' +homepage = 'https://github.com/modmail-dev/modmail' keywords = ['discord', 'modmail'] -[tool.poetry.dependencies] -python = "^3.7" -"discord.py" = "discord.py==1.6.0" -uvloop = {version = ">=0.12.0", markers = "sys_platform != 'win32'"} -python-dotenv = ">=0.10.3" -parsedatetime = "^2.6" -dnspython = "^1.16" -isodate = "^0.6.0" -natural = "^0.2.0" -motor = {version = "^2.1", optional = true} -emoji = "^0.5.4" -python-dateutil = "^2.8" -colorama = "^0.4.3" -aiohttp = ">=3.6.0,<3.7.0" - -[tool.poetry.dev-dependencies] -black = {version = "=19.10b0", allow-prereleases = true} -pylint = "^2.4" -bandit = "^1.6" - -[tool.poetry.extras] -mongodb = ["motor"] +[tool.pylint.format] +max-line-length = "110" diff --git a/requirements.min.txt b/requirements.min.txt deleted file mode 100644 index d756599661..0000000000 --- a/requirements.min.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Generated as of March, 2021 -# This is the bare minimum requirements.txt for running Modmail. -# To install requirements.txt run: pip install -r requirements.min.txt - -aiohttp==3.6.2 -discord.py==1.6.0 -dnspython==1.16.0 -emoji==0.5.4 -isodate==0.6.0 -motor==2.1.0 -natural==0.2.0 -parsedatetime==2.6 -pymongo==3.10.1 -python-dateutil==2.8.1 -python-dotenv==0.14.0 -websockets==8.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..2c7bdb7880 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +-i https://pypi.org/simple +aiodns==3.1.1 +aiohttp==3.9.0; python_version >= '3.8' +aiosignal==1.3.1; python_version >= '3.7' +async-timeout==4.0.3; python_version < '3.11' +attrs==23.1.0; python_version >= '3.7' +brotli==1.1.0 +cairocffi==1.6.1; python_version >= '3.7' +cairosvg==2.7.1; python_version >= '3.5' +certifi==2023.11.17; python_version >= '3.6' +cffi==1.16.0; python_version >= '3.8' +charset-normalizer==3.3.2; python_full_version >= '3.7.0' +colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' +cssselect2==0.7.0; python_version >= '3.7' +defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +discord.py[speed]==2.3.2; python_full_version >= '3.8.0' +dnspython==2.4.2; python_version >= '3.8' and python_version < '4.0' +emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +frozenlist==1.4.0; python_version >= '3.8' +idna==3.4; python_version >= '3.5' +isodate==0.6.1 +lottie[pdf]==0.7.0; python_version >= '3' +motor==3.3.2; python_version >= '3.7' +multidict==6.0.4; python_version >= '3.7' +natural==0.2.0 +orjson==3.9.10 +packaging==23.2; python_version >= '3.7' +parsedatetime==2.6 +pillow==10.1.0; python_version >= '3.8' +pycares==4.4.0; python_version >= '3.8' +pycparser==2.21 +pymongo[srv]==4.6.0; python_version >= '3.7' +python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-dotenv==1.0.0; python_version >= '3.8' +requests==2.31.0; python_version >= '3.7' +six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +tinycss2==1.2.1; python_version >= '3.7' +urllib3==2.1.0; python_version >= '3.8' +uvloop==0.19.0; sys_platform != 'win32' +webencodings==0.5.1 +yarl==1.9.3; python_version >= '3.7' diff --git a/runtime.txt b/runtime.txt index 87665291b8..119ff10234 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.9.4 +python-3.10.7