diff --git a/.bandit_baseline.json b/.bandit_baseline.json index 539fe85a88..8a61d4006f 100644 --- a/.bandit_baseline.json +++ b/.bandit_baseline.json @@ -1,243 +1,342 @@ { "errors": [], - "generated_at": "2019-10-07T08:19:22Z", + "generated_at": "2022-09-06T16:19:31Z", "metrics": { "./bot.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": 933, - "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, + "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, + "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, + "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, + "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, + "skipped_tests": 0 + }, + "./core/changelog.py": { + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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/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": 2.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 2.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 7299, - "nosec": 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": 973, - "nosec": 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": 537, - "nosec": 0 - }, - "cogs/utility.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1587, - "nosec": 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, - "loc": 1168, - "nosec": 0 - }, - "core/changelog.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": 154, - "nosec": 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": 75, - "nosec": 0 - }, - "core/clients.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": 200, - "nosec": 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": 276, - "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": 7, - "nosec": 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": 91, - "nosec": 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": 214, - "nosec": 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": 716, - "nosec": 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": 169, - "nosec": 0 - }, - "core/utils.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": 199, - "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": "14 from site import USER_SITE\n15 from subprocess import PIPE\n16 \n17 import discord\n", - "filename": "cogs/plugins.py", + "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.", + "issue_text": "Consider possible security implications associated with the subprocess module.", "line_number": 15, "line_range": [ - 15, - 16 + 15 + ], + "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 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": "1824 try:\n1825 exec(to_compile, env) # pylint: disable=exec-used\n1826 except Exception as exc:\n", - "filename": "cogs/utility.py", + "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 the subprocess module.", + "line_number": 12, + "line_range": [ + 12 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "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": 1825, + "line_number": 2094, "line_range": [ - 1825 + 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": "219 for token in shlex.shlex(alias, punctuation_chars=\"&\"):\n220 if token != \"&&\":\n221 buffer += \" \" + token\n", - "filename": "core/utils.py", + "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 the subprocess module.", + "line_number": 3, + "line_range": [ + 3 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "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": 220, + "issue_text": "Possible hardcoded password: ''", + "line_number": 71, "line_range": [ - 220 + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b105_hardcoded_password_string.html", - "test_id": "B105", - "test_name": "hardcoded_password_string" + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b107_hardcoded_password_default.html", + "test_id": "B107", + "test_name": "hardcoded_password_default" } ] } \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index bae84fb2e4..a3de147db4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -133,23 +133,22 @@ node_modules/ config.json plugins/ !plugins/registry.json +!plugins/@local/ 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 b3554bf9c9..972eca4517 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ TOKEN=MyBotToken LOG_URL=https://logviewername.herokuapp.com/ GUILD_ID=1234567890 OWNERS=Owner1ID,Owner2ID,Owner3ID -MONGO_URI=mongodb+srv://mongodburi +CONNECTION_URI=mongodb+srv://mongodburi +DISABLE_AUTOUPDATES=true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..176a458f94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 68% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index a95e344610..6c6694b809 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,36 +15,45 @@ We use GitHub to host code, to track issues and feature requests, as well as acc ## We Use [Git Flow](https://atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) ![Simple Image Of A Git Flow Workflow](https://nvie.com/img/hotfix-branches@2x.png) -When contributing to this project please make sure you follow this and name your branches appropriately! +When contributing to this project, please make sure you follow this and name your branches appropriately! ## All Code Changes Happen Through Pull Requests Make sure you know how Git Flow works before contributing! Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: 1. Fork the repo and create your branch from `master` or `development` according to Git Flow. -2. If you've added code that should be tested, add tests. -3. If you've changed APIs, update the documentation. -4. Ensure the test suite passes. -5. Make sure your code lints. -6. Issue that pull request! +2. Update the CHANGELOG. +3. If you've changed `core/*` or `bot.py`, mark changelog as "BREAKING" since plugins may break. +4. Make sure your code passes the lint checks. +5. Create Issues and pull requests! ## 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: -- A quick summary and/or background +- A quick summary and background - Steps to reproduce - Be specific! - What you expected would happen -- What actually happens +- 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/FUNDING.yml b/.github/FUNDING.yml index fbcb038e69..8125c1756a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -patreon: kyber +buy_me_a_coffee: modmaildev 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..35fc2bedb1 --- /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 + - Buy Me A Coffee / 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 44902f2cf3..0000000000 --- a/.github/ISSUE_TEMPLATE/command-request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Command request -about: Request a new command -title: "[COMMAND-REQUEST] 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 48b986344d..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: "[FEATURE-REQUEST] 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 c84c9af773..b283dff078 100644 --- a/.github/workflows/lints.yml +++ b/.github/workflows/lints.yml @@ -4,31 +4,30 @@ 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 pylint black - continue-on-error: true - - name: Bandit syntax check - run: bandit ./bot.py cogs/*.py core/*.py -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 and flake8 + - name: Black run: | - black . --check + black . --diff --check diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index b3003ccf92..0000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Close Stale Issues" - -on: - schedule: - - cron: "0 0 * * *" - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue is stale because it has been open for 100 days with no activity. Remove stale label or comment or this will be closed in 5 days. Please do not un-stale this issue unless it carries significant contribution.' - days-before-stale: 100 - days-before-close: 5 - exempt-issue-label: 'high priority' diff --git a/.gitignore b/.gitignore index 463016ca95..635e3160e8 100644 --- a/.gitignore +++ b/.gitignore @@ -130,9 +130,10 @@ package-lock.json node_modules/ # Modmail -config.json -plugins/ +plugins/* !plugins/registry.json +!plugins/@local +config.json temp/ test.py stack.yml diff --git a/.lint.py b/.lint.py deleted file mode 100644 index 9d29372fd8..0000000000 --- a/.lint.py +++ /dev/null @@ -1,17 +0,0 @@ -if __name__ == "__main__": - import sys - from os import listdir - from os.path import join - - from pylint.lint import Run - - THRESHOLD = 9.75 - - cogs = [join("cogs", c) for c in listdir("cogs") if c.endswith(".py")] - core = [join("core", c) for c in listdir("core") if c.endswith(".py")] - - results = Run(["bot.py", *cogs, *core], do_exit=False) - - score = results.linter.stats["global_note"] - if score <= THRESHOLD: - sys.exit(1) diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 21087a91f7..0000000000 --- a/.pylintrc +++ /dev/null @@ -1,511 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=0 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - metaclass-assignment, - missing-docstring, # No doc-string - no-name-in-module, # No name 'file' in module 'core' - fixme, - too-many-public-methods, - too-many-locals, - too-many-statements, - too-many-branches, - too-many-instance-attributes, - too-many-arguments, - too-few-public-methods, - too-many-lines, - line-too-long, - bad-continuation, - invalid-name, - logging-too-many-args - - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=new - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - TODO, - BUG - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=99 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - ex, - _, - id, - db, - f, - dt, - ch, - ts - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=yes - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception". -overgeneral-exceptions=BaseException diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e9d761f2..2b7a7e28ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,717 @@ 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 does not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). +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.2.1 -# v3.4.0 +### Added + +**New Configuration Options:** +* `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown). +* `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing. +* `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`. +* `snooze_store_attachments`: When enabled, image attachments are stored as base64 when snoozing with delete behavior, allowing them to be re-uploaded on unsnooze. +* `snooze_attachment_max_bytes`: Maximum size per attachment to store as base64 (default: 4 MiB). +* `thread_creation_menu_timeout`: Timeout duration for user interaction with the menu (default: 30 seconds). +* `thread_creation_menu_close_on_timeout`: Silently abort thread creation if user doesn't select an option. +* `thread_creation_menu_anonymous_menu`: Anonymize the initial menu prompt relayed to staff. +* `thread_creation_menu_embed_text`: Text shown in the embed above the selection dropdown. +* `thread_creation_menu_dropdown_placeholder`: Placeholder text in the dropdown before selection. +* `thread_creation_menu_selection_log`: Log the chosen menu option in the newly created thread channel. +* `thread_creation_menu_precreate_channel`: Create thread channel immediately upon first DM even if menu is enabled. +* `thread_creation_menu_embed_title`: Optional title for the thread-creation menu embed. +* `thread_creation_menu_embed_footer`: Optional footer text for the menu embed. +* `thread_creation_menu_embed_footer_icon_url`: Optional URL for the footer icon. +* `thread_creation_menu_embed_thumbnail_url`: Optional thumbnail image URL. +* `thread_creation_menu_embed_image_url`: Optional large hero image URL for the menu embed. +* `thread_creation_menu_embed_large_image`: Promote thumbnail to large hero image if no separate image URL is set. +* `thread_creation_menu_embed_color`: Color for the menu embed's side strip. + +**Thread-Creation Menu Feature:** +* Full thread-creation menu system with interactive select menus: + * `?threadmenu toggle`: Enable/disable the menu globally. + * `?threadmenu show`: List current top-level options. + * `?threadmenu option add`: Interactive wizard to create an option. + * `?threadmenu option edit/remove/show`: Manage or inspect existing options. + * `?threadmenu submenu create/delete/list/show`: Manage submenus (nested menu levels). + * `?threadmenu submenu option add/edit/remove`: Manage options inside submenus. + * `?threadmenu dump_config`: Export current configuration to a file. + * `?threadmenu load_config`: Import configuration from a file. + * `?threadmenu reset`: Reset all thread-creation menu settings to defaults. +* Per-option category targeting: Each menu option can specify a target category where threads are created. +* Submenu support: Create up to 25 main-level options, each with up to 24 nested options. +* Optional selection logging: Log which menu option was chosen in the newly created thread channel. +* Anonymous menu support: Hide original prompt author context from staff when menu is anonymized. +* Category fallback: If an option's category is invalid/missing, creation falls back to `main_category_id`. + +**Snooze Enhancements:** +* Attachment persistence for delete-behavior snoozing: Image attachments can now be stored as base64 data. +* Enhanced unsnooze functionality with configurable message replay limits. +* Auto-unsnooze task continuously monitors and automatically unsnoozes threads when duration expires. + +### Changed +- Renamed `max_snooze_time` to `snooze_default_duration` (accepts seconds or human-readable time like "7 days"). +- When `snooze_behavior` is set to `move`, the snoozed category now has a hard limit of 49 channels. New snoozes are blocked once it’s full until space is freed. +- When switching `snooze_behavior` to `move` via `?config set`, the bot reminds admins to set `snoozed_category_id` if it’s missing. +- Thread-creation menu options and submenu options now support per-option `category` targeting. +- Category selection in menu option wizards allows specifying ID, name, or mention format. +- Snoozed thread restoration now respects `unsnooze_history_limit` (if set) to replay only the last N messages. +- Enhanced auto-unsnooze task monitors and automatically unsnoozes threads when their snooze duration expires. +- Snoozed threads can now be moved to a dedicated category instead of being deleted (via `snooze_behavior: move`). + +### Fixed + +- Corrected behavior when snooze channel count reaches the 49-channel limit in move-based snoozing. +- Improved category resolution in threadmenu wizards (handles ID, name, and mention formats reliably). +- Enhanced thread state restoration after unsnoozing to properly re-add all recipients. + + +# v4.2.0 + +Upgraded discord.py to version 2.6.3, added support for CV2. +Forwarded messages now properly show in threads, rather than showing as an empty embed. + +### Fixed +- Make Modmail keep working when typing is disabled due to an outage caused by Discord. +- Resolved an issue where forwarded messages appeared as empty embeds. +- Fixed internal message handling and restoration processes. +- Eliminated duplicate logs and notes. +- Addressed inconsistent use of `logkey` after ticket restoration. +- Fixed issues with identifying the user who sent internal messages. +- Solved an ancient bug where closing with words like `evening` wouldn't work. +- Fixed the command from being included in the reply in rare conditions. + +### Added +Commands: +* `snooze`: Initiates a snooze action. +* `snoozed`: Displays snoozed items. +* `unsnooze`: Reverses the snooze action. +* `clearsnoozed`: Clears all snoozed items. + +Configuration Options: +* `max_snooze_time`: Sets the maximum duration for snooze. +* `snooze_title`: Customizes the title for snooze notifications. +* `snooze_text`: Customizes the text for snooze notifications. +* `unsnooze_text`: Customizes the text for unsnooze notifications. +* `unsnooze_notify_channel`: Specifies the channel for unsnooze notifications. +* `thread_min_characters`: Minimum number of characters required. +* `thread_min_characters_title`: Title shown when the message is too short. +* `thread_min_characters_response`: Response shown to the user if their message is too short. +* `thread_min_characters_footer`: Footer displaying the minimum required characters. + +# v4.1.2 + +### Fixed +- Members not caching correctly for large servers. ([PR #3365](https://github.com/modmail-dev/Modmail/pull/3365)) + +# v4.1.1 + +### Fixed +- `?msglink` now supports threads with multiple recipients. ([PR #3341](https://github.com/modmail-dev/Modmail/pull/3341)) +- Fixed persistent notes not working due to discord.py internal change. ([PR #3324](https://github.com/modmail-dev/Modmail/pull/3324)) + +### Added +- Support for custom activities with `?activity custom ` ([PR #3352](https://github.com/modmail-dev/Modmail/pull/3352)) + +# v4.1.0 + +Drops support for Python 3.9. Python 3.10 and Python 3.11 are now the only supported versions. + +### Fixed +- GIF stickers no longer cause the bot to crash. +- `?alias make/create` as aliases to `?alias add`. This improves continuity between the bot and its command structure. ([PR #3195](https://github.com/kyb3r/modmail/pull/3195)) +- Loading the blocked list with the `?blocked` command takes a long time when the list is large. ([PR #3242](https://github.com/kyb3r/modmail/pull/3242)) +- Reply not being forwarded from DM. ([PR #3239](https://github.com/modmail-dev/modmail/pull/3239)) +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) +- Fixed a syntactic error in the close message when a thread is closed after a certain duration. ([PR #3233](https://github.com/modmail-dev/Modmail/pull/3233)) +- Removed an extra space in the help command title when the command has no parameters. ([PR #3271](https://github.com/modmail-dev/Modmail/pull/3271)) +- Corrected some incorrect config help descriptions. ([PR #3277](https://github.com/modmail-dev/Modmail/pull/3277)) +- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) +- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) + +### Added +- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) +- `REGISTRY_PLUGINS_ONLY`, environment variable, when set, restricts to only allow adding registry plugins. ([PR #3247](https://github.com/modmail-dev/modmail/pull/3247)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `STREAM_LOG_FORMAT` and `FILE_LOG_FORMAT` environment variable to set the log format of the stream and file handlers respectively. Possible options are `json` and `plain` (default). ([PR #3305](https://github.com/modmail-dev/Modmail/pull/3305)) +- `LOG_EXPIRATION` environment variable to set the expiration time of logs. ([PR #3257](https://github.com/modmail-dev/Modmail/pull/3257)) +- New registry plugins: [`autoreact`](https://github.com/martinbndr/kyb3r-modmail-plugins/tree/master/autoreact) and [`rename`](https://github.com/Nicklaus-s/modmail-plugins/tree/main/rename). +- Improved join/leave message for multiple servers. + +### Changed +- Repo moved to https://github.com/modmail-dev/modmail. +- Channel name no longer shows `-0` if the user has migrated to the new username system. +- `?note` and `?reply` now allows you to send a sticker without any message. +- Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) +- Discord.py internal logging is now enabled by default. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- The confirm-thread-creation dialog now uses buttons instead of reactions. ([PR #3273](https://github.com/modmail-dev/Modmail/pull/3273)) +- `?disable all` no longer overrides `?disable new`. ([PR #3278](https://github.com/modmail-dev/Modmail/pull/3278)) +- Dropped root privileges for Modmail running under Docker. ([PR #3284](https://github.com/modmail-dev/Modmail/pull/3284)) + +### Internal +- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. +- Various dependencies have been updated to their latest versions. + +# v4.0.2 + +### Breaking + +- Presence intent is now by-default OFF. You can turn it on by setting `ENABLE_PRESENCE_INTENT=true` in the environment variables. + +### Fixed + +- Not having a guild icon no longer raises an exception. ([PR #3235](https://github.com/modmail-dev/modmail/pull/3235)) + - When no icon is set, use the default user icon. +- Resolved an issue where `?logs` doesn't work when the thread has no title. ([PR #3201](https://github.com/modmail-dev/modmail/pull/3201)) +- AttributeError raised when failing to forward a reaction. ([GH #3218](https://github.com/modmail-dev/modmail/issues/3218)) + +### Changed + +- Plain messages no longer forces `()` around the respondent text. ([PR #3234](https://github.com/modmail-dev/modmail/pull/3234)) +- Added workflow to automatically build Docker image ([PR #3232](https://github.com/modmail-dev/modmail/pull/3228)) +- Updated installation guide to reflect new preferred hosting methods + +# v4.0.1 + +This is a hotfix release. + +### Improved + +- Error Messages + +### Fixed + +- Thread cooldown + +# v4.0.0 + +### Breaking + +- Modmail now requires [`Message Content` privileged intent](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots). +- Upgraded to discord.py v2.0 ([internal changes](https://discordpy.readthedocs.io/en/latest/migrating.html), [GH #2990](https://github.com/modmail-dev/modmail/issues/2990)). +- Python 3.8 or higher is required. +- Asyncio changes ([gist](https://gist.github.com/Rapptz/6706e1c8f23ac27c98cee4dd985c8120)) +- Plugin registry is purged and all developers have to re-apply due to breaking changes. + +### Added + +- `use_hoisted_top_role` config to use change how default mod tags work, see `v3.10.0#Added` for details. ([PR #3093](https://github.com/modmail-dev/modmail/pull/3093)) +- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/modmail-dev/modmail/issues/3107)) +- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/modmail-dev/modmail/issues/3083)) +- `?fpareply` and `?fpreply` to reply to messages with variables plainly. +- `use_nickname_channel_name` config to use nicknames instead of usernames for channel names. ([GH #3112](https://github.com/modmail-dev/modmail/issues/3112)) +- `use_random_channel_name` config to use random nicknames vaguely tied to user ID. It is unable to be computed in reverse. ([GH #3143](https://github.com/modmail-dev/modmail/issues/3143)) +- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/modmail-dev/modmail/issues/3122)) +- Select menus for certain paginators. +- `Title` field in `?logs`. ([GH #3142](https://github.com/modmail-dev/modmail/issues/3142)) +- Snippets can be used in aliases. ([GH #3108](https://github.com/modmail-dev/modmail/issues/3108), [PR #3124](https://github.com/modmail-dev/modmail/pull/3124)) +- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/modmail-dev/modmail/issues/3173), [PR #3174](https://github.com/modmail-dev/modmail/pull/3174)) + +### Improved + +- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/modmail-dev/modmail/issues/3048)) +- Use discord relative timedeltas. ([GH #3046](https://github.com/modmail-dev/modmail/issues/3046)) +- Use discord native buttons for all paginator sessions. +- `?help` and `?blocked` paginator sessions now have better multi-page UI. +- Autoupdate now automatically updates pipenv dependencies if possible. + +### Fixed + +- Several minor typos. ([PR #3095](https://github.com/modmail-dev/modmail/pull/3095), [PR #3116](https://github.com/modmail-dev/modmail/pull/3116)) +- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/modmail-dev/modmail/pull/3109)) +- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/modmail-dev/modmail/issues/3091), [PR #3092](https://github.com/modmail-dev/modmail/pull/3092)) +- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/modmail-dev/modmail/issues/3104), [PR #3105](https://github.com/modmail-dev/modmail/pull/3105)) +- Stickers now work in Modmail. +- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/modmail-dev/modmail/issues/3088)) +- Attachments now work on plain replies. ([GH #3102](https://github.com/modmail-dev/modmail/issues/3102)) +- Support LOTTIE stickers. ([GH #3119](https://github.com/modmail-dev/modmail/issues/3119)) +- Editing notes now work. ([GH #3094](https://github.com/modmail-dev/modmail/issues/3094)) +- Commands now work in threads. +- Audit log searching now properly works. +- Old data causing `?blocked` to fail. ([GH #3131](https://github.com/modmail-dev/modmail/issues/3131)) +- Delete channel auto close functionality now works. +- Improved error handling for autoupdate. ([PR #3161](https://github.com/modmail-dev/modmail/pull/3161)) +- Skip loading of already-loaded cog. ([PR #3172](https://github.com/modmail-dev/modmail/pull/3172)) +- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/modmail-dev/modmail/issues/3170), [PR #3178](https://github.com/modmail-dev/modmail/pull/3178)) +- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/modmail-dev/modmail/issues/3179)) + +### Internal + +- Improve regex parsing of channel topics. ([GH #3114](https://github.com/modmail-dev/modmail/issues/3114), [PR #3111](https://github.com/modmail-dev/modmail/pull/3111)) +- Add warning if deploying on a developmental version. +- Extensions are now loaded `on_connect`. +- MongoDB v5.0 clients are now supported. ([GH #3126](https://github.com/modmail-dev/modmail/issues/3126)) +- Bump python-dotenv to v0.20.0, support for python 3.10 +- Bump emoji to v1.7.0 +- Bump aiohttp to v3.8.1 +- Bump lottie to v0.6.11 +- Remove deprecated `core/decorators.py` from v3.3.0 + +# v3.10.5 + +### Internal + +- Locked plugin registry version impending v4 release. + +# v3.10.4 + +### Improved + +- Thread genesis message now shows other recipients. + +### Fixed + +- `?snippet add` now properly blocks command names. + +### Internal + +- Set `LOG_DISCORD` environment variable to the logger level and log discord events. + +# v3.10.3 +This is a hotfix for contact command. +### Fixed + +- Fixed a bug where contacting with no category argument defaults to the top category. + +# v3.10.2 +This is a hotfix for react to contact. + +### Fixed + +- React to contact now works properly. + +# v3.10.1 + +This is a hotfix for the edit command. + +### Fixed + +- `?edit` now works properly. + +# v3.10.0 + +v3.10 adds group conversations while resolving other bugs and QOL changes. It is potentially breaking to some plugins that adds functionality to threads. + +### Breaking + +- `Thread.recipient` (`str`) is now `Thread.recipients` (`List[str]`). +- `Thread.reply` now returns `mod_message, user_message1, user_message2`... It is no longer limited at a size 2 tuple. + +### Added + +- Ability to have group conversations with up to 5 users. ([GH #143](https://github.com/modmail-dev/modmail/issues/143)) +- Snippets are invoked case insensitively. ([GH #3077](https://github.com/modmail-dev/modmail/issues/3077), [PR #3080](https://github.com/modmail-dev/modmail/pull/3080)) +- Default tags now use top hoisted role. ([GH #3014](https://github.com/modmail-dev/modmail/issues/3014)) +- New thread-related config - `thread_show_roles`, `thread_show_account_age`, `thread_show_join_age`, `thread_cancelled`, `thread_creation_contact_title`, `thread_creation_self_contact_response`, `thread_creation_contact_response`. ([GH #3072](https://github.com/modmail-dev/modmail/issues/3072)) +- `use_timestamp_channel_name` config to create thread channels by timestamp. + +### Improved + +- `?contact` now accepts a role or multiple users (creates a group conversation). ([GH #3082](https://github.com/modmail-dev/modmail/issues/3082)) +- Aliases are now supported in autotrigger. ([GH #3081](https://github.com/modmail-dev/modmail/pull/3081)) + +### Fixed + +- Certain situations where the internal thread cache breaks and spams new channels. ([GH #3022](https://github.com/modmail-dev/modmail/issues/3022), [PR #3028](https://github.com/modmail-dev/modmail/pull/3028)) +- Blocked users are now no longer allowed to use `?contact` and react to contact. ([COMMENT #819004157](https://github.com/modmail-dev/modmail/issues/2969#issuecomment-819004157), [PR #3027](https://github.com/modmail-dev/modmail/pull/3027)) +- UnicodeEncodeError will no longer be raised on Windows. ([PR #3043](https://github.com/modmail-dev/modmail/pull/3043)) +- Notifications are no longer duplicated when using both `?notify` and `subscribe`. ([PR #3015](https://github.com/modmail-dev/modmail/pull/3015)) +- `?contact` now works properly with both category and silent. ([GH #3076](https://github.com/modmail-dev/modmail/issues/3076)) +- `close_on_leave_reason` now works properly when `close_on_leave` is enabled. ([GH #3075](https://github.com/modmail-dev/modmail/issues/3075)) +- Invalid arguments are now properly catched and a proper error message is sent. +- Update database after resetting/purging all plugins. ([GH #3011](https://github.com/modmail-dev/modmail/pull/3011)) +- `thread_auto_close` timer now only resets on non-note and replies from mods. ([GH #3030](https://github.com/modmail-dev/modmail/issues/3030)) +- Deleted messages are now deleted on both ends. ([GH #3041](https://github.com/modmail-dev/modmail/issues/3041), [@JerrieAries](https://github.com/modmail-dev/modmail/commit/20b31f8e8b5497943513997fef788d72ae668438)) +- Persistent notes are now properly deleted from the database. ([GH #3013](https://github.com/modmail-dev/modmail/issues/3013)) +- Modmail Bot is now recognized to have `OWNER` permission level. This affects what can be run in autotriggers. + +### Internal + +- Fix return types, type hints and unresolved references ([PR #3009](https://github.com/modmail-dev/modmail/pull/3009)) +- Reload thread cache only when it's the first on_ready trigger. ([GH #3037](https://github.com/modmail-dev/modmail/issues/3037)) +- `format_channel_name` is now extendable to plugins. Modify `Bot.format_channel_name(bot, author, exclude_channel=None, force_null=False):`. ([GH #2982](https://github.com/modmail-dev/modmail/issues/2982)) + +# v3.9.5 + +### Internal + +- Bumped discord.py to v1.7.3, updated all other packages to latest. +- More debug log files are now kept. +- Resolve SSL errors by retrying without SSL + +# v3.9.4 + +### Fixed + +- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/modmail-dev/modmail/issues/3002), [PR #3003](https://github.com/modmail-dev/modmail/pull/3003)) +- There is now a proper message when trying to contact a bot. + +### Improved + +- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) +- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) + +### Internal + +- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/modmail-dev/modmail/pull/3001)) + +# v3.9.3 + +### Added + +- New config: `use_user_id_channel_name`, when set to TRUE, channel names would get created with the recipient's ID instead of their name and discriminator. + - This is now an option to better suit the needs of servers in Server Discovery + +### Internal + +- Signature of `format_channel_name` in core/util.py changed to: + - `format_channel_name(bot, author, exclude_channel=None, force_null=False)` + + +# v3.9.2 + +### Improved + +- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/modmail-dev/modmail/issues/2977), [PR #2988](https://github.com/modmail-dev/modmail/pull/2988)) + +### Fixed + +- `user_typing` default in the config help is now correct. + +# v3.9.1 + +### Internal + +- `bot.run` now more gracefully handles closing, similar to how discord.py handles it. + - No longer displays `PrivilegedIntentsRequired` exception when exiting when presence intent is disabled. + +# v3.9.0 + +### Breaking + +- `on_thread_initiate` and `on_thread_ready` events now have `thread, creator, category, initial_message` as additional arguments. + +### Fixed + +- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/modmail-dev/modmail/issues/2930), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/modmail-dev/modmail/issues/2969), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- Ghost errors are no longer raised when threads are created using non-organic methods. + +### Internal + +- `thread.reply` now returns (msg_to_user, msg_to_thread). Can be useful in plugins. + +# v3.8.6 + +### Added + +- Ability to install local plugins without relying on git / external sources + - Simply add your extension to plugins/@local, and use `?plugin add local/plugin-name` to load the plugin as normal +- Updated deps for requirements.min.txt and pyproject.toml + +# v3.8.5 + +### Added + +- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/modmail-dev/modmail/issues/2963), [PR #2964](https://github.com/modmail-dev/modmail/pull/2964)) +- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/modmail-dev/modmail/pull/2951)) + +### Fixed + +- Non-master/development branch deployments no longer cause errors to be raised. +- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/modmail-dev/modmail/issues/2961)) + +# v3.8.4 + +This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 where users were not properly cached. + +### Fixed + +- Corrupted data is no longer saved to thread cache. + +# v3.8.2 + +### Fixed + +- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/modmail-dev/modmail/issues/2934)) +- Fix update notifications. +- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/modmail-dev/modmail/issues/2935), [PR #2936](https://github.com/modmail-dev/modmail/pull/2936)) +- IDs in `` commands work now. + +# v3.8.1 + +### Fixed + +- Additional image uploads now render properly. ([PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/modmail-dev/modmail/issues/2931), [PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/modmail-dev/modmail/issues/2932)) + +# v3.8.0 + +### Added + +- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/modmail-dev/modmail/issues/2896)) +- `?fareply`, anonymously reply with variables. +- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/modmail-dev/modmail/issues/2905)) +- `disable_updates` config variable to control if the update command should be disabled or not. +- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/modmail-dev/modmail/issues/2907)) +- Support for only the "Server Members" intent. + +### Improved + +- Added command validation to `autotrigger add/edit`. +- `GITHUB_TOKEN` is now no longer required in Heroku setups. +- Clearer error messages on reply fails. + +### Fixed + +- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/modmail-dev/modmail/pull/2902)) +- Level permissions were not checked if command permissions were set. +- Regex autotriggers were not working if term was in the middle of strings. +- `?blocked` now no longers show blocks that have expired. +- Blocked roles will no longer trigger an error during unblock. +- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/modmail-dev/modmail/issues/2916)) +- Finding linked messages in replies work now. ([GH #2920](https://github.com/modmail-dev/modmail/issues/2920), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2920#issuecomment-751530495)) +- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/modmail-dev/modmail/issues/2926)) +- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/modmail-dev/modmail/issues/2910), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2910#issuecomment-753557313)) +- Display an error on [Lottie](https://airbnb.io/lottie/#/) stickers, instead of failing the send. +- `?perms get` now shows role/user names. ([PR #2927](https://github.com/modmail-dev/modmail/pull/2927)) + +### Internal + +- Make use of `git branch --show-current` to retrieve branch instead of using prerelease version check. +- Use discord.py 1.6.0 from PyPi instead of the development clone. + +# v3.7.13 + +### Fixed + +- Notes in config help are now optional. + +# v3.7.12 + +### Fixed + +- Bot was not responding to union errors. +- `?block [reason]` now works in threads. + +# v3.7.11 + +### Improved + +- Role block will now work better with seperate server setups. + +### Fixed + +- Bot not restarting after autoupdate on PM2. + +### Internal + +- Removed unnecessary loggings. + +# v3.7.10 + +### Added + +- Added `update_channel_id` to specify which channel autoupdate notifications were being sent to. +- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/modmail-dev/modmail/issues/2885)) + +# v3.7.9 + +### Fixed + +- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/modmail-dev/modmail/issues/2892), [PR #2893](https://github.com/modmail-dev/modmail/pull/2893)) + +### Improved + +- Clearer plugin debug messages when plugins are disabled + +# v3.7.8 + +### Added + +- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/modmail-dev/modmail/pull/2887)) + +### Fixed +- Permission levels were not respected. +- `perms remove` was not working. +- `logs` and `block` would not recognise users in a seperate server setup. +- Custom emojis were not working with `confirm_thread_creation`. + +### Internal +- Optimised `perms get`, bot should respond faster now. + +# v3.7.7 + +### Added + +- Added updating github fork if GITHUB_TOKEN was provided + +### Fixed + +- Skip blocked roles check if user is not in main guild. + +# v3.7.6 + +### Fixed + +- Autoupdate persists despite errors. +- Mention when normal thread created was not working. ([GH #2883](https://github.com/modmail-dev/modmail/issues/2883)) + +# v3.7.5 + +### Fixed + +- Close on emoji was not working. + +# v3.7.3 + +### Fixed + +- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/modmail-dev/modmail/issues/2881)) + +# v3.7.2 + +### Added + +- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/modmail-dev/modmail/issues/2880)) + +### Fixed + +- `?config set` would not respond if an invalid key was provided. + +# v3.7.1 + +### Fixed + +- Bot will now leave a reaction on the react to contact message. +- Added docstring to selfcontact + +# v3.7.0 + +### Added + +- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/modmail-dev/modmail/issues/2872)) +- Added `react_to_contact_message`, `react_to_contact_emoji` to allow users to create threads by reacting to a message. +- Added `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/modmail-dev/modmail/issues/215)) +- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/modmail-dev/modmail/issues/2763)) +- Added `close_on_leave`, `close_on_leave_reason` to automatically close threads upon recipient leaving the server. ([GH #2757](https://github.com/modmail-dev/modmail/issues/2757)) +- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/modmail-dev/modmail/issues/2833)) +- Added `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny` to allow users to confirm that they indeed want to create a new thread. ([GH #2773](https://github.com/modmail-dev/modmail/issues/2773)) +- Support Gyazo image links in message embeds. ([GH #282](https://github.com/modmail-dev/modmail/issues/282)) +- Added `silent` argument to `?contact` to restore old behaviour. +- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/modmail-dev/modmail/issues/2847)) +- Added a way to block roles. ([GH #2753](https://github.com/modmail-dev/modmail/issues/2753)) +- Added `cooldown_thread_title`, `cooldown_thread_response` to customise message sent when user is on a creating thread cooldown. ([GH #2865](https://github.com/modmail-dev/modmail/issues/2865)) +- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/modmail-dev/modmail/issues/2762)) +- Support stickers and reject non-messages. (i.e. pin_add) +- Added support for thread titles, `?title`. ([GH #2838](https://github.com/modmail-dev/modmail/issues/2838)) +- Added `data_collection` to specify if bot metadata should be collected by Modmail developers. +- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/modmail-dev/modmail/issues/130), [GH #649](https://github.com/modmail-dev/modmail/issues/649)) +- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/modmail-dev/modmail/issues/2842), [PR #2878](https://github.com/modmail-dev/modmail/pull/2878)) +- Autoupdates and `?update` which was removed in v3.0.0 + +### Fixed + +- `?contact` now sends members a DM. +- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/modmail-dev/modmail/issues/2856)) +- Command truncated after && in alias. ([GH #2870](https://github.com/modmail-dev/modmail/issues/2870)) +- `on_plugins_ready` event for plugins works now. + +### Improved + +- Plugins installations have clearer error messages. +- `?move` now does not require exact category names, accepts case-insensitive and startswith names. + +### Internal +- Use enums in config. ([GH #2821](https://github.com/modmail-dev/modmail/issues/2821)) +- `on_thread_close` event for plugins. +- `on_thread_reply` event for plugins. + +# v3.6.2 + +### Fixed + +- Plugins downloading requirements in virtual environments. + + +# v3.6.1 + +### Added + +- Proper error message if privileged intents not explicitly granted to bot. + + +# v3.6.0 + +### Added + +- Added `thread_move_title` to specify title of thread moved embed. +- Mark NSFW logs in log message. ([GH #2792](https://github.com/modmail-dev/modmail/issues/2792)) +- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/modmail-dev/modmail/issues/2828)) +- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/modmail-dev/modmail/issues/2796)) + +### Changed + +- `?move` now consumes rest in category name, which means `?move Long Category Name` works without quotes! +- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/modmail-dev/modmail/pull/2845)) + +### Fixed +- Unicode errors raised during windows selfhosting + +### Internal + +- Bump discord.py version to 1.5.1 +- Explicitly state intents used for connection +- Use `--diff` for black CI instead of `--check` ([GH #2816](https://github.com/modmail-dev/modmail/issues/2816)) + + +# v3.5.0 + +Fixed discord.py issue. + +### Added + +- A confirmation when you manually delete a thread message embed. +- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/modmail-dev/modmail/issues/2803)) +- Added `?plugins reset` command to completely reset everything related to plugins. This will fix some problems caused by broken plugins in the file system. +- Support private GitHub repos for plugins (thanks to @officialpiyush pr#2767) + +### Changed + +- Bump discord.py version to v1.3.3. +- Renamed `bot.owner_ids` to `bot.bot_owner_ids` as the attribute is now defined internally for team support. +- Deleting channel manually will now close the thread. +- Deleting messages will no longer cause the bot to produce warnings. +- Plugins will automatically be removed when it fails to load. +- Moved all database-related activities to clients.py under MongoDBClient, with possible future hook for additional database support. +- `bot.db` is deprecated in favour of `bot.api.db` and will be removed in the future. +- Deprecated `bot.plugin_db.get_partition` in favour of `bot.api.get_plugin_partition` (not final). +- Deprecated `MONGO_URI` config var (but will keep support in the future) in favour of `CONNECTION_URI` and `DATABASE_TYPE`. Right now there is one supported database - "mongodb", which is the default. + +### Fixed + +- Plugins not loading in Windows OS. Now uses proactor event loop for asyncio which should fix this. + + +# v3.4.1 + +### Fixed + +- Masked a bunch of noise errors when deleting messages. +- Added more checks for deleting messages. + +### Breaking + +- `thread_initiate` will be dispatched at the beginning of the setup process. +- `thread_create` is dispatched when the thread is registered as a thread by Modmail (i.e., when channel topic is edited). +- `thread_ready` is dispatched when a thread finishes its setup steps. + + +# v3.4.0 ### Added @@ -23,35 +729,35 @@ however, insignificant breaking changes does not guarantee a major version bump, - Multi-command alias is now more stable. With support for a single quote escape `\"`. - New command `?freply`, which behaves exactly like `?reply` with the addition that you can substitute `{channel}`, `{recipient}`, and `{author}` to be their respective values. - New command `?repair`, repair any broken Modmail thread (with help from @officialpiyush). -- Recipient get feedback when they edit message. +- Recipients get feedback when they edit their messages. - Chained delete for DMs now comes with a message. - poetry (in case someone needs it). ### Changed - The look of alias and snippet when previewing. -- Message ID of the thread embed is saved in DB, instead of the original message. +- The database now saves the message ID of the thread embed, instead of the original message. - Swapped the position of user and category for `?contact`. - The log file will no longer grow infinitely large. -- Hard limit of maximum 25 steps for alias. +- A hard limit of a maximum of 25 steps for aliases. - `?disable` is now `?disable new`. ### Fixed - Setting config vars using human time wasn't working. - Fixed some bugs with aliases. -- Fixed a lot of issues with `?edit` and `?delete` and recipient message edit. +- Fixed many issues with `?edit` and `?delete` and recipient message edit. - Masked the error: "AttributeError: 'int' object has no attribute 'name'" - Channel delete event will not be checked until discord.py fixes this issue. -- Chained reaction add / remove. +- Chained reaction add/remove. - Chained delete for thread channels. ### Internal -- Commit to black format line width max = 99, consistent with pylint. -- Alias parser is rewritten without shlex. +- Commit to black format line width max = 99, consistent with PyLint. +- No longer requires shlex for alias parsing. - New checks with thread create / find. -- No more flake8 and travis. +- No more flake8 and Travis. # v3.3.2 @@ -75,19 +781,19 @@ however, insignificant breaking changes does not guarantee a major version bump, - Three new config vars: - `enable_plugins` (yes/no default yes) - - When set to no, plugins will not be loaded into the bot. + - When set to no, Modmail will not load plugins. - `error_color` (color format, defaults discord red) - The color of error messages. - `anon_reply_without_command` (yes/no default no) (Thanks to papiersnipper PR#288) - When set, all non-command messages sent to thread channels are forwarded to the recipient anonymously without the need of `?anonreply`. - This config takes precedence over `reply_without_command`. -- `?logs responded [user]` command, it will show all logs that the user has sent an reply. (Thanks to papiersnipper PR#288) +- `?logs responded [user]` command. It will show all the logs that the user has sent a reply. (Thanks to papiersnipper PR#288) - `user` when not provided, defaults to the user who ran the command. -- Open threads in limbo now auto closes if the channel cannot be found. This check is done every time the bot restarts. +- Open threads in limbo now auto-close if Modmail cannot find the channel. Modmail does this check every time the bot restarts. - Ability to disable new threads from getting created. - - `?disable` + - `?disable`. - Ability to fully disable Modmail DM. - - `?disable all` + - `?disable all`. - To re-enable DM: `?enable`, and to see the current status: `?isenable`. - This disabled Modmail interface is customizable with the following config vars: - `disabled_new_thread_title` @@ -101,33 +807,33 @@ however, insignificant breaking changes does not guarantee a major version bump, ### Changed -- `?contact` no longer send the "thread created" message to where the command is ran, instead, it's now sent to the newly created thread channel. (Thanks to DAzVise) +- `?contact` no longer send the "thread created" message to where the command was run, instead, it's now sent to the newly created thread channel. (Thanks to DAzVise) - Automatically delete notes command `?note` when there're no attachments attached. - Embed author links used to be inaccessible in many cases, now: - `?anonreply`, `?reply`, and `?note` in the thread channel will link to the sender's profile. - - `?reply` and recipient's DM will also link the sender's profile. + - `?reply` and the recipient's DM will also link the sender's profile. - `?anonreply` in DM channel will link to the first channel of the main guild. - Plugins update (mostly internal). - - `git` is no longer used to install plugins, it now downloads through zip files. + - `git` is no longer used to install plugins; it now downloads through zip files. - `?plugins enabled` renamed to `?plugins loaded` while `enabled` is still an alias to that command. - Reorganized plugins folder structure. - Logging / plugin-related messages change. - - Updating one plugin will not update all other plugins (plugins are no longer separated by repos, but the plugin name itself). -- Help command is in alphabetical order grouped by permissions. -- Notes are no longer always blurple, it's set to `MAIN_COLOR` now. + - Updating one plugin will not update other plugins; repositories no longer separate plugins, but the plugin name itself. +- The help command is in alphabetical order grouped by permissions. +- Notes are no longer always blurple; it's set to `MAIN_COLOR` now. - Added `?plugins update` for updating all installed plugins. - Reintroduce flake8 and use bandit for security issues detection. -- Add travis checks for 3.6 in Linux and 3.7 for MacOS and Windows. -- Eval commands are logged in debug logs. +- Add Travis checks for 3.6 in Linux and 3.7 for macOS and Windows. +- Debug logs not logs eval commands. - Presence updates 30 minutes instead of 45 now. -- Fixed an assortment of problems to do with block. +- Fixed an assortment of problems to do with `?block`. - Existing aliases can be used when creating new aliases. (Thanks to papiersnipper PR#402) ### Internal - Reworked `config.get` and `config.set`, it feeds through the converters before setting/getting. - To get/set the raw value, access through `config[]`. -- Prerelease naming scheme is now `x.x.x-devN`. +- The prerelease naming scheme is now `x.x.x-devN`. - `trigger_typing` has been moved to `core.utils.trigger_typing`, the original location is deprecated. - Simpler status and activity logic. - New logging logic. @@ -140,9 +846,9 @@ Security update! - Supporter permission users used to be able to "hack" snippets to reveal all your config vars, including your token and MongoURI. - Implemented some changes to address this bug: - - All customizable variables used in snippets, close messages, etc, using the `{}` syntax, now forbids chaining 2 or more attributes and attributes that starts with `_`. -- It is advised to update to this version. -- If you felt your credentials have been leaked, consider changing your bot token / mongo uri. + - All customizable variables used in snippets, close messages, etc., using the `{}` syntax, now forbids chaining two or more attributes and attributes that start with `_`. +- We advise you to update to this version. +- If you felt your credentials had been leaked, consider changing your bot token / MongoURI. # v3.2.1 @@ -172,7 +878,7 @@ Security update! ### Internal -- Use regex to parse Changes, Added, Fixed, etc and description. +- Use regex to parse Changes, Added, Fixed, etc. and description. - Adds `PermissionLevel.INVALID` when commands don't have a permission level. # v3.1.1 @@ -192,33 +898,33 @@ Security update! ### Added - `?sfw`, mark a thread as "safe for work", undos `?nsfw`. -- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when thread is auto-closed. +- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when a thread is auto-closed. - New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. - Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. - For users having trouble with pipenv or any other reason. -- Multi-step alias, see `?help alias add`. Public beta testing, might be unstable. +- Multi-step alias, see `?help alias add`. Public beta testing might be unstable. - Misc commands without cogs are now displayed in `?help`. - `?help` works for alias and snippets. - `?config help ` shows a help embed for the configuration. -- Support setting permissions for sub commands. -- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` sub commands. +- Support setting permissions for subcommands. +- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` subcommands. ### Changes - `thread_auto_close_response` has a configurable variable `{timeout}`. - `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. -- `colorama` is no longer a necessity, this is due to some unsupported OS. -- Changelog command can now take a version argument to jump straight to specified version. +- `colorama` is no longer a necessity; this is due to some unsupported OS. +- Changelog command can now take a version argument to jump straight to the specified version. - `?plugin enabled` results are now sorted alphabetically. -- `?plugin registry` results are now sorted alphabetically, helps user find plugins more easily. +- `?plugin registry` results are now sorted alphabetically, helps users find plugins more easily. - `?plugin registry page-number` plugin registry can specify a page number for quick access. - A reworked interface for `?snippet` and `?alias`. - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). - - Add an `?alias raw ` command for viewing the raw content of a alias (escaped markdown). + - Add an `?alias raw ` command for displaying the raw content of an alias (escaped markdown). - The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. - Removed unclear `rm` alias for some `remove` commands. - Paginate `?config options`. -- All users configured with a permission level greater than REGULAR has access to the main Modmail category. +- All users configured with a permission level higher than REGULAR has access to the main Modmail category. - Category overrides also changes when a level is removed or added to a user or role. - `@everyone` is now accepted for `?perms add`. @@ -226,12 +932,12 @@ Security update! - `?notify` no longer carries over to the next thread. - `discord.NotFound` errors for `on_raw_reaction_add`. -- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when user is blocked. +- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when the user is blocked. - Better `?block` usage message. -- Resolves errors when message was sent by mods after thread is closed somehow. +- Resolved errors when mods sent messages after a thread is closed somehow. - Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. -- When creating snippets and aliases, it now checks if another snippets/aliases with the same name exists. -- Was looking for `config.json` in the wrong directory. +- When creating snippets and aliases, it now checks if other snippets/aliases with the same name exist. +- Modmail looked for `config.json` in the wrong directory. ### Internal @@ -251,13 +957,13 @@ Security update! ### Added - New commands, `?alias edit ` and `?snippets edit `. - - They can be used to edit aliases and snippets respectively. + - They can be used to edit aliases and snippets, respectively. # v3.0.2 ### Added -- New command, `?blocked whitelist `, this command prevents users from getting blocked by any means. +- A new command, `?blocked whitelist `, this command prevents users from getting blocked by any means. ### Changed @@ -267,30 +973,30 @@ Security update! ### Fixed -- A lot of bugs with `thread_auto_close` 😅 +- Many bugs with `thread_auto_close`. # v3.0.0 ### Added -- Sponsors command that will list sponsors. -- An alert will now be sent to the log channel if a thread channel fails to create. This could be due to a variety of problems such as insufficient permissions or the category channel limit is met. +- `?sponsors` command will list sponsors. +- An alert will now be sent to the log channel if a thread channel fails to create. This could be due to a variety of problems such as insufficient permissions, or the category channel limit is met. - Threads will close automatically after some time when `thread_auto_close` is set. -- Custom closing message can be set with `thread_auto_close_response`. +- Custom closing messages can be configured with `thread_auto_close_response`. ### Breaking Changes -- Removed autoupdate functionality and the `?update` command in favour of the [Pull app](https://github.com/apps/pull). +- 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. -- Debug logs are now located in a unique file for each bot. (Internal change) +- Channel names now can contain Unicode characters. +- Debug logs are now located in a different file for each bot. (Internal change) - Default cogs always appear first in the help command now. ### Fixed -- Editing notes now works, minor bug with edit command is fixed. +- Editing notes now work, minor bug with edit command is fixed. - Bug in the `?oauth` command where the response message fails to send when an ID is provided. - Plugin requirement installation now works in virtual environments @@ -305,7 +1011,7 @@ Fixed a bug with branches and `?plugin update`. ### Added -Branch support for `?plugin add` and in registry. Typically for developers. +Branch support for `?plugin add` and in the registry. Typically for developers. # v2.23.0 @@ -313,11 +1019,11 @@ Branch support for `?plugin add` and in registry. Typically for developers. Added a "Mutual servers" field to the genesis embed if: a) The user is not in the main guild. -b) The user shares more than 1 server with the bot. +b) The user shares more than one server with the bot. ### Changed -Notes taken with the `?note` command are now automatically pinned within the thread channel. +Notes with the `?note` command are now automatically pinned within the thread channel. # v2.22.0 @@ -327,7 +1033,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 @@ -359,14 +1065,14 @@ Add your plugin in the `plugins/registry.json` file in the main repository. This update contains mostly internal changes. - Implemented support for the new discord.py v1.1.1. - Improved help text for most commands. - - Completely revamped help command, few user changes. - - Removed abc (internal). + - Completely revamped help command, few users changes. + - Removed ABC (internal). # v2.20.0 ### What's new? -New `oauth` whitelist command which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app check the logviewer [repo](https://github.com/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 @@ -374,7 +1080,7 @@ New `oauth` whitelist command which allows you to whitelist users so they can lo - Ability to force an update despite having the same version number. Helpful to keep up-to-date with the latest GitHub commit. - `?update force`. -- Plugin developers now have a new event called `on_plugin_ready`, this is coroutine is awaited when all plugins are loaded. Use `on_plugin_ready` instead of `on_ready` since `on_ready` will not get called in plugins. +- Plugin developers now have a new event called `on_plugin_ready`; this is a coroutine and is awaited when all plugins are loaded. Use `on_plugin_ready` instead of `on_ready` since `on_ready` will not get called in plugins. # v2.19.0 @@ -395,17 +1101,17 @@ Fix the teams permission bug. ### Changed -Commands now have better error messages, instead of just sending the help message for a command when an argument fails to be converted to its specified object, the bot now says things like "User 'bob' not found" instead. +Commands now have better error messages. Instead of sending the help message for a command when an argument fails to be converted, the bot now says like "User 'bob' not found" instead. # v2.18.1 -Un-deprecated the `OWNERS` config variable to support discord developer team accounts. +Un-deprecated the `OWNERS` config variable to support Discord developer team accounts. # v2.18.0 ### New Permissions System -- A brand new permission system! Replacing the old guild-based permissions (ie. manage channels, manage messages), the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. +- A brand new permission system! Replaced the old guild-based permissions (i.e., manage channels, manage messages), with the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. - There are five permission levels: - Owner [5] - Administrator [4] @@ -424,7 +1130,7 @@ You may add a role or user to a permission group through any of the following me The same applies to individual commands permissions: - `?permissions add command command-name @member#1234` -- ... and the other methods listed above. +- and the other methods listed above. To revoke permission, use `remove` instead of `add`. @@ -437,11 +1143,11 @@ By default, all newly set up Modmail will have `OWNER` set to the owner of the b ### Breaking When updating to this version, all prior permission settings with guild-based permissions will be invalidated. You will need to convert to the above system. -`OWNERS` will also get removed, you will need to set owners through `?permissions add level owner 212931293123129` or any way listed above. +`OWNERS` will also get removed; you will need to set owners through `?permissions add level owner 212931293123129` or any way listed above. ### New Command -- A `?delete` command, which is an alternative to manually deleting a message. This command is created to no longer requires manage messages permission to recall thread messages. +- A `?delete` command, which is an alternative to manually deleting a message. This command is created to no longer require "manage messages" permission to recall thread messages. ### Changed @@ -462,28 +1168,28 @@ Stricter fallback genesis embed search. ### Changed -How modmail checks if a channel is a thread: +How Modmail checks if a channel is a thread: -1. The bot first checks if the channel topic is in the format `User ID: xxxx`, this means it is a thread. -2. If a channel topic is not found, the bot searches through the message history of a channel to find the thread creation embed. This step should never yield a thread for a normal user, but in the case of another bot messing up the channel topic (happened to a user before) this extra step was added. +1. The bot first checks if the channel topic is in the format `User ID: XXXX`, this means it is a thread. +2. If a channel topic is not found, the bot searches through the message history of a channel to find the thread creation embed. This step should never yield a thread for an average user. Still, in the case of another bot messing up the channel topic (happened to a user before), this extra step was added. # v2.17.0 ### What's new? -Added a config option `reply_without_command` which when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) +Added a config option `reply_without_command`, which, when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) To enable this functionality, do `?config set reply_without_command true` and to disable it, use `?config del reply_without_command`. ### Changed -The `move` command now only requires `manage_messages` perms instead of `manage_channels` +The `move` command now only requires `manage_messages` perms instead of `manage_channels`. # v2.16.1 ### Fixed -An issue where a scheduled close would not execute over a long period of time if the recipient no shares any servers with the bot. +An issue where a scheduled close would not execute over a long time if the recipient no shares any servers with the bot. # v2.16.0 @@ -513,8 +1219,8 @@ Added the ability to change the default close message via the introduction of tw They will be provided by string variables that you can incorporate into them: - `closer` - the user object that closed the thread. -- `logkey` - the key for the thread logs e.g. (`5219ccc82ad4`) -- `loglink` - the full link to the thread logs e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) +- `logkey` - the key for the thread logs, e.g. (`5219ccc82ad4`) +- `loglink` - the full link to the thread logs, e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) Example usage would be: ``?config set thread_close_message {closer.mention} closed the thread, here is the link to your logs: [**`{logkey}`**]({loglink})`` @@ -543,9 +1249,9 @@ You now have complete control of the look of the thread creation and close embed ### What's new? -Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages modmail. +Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages Modmail. -You can do this via `?config set sent_emoji disable` +You can do this via `?config set sent_emoji disable`. ### Fixed @@ -562,17 +1268,17 @@ Added image link in title in case discord fails to embed an image. - Introduced a new configuration variable `account_age` for setting a minimum account creation age. - Users blocked by this reason will be stored in `blocked` along with other reasons for being blocked. - `account_age` needs to be an ISO-8601 Duration Format (examples: `P12DT3H` 12 days and 3 hours, `P3Y5M` 3 years and 5 months `PT4H14M999S` 4 hours 14 minutes and 999 seconds). https://en.wikipedia.org/wiki/ISO_8601#Durations. - - You can set `account_age` using `config set account_age time` where "time" can be a simple human readable time string or an ISO-8601 Duration Format string. + - You can set `account_age` using `config set account_age time` where "time" can be a simple human-readable time string or an ISO-8601 Duration Format string. ### Changed -- `block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. -- `block`, like `close`, now supports a block duration (temp blocking). +- `?block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. +- `?block`, like `?close`, now supports a block duration (temp blocking). # v2.13.10 ### Fixed - Fixed an issue where status and activity do not work if they were modified wrongly in the database. - - This was especially an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. + - This was primarily an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. # v2.13.9 @@ -591,7 +1297,7 @@ Added image link in title in case discord fails to embed an image. ### What's new? - The ability to enable typing interactions. - - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. + - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". Use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. - New `status` command, change the bot's status to `online`, `idle`, `dnd`, `invisible`, or `offline`. - To remove the status (change it back to default), use `status clear`. - This also introduces a new internal configuration variable: `status`. Possible values are `online`, `idle`, `dnd`, `invisible`, and `offline`. @@ -608,15 +1314,15 @@ Added image link in title in case discord fails to embed an image. ### What's new? - You will no longer need to view your bot debug logs from Heroku. `debug` will show you the recent logs within 24h through a series of embeds. - - If you don't mind your data (may or may not be limited to: user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. + - If you don't mind your data (may or may not be limited to user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. - `debug clear` will clear the locally cached logs. - - Local logs are automatically cleared at least once every 27h for bots hosted on Heroku. + - Local logs are automatically erased at least once every 27h for bots hosted on Heroku. ### Fixed -- Will no longer show `Unclosed client session` and `Task was destroyed but it is pending!` when the bot terminates. +- Will no longer show `Unclosed client session` and `Task was destroyed, but it is pending!` when the bot terminates. - `thread.create` is now synchronous so that the first message sent can be queued to be sent as soon as a thread is created. - This fixes a problem where if multiple messages are sent in quick succession, the first message sent (which triggers the thread creation) is not sent in order. -- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled and the bot will send a message saying so. +- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled, and the bot will send a message saying so. ### Changed - `print` is replaced by logging. @@ -650,19 +1356,19 @@ 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 from? Plugins are your go-to! - - [Creating Plugins Documentation](https://github.com/kyb3r/modmail/wiki/Plugins). + - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit? Plugins are your go-to! + - [Creating Plugins Documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). # v2.12.5 ### Fixed -- `config del` command will now work properly on self-hosted db bots. +- `config del` command will now work correctly on self-hosted DB bots. # v2.12.4 ### What's new? -- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/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`. @@ -671,14 +1377,14 @@ Added image link in title in case discord fails to embed an image. ### Fixed - Patched a bug where `logs` sub-commands were accessible by anyone. -- Patched a bug where an error was raised if there was an open thread where the recipient had left the server. +- Patched a bug where an error was raised when a thread is open where the recipient left the server. Huge thanks to Sasiko for reporting these issues. # v2.12.2 ### Fixed -- Fixed a bug in self-hosted `update` command. +- Fixed a bug in self-hosted `?update` command. # v2.12.1 @@ -689,12 +1395,12 @@ Huge thanks to Sasiko for reporting these issues. # v2.12.0 ### Important -**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is due to the fact that we are providing a free service without getting anything in return, and thus we do not have the resources to scale to accommodate for more users. +**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is because we are providing free service without getting anything in return. Thus we do not have the resources to scale to accommodate more users. We recommend using your own database for logs. In the future you will soon get a `backup` command so you can download all your pre-existing data and migrate to your own database.** ### Changed - A lot of painful code cleanup, which is good for us (the developers), but shouldn't affect you. -- The appearance of the `logs` command. Should be clearer with better info now. +- The appearance of the `?logs` command. It should be clearer with better info now. - Bot owners get access to all commands regardless of server permissions. - Blocked users no longer receive a message, only the blocked emoji will be sent. @@ -702,10 +1408,10 @@ We recommend using your own database for logs. In the future you will soon get a - **Note:** The following commands only work if you are self-hosting your logs. We recommend you to use your own database. - Log search queries, in the form of two new commands. - `logs search [query]` - this searches all log messages for a query string. -- `logs closed-by [user]` this returns all logs closed by a certain user +- `logs closed-by [user]` this returns all logs closed by a particular user ### Fixed -- `activity listening to music` no longer result in two "to"s ("listening to to music"). +- `activity listening to music` no longer results in two "to"s ("listening to to music"). - This may require you to change your activity message to accommodate this fix. - A problem where `main_category_id` and `log_channel_id` weren't updated when their corresponding channel or category get deleted. @@ -728,7 +1434,7 @@ We recommend using your own database for logs. In the future you will soon get a ### What's new? - `anonreply` command to anonymously reply to the recipient. -The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message). The avatar defaults the guild icon URL. However you can change both of these via the `anon_username`, `anon_avatar_url` and `anon_tag` config variables. +The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message) — the avatar defaults to the guild icon URL. However, you can change both of these via the `anon_username`, `anon_avatar_url`, and `anon_tag` config variables. ### Changed - Your bot now logs all messages sent in a thread channel, including discussions that take place. You can now toggle to view them in the log viewer app. @@ -753,7 +1459,7 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of - All commands are now blurple instead of green. ### Fixed -- Bug where the close command wouldn't work if you didnt configure a log channel. +- Bug where the close command wouldn't work if you didn't configure a log channel. ### What's new? - Ability to set your own custom `mod_color` and `recipient_color` for the thread message embeds. @@ -770,13 +1476,13 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of # v2.9.0 ### What's new? -- New command `note` will add a system message to your thread logs. This is useful for noting the context of a conversation. +- New command `note` will add a system message to your thread logs. - - This is useful for noting the context of a conversation. # v2.8.1 ### Fixed - Fixed bug where thread logs were getting duplicated when using the `contact` command. -- Fixed bug where the wrong key was used for logs which caused some `log` command log links to point to an HTTP 404 Not Found. +- Fixed bug where the wrong key was used for logs, which caused some `log` command log links to point to an HTTP 404 Not Found. - A minor oversight from commit 1ba74d9. # v2.8.0 @@ -793,7 +1499,7 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of ### Security Thread channels will now default to being private (`@everyone`'s read message perms set to `false`). - If the thread creation category could not be resolved. - - This will save you from some trouble if for whatever reason your configuration gets messed up. + - This will save you from some trouble if, for whatever reason, your configuration gets messed up. # v2.7.1 @@ -805,20 +1511,20 @@ Thread channels will now default to being private (`@everyone`'s read message pe ### Note -- If your Modmail bot was set up a long time ago, you may experience an issue where messages were sent outside of the category. +- If your Modmail bot was set up a long time ago, you might experience an issue where messages were sent outside of the category. - To fix this, set `main_category_id` to the ID of the Modmail category. # v2.7.0 ### Changed -- `move` command now syncs thread channel permissions with the category that it was moved to. +- `move` command now syncs thread channel permissions with the destination category. - `contact` command now supports an optional category argument (where the thread channel will be created). # v2.6.3 ### Fixes -- Fixed small issue with finding thread. +- Fixed small issue with finding threads. # v2.6.2 @@ -838,8 +1544,8 @@ Thread channels will now default to being private (`@everyone`'s read message pe ### Changed - Log URLs are moved to their own collection. - Log URLs are now `https://logs.modmail.tk/LOGKEY`, no more numbers before the log key. -- We still support the numbers so as to not break everyone's URLs so quickly but both work at the moment. -- This is a huge change to the backend logging and there might be migration errors. If so, please contact us in our [discord server](https://discord.gg/2fMbf2N). +- We still support the numbers to not break everyone's URLs so quickly, but both work at the moment. +- This is a huge change to the backend logging, and there might be migration errors. If so, please contact us in our [Discord server](https://discord.gg/2fMbf2N). # v2.5.2 @@ -849,14 +1555,14 @@ Thread channels will now default to being private (`@everyone`'s read message pe # v2.5.1 ### Fixes -- Emergency patch to save config. +- Emergency patch to save configs. # v2.5.0 ### Background - Bots hosted by Heroku restart at least once every 27 hours. -- During this period, local caches are 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) +- During this period, local caches will be deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. +- [PR #135](https://github.com/modmail-dev/modmail/pull/135) ### Changed - Created a new internal config var: `closures`. @@ -873,7 +1579,7 @@ Fixed activity setting due to flawed logic in `config.get()` function. # v2.4.4 ### Fixed -Fixed a bug in activity command where it would fail to set the activity on bot restart if the activity type was `playing`. +Fixed a bug in the `?activity` command where it would fail to set the activity on bot restart if the activity type was `playing`. # v2.4.3 @@ -888,17 +1594,17 @@ Fixed a bug in activity command where it would fail to set the activity on bot r # v2.4.1 ### Fixed -- Small bug in `activity` command. +- Small bug in `?activity` command. # v2.4.0 ### 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`). +- Added the `?activity` command for setting the activity +- [PR #131](https://github.com/modmail-dev/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). ### Removed - Removed the deprecated `status` command. -- This also means you will have to reset your bot status with the `activity` command, as `status` command is removed. +- This also means you will have to reset your bot status with the `?activity` command, as the `?status` command was removed. # v2.3.0 @@ -919,7 +1625,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### What's new? - Notify command `notify [role]`. - Notify a given role or yourself to the next thread message received. - - Once a thread message is received you will be pinged once only. + - Once a thread message is received, you will be pinged once only. - Subscribe command `sub [role]` / `unsub [role]`. - Subscribes yourself or a given role to be notified when thread messages are received. @@ -936,15 +1642,15 @@ Fixed a bug in activity command where it would fail to set the activity on bot r # v2.1.0 ### What's new? -- Ability to set a custom thread creation response message. +- Ability to set a custom thread-creation-response message. - Via `config set thread_creation_response [message]`. ### Changed -- Improve logs command format. -- Improve thread log channel message to have more relevant info. +- Improve `?logs` command format. +- Improve thread log channel messages to have more relevant info. - Improve close command. - - You now can close the thread after a delay and use a custom thread close message. - - You also now have the ability to close a thread silently. + - You can now close the thread after a delay and use a custom thread close message. + - You also now can close a thread silently. # v2.0.10 @@ -959,7 +1665,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Fixes - Support multiple images and file attachments in one message. -- This is only possible on mobile so its good to handle it in code. +- This is only possible on mobile, so its good to handle it in code. # v2.0.8 @@ -970,7 +1676,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r - You can do this via the `config set main_category_id ` command. ### Changed -- You now have the ability to supply a reason when blocking a user. +- You can now supply a reason when blocking a user. - Blocked users are now stored in the database instead of in the channel topic. - This means you can delete the top channel in the Modmail category now (after migrating the currently blocked users). @@ -981,8 +1687,8 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - `update` command now shows the latest changes directly from CHANGELOG.md. -- Auto update messages also show the latest changes from the GitHub repo. -- Removed "latest changes" section from the `about` command. +- Auto-update messages also show the latest changes from the GitHub repo. +- Removed the "latest changes" section from the `about` command. # v2.0.6 @@ -995,20 +1701,20 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - `alias` command now checks if you are adding a valid alias-command combo. -- Deleting a channel manually will now correctly close the thread and post logs. +- Manually deleting a channel will now correctly close the thread and post logs. # v2.0.4 ### Fixed -- Fixed a one-off bug where the channel topic disappears, but Modmail operations should still continue. +- Fixed a one-off bug where the channel topic disappears, but Modmail operations should continue. - Fixed `linked_message_id` issues. # v2.0.3 ### Fixed -- Thread creation embed now shows the correct number of past logs. +- The thread creation embed now shows the correct number of past logs. - If using a separate server setup, roles in the info embed now are shown as names instead of mentions. - - This is due to the fact that you can't mention roles across servers. + - This is because you can't mention roles across servers. # v2.0.2 @@ -1020,16 +1726,16 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - Improved `block` / `unblock` commands. - - They now take a wider range of arguments: usernames, nicknames, mentions and user IDs. + - They now take a more comprehensive range of arguments: usernames, nicknames, mentions, and user IDs. ### Fixed - Setup command now configures permissions correctly so that the bot will always be able to see the main operations category. # v2.0.0 -This release introduces the use of our centralized [API service](https://github.com/kyb3r/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). +This release introduces the use of our centralized [API service](https://github.com/modmail-dev/webserver) to enable dynamic configuration, auto-updates, and thread logs. +To use this release, you must acquire an API token from https://modmail.tk. +Read the updated installation guide [here](https://github.com/modmail-dev/modmail/wiki/installation). ### Changed - Stability improvements through synchronization primitives. diff --git a/Dockerfile b/Dockerfile index f616ef29f3..246d3cf7a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,38 @@ -FROM python:3.7-alpine -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/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000000..b4283e3660 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,122 @@ +# Privacy Statement + +Hey, we are the lead developers of Modmail bot. This is a look into the data we collect, the data you collect, the data other parties collect, and what can be done about any of this data. +> **Disclaimer**: None of us are lawyers. We are just trying to be more transparent + +### TL;DR + +Yes, we collect some data to keep us happy. You collect some data to keep the bot functioning. External services also collect some data that is out of our control. + +## Interpretation + +- Modmail: This application that has been made open-source. +- Modmail Team: Lead developers, namely kyb3r, fourjr and taku. +- Bot: Your instance of the Modmail bot. +- Bot owner: The person managing the bot. +- Guild: A [server](https://discord.com/developers/docs/resources/guild#guild-resource), an isolated collection of users and channels, within Discord +- User: The end user, or server members, that interface with the bot. +- Database: A location where data is stored, hosted by the bot owner. The following types of database are currently supported: [MongoDB](#MongoDB). +- Logviewer: A webserver hosted by the bot owner. + +## The Data We Collect + +No data is being collected unless someone decides to host the bot and the bot is kept online. + +The Modmail Team collect some metadata to keep us updated on the number of instances that are making use of the bot and know what features we should focus on. The following is a list of data that we collect: +- Bot ID +- Bot username and discriminator +- Bot avatar URL +- Main guild ID +- Main guild name +- Main guild member count +- Bot uptime +- Bot latency +- Bot version +- Whether the bot is selfhosted + +No tokens/passwords/private data is ever being collected or sent to our servers. + +This metadata is sent to our centralised servers every hour that the bot is up and can be viewed in the bot logs when the `log_level` is set to `DEBUG`. + +As our bot is completely open-source, the part that details this behaviour is located in `bot.py > ModmailBot > post_metadata`. + +We assure you that the data is not being sold to anybody. + +### Opting out + +The bot owner can opt out of this data collection by setting `data_collection` to `off` within the configuration variables or the `.env` file. + +### Data deletion + +Data can be deleted with a request in a DM to our [support server](https://discord.gg/etJNHCQ)'s Modmail bot. + +## The Data You Collect + +When using the bot, the bot can collect various bits of user data to ensure that the bot can run smoothly. +This data is stored in a database instance that is hosted by the bot owner (more details below). + +When a thread is created, the bot saves the following data: +- Timestamp +- Log Key +- Channel ID +- Guild ID +- Bot ID +- Recipient ID +- Recipient Username and Discriminator +- Recipient Avatar URL +- Whether the recipient is a moderator + +When a message is sent in a thread, the bot saves the following data: +- Timestamp +- Message ID +- Message author ID +- Message author username and discriminator +- Message author avatar URL +- Whether the message author is a moderator +- Message content +- All attachment urls in the message + +This data is essential to have live logs for the web logviewer to function. +The Modmail team does not track any data by users. + +### Opting out + +There is no way for users or moderators to opt out from this data collection. + +### Data deletion + +Logs can be deleted using the `?logs delete ` command. This will remove all data from that specific log entry from the database permenantly. + +## The Data Other Parties Collect + +Plugins form a large part of the Modmail experience. Although we do not have any control over the data plugins collect, including plugins within our registry, all plugins are open-sourced by design. Some plugin devs may collect data beyond our control, and it is the bot owner's responsibility to check with the various plugin developers involved. + +We recommend 4 external services to be used when setting up the Modmail bot. +We have no control over the data external parties collect and it is up to the bot owner's choice as to which external service they choose to employ when using Modmail. +If you wish to opt out of any of this data collection, please view their own privacy policies and data collection information. We will not provide support for such a procedure. + +### Discord + +- [Discord Privacy Policy](https://discord.com/privacy) + +### Heroku + +- [Heroku Security](https://www.heroku.com/policy/security) +- [Salesforce Privacy Policy](https://www.salesforce.com/company/privacy/). + +### MongoDB + +- [MongoDB Privacy Policy](https://www.mongodb.com/legal/privacy-policy). + +### Github + +- [Github Privacy Statement](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-privacy-statement) + +## Maximum Privacy Setup + +For a maximum privacy setup, we recommend the following hosting procedure. We have included links to various help articles for each relevant step. We will not provide support for such a procedure. +- [Creating a local mongodb instance](https://zellwk.com/blog/local-mongodb/) +- [Hosting Modmail on your personal computer](https://taaku18.github.io/modmail/local-hosting/) +- Ensuring `data_collection` is set to `no` in the `.env` file. +- [Opt out of discord data collection](https://support.discord.com/hc/en-us/articles/360004109911-Data-Privacy-Controls) +- Do not use any plugins, setting `enable_plugins` to `no`. diff --git a/Pipfile b/Pipfile index 9173c3a5fe..6feb42454e 100644 --- a/Pipfile +++ b/Pipfile @@ -4,27 +4,32 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -black = "==19.3b0" -pylint = "*" -bandit = "==1.6.2" +bandit = ">=1.7.5" +black = "==23.11.0" +pylint = "==3.0.2" +tomli = "==2.2.1" [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" +aiohttp = "==3.13.2" +async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} +typing-extensions = "==4.15.0" +colorama = "==0.4.6" +"discord.py" = {version = "==2.6.3", extras = ["speed"]} +emoji = "==2.8.0" +isodate = "==0.6.1" +motor = "==3.7.1" natural = "==0.2.0" -isodate = ">=0.6.0" -dnspython = "~=1.16.0" -parsedatetime = "==2.5" -aiohttp = "<3.6.0,>=3.3.0" -python-dotenv = ">=0.10.3" -pipenv = "*" -"discord.py" = "==1.2.5" - -[requires] -python_version = "3.7" +packaging = "==23.2" +parsedatetime = "==2.6" +dnspython = "==2.8.0" +pymongo = "==4.15.3" +python-dateutil = "==2.8.2" +python-dotenv = "==1.0.0" +uvloop = {version = "==0.22.1", markers = "sys_platform != 'win32'"} +lottie = {version = "==0.7.2", extras = ["pdf"]} +setuptools = "==80.9.0" +requests = "==2.31.0" +orjson = "==3.11.4" [scripts] bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index 06d263645b..011514b29b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,12 +1,10 @@ { "_meta": { "hash": { - "sha256": "c2eb0898f236534a02cb1c198d74c82fed052b4445e39f99c1af3e58d22aa435" + "sha256": "6dc9fd3ca0aa2c413384ee16afb30290a840f6755cbf0bf828d0661171604db4" }, "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, + "requires": {}, "sources": [ { "name": "pypi", @@ -16,137 +14,879 @@ ] }, "default": { + "aiohappyeyeballs": { + "hashes": [ + "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", + "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" + ], + "markers": "python_version >= '3.9'", + "version": "==2.6.1" + }, "aiohttp": { "hashes": [ - "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55", - "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed", - "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10", - "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5", - "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1", - "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939", - "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390", - "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa", - "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc", - "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5", - "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d", - "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf", - "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6", - "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72", - "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12", - "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366", - "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4", - "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300", - "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d", - "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303", - "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6", - "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889" + "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329", + "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", + "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7", + "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", + "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", + "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", + "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", + "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5", + "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", + "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", + "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca", + "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", + "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", + "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", + "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", + "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", + "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6", + "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", + "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", + "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", + "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b", + "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa", + "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", + "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", + "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", + "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", + "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", + "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07", + "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", + "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", + "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", + "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", + "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", + "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d", + "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", + "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", + "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", + "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", + "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", + "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", + "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", + "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", + "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", + "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", + "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", + "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", + "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", + "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", + "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", + "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d", + "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989", + "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", + "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", + "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", + "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", + "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", + "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac", + "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", + "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", + "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", + "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", + "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", + "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", + "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", + "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", + "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", + "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", + "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5", + "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", + "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", + "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", + "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084", + "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", + "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", + "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", + "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", + "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", + "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", + "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", + "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", + "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", + "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", + "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", + "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", + "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", + "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", + "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", + "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", + "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", + "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", + "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700", + "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", + "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901", + "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2", + "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", + "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", + "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", + "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", + "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", + "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", + "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", + "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", + "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", + "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", + "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", + "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", + "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", + "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", + "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", + "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", + "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", + "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", + "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", + "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", + "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", + "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", + "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", + "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", + "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", + "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a" ], "index": "pypi", - "version": "==3.5.4" + "markers": "python_version >= '3.9'", + "version": "==3.13.2" + }, + "aiosignal": { + "hashes": [ + "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", + "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" + ], + "markers": "python_version >= '3.9'", + "version": "==1.4.0" }, "async-timeout": { "hashes": [ - "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", - "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" + "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", + "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" ], - "version": "==3.0.1" + "markers": "python_version < '3.11'", + "version": "==5.0.1" }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", + "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" ], - "version": "==19.3.0" + "markers": "python_version >= '3.9'", + "version": "==25.4.0" + }, + "brotli": { + "hashes": [ + "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", + "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", + "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4", + "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", + "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", + "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470", + "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", + "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", + "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", + "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502", + "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937", + "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7", + "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", + "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", + "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17", + "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc", + "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", + "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971", + "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", + "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d", + "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", + "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", + "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", + "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", + "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", + "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a", + "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", + "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", + "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0", + "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46", + "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", + "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8", + "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", + "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3", + "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a", + "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6", + "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64", + "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", + "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", + "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", + "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5", + "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a", + "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", + "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", + "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", + "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982", + "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f", + "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b", + "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", + "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518", + "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", + "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", + "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16", + "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a", + "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", + "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1", + "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190", + "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", + "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e", + "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", + "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea", + "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8", + "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", + "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", + "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526", + "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", + "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92", + "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12", + "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", + "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8", + "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", + "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", + "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", + "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", + "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", + "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", + "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb", + "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533", + "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8", + "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2", + "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69", + "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96", + "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49", + "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", + "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", + "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f", + "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", + "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7", + "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", + "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", + "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8", + "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990", + "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e", + "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", + "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675", + "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", + "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c", + "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13", + "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", + "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d" + ], + "version": "==1.2.0" + }, + "cairocffi": { + "hashes": [ + "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", + "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f" + ], + "markers": "python_version >= '3.8'", + "version": "==1.7.1" + }, + "cairosvg": { + "hashes": [ + "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", + "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5" + ], + "markers": "python_version >= '3.9'", + "version": "==2.8.2" }, "certifi": { "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", + "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" ], - "version": "==2019.11.28" + "markers": "python_version >= '3.7'", + "version": "==2025.11.12" }, - "chardet": { + "cffi": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", + "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", + "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", + "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", + "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", + "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", + "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", + "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", + "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", + "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", + "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", + "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", + "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", + "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", + "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", + "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", + "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", + "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", + "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", + "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", + "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", + "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", + "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", + "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", + "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", + "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", + "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", + "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", + "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", + "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", + "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", + "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", + "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", + "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", + "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", + "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", + "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", + "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", + "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", + "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", + "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", + "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", + "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", + "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", + "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", + "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", + "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", + "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", + "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", + "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", + "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", + "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", + "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", + "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", + "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", + "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", + "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", + "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", + "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", + "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", + "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", + "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", + "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", + "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", + "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", + "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", + "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", + "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", + "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", + "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", + "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", + "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", + "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", + "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", + "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", + "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", + "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", + "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", + "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", + "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", + "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", + "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", + "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", + "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" ], - "version": "==3.0.4" + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" }, "colorama": { "hashes": [ - "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", - "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], "index": "pypi", - "version": "==0.4.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "cssselect2": { + "hashes": [ + "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", + "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a" + ], + "markers": "python_version >= '3.9'", + "version": "==0.8.0" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" }, "discord.py": { + "extras": [ + "speed" + ], "hashes": [ - "sha256:7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d" + "sha256:69835269d73d9889a2f0efff4c91264a18998db0fdc4295a3c886fe9196dea4e", + "sha256:92bb3ef9dbe08525803be1e357bc0191f59ae16956690fc96c34f40bcd02c649" ], - "index": "pypi", - "version": "==1.2.5" + "markers": "python_version >= '3.8'", + "version": "==2.6.3" }, "dnspython": { "hashes": [ - "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", - "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d" + "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" ], "index": "pypi", - "version": "==1.16.0" + "markers": "python_version >= '3.10'", + "version": "==2.8.0" }, "emoji": { "hashes": [ - "sha256:60652d3a2dcee5b8af8acb097c31776fb6d808027aeb7221830f72cdafefc174" + "sha256:8d8b5dec3c507444b58890e598fc895fcec022b3f5acb49497c6ccc5208b8b00", + "sha256:a8468fd836b7ecb6d1eac054c9a591701ce0ccd6c6f7779ad71b66f76664df90" ], "index": "pypi", - "version": "==0.5.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.0" + }, + "frozenlist": { + "hashes": [ + "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", + "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", + "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", + "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", + "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", + "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", + "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", + "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", + "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", + "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", + "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", + "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", + "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", + "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", + "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", + "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", + "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", + "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", + "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", + "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", + "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", + "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", + "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", + "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", + "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", + "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", + "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", + "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", + "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", + "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", + "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", + "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", + "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", + "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", + "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", + "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", + "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", + "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", + "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", + "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", + "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", + "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", + "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", + "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", + "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", + "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", + "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", + "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", + "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", + "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", + "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", + "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", + "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", + "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", + "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", + "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", + "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", + "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", + "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", + "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", + "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", + "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", + "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", + "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", + "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", + "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", + "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", + "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", + "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", + "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", + "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", + "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", + "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", + "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", + "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", + "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", + "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", + "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", + "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", + "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", + "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", + "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", + "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", + "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", + "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", + "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", + "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", + "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", + "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", + "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", + "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", + "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", + "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", + "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", + "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", + "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", + "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", + "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", + "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", + "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", + "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", + "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", + "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", + "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", + "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", + "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", + "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", + "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", + "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", + "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", + "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", + "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", + "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", + "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", + "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", + "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", + "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", + "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", + "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", + "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", + "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", + "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", + "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", + "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", + "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", + "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", + "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", + "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", + "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", + "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" + ], + "markers": "python_version >= '3.9'", + "version": "==1.8.0" }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" ], - "version": "==2.8" + "markers": "python_version >= '3.8'", + "version": "==3.11" }, "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:85304ac152cd042c9fe98a527b5d136a31c6fe2aacd859ce1c58189bb946d127" + ], + "markers": "python_version >= '3'", + "version": "==0.7.2" }, "motor": { "hashes": [ - "sha256:599719bc6dcddc3b9ea4e09659fb0073d5fadcc24735999b2902f48cef33f909", - "sha256:756c587985d166166e644ccd36fb8b586fb987eb42fc0fc60cce9a3d76d809b4", - "sha256:97b4fc0a00a84df30f866d18693c503eef46c7642f75218a2c44d74d835be38a" + "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", + "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298" ], "index": "pypi", - "version": "==2.1.0" + "markers": "python_version >= '3.9'", + "version": "==3.7.1" }, "multidict": { "hashes": [ - "sha256:09c19f642e055550c9319d5123221b7e07fc79bda58122aa93910e52f2ab2f29", - "sha256:0c1a5d5f7aa7189f7b83c4411c2af8f1d38d69c4360d5de3eea129c65d8d7ce2", - "sha256:12f22980e7ed0972a969520fb1e55682c9fca89a68b21b49ec43132e680be812", - "sha256:258660e9d6b52de1a75097944e12718d3aa59adc611b703361e3577d69167aaf", - "sha256:3374a23e707848f27b3438500db0c69eca82929337656fce556bd70031fbda74", - "sha256:503b7fce0054c73aa631cc910a470052df33d599f3401f3b77e54d31182525d5", - "sha256:6ce55f2c45ffc90239aab625bb1b4864eef33f73ea88487ef968291fbf09fb3f", - "sha256:725496dde5730f4ad0a627e1a58e2620c1bde0ad1c8080aae15d583eb23344ce", - "sha256:a3721078beff247d0cd4fb19d915c2c25f90907cf8d6cd49d0413a24915577c6", - "sha256:ba566518550f81daca649eded8b5c7dd09210a854637c82351410aa15c49324a", - "sha256:c42362750a51a15dc905cb891658f822ee5021bfbea898c03aa1ed833e2248a5", - "sha256:cf14aaf2ab067ca10bca0b14d5cbd751dd249e65d371734bc0e47ddd8fafc175", - "sha256:cf24e15986762f0e75a622eb19cfe39a042e952b8afba3e7408835b9af2be4fb", - "sha256:d7b6da08538302c5245cd3103f333655ba7f274915f1f5121c4f4b5fbdb3febe", - "sha256:e27e13b9ff0a914a6b8fb7e4947d4ac6be8e4f61ede17edffabd088817df9e26", - "sha256:e53b205f8afd76fc6c942ef39e8ee7c519c775d336291d32874082a87802c67c", - "sha256:ec804fc5f68695d91c24d716020278fcffd50890492690a7e1fef2e741f7172c" - ], - "version": "==4.7.1" + "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", + "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", + "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", + "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", + "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", + "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", + "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", + "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", + "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", + "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", + "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", + "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", + "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", + "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", + "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", + "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", + "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", + "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", + "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", + "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", + "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", + "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", + "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", + "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", + "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", + "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", + "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", + "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", + "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", + "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", + "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", + "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c", + "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", + "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6", + "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", + "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", + "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84", + "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", + "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", + "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", + "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", + "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", + "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", + "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", + "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", + "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", + "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", + "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", + "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", + "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", + "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", + "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", + "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", + "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4", + "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", + "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", + "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", + "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", + "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", + "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", + "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c", + "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", + "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546", + "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", + "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", + "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", + "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", + "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", + "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", + "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", + "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f", + "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", + "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e", + "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", + "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", + "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", + "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", + "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851", + "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb", + "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32", + "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", + "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", + "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", + "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", + "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", + "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", + "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", + "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5", + "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", + "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", + "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", + "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", + "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", + "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", + "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", + "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", + "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", + "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", + "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", + "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", + "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", + "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", + "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", + "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", + "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", + "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", + "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", + "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", + "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", + "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329", + "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", + "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", + "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", + "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", + "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", + "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", + "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", + "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", + "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", + "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", + "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", + "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91", + "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", + "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", + "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", + "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40", + "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", + "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", + "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", + "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", + "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", + "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", + "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", + "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", + "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", + "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e", + "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", + "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", + "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", + "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", + "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", + "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", + "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687", + "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e", + "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", + "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7" + ], + "markers": "python_version >= '3.9'", + "version": "==6.7.0" }, "natural": { "hashes": [ @@ -155,372 +895,1101 @@ "index": "pypi", "version": "==0.2.0" }, - "parsedatetime": { + "orjson": { "hashes": [ - "sha256:3b835fc54e472c17ef447be37458b400e3fefdf14bb1ffdedb5d2c853acf4ba1", - "sha256:d2e9ddb1e463de871d32088a3f3cea3dc8282b1b2800e081bd0ef86900451667" + "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9", + "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", + "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", + "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", + "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", + "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10", + "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350", + "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", + "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", + "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13", + "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", + "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc", + "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", + "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac", + "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", + "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c", + "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", + "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", + "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905", + "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907", + "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a", + "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a", + "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b", + "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b", + "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", + "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", + "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", + "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d", + "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", + "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63", + "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39", + "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", + "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc", + "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", + "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", + "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23", + "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", + "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045", + "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", + "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9", + "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", + "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", + "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", + "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", + "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19", + "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549", + "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c", + "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040", + "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", + "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", + "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827", + "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be", + "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", + "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", + "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", + "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea", + "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44", + "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", + "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", + "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", + "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7", + "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f", + "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", + "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", + "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", + "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d", + "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", + "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", + "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", + "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919", + "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", + "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", + "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79", + "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", + "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", + "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175", + "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba", + "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6", + "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a", + "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", + "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1", + "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", + "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", + "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", + "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3", + "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1", + "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff" ], "index": "pypi", - "version": "==2.5" + "markers": "python_version >= '3.9'", + "version": "==3.11.4" }, - "pipenv": { + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "parsedatetime": { "hashes": [ - "sha256:56ad5f5cb48f1e58878e14525a6e3129d4306049cb76d2f6a3e95df0d5fc6330", - "sha256:7df8e33a2387de6f537836f48ac6fcd94eda6ed9ba3d5e3fd52e35b5bc7ff49e", - "sha256:a673e606e8452185e9817a987572b55360f4d28b50831ef3b42ac3cab3fee846" + "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", + "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" ], "index": "pypi", - "version": "==2018.11.26" + "version": "==2.6" + }, + "pillow": { + "hashes": [ + "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", + "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", + "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", + "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", + "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", + "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", + "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", + "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", + "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", + "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", + "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", + "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", + "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", + "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", + "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", + "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", + "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", + "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", + "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", + "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", + "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", + "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", + "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", + "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", + "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", + "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", + "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", + "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", + "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", + "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", + "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", + "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", + "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", + "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", + "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", + "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", + "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", + "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", + "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", + "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", + "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", + "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", + "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", + "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", + "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", + "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", + "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", + "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", + "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", + "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", + "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", + "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", + "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", + "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", + "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", + "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", + "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", + "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", + "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", + "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", + "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", + "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", + "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", + "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", + "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", + "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", + "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", + "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", + "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", + "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", + "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", + "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", + "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", + "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", + "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", + "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", + "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", + "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", + "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", + "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", + "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", + "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", + "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", + "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", + "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", + "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", + "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", + "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", + "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", + "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", + "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1" + ], + "markers": "python_version >= '3.10'", + "version": "==12.0.0" + }, + "propcache": { + "hashes": [ + "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", + "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", + "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", + "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", + "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", + "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", + "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", + "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", + "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", + "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", + "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", + "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", + "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", + "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", + "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", + "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", + "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", + "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", + "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", + "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", + "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", + "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", + "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", + "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", + "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", + "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", + "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", + "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", + "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", + "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", + "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", + "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", + "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", + "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", + "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", + "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", + "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", + "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", + "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", + "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", + "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", + "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", + "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", + "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", + "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", + "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", + "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", + "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", + "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", + "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", + "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", + "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", + "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", + "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", + "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", + "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", + "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", + "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", + "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", + "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", + "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", + "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", + "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", + "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", + "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", + "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", + "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", + "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", + "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", + "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", + "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", + "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", + "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", + "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", + "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", + "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", + "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", + "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", + "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", + "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", + "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", + "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", + "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", + "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", + "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", + "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", + "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", + "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", + "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", + "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", + "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", + "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", + "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", + "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", + "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", + "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", + "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", + "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", + "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", + "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", + "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", + "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", + "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", + "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", + "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", + "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", + "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", + "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", + "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", + "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", + "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", + "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", + "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", + "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", + "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", + "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", + "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", + "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", + "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", + "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", + "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", + "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781" + ], + "markers": "python_version >= '3.9'", + "version": "==0.4.1" + }, + "pycparser": { + "hashes": [ + "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", + "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934" + ], + "markers": "python_version >= '3.8'", + "version": "==2.23" }, "pymongo": { "hashes": [ - "sha256:0369136c6e79c5edc16aa5de2b48a1b1c1fe5e6f7fc5915a2deaa98bd6e9dad5", - "sha256:08364e1bea1507c516b18b826ec790cb90433aec2f235033ec5eecfd1011633b", - "sha256:0af1d2bc8cc9503bf92ec3669a77ec3a6d7938193b583fb867b7e9696eed52e8", - "sha256:0cfd1aeeb8c0a634646ab3ebeb4ce6828b94b2e33553a69ff7e6c07c250bf201", - "sha256:1b4a13dff15641e58620524db15d7a323d60572b2b187261c5cb58c36d74778d", - "sha256:22fbdb908257f9aaaa372a7684f3e094a05ca52eb84f8f381c8b1827c49556fd", - "sha256:264272fd1c95fc48002ad85d5e41270831777b4180f2500943e45e12b2a3ab43", - "sha256:3372e98eebbfd05ebf020388003f8a4438bed41e0fef1ef696d2c13633c416c8", - "sha256:339d24ecdc42745d2dc09b26fda8151988e806ca81134a7bd10513c4031d91e1", - "sha256:38281855fc3961ba5510fbb503b8d16cc1fcb326e9f7ba0dd096ed4eb72a7084", - "sha256:4acdd2e16392472bfd49ca49038845c95e5254b5af862b55f7f2cc79aa258886", - "sha256:4e0c006bc6e98e861b678432e05bf64ba3eb889b6ab7e7bf1ebaecf9f1ba0e58", - "sha256:4e4284bcbe4b7be1b37f9641509085b715c478e7fbf8f820358362b5dd359379", - "sha256:4e5e94a5f9823f0bd0c56012a57650bc6772636c29d83d253260c26b908fcfd9", - "sha256:4e61f30800a40f1770b2ec56bbf5dc0f0e3f7e9250eb05fa4feb9ccb7bbe39ca", - "sha256:53577cf57ba9d93b58ab41d45250277828ff83c5286dde14f855e4b17ec19976", - "sha256:681cb31e8631882804a6cc3c8cc8f54a74ff3a82261a78e50f20c5eec05ac855", - "sha256:6dfc2710f43dd1d66991a0f160d196356732ccc8aa9dbc6875aeba78388fa142", - "sha256:72218201b13d8169be5736417987e9a0a3b10d4349e40e4db7a6a5ac670c7ef2", - "sha256:7247fbcdbf7ab574eb70743461b3cfc14d9cfae3f27a9afb6ce14d87f67dd0b5", - "sha256:72651f4b4adf50201891580506c8cca465d94d38f26ed92abfc56440662c723c", - "sha256:87b3aaf12ad6a9b5570b12d2a4b8802757cb3588a903aafd3c25f07f9caf07e3", - "sha256:87c28b7b37617c5a01eb396487f7d3b61a453e1fa0475a175ab87712d6f5d52f", - "sha256:88efe627b628f36ef53f09abb218d4630f83d8ebde7028689439559475c43dae", - "sha256:89bfbca22266f12df7fb80092b7c876734751d02b93789580b68957ad4a8bf56", - "sha256:908a3caf348a672b28b8a06fe7b4a27c2fdcf7f873df671e4027d48bcd7f971f", - "sha256:9128e7bea85f3a3041306fa14a7aa82a24b47881918500e1b8396dd1c933b5a6", - "sha256:9737d6d688a15b8d5c0bfa909638b79261e195be817b9f1be79c722bbb23cd76", - "sha256:98a8305da158f46e99e7e51db49a2f8b5fcdd7683ea7083988ccb9c4450507a6", - "sha256:99285cd44c756f0900cbdb5fe75f567c0a76a273b7e0467f23cb76f47e60aac0", - "sha256:9ed568f8026ffeb00ce31e5351e0d09d704cc19a29549ba4da0ac145d2a26fdf", - "sha256:a006162035032021dfd00a879643dc06863dac275f9210d843278566c719eebc", - "sha256:a03cb336bc8d25a11ff33b94967478a9775b0d2b23b39e952d9cc6cb93b75d69", - "sha256:a863ceb67be163060d1099b7e89b6dd83d6dd50077c7ceae31ac844c4c2baff9", - "sha256:b82628eaf0a16c1f50e1c205fd1dd406d7874037dd84643da89e91b5043b5e82", - "sha256:bc6446a41fb7eeaf2c808bab961b9bac81db0f5de69eab74eebe1b8b072399f7", - "sha256:c42d290ed54096355838421cf9d2a56e150cb533304d2439ef1adf612a986eaf", - "sha256:c43879fe427ea6aa6e84dae9fbdc5aa14428a4cfe613fe0fee2cc004bf3f307c", - "sha256:c566cbdd1863ba3ccf838656a1403c3c81fdb57cbe3fdd3515be7c9616763d33", - "sha256:c5b7a0d7e6ca986de32b269b6dbbd5162c1a776ece72936f55decb4d1b197ee9", - "sha256:ca109fe9f74da4930590bb589eb8fdf80e5d19f5cd9f337815cac9309bbd0a76", - "sha256:d0260ba68f9bafd8775b2988b5aeace6e69a37593ec256e23e150c808160c05c", - "sha256:d2ce33501149b373118fcfec88a292a87ef0b333fb30c7c6aac72fe64700bdf6", - "sha256:d582ea8496e2a0e124e927a67dca55c8833f0dbfbc2c84aaf0e5949a2dd30c51", - "sha256:d68b9ab0a900582a345fb279675b0ad4fac07d6a8c2678f12910d55083b7240d", - "sha256:dbf1fa571db6006907aeaf6473580aaa76041f4f3cd1ff8a0039fd0f40b83f6d", - "sha256:e032437a7d2b89dab880c79379d88059cee8019da0ff475d924c4ccab52db88f", - "sha256:e0f5798f3ad60695465a093e3d002f609c41fef3dcb97fcefae355d24d3274cf", - "sha256:e756355704a2cf91a7f4a649aa0bbf3bbd263018b9ed08f60198c262f4ee24b6", - "sha256:e824b4b87bd88cbeb25c8babeadbbaaaf06f02bbb95a93462b7c6193a064974e", - "sha256:ea1171470b52487152ed8bf27713cc2480dc8b0cd58e282a1bff742541efbfb8", - "sha256:fa19aef44d5ed8f798a8136ff981aedfa508edac3b1bed481eca5dde5f14fd3d", - "sha256:fceb6ae5a149a42766efb8344b0df6cfb21b55c55f360170abaddb11d43af0f1" - ], - "version": "==3.10.0" + "sha256:07bcc36d11252f24fe671e7e64044d39a13d997b0502c6401161f28cc144f584", + "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1", + "sha256:1246a82fa6dd73ac2c63aa7e463752d5d1ca91e0c7a23396b78f21273befd3a7", + "sha256:17d13458baf4a6a9f2e787d95adf8ec50d412accb9926a044bd1c41029c323b2", + "sha256:17fc94d1e067556b122eeb09e25c003268e8c0ea1f2f78e745b33bb59a1209c4", + "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834", + "sha256:21c0a95a4db72562fd0805e2f76496bf432ba2e27a5651f4b9c670466260c258", + "sha256:292fd5a3f045751a823a54cdea75809b2216a62cc5f74a1a96b337db613d46a8", + "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b", + "sha256:2f3d66f7c495efc3cfffa611b36075efe86da1860a7df75522a6fe499ee10383", + "sha256:2fd3b99520f2bb013960ac29dece1b43f2f1b6d94351ca33ba1b1211ecf79a09", + "sha256:300eaf83ad053e51966be1839324341b08eaf880d3dc63ada7942d5912e09c49", + "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e", + "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68", + "sha256:39a13d8f7141294404ce46dfbabb2f2d17e9b1192456651ae831fa351f86fbeb", + "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3", + "sha256:45aebbd369ca79b7c46eaea5b04d2e4afca4eda117b68965a07a9da05d774e4d", + "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259", + "sha256:482ca9b775747562ce1589df10c97a0e62a604ce5addf933e5819dd967c5e23c", + "sha256:49fd6e158cf75771b2685a8a221a40ab96010ae34dd116abd06371dc6c38ab60", + "sha256:4a0a054e9937ec8fdb465835509b176f6b032851c8648f6a5d1b19932d0eacd6", + "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8", + "sha256:58d0f4123855f05c0649f9b8ee083acc5b26e7f4afde137cd7b8dc03e9107ff3", + "sha256:5bf879a6ed70264574d4d8fb5a467c2a64dc76ecd72c0cb467c4464f849c8c77", + "sha256:5c78237e878e0296130e398151b0d4aa6c9eaf82e38fb6e0aaae2029bc7ef0ce", + "sha256:5c85a4c72b7965033f95c94c42dac27d886c01dbc23fe337ccb14f052a0ccc29", + "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08", + "sha256:6a054d282dd922ac400b6f47ea3ef58d8b940968d76d855da831dc739b7a04de", + "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a", + "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53", + "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f", + "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5", + "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385", + "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48", + "sha256:82a490f1ade4ec6a72068e3676b04c126e3043e69b38ec474a87c6444cf79098", + "sha256:86b1b5b63f4355adffc329733733a9b71fdad88f37a9dc41e163aed2130f9abc", + "sha256:89e45d7fa987f4e246cdf43ff001e3f911f73eb19ba9dabc2a6d80df5c97883b", + "sha256:8bd6dd736f5d07a825caf52c38916d5452edc0fac7aee43ec67aba6f61c2dbb7", + "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289", + "sha256:90ad56bd1d769d2f44af74f0fd0c276512361644a3c636350447994412cbc9a1", + "sha256:9483521c03f6017336f54445652ead3145154e8d3ea06418e52cea57fee43292", + "sha256:959ef69c5e687b6b749fbf2140c7062abdb4804df013ae0507caabf30cba6875", + "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8", + "sha256:982107c667921e896292f4be09c057e2f1a40c645c9bfc724af5dd5fb8398094", + "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b", + "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298", + "sha256:9bc9f99e7702fdb0dcc3ff1dd490adc5d20b3941ad41e58f887d4998b9922a14", + "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340", + "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010", + "sha256:b33d59bf6fa1ca1d7d96d4fccff51e41312358194190d53ef70a84c070f5287e", + "sha256:b3a0ec660d61efb91c16a5962ec937011fe3572c4338216831f102e53d294e5c", + "sha256:b63bac343b79bd209e830aac1f5d9d552ff415f23a924d3e51abbe3041265436", + "sha256:bd0497c564b0ae34fb816464ffc09986dd9ca29e2772a0f7af989e472fecc2ad", + "sha256:c4fdd8e6eab8ff77c1c8041792b5f760d48508623cd10b50d5639e73f1eec049", + "sha256:c57dad9f289d72af1d7c47a444c4d9fa401f951cedbbcc54c7dd0c2107d6d786", + "sha256:c7eb497519f42ac89c30919a51f80e68a070cfc2f3b0543cac74833cd45a6b9c", + "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b", + "sha256:d09d895c7f08bcbed4d2e96a00e52e9e545ae5a37b32d2dc10099b205a21fc6d", + "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37", + "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992", + "sha256:dc583a1130e2516440b93bb2ecb55cfdac6d5373615ae472a9d1f26801f58749", + "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4", + "sha256:de3bc878c3be54ae41c2cabc9e9407549ed4fec41f4e279c04e840dddd7c630c", + "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca", + "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7", + "sha256:f6b0513e5765fdde39f36e6a29a36c67071122b5efa748940ae51075beb5e4bc", + "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95", + "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d", + "sha256:fe4bcb8acfb288e238190397d4a699aeb4adb70e8545a6f4e44f99d4e8096ab1", + "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6", + "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.3" }, "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'", + "version": "==2.8.2" }, "python-dotenv": { "hashes": [ - "sha256:debd928b49dbc2bf68040566f55cdb3252458036464806f4094487244e2a4093", - "sha256:f157d71d5fec9d4bd5f51c82746b6344dffa680ee85217c123f4a0c8117c4544" + "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", + "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" ], "index": "pypi", - "version": "==0.10.3" + "markers": "python_version >= '3.8'", + "version": "==1.0.0" }, - "six": { + "requests": { "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], - "version": "==1.13.0" + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" }, - "uvloop": { + "setuptools": { "hashes": [ - "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", - "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", - "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", - "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", - "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", - "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", - "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", - "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", - "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362" + "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", + "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c" ], "index": "pypi", - "markers": "sys_platform != 'win32'", - "version": "==0.14.0" + "markers": "python_version >= '3.9'", + "version": "==80.9.0" }, - "virtualenv": { + "six": { "hashes": [ - "sha256:116655188441670978117d0ebb6451eb6a7526f9ae0796cc0dee6bd7356909b0", - "sha256:b57776b44f91511866594e477dd10e76a6eb44439cdd7f06dcd30ba4c5bd854f" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "version": "==16.7.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" }, - "virtualenv-clone": { + "tinycss2": { "hashes": [ - "sha256:532f789a5c88adf339506e3ca03326f20ee82fd08ee5586b44dc859b5b4468c5", - "sha256:c88ae171a11b087ea2513f260cdac9232461d8e9369bcd1dc143fc399d220557" + "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", + "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957" ], - "version": "==0.5.3" + "markers": "python_version >= '3.10'", + "version": "==1.5.1" }, - "websockets": { + "typing-extensions": { "hashes": [ - "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136", - "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6", - "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1", - "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538", - "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4", - "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908", - "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0", - "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d", - "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c", - "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d", - "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c", - "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb", - "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf", - "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e", - "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96", - "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584", - "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484", - "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d", - "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559", - "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff", - "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454" + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" ], - "version": "==6.0" + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" }, - "yarl": { + "urllib3": { "hashes": [ - "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", - "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", - "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", - "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", - "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", - "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", - "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", - "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", - "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", - "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", - "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", - "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", - "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", - "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", - "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", - "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", - "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" - ], - "version": "==1.4.2" - } - }, - "develop": { - "appdirs": { + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + }, + "uvloop": { "hashes": [ - "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", - "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" + "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772", + "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", + "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743", + "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54", + "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", + "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659", + "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", + "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", + "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7", + "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", + "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", + "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", + "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", + "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", + "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", + "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193", + "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", + "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", + "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", + "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", + "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", + "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242", + "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", + "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", + "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6", + "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", + "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", + "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", + "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", + "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", + "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", + "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa", + "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", + "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", + "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", + "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", + "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4", + "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", + "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", + "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", + "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", + "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", + "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820", + "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", + "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", + "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", + "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c", + "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", + "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" ], - "version": "==1.4.3" + "markers": "sys_platform != 'win32'", + "version": "==0.22.1" }, - "astroid": { + "webencodings": { "hashes": [ - "sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a", - "sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42" + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" ], - "version": "==2.3.3" + "version": "==0.5.1" }, - "attrs": { + "yarl": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", + "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", + "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", + "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", + "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", + "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", + "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", + "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", + "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", + "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", + "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", + "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", + "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", + "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", + "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", + "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", + "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", + "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", + "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", + "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", + "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", + "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", + "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", + "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", + "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", + "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", + "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", + "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", + "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", + "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", + "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", + "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", + "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", + "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", + "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", + "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", + "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", + "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", + "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", + "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", + "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", + "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", + "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", + "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", + "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", + "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", + "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", + "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", + "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", + "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", + "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", + "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", + "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", + "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", + "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", + "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", + "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", + "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", + "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", + "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", + "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", + "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", + "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", + "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", + "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", + "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", + "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", + "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", + "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", + "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", + "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", + "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", + "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", + "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", + "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", + "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", + "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", + "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", + "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", + "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", + "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", + "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", + "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", + "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", + "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", + "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", + "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", + "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", + "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", + "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", + "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", + "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", + "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", + "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", + "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", + "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", + "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", + "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", + "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", + "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", + "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", + "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", + "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", + "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", + "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", + "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", + "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", + "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", + "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", + "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", + "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", + "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", + "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", + "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", + "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", + "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", + "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", + "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", + "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", + "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", + "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", + "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", + "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", + "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", + "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", + "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", + "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", + "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", + "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", + "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" ], - "version": "==19.3.0" + "markers": "python_version >= '3.9'", + "version": "==1.22.0" + }, + "zstandard": { + "hashes": [ + "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", + "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", + "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3", + "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", + "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", + "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936", + "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", + "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", + "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", + "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", + "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", + "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", + "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", + "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", + "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362", + "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649", + "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb", + "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", + "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", + "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", + "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", + "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", + "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", + "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", + "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", + "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", + "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860", + "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", + "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53", + "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", + "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", + "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", + "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", + "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2", + "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", + "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", + "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", + "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388", + "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530", + "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577", + "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", + "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", + "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", + "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", + "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", + "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", + "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", + "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", + "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", + "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", + "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", + "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", + "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", + "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", + "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", + "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", + "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27", + "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", + "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", + "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", + "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", + "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", + "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", + "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", + "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", + "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", + "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", + "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", + "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df", + "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", + "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", + "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", + "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", + "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0", + "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", + "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", + "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", + "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7", + "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", + "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", + "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", + "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751", + "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", + "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", + "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", + "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", + "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", + "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", + "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", + "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", + "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", + "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", + "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", + "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", + "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", + "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", + "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", + "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", + "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01" + ], + "markers": "python_version >= '3.9'", + "version": "==0.25.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:4148645659b08b70d72460ed1921158027a9e53ae8b7234149b1400eddacbb93", + "sha256:92fcf218b89f449cdf9f7b39a269f8d5d617b27be68434912e11e79203963a17" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.3" }, "bandit": { "hashes": [ - "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952", - "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065" + "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce", + "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868" ], "index": "pypi", - "version": "==1.6.2" + "markers": "python_version >= '3.10'", + "version": "==1.9.2" }, "black": { "hashes": [ - "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", - "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c" + "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.3b0" + "markers": "python_version >= '3.8'", + "version": "==23.11.0" }, "click": { "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", + "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" ], - "version": "==7.0" + "markers": "python_version >= '3.10'", + "version": "==8.3.1" }, - "gitdb2": { + "colorama": { "hashes": [ - "sha256:1b6df1433567a51a4a9c1a5a0de977aa351a405cc56d7d35f3388bad1f630350", - "sha256:96bbb507d765a7f51eb802554a9cfe194a174582f772e0d89f4e87288c288b7b" + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], - "version": "==2.0.6" + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" }, - "gitpython": { + "dill": { "hashes": [ - "sha256:9c2398ffc3dcb3c40b27324b316f08a4f93ad646d5a6328cafbb871aa79f5e42", - "sha256:c155c6a2653593ccb300462f6ef533583a913e17857cfef8fc617c246b6dc245" + "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", + "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" ], - "version": "==3.0.5" + "markers": "python_version >= '3.8'", + "version": "==0.4.0" }, "isort": { "hashes": [ - "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", - "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" - ], - "version": "==4.3.21" - }, - "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" - ], - "version": "==1.4.3" + "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", + "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.13.2" + }, + "markdown-it-py": { + "hashes": [ + "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", + "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.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:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", + "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" + ], + "markers": "python_version >= '3.8'", + "version": "==1.1.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", + "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.5.0" }, - "pbr": { + "pygments": { "hashes": [ - "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b", - "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488" + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" ], - "version": "==5.4.4" + "markers": "python_version >= '3.8'", + "version": "==2.19.2" }, "pylint": { "hashes": [ - "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", - "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" + "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", + "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" ], "index": "pypi", - "version": "==2.4.4" + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.2" }, "pyyaml": { "hashes": [ - "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", - "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", - "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", - "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", - "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", - "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", - "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", - "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", - "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", - "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", - "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4" + "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", + "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", + "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", + "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", + "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", + "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", + "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", + "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", + "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", + "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", + "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", + "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", + "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", + "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", + "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", + "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", + "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", + "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", + "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", + "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", + "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", + "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", + "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", + "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", + "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", + "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", + "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", + "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", + "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", + "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", + "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", + "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", + "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", + "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", + "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", + "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", + "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", + "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", + "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", + "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", + "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", + "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", + "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", + "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", + "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", + "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", + "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", + "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", + "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", + "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", + "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", + "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", + "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", + "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", + "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", + "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", + "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", + "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", + "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", + "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", + "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", + "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", + "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", + "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", + "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", + "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", + "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", + "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", + "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", + "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", + "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", + "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", + "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" ], - "version": "==5.2" + "markers": "python_version >= '3.8'", + "version": "==6.0.3" }, - "six": { + "rich": { "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" + "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", + "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" ], - "version": "==1.13.0" + "markers": "python_full_version >= '3.8.0'", + "version": "==14.2.0" }, - "smmap2": { + "stevedore": { "hashes": [ - "sha256:0555a7bf4df71d1ef4218e4807bbf9b201f910174e6e08af2e138d4e517b4dde", - "sha256:29a9ffa0497e7f2be94ca0ed1ca1aa3cd4cf25a1f6b4f5f87f74b46ed91d609a" + "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", + "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945" ], - "version": "==2.0.5" + "markers": "python_version >= '3.10'", + "version": "==5.6.0" }, - "stevedore": { + "tomli": { "hashes": [ - "sha256:01d9f4beecf0fbd070ddb18e5efb10567801ba7ef3ddab0074f54e3cd4e91730", - "sha256:e0739f9739a681c7a1fda76a102b65295e96a144ccdb552f2ae03c5f0abe8a14" - ], - "version": "==1.31.0" - }, - "toml": { - "hashes": [ - "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", - "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" - ], - "version": "==0.10.0" - }, - "typed-ast": { - "hashes": [ - "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", - "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", - "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", - "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", - "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", - "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", - "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", - "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", - "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", - "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", - "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", - "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", - "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", - "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", - "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", - "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", - "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", - "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", - "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", - "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" - ], - "markers": "implementation_name == 'cpython' and python_version < '3.8'", - "version": "==1.4.0" + "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", + "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", + "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", + "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", + "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", + "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", + "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", + "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", + "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", + "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", + "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", + "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", + "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", + "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", + "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", + "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", + "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", + "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", + "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", + "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", + "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", + "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", + "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", + "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", + "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", + "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", + "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", + "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", + "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", + "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", + "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", + "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.2.1" }, - "wrapt": { + "tomlkit": { "hashes": [ - "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", + "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0" ], - "version": "==1.11.2" + "markers": "python_version >= '3.8'", + "version": "==0.13.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } } } diff --git a/Procfile b/Procfile index 5ae4640def..29cff6d9d1 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -worker: pipenv run bot +worker: python bot.py diff --git a/README.md b/README.md index d162f03bc3..58243cab61 100644 --- a/README.md +++ b/README.md @@ -6,40 +6,36 @@
- +
- + - - Bot instances + + Bot instances - - Support + + Support - - Python 3.7 + + Buy Me A Coffee - Made with Python 3.7 + Made with Python 3.10 - - - - - + MIT License @@ -50,31 +46,33 @@ ## What is Modmail? -Modmail is similar to Reddit's Modmail both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. +Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. + +This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**, cool benefits included! -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" within an isolated category. All further DM messages will automatically relay to that channel, for any available staff can respond within the channel. +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. -All threads are logged and you can view previous threads through their corresponding log link. Here is an [**example**](https://logs.logviewer.tech/example). +Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). ## Features * **Highly Customisable:** * Bot activity, prefix, category, log channel, etc. * Command permission system. - * Interface elements (color, responses, reactions, etc). + * Interface elements (color, responses, reactions, etc.). * Snippets and *command aliases*. * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). - * Minimum duration for members to be in the guild before allowed to contact Modmail (`guild_age`). + * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). * **Advanced Logging Functionality:** - * When you close a thread, Modmail will generate a [log link](https://logs.logviewer.tech/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)). + * Login via Discord to protect your logs ([premium feature](https://buymeacoffee.com/modmaildev/membership)). * See past logs of a user with `?logs`. * Searchable by text queries using `?logs search`. @@ -88,117 +86,90 @@ 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. This is to ensure the individuality to your server and grant you full control over your bot and data. Nonetheless, you can easily 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 - -This bot can be hosted 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, and/or want to support this project, we offer the all inclusive 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/). - -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. +There are a number of options for hosting your very own dedicated Modmail bot. -Clone the repo: +Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. -```console -$ git clone https://github.com/kyb3r/modmail -$ cd modmail -``` +### Paid Hosting -Install dependencies: +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 [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). -```console -$ pipenv install -``` +## FAQ -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. +**Q: Where can I find the Modmail bot invite link?** -Finally, start Modmail. +**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 [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). -```console -$ pipenv run bot -``` +**Q: Where can I find out more info about Modmail?** -#### Docker +**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. -This repo supplies a Dockerfile for simplified deployment. - -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/. Currently there are two community release of Modmail: - -- Kyber's: - -```console -$ docker pull kyb3rr/modmail -``` +## Plugins -- Taku'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 taaku18/modmail -# You can also choose one of the following: -$ docker pull taaku18/modmail:dev -$ docker pull taaku18/modmail: ( ex: 3.2.0, 3.2, etc.) -``` +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 user/modmail -``` -- Replace `user/modmail` with `kyb3rr/modmail`, `taaku18/modmail`, `taaku18/modmail:3.2`, etc as above. -- `.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. +SirReddit: +
- + - - - +
+
+Prime Servers Inc: +
+
+ + +
+
+Real Madrid: +
+ + + +
+
+Advertise Your Server: +
+ + + +
+
+Discord Advice Center: +
+ + + +
+
+Kistó Bakery: +
+ + -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. -This allows niche features as well as anything else outside of the scope of the core functionality of Modmail. +Become a sponsor on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev/membership). -A list of third-party plugins can be found 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. +## Contributing -To develop your own, check out the [plugins documentation](https://github.com/kyb3r/modmail/wiki/Plugins). +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. -Plugins requests and support is available in our [Modmail Plugins Server](https://discord.gg/4JE4XSW). +If you like this project and would like to show your appreciation, support us on **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**! -## Contributing +## Beta Testing -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 contribution [guidelines](https://github.com/kyb3r/modmail/blob/master/CONTRIBUTING.md) before you get started. +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 like this project and would like to show your appreciation, support us on **[Patreon](https://www.patreon.com/kyber)**! +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 d30134e015..955b839fd7 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)" } ] @@ -29,39 +29,123 @@ }, { "embed": { - "title": "Hey there!", - "description": "Nice to see you here! You can support us by subscribing on youtube -> [Youtube](https://www.youtube.com/user/RoomieOfficial) <- and also join our [Discord](https://discord.gg/zaeVCaV)!", - "url": "https://discord.gg/zaeVCaV", - "color": 13003681, + "title": "Berkand Karadere", + "description": "Berkand Karadere is an German Community Manager who integrated new systems into the game industry. He also is hosting and developing web servers and game servers. He also plays American Football for the Dortmund Giants and his journey has just begun.", + "color": 2968248, + "thumbnail": { + "url": "https://i.imgur.com/cs2QEcp.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click here**](https://discord.gg/BanCwptMJV)" + } + ] + } + }, + { + "embed": { + "description": "Quality Hosting at Prices You Deserve!", + "color": 3137203, "footer": { - "icon_url": "https://imgur.com/Mrc9pLd.gif", - "text": "everyone is a clown" + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", + "text": "Prime Servers, Inc." }, "thumbnail": { - "url": "https://imgur.com/Mrc9pLd.gif" - }, - "image": { - "url": "https://imgur.com/ZUFiL6b.gif" + "url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" }, "author": { - "name": "Roomieofficial", - "url": "https://discord.gg/zaeVCaV", - "icon_url": "https://imgur.com/6hBkt7Z.png" + "name": "Prime Servers, Inc.", + "url": "https://primeserversinc.com", + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" }, "fields": [ { - "name": "What is all this about 🤔", - "value": "We are mainly focused on everything that has anything to do with music or singing! " + "name": "Twitter", + "value": "[**Click Here**](https://twitter.com/PrimeServersInc)" }, { - "name": "Youtube 🙄", - "value": "U will get great content if you follow our [Youtube](https://www.youtube.com/user/RoomieOfficial) with weekly uploads." - }, + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/cYM6Urn)" + } + ] + } + }, + { + "embed": { + "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", + "color": 53380, + "author": { + "name": "Discord Advice Center", + "url": "https://discord.gg/nkMDQfuK", + "icon_url": "https://i.imgur.com/cjVtRw5.jpg" + }, + "image": { + "url": "https://i.imgur.com/1hrjcHd.png" + }, + "fields": [ { - "name": "Discord 😁", - "value": "Make sure to join our [Discord](https://discord.gg/zaeVCaV) We have weekly events, 24/7 chats and more!" + "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": { + "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", + "author": { + "name": "New York Roleplay", + "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + }, + "color": 431075, + "thumbnail": { + "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + } + } + }, + { + "embed": { + "title": "CityStore PLC", + "description": "*Your Retail Journey*\n*\"Better choice and better value in food, fashion & homewares.\"*\n\n\n**------------------------------------------**\n*__About us__*\nSupermarket, CityStore PLC! Attend a training to become staff!\n\nThis game is currently in V3\n\nWe have a training Centre and applications center!\n\n**------------------------------------------**\n\n> *❤️ Don't hesitate! Dive into the excitement today by joining our vibrant community on Discord. Experience our unique perspective and become an integral part of our group. Your **journey** with us promises to be unforgettable no regrets, only great memories await! ❤️*\n\n*We hope to see you. *\n\n*Signed,*\n**CityStore PLC**\n> Discord: https://discord.gg/yjFQb5mrSk\n> Roblox Group: https://www.roblox.com/groups/32819373/CityStore-PLC#!/about\n\nJoin us now and become apart of Citystore PLC community! 🎉", + "color": 15523550 + } + }, + { + "embed": { + "description": "✨ *\"Let's bake it!\"* ✨ \n\nKistó is a very successful and well-known **Bakery Group** on the platform. Its goal is to give every guest the **ultimate bakery experience**. \n\nWe have a wide variety of hand-made treats, from our rich **drinks** to our freshly baked **pastries**. Every item is made with care by our **skilled and passionate team**, redefining what it means to be a modern bakery. \n\n💖 Come visit us today and taste the *sweet side of perfection* at **Kistó Bakery.** \n\n**Roblox Group:** [Click here!](https://www.roblox.com/communities/9318596/Kist#!/about) \n**Discord Server:** [Click here!](https://discord.gg/aGt8Wv3gP9)", + "color": 16736255, + "author": { + "name": "Kistó Bakery", + "icon_url": "https://cdn.discordapp.com/attachments/1413609998797242522/1436882475883298887/noFilter_10_1.png" + }, + "footer": { + "text": "Proudly serving since 2021" + }, + "image": { + "url": "https://cdn.discordapp.com/attachments/1413609998797242522/1436882475543429260/0904.png" + } + } } ] diff --git a/app.json b/app.json index 76fe320a30..decd58695c 100644 --- a/app.json +++ b/app.json @@ -1,27 +1,39 @@ { - "name": "Modmail", - "description": "An easy to install Modmail bot for Discord - DM to contact mods!", - "repository": "https://github.com/kyb3r/modmail", - "env": { - "TOKEN": { - "description": "Your discord bot's token.", - "required": true - }, - "GUILD_ID": { - "description": "The id for the server you are hosting this bot for.", - "required": true - }, - "OWNERS": { - "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", - "required": true - }, - "MONGO_URI": { - "description": "Mongo DB connection URI for self-hosting your data.", - "required": true - }, - "LOG_URL": { - "description": "The url of the log viewer app for viewing self-hosted logs.", - "required": true + "name": "Modmail", + "description": "An easy to install Modmail bot for Discord - DM to contact mods!", + "repository": "https://github.com/modmail-dev/modmail", + "env": { + "TOKEN": { + "description": "Your discord bot's token.", + "required": true + }, + "GUILD_ID": { + "description": "The id for the server you are hosting this bot for.", + "required": true + }, + "OWNERS": { + "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", + "required": true + }, + "CONNECTION_URI": { + "description": "The connection URI for your database.", + "required": true + }, + "DATABASE_TYPE": { + "description": "The type of your database. There is only one supported database at the moment - MongoDB (default).", + "required": false + }, + "LOG_URL": { + "description": "The url of the log viewer app for viewing self-hosted logs.", + "required": true + }, + "GITHUB_TOKEN": { + "description": "A github personal access token with the repo scope.", + "required": false + }, + "REGISTRY_PLUGINS_ONLY": { + "description": "If set to true, only plugins that are in the registry can be loaded.", + "required": false + } } - } } diff --git a/bot.py b/bot.py index b5ccdaccd5..9f3de008a1 100644 --- a/bot.py +++ b/bot.py @@ -1,27 +1,28 @@ -__version__ = "3.4.0" +__version__ = "4.2.1" import asyncio -import logging +import copy +import hashlib import os import re +import string +import struct import sys +import platform import typing -from datetime import datetime +from datetime import datetime, timezone +from subprocess import PIPE from types import SimpleNamespace import discord +import isodate +from aiohttp import ClientSession, ClientResponseError from discord.ext import commands, tasks from discord.ext.commands.view import StringView +from emoji import is_emoji +from packaging.version import Version -import isodate - -from aiohttp import ClientSession -from emoji import UNICODE_EMOJI -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo.errors import ConfigurationError - -from pkg_resources import parse_version try: # noinspection PyUnresolvedReferences @@ -32,13 +33,29 @@ pass from core import checks -from core.clients import ApiClient, PluginDatabaseClient +from core.changelog import Changelog +from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import human_join, normalize_alias -from core.models import PermissionLevel, SafeFormatter, getLogger, configure_logging +from core.models import ( + DMDisabled, + HostingMethod, + InvalidConfigError, + PermissionLevel, + SafeFormatter, + configure_logging, + getLogger, +) from core.thread import ThreadManager from core.time import human_timedelta - +from core.utils import ( + extract_block_timestamp, + normalize_alias, + parse_alias, + truncate, + tryint, + human_join, + extract_forwarded_content, +) logger = getLogger(__name__) @@ -46,47 +63,89 @@ if not os.path.exists(temp_dir): os.mkdir(temp_dir) +if sys.platform == "win32": + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except AttributeError: + logger.error("Failed to use WindowsProactorEventLoopPolicy.", exc_info=True) + class ModmailBot(commands.Bot): def __init__(self): - super().__init__(command_prefix=None) # implemented in `get_prefix` - self._session = None - self._api = None - self.metadata_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.threads = ThreadManager(self) + intents = discord.Intents.all() + if not self.config["enable_presence_intent"]: + intents.presences = False - self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") - self._configure_logging() + super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` + self.session = None + self._api = None + self.formatter = SafeFormatter() + self.loaded_cogs = [ + "cogs.modmail", + "cogs.plugins", + "cogs.utility", + "cogs.threadmenu", + ] + self._connected = None + self.start_time = discord.utils.utcnow() + self._started = False - mongo_uri = self.config["mongo_uri"] - if mongo_uri is None: - logger.critical("A Mongo URI is necessary for the bot to function.") - raise RuntimeError + self.threads = ThreadManager(self) + self._message_queues = {} # User ID -> asyncio.Queue for message ordering - try: - self.db = AsyncIOMotorClient(mongo_uri).modmail_bot - except ConfigurationError as e: - logger.critical( - "Your MONGO_URI might be copied wrong, try re-copying from the source again. " - "Otherwise noted in the following message:" - ) - logger.critical(e) - sys.exit(0) + 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) + 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) @@ -98,6 +157,26 @@ def uptime(self) -> str: return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) + @property + def hosting_method(self) -> HostingMethod: + # use enums + if ".heroku" in os.environ.get("PYTHONHOME", ""): + return HostingMethod.HEROKU + + if os.environ.get("pm_id"): + return HostingMethod.PM2 + + if os.environ.get("INVOCATION_ID"): + return HostingMethod.SYSTEMD + + if os.environ.get("USING_DOCKER"): + return HostingMethod.DOCKER + + if os.environ.get("TERM"): + return HostingMethod.SCREEN + + return HostingMethod.OTHER + def startup(self): logger.line() logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") @@ -106,81 +185,114 @@ def startup(self): 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: if self._api is None: - self._api = ApiClient(self) + if self.config["database_type"].lower() == "mongodb": + self._api = MongoDBClient(self) + else: + logger.critical("Invalid database type.") + raise RuntimeError return self._api + @property + def db(self): + # deprecated + return self.api.db + async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] - def run(self, *args, **kwargs): + def run(self): + async def runner(): + async with self: + self._connected = asyncio.Event() + self.session = ClientSession(loop=self.loop) + + if self.config["enable_presence_intent"]: + logger.info("Starting bot with presence intent.") + else: + logger.info("Starting bot without presence intent.") + + try: + await self.start(self.token) + except discord.PrivilegedIntentsRequired: + logger.critical( + "Privileged intents are not explicitly granted in the discord developers dashboard." + ) + except discord.LoginFailure: + logger.critical("Invalid token") + except Exception: + logger.critical("Fatal exception", exc_info=True) + finally: + if self.session: + await self.session.close() + if not self.is_closed(): + await self.close() + + async def _cancel_tasks(): + async with self: + task_retriever = asyncio.all_tasks + loop = self.loop + tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} + + if not tasks: + return + + logger.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("All tasks finished cancelling.") + + for task in tasks: + try: + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + except (asyncio.InvalidStateError, asyncio.CancelledError): + pass + try: - self.loop.run_until_complete(self.start(self.token)) - except KeyboardInterrupt: - pass - except discord.LoginFailure: - logger.critical("Invalid token") - except Exception: - logger.critical("Fatal exception", exc_info=True) + asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) + except (KeyboardInterrupt, SystemExit): + logger.info("Received signal to terminate bot and event loop.") finally: - self.loop.run_until_complete(self.logout()) - for task in asyncio.all_tasks(self.loop): - task.cancel() + logger.info("Cleaning up tasks.") + try: - self.loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(self.loop))) - except asyncio.CancelledError: - logger.debug("All pending tasks has been cancelled.") + cancel_tasks_coro = _cancel_tasks() + asyncio.run(cancel_tasks_coro) finally: - self.loop.run_until_complete(self.session.close()) - logger.error(" - Shutting down bot - ") + logger.info("Closing the event loop.") @property - def owner_ids(self): + def bot_owner_ids(self): owner_ids = self.config["owners"] if owner_ids is not None: owner_ids = set(map(int, str(owner_ids).split(","))) @@ -192,7 +304,7 @@ def owner_ids(self): return owner_ids async def is_owner(self, user: discord.User) -> bool: - if user.id in self.owner_ids: + if user.id in self.bot_owner_ids: return True return await super().is_owner(user) @@ -213,7 +325,8 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: 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 + "No log channel set, setting #%s to be the log channel.", + channel.name, ) return channel except IndexError: @@ -225,6 +338,36 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: ) return None + @property + def mention_channel(self): + channel_id = self.config["mention_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("MENTION_CHANNEL_ID was invalid, removed.") + self.config.remove("mention_channel_id") + + return self.log_channel + + @property + def update_channel(self): + channel_id = self.config["update_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("UPDATE_CHANNEL_ID was invalid, removed.") + self.config.remove("update_channel_id") + + return self.log_channel + async def wait_for_connected(self) -> None: await self.wait_until_ready() await self._connected.wait() @@ -238,13 +381,15 @@ def snippets(self) -> typing.Dict[str, str]: def aliases(self) -> typing.Dict[str, str]: return self.config["aliases"] + @property + def auto_triggers(self) -> typing.Dict[str, str]: + return self.config["auto_triggers"] + @property def token(self) -> str: token = self.config["token"] 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 @@ -318,6 +463,10 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: def blocked_users(self) -> typing.Dict[str, str]: return self.config["blocked"] + @property + def blocked_roles(self) -> typing.Dict[str, str]: + return self.config["blocked_roles"] + @property def blocked_whitelisted_users(self) -> typing.List[str]: return self.config["blocked_whitelist"] @@ -356,11 +505,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: @@ -370,37 +515,17 @@ def command_perm(self, command_name: str) -> PermissionLevel: async def on_connect(self): try: - await self.validate_database_connection() + 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.setup_indexes() + await self.api.setup_indexes() + await self.load_extensions() self._connected.set() - async def setup_indexes(self): - """Setup text indexes so we can use the $search operator""" - coll = self.db.logs - index_name = "messages.content_text_messages.author.name_text_key_text" - - index_info = await coll.index_information() - - # Backwards compatibility - old_index = "messages.content_text_messages.author.name_text" - if old_index in index_info: - logger.info("Dropping old index: %s", old_index) - await coll.drop_index(old_index) - - if index_name not in index_info: - logger.info('Creating "text" index for logs collection.') - logger.info("Name: %s", index_name) - await coll.create_index( - [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")] - ) - logger.debug("Successfully configured and verified database indexes.") - async def on_ready(self): """Bot startup, sets uptime.""" @@ -409,14 +534,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.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) @@ -426,6 +558,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 @@ -434,13 +573,17 @@ 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 + "Thread for recipient %s will be closed after %s seconds.", + recipient_id, + after, ) thread = await self.threads.find(recipient_id=int(recipient_id)) @@ -453,7 +596,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"], @@ -462,19 +605,20 @@ async def on_ready(self): ) for log in await self.api.get_open_logs(): - if self.get_channel(int(log["channel_id"])) is None: + if log.get("channel_id") is None or self.get_channel(int(log["channel_id"])) is None: logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) log_data = await self.api.post_log( log["channel_id"], { "open": False, - "closed_at": str(datetime.utcnow()), + "title": None, + "closed_at": str(discord.utils.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { "id": str(self.user.id), "name": self.user.name, "discriminator": self.user.discriminator, - "avatar_url": str(self.user.avatar_url), + "avatar_url": self.user.display_avatar.url, "mod": True, }, }, @@ -483,35 +627,56 @@ async def on_ready(self): 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"] + "Failed to close thread with channel %s, skipping.", + log["channel_id"], ) - self.metadata_loop = tasks.Loop( - self.post_metadata, - seconds=0, - minutes=0, - hours=1, - count=None, - reconnect=True, - loop=None, - ) - self.metadata_loop.before_loop(self.before_post_metadata) - self.metadata_loop.start() + other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] + if any(other_guilds): + logger.warning( + "The bot is in more servers other than the main and staff server. " + "This may cause data compromise (%s).", + ", ".join(str(guild.name) for guild in other_guilds), + ) + logger.warning("If the external servers are valid, you may ignore this message.") + + self.post_metadata.start() + self.autoupdate.start() + self.log_expiry.start() + self._started = True async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() - if 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) + + @staticmethod + async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Returns: + The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. + """ + return guild.get_member(member_id) or await guild.fetch_member(member_id) + + async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] @@ -535,7 +700,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 @@ -549,7 +714,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 @@ -557,7 +722,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) @@ -575,37 +740,52 @@ 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 return True + def check_manual_blocked_roles(self, author: discord.Member) -> bool: + if isinstance(author, discord.Member): + for r in author.roles: + if str(r.id) in self.blocked_roles: + blocked_reason = self.blocked_roles.get(str(r.id)) or "" + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False + + if end_time is not None: + if after <= 0: + # No longer blocked + self.blocked_roles.pop(str(r.id)) + logger.debug("No longer blocked, role %s.", r.name) + return True + logger.debug("User blocked, role %s.", r.name) + return False + + return True + def check_manual_blocked(self, author: discord.Member) -> bool: if str(author.id) not in self.blocked_users: return True blocked_reason = self.blocked_users.get(str(author.id)) or "" - 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)) @@ -614,6 +794,33 @@ def check_manual_blocked(self, author: discord.Member) -> bool: logger.debug("User blocked, user %s.", author.name) return False + def check_local_git(self) -> bool: + """ + Checks if the bot is installed via git. + """ + valid_local_git = False + git_folder_path = os.path.join(".git") + + # Check if the .git folder exists and is a directory + if os.path.exists(git_folder_path) and os.path.isdir(git_folder_path): + required_files = ["config", "HEAD"] + required_dirs = ["refs", "objects"] + + # Verify required files exist + for file in required_files: + if not os.path.isfile(os.path.join(git_folder_path, file)): + return valid_local_git + + # Verify required directories exist + for directory in required_dirs: + if not os.path.isdir(os.path.join(git_folder_path, directory)): + return valid_local_git + + # If all checks pass, set valid_local_git to True + valid_local_git = True + + return valid_local_git + async def _process_blocked(self, message): _, blocked_emoji = await self.retrieve_emoji() if await self.is_blocked(message.author, channel=message.channel, send_message=True): @@ -627,12 +834,19 @@ 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: - logger.debug("User not in guild, %s.", author.id) - else: + # try to find in other guilds + for g in self.guilds: + member = g.get_member(author.id) + if member: + break + + if member is None: + logger.debug("User not in guild, %s.", author.id) + + if member is not None: author = member if str(author.id) in self.blocked_whitelisted_users: @@ -659,12 +873,15 @@ async def is_blocked( if not self.check_manual_blocked(author): return True + if not self.check_manual_blocked_roles(author): + return True + await self.config.update() return False 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 @@ -682,12 +899,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 @@ -697,15 +914,48 @@ 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 + async def _queue_dm_message(self, message: discord.Message) -> None: + """Queue DM messages to ensure they're processed in order per user.""" + user_id = message.author.id + + if user_id not in self._message_queues: + self._message_queues[user_id] = asyncio.Queue() + # Start processing task for this user + self.loop.create_task(self._process_user_messages(user_id)) + + await self._message_queues[user_id].put(message) + + async def _process_user_messages(self, user_id: int) -> None: + """Process messages for a specific user in order.""" + queue = self._message_queues[user_id] + + while True: + try: + # Wait for a message with timeout to clean up inactive queues + message = await asyncio.wait_for(queue.get(), timeout=300) # 5 minutes + await self.process_dm_modmail(message) + queue.task_done() + except asyncio.TimeoutError: + # Clean up inactive queue + if queue.empty(): + self._message_queues.pop(user_id, None) + break + except Exception as e: + logger.error(f"Error processing message for user {user_id}: {e}", exc_info=True) + queue.task_done() + async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" blocked = await self._process_blocked(message) @@ -713,37 +963,240 @@ async def process_dm_modmail(self, message: discord.Message) -> None: return sent_emoji, blocked_emoji = await self.retrieve_emoji() + # Handle forwarded messages (Discord forwards) + # See: https://discord.com/developers/docs/resources/message#message-reference-content-attribution-forwards + # 1. Multi-forward (message_snapshots) + if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): + if hasattr(message, "message_snapshots") and message.message_snapshots: + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + # Extract forwarded content using utility function + combined_content = extract_forwarded_content(message) or "[Forwarded message with no content]" + + class ForwardedMessage: + def __init__(self, original_message, forwarded_content): + self.author = original_message.author + self.content = forwarded_content + self.attachments = [] + self.stickers = [] + self.created_at = original_message.created_at + self.embeds = [] + self.id = original_message.id + self.flags = original_message.flags + self.message_snapshots = original_message.message_snapshots + self.type = getattr(original_message, "type", None) + + forwarded_msg = ForwardedMessage(message, combined_content) + await thread.send(forwarded_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + # 2. Single-message forward (MessageType.forward) + elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + # Check for message.reference and its type + ref = getattr(message, "reference", None) + if ref and getattr(ref, "type", None) == getattr(discord, "MessageReferenceType", None).forward: + # Try to fetch the referenced message + ref_msg = None + try: + if ref.resolved: + ref_msg = ref.resolved + elif ref.message_id and ref.channel_id: + channel = self.get_channel(ref.channel_id) or ( + await self.fetch_channel(ref.channel_id) + ) + ref_msg = await channel.fetch_message(ref.message_id) + except Exception: + ref_msg = None + if ref_msg: + # Forward the referenced message as if it was sent + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A message was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + # Create a forwarded message wrapper to preserve forward info + class ForwardedMessage: + def __init__(self, original_message, ref_message): + self.author = original_message.author + # Use the utility function to extract content or fallback to ref message content + extracted_content = extract_forwarded_content(original_message) + self.content = ( + extracted_content + or ref_message.content + or "[Forwarded message with no text content]" + ) + self.attachments = getattr(ref_message, "attachments", []) + self.stickers = getattr(ref_message, "stickers", []) + self.created_at = original_message.created_at + self.embeds = getattr(ref_message, "embeds", []) + self.id = original_message.id + self.type = getattr(original_message, "type", None) + self.reference = original_message.reference + + forwarded_msg = ForwardedMessage(message, ref_msg) + await thread.send(forwarded_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + + if message.type not in [discord.MessageType.default, discord.MessageType.reply]: + return + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + self.threads.cache[thread.id] = thread + # No need to re-fetch the thread - it's already restored and cached properly + + # If the previous thread was closed with delete_channel=True the channel object + # stored on the thread will now be invalid (deleted). In some rare race cases + # the thread can still be returned from the cache (or reconstructed) while the + # channel lookup returns None, causing downstream relay attempts to raise + # discord.NotFound ("Channel not found when trying to send message."). Treat + # this situation as "no active thread" so the user's new DM starts a fresh + # thread instead of silently failing. + try: + if ( + thread + and thread.channel + and isinstance(thread.channel, discord.TextChannel) + and self.get_channel(getattr(thread.channel, "id", None)) is None + ): + logger.info( + "Stale thread detected for %s (channel deleted). Purging cache entry and creating new thread.", + message.author, + ) + # Best-effort removal; ignore if already gone. + self.threads.cache.pop(thread.id, None) + thread = None + except Exception: + # If any attribute access fails, fall back to treating it as closed. + self.threads.cache.pop(getattr(thread, "id", None), None) + thread = None + if thread is None: delta = await self.get_thread_cooldown(message.author) if delta: await message.channel.send( embed=discord.Embed( - title="Message not sent!", - description=f"You must wait for {delta} before you can contact me again.", + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), color=self.error_color, ) ) return - if self.config["dm_disabled"] >= 1: + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, description=self.config["disabled_new_thread_response"], ) embed.set_footer( - text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon_url + 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 + "A new thread was blocked from %s due to disabled Modmail.", + message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) - thread = await self.threads.create(message.author) + thread = await self.threads.create(message.author, message=message) + # If thread menu is enabled, thread creation is deferred until user selects an option. + if getattr(thread, "_pending_menu", False): + return else: - if self.config["dm_disabled"] == 2: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( title=self.config["disabled_current_thread_title"], color=self.error_color, @@ -751,21 +1204,86 @@ 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, + 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 + "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) - try: - await thread.send(message) - except Exception: - logger.error("Failed to send message:", exc_info=True) - await self.add_reaction(message, blocked_emoji) - else: - await self.add_reaction(message, sent_emoji) + if not thread.cancelled: + try: + await thread.send(message) + except Exception: + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, blocked_emoji) + + try: + # Re-check channel existence + if thread and thread.channel and isinstance(thread.channel, discord.TextChannel): + if self.get_channel(thread.channel.id) is None: + logger.info( + "Relay failed due to deleted channel for %s; creating new thread.", + message.author, + ) + self.threads.cache.pop(thread.id, None) + new_thread = await self.threads.create(message.author, message=message) + if not getattr(new_thread, "_pending_menu", False) and not new_thread.cancelled: + try: + await new_thread.send(message) + except Exception: + logger.error( + "Failed to relay message after creating new thread:", + exc_info=True, + ) + else: + for user in new_thread.recipients: + if user != message.author: + try: + await new_thread.send(message, user) + except Exception: + logger.error( + "Failed to send message to additional recipient:", + exc_info=True, + ) + await self.add_reaction(message, sent_emoji) + self.dispatch( + "thread_reply", + new_thread, + False, + message, + False, + False, + ) + except Exception: + logger.warning( + "Unexpected failure in DM relay/new-thread follow-up block.", + exc_info=True, + ) + 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): """ @@ -777,7 +1295,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() @@ -788,9 +1306,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: @@ -798,20 +1325,92 @@ 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): + message.author = self.modmail_guild.me + message.channel = channel + message.guild = channel.guild + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + thread = await self.threads.find(channel=ctx.channel) + + invoked_prefix = self.prefix + invoker = None + + if self.config.get("use_regex_autotrigger"): + trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) + if trigger: + invoker = re.search(trigger, message.content).group(0) + else: + trigger = next( + filter( + lambda x: x.lower() in message.content.lower(), + self.auto_triggers.keys(), + ) + ) + if trigger: + invoker = trigger.lower() + + alias = self.auto_triggers[trigger] + + ctxs = [] + + if alias is not None: + ctxs = [] + aliases = normalize_alias(alias) + if not aliases: + logger.warning("Alias %s is invalid as called in autotrigger.", invoker) + + message.author = thread.recipient # Allow for get_contexts to work + + for alias in aliases: + message.content = invoked_prefix + alias + ctxs += await self.get_contexts(message) + + message.author = self.modmail_guild.me # Fix message so commands execute properly + + for ctx in ctxs: + if ctx.command: + old_checks = copy.copy(ctx.command.checks) + ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)] + + await self.invoke(ctx) + + ctx.command.checks = old_checks + continue + async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. @@ -821,7 +1420,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) @@ -842,11 +1441,14 @@ async def get_context(self, message, *, cls=commands.Context): async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: - value = int(value) + if value != -1: + value = str(value) if isinstance(name, PermissionLevel): + level = True permissions = self.config["level_permissions"] name = name.name else: + level = False permissions = self.config["command_permissions"] if name not in permissions: if add: @@ -858,6 +1460,11 @@ async def update_perms( else: if value in permissions[name]: permissions[name].remove(value) + + if level: + self.config["level_permissions"] = permissions + else: + self.config["command_permissions"] = permissions logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) await self.config.update() @@ -865,6 +1472,39 @@ async def on_message(self, message): await self.wait_for_connected() if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() + + if ( + (f"<@{self.user.id}" in message.content or f"<@!{self.user.id}" in message.content) + and self.config["alert_on_mention"] + and not message.author.bot + ): + em = discord.Embed( + title="Bot mention", + description=f"[Jump URL]({message.jump_url})\n{truncate(message.content, 50)}", + color=self.main_color, + ) + if self.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + if not self.config["silent_alert_on_mention"]: + content = self.config["mention"] + else: + content = "" + await self.mention_channel.send(content=content, embed=em) + + # --- MODERATOR-ONLY MESSAGE LOGGING --- + # If a moderator sends a message directly in a thread channel (not via modmail command), log it + if not message.author.bot and not isinstance(message.channel, discord.DMChannel): + thread = await self.threads.find(channel=message.channel) + if thread is not None: + ctxs = await self.get_contexts(message) + is_command = any(ctx.command for ctx in ctxs) + if not is_command: + # Only log if not a command + perms = message.channel.permissions_for(message.author) + if perms.manage_messages or perms.administrator: + await self.api.append_log(message, type_="internal") + await self.process_commands(message) async def process_commands(self, message): @@ -872,43 +1512,73 @@ async def process_commands(self, message): return 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] - message.content = f"{self.prefix}freply {snippet}" + return await self._queue_dm_message(message) ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: - if not any( - 1 for check in ctx.command.checks if hasattr(check, "permission_level") - ): + if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): logger.debug( "Command %s has no permissions check, adding invalid level.", ctx.command.qualified_name, ) checks.has_permissions(PermissionLevel.INVALID)(ctx.command) + # Check if thread is unsnoozing and queue command if so + thread = await self.threads.find(channel=ctx.channel) + if thread and thread._unsnoozing: + queued = await thread.queue_command(ctx, ctx.command) + if queued: + # Send a brief acknowledgment that command is queued + try: + await ctx.message.add_reaction("⏳") + except Exception as e: + logger.warning("Failed to add queued-reaction: %s", e) + continue + await self.invoke(ctx) continue thread = await self.threads.find(channel=ctx.channel) if thread is not None: + # If thread is snoozed (moved), auto-unsnooze when a mod sends a message directly in channel + behavior = (self.config.get("snooze_behavior") or "delete").lower() + if thread.snoozed and behavior == "move": + if not thread.snooze_data: + try: + log_entry = await self.api.logs.find_one( + {"recipient.id": str(thread.id), "snoozed": True} + ) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + except Exception: + logger.debug( + "Failed to add queued command reaction (⏳).", + exc_info=True, + ) + try: + await thread.restore_from_snooze() + # refresh local cache + self.threads.cache[thread.id] = thread + except Exception as e: + logger.warning("Auto-unsnooze on direct message failed: %s", e) + anonymous = False + plain = False if self.config.get("anon_reply_without_command"): - await thread.reply(message, anonymous=True) - elif self.config.get("reply_without_command"): - await thread.reply(message) - else: - await self.api.append_log(message, type_="internal") + anonymous = True + if self.config.get("plain_reply_without_command"): + plain = True + + if ( + self.config.get("reply_without_command") + or self.config.get("anon_reply_without_command") + or self.config.get("plain_reply_without_command") + ): + # When replying without a command in a thread channel, use the raw content + # from the sent message as reply text while still preserving attachments. + await thread.reply(message, message.content, anonymous=anonymous, plain=plain) 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, _): @@ -924,107 +1594,184 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(recipient=user) if thread: - await thread.channel.trigger_typing() + try: + await thread.channel.typing() + except Exception: + logger.debug( + "Failed to trigger typing indicator in recipient DM.", + exc_info=True, + ) 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 + try: + await user.typing() + except Exception: + logger.debug( + "Failed to trigger typing for recipient %s.", + getattr(user, "id", "?"), + exc_info=True, + ) - async def handle_reaction_events(self, payload, *, add): + async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) - if user.bot: + if user is None or user.bot: 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() + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return + + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: + return + + # thread must exist before doing this API call try: message = await channel.fetch_message(payload.message_id) except (discord.NotFound, discord.Forbidden): return reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config["close_emoji"]) - - if isinstance(channel, discord.DMChannel): - thread = await self.threads.find(recipient=user) - if not thread: - return + if from_dm: if ( - add + payload.event_type == "REACTION_ADD" and message.embeds and str(reaction) == str(close_emoji) and self.config.get("recipient_thread_close") ): ts = message.embeds[0].timestamp - if 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) + if ( + message.author == self.user + and message.embeds + and self.config.get("confirm_thread_creation") + and message.embeds[0].title == self.config["confirm_thread_creation_title"] + and message.embeds[0].description == self.config["confirm_thread_response"] + ): + return if not thread.recipient.dm_channel: await thread.recipient.create_dm() try: - linked_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 add: - if await self.add_reaction(linked_message, reaction): + if self.config["transfer_reactions"] and linked_messages != [None]: + if payload.event_type == "REACTION_ADD": + for msg in linked_messages: + await self.add_reaction(msg, reaction) await self.add_reaction(message, reaction) + else: + try: + for msg in linked_messages: + await msg.remove_reaction(reaction, self.user) + await message.remove_reaction(reaction, self.user) + except (discord.HTTPException, TypeError) as e: + logger.warning("Failed to remove reaction: %s", e) + + async def handle_react_to_contact(self, payload): + react_message_id = tryint(self.config.get("react_to_contact_message")) + react_message_emoji = self.config.get("react_to_contact_emoji") + if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: + return + if payload.emoji.is_unicode_emoji(): + emoji_fmt = payload.emoji.name else: - try: - await linked_message.remove_reaction(reaction, self.user) - await message.remove_reaction(reaction, self.user) - except (discord.HTTPException, discord.InvalidArgument) as e: - logger.warning("Failed to remove reaction: %s", e) + emoji_fmt = f"<:{payload.emoji.name}:{payload.emoji.id}>" + + if emoji_fmt != react_message_emoji: + return + channel = self.get_channel(payload.channel_id) + member = channel.guild.get_member(payload.user_id) + if member.bot: + return + message = await channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, member) + await message.add_reaction(emoji_fmt) # bot adds as well + + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=channel.guild, size=128), + ) + logger.info( + "A new thread using react to contact was blocked from %s due to disabled Modmail.", + member, + ) + return await member.send(embed=embed) + + # Check if user has a snoozed thread + existing_thread = await self.threads.find(recipient=member) + if existing_thread and existing_thread.snoozed: + # Unsnooze the thread + await existing_thread.restore_from_snooze() + self.threads.cache[existing_thread.id] = existing_thread + # Send notification to the thread channel + if existing_thread.channel: + await existing_thread.channel.send( + f"ℹ️ {member.mention} reacted to contact and their snoozed thread has been unsnoozed." + ) + return + + 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 self.handle_reaction_events(payload, add=True) + await asyncio.gather( + self.handle_reaction_events(payload), + self.handle_react_to_contact(payload), + ) async def on_raw_reaction_remove(self, payload): - await self.handle_reaction_events(payload, add=False) + if self.config["transfer_reactions"]: + await self.handle_reaction_events(payload) async def on_guild_channel_delete(self, channel): if channel.guild != self.modmail_guild: return - try: - audit_logs = self.modmail_guild.audit_logs() - entry = await audit_logs.find(lambda a: a.target == channel) - mod = entry.user - except AttributeError as e: - # discord.py broken implementation with discord API - # TODO: waiting for dpy - logger.warning("Failed to retrieve audit log: %s.", e) - return - - if mod == self.user: - return - if isinstance(channel, discord.CategoryChannel): if self.main_category == channel: logger.debug("Main category was deleted.") @@ -1041,47 +1788,140 @@ async def on_guild_channel_delete(self, channel): await self.config.update() return + # Attempt to attribute channel deletion to a moderator via audit logs. + # This requires the "View Audit Log" permission; if missing, skip silently. + if not self.modmail_guild.me.guild_permissions.view_audit_log: + logger.debug( + "Skipping audit log lookup for deleted channel %d: missing view_audit_log permission.", + channel.id, + ) + return + + try: + 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 + except discord.Forbidden: + logger.debug( + "Forbidden when fetching audit logs for deleted channel %d (missing permission).", channel.id + ) + return + except discord.HTTPException as e: + logger.debug("HTTPException when fetching audit logs for deleted channel %d: %s", channel.id, e) + return + + if not found_entry: + logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) + return + + mod = entry.user + if mod == self.user: + return + thread = await self.threads.find(channel=channel) if thread and thread.channel == channel: logger.debug("Manually closed channel %s.", channel.name) await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - embed = discord.Embed( - description="The recipient has left the server.", color=self.error_color - ) - await thread.channel.send(embed=embed) + if member.guild == self.guild and self.config["close_on_leave"]: + await thread.close( + closer=member.guild.me, + message=self.config["close_on_leave_reason"], + silent=True, + ) + else: + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) + await thread.channel.send(embed=embed) async def on_member_join(self, member): - 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): """Support for deleting linked messages""" - # TODO: use audit log to check if modmail deleted the message - if message.embeds and not isinstance(message.channel, discord.DMChannel): - thread = await self.threads.find(channel=message.channel) + + if message.is_system(): + return + + if isinstance(message.channel, discord.DMChannel): + if message.author == self.user: + return + thread = await self.threads.find(recipient=message.author) + if not thread: + return try: - await thread.delete_message(message) + message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) except ValueError as e: - if str(e) not in {"DM message not found.", " Malformed thread message."}: - logger.warning("Failed to find linked message to delete: %s", e) - else: - thread = await self.threads.find(recipient=message.author) - message = await thread.find_linked_message_from_dm(message) + 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 + + if message.author != self.user: + return + + thread = await self.threads.find(channel=message.channel) + if not thread: + return + + try: + await thread.delete_message(message, note=False) + embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) + except ValueError as e: + # Treat common non-fatal cases as benign: relay counterpart not present, note embeds, etc. + if str(e) not in { + "DM message not found.", + "Malformed thread message.", + "Thread message not found.", + }: + logger.debug("Failed to find linked message to delete: %s", e) + embed = discord.Embed(description="Failed to delete message.", color=self.error_color) + else: + return + except discord.NotFound: + return + embed.set_footer(text=f"Message ID: {message.id} from {message.author}.") + return await message.channel.send(embed=embed) async def on_bulk_message_delete(self, messages): await discord.utils.async_all(self.on_message_delete(msg) for msg in messages) @@ -1094,15 +1934,16 @@ async def on_message_edit(self, before, after): if isinstance(after.channel, discord.DMChannel): thread = await self.threads.find(recipient=before.author) + if not thread: + return + try: await thread.edit_dm_message(after, after.content) except ValueError: _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(after, blocked_emoji) else: - embed = discord.Embed( - description="Successfully Edited Message", color=self.main_color - ) + embed = 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) @@ -1110,23 +1951,42 @@ 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.BadUnionArgument): - msg = "Could not find the specified " + human_join( - [c.__name__ for c in exception.converters] - ) - await context.trigger_typing() - await context.send(embed=discord.Embed(color=self.error_color, description=msg)) + 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 - elif isinstance(exception, commands.BadArgument): - await context.trigger_typing() - await context.send( - embed=discord.Embed(color=self.error_color, description=str(exception)) - ) + if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): + try: + await context.typing() + except Exception: + logger.debug( + "Failed to start typing context for command error feedback.", + exc_info=True, + ) + await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) + elif isinstance(exception, commands.CommandOnCooldown): + await context.send( + embed=discord.Embed( + title="Command on cooldown", + description=f"Try again in {exception.retry_after:.2f} seconds", + color=self.error_color, + ) + ) elif isinstance(exception, commands.CheckFailure): for check in context.command.checks: if not await check(context): @@ -1135,9 +1995,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, @@ -1145,79 +2003,294 @@ async def on_command_error(self, context, exception): corrected_permission_level.name, ) logger.warning("CheckFailure: %s", exception) + elif isinstance(exception, commands.DisabledCommand): + logger.info( + "DisabledCommand: %s is trying to run eval but it's disabled", + context.author.name, + ) else: logger.error("Unexpected exception:", exc_info=exception) - async def validate_database_connection(self): - 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 "ServerSelectionTimeoutError" in message: - logger.critical( - "This may have been caused by not whitelisting " - "IPs correctly. Make sure to whitelist all " - "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" - ) - - if "OperationFailure" in message: - logger.critical( - "This is due to having invalid credentials in your MONGO_URI. " - "Remember you need to substitute `` with your actual password." - ) - logger.critical( - "Be sure to URL encode your username and password (not the entire URL!!), " - "https://www.urlencoder.io/, if this issue persists, try changing your username and password " - "to only include alphanumeric characters, no symbols." - "" - ) - raise - else: - logger.debug("Successfully connected to the database.") - logger.line("debug") - + @tasks.loop(hours=1) async def post_metadata(self): - owner = (await self.application_info()).owner + info = await self.application_info() + + delta = discord.utils.utcnow() - self.start_time data = { - "owner_name": str(owner), - "owner_id": owner.id, "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()), } - async with self.session.post("https://api.logviewer.tech/metadata", json=data): + if info.team is not None: + data.update( + { + "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", + "owner_id": info.team.owner_id, + "team": True, + } + ) + else: + data.update( + { + "owner_name": info.owner.name, + "owner_id": info.owner.id, + "team": False, + } + ) + + async with self.session.post("https://api.modmail.dev/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") + @post_metadata.before_loop async def before_post_metadata(self): await self.wait_for_connected() + if not self.config.get("data_collection") or not self.guild: + self.post_metadata.cancel() + return + logger.debug("Starting metadata loop.") logger.line("debug") - 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 < Version(latest.version): + error = None + data = {} + try: + # update fork if gh_token exists + data = await self.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + if self.hosting_method == HostingMethod.HEROKU: + if error is not None: + logger.error(f"Autoupdate failed! Status: {error.status}.") + logger.error(f"Error message: {error.message}") + self.autoupdate.cancel() + return + + commit_data = data.get("data") + if not commit_data: + return + + logger.info("Bot has been updated.") + + if not self.config["update_notifications"]: + return + + embed = discord.Embed(color=self.main_color) + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] + user = data["user"] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}", + ) + embed.set_author( + name=user["username"] + " - Updating Bot", + icon_url=user["avatar_url"], + url=user["url"], + ) + + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + + embed.description = latest.description + for name, value in latest.fields.items(): + embed.add_field(name=name, value=value) + + channel = self.update_channel + await channel.send(embed=embed) + else: + command = "git pull" + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) + err = await proc.stderr.read() + err = err.decode("utf-8").rstrip() + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if err and not res: + logger.warning(f"Autoupdate failed: {err}") + self.autoupdate.cancel() + return + + elif res != "Already up to date.": + if os.getenv("PIPENV_ACTIVE"): + # Update pipenv if possible + await asyncio.create_subprocess_shell( + "pipenv sync", + stderr=PIPE, + stdout=PIPE, + ) + message = "" + else: + message = "\n\nDo manually update dependencies if your bot has crashed." + + logger.info("Bot has been updated.") + channel = self.update_channel + if self.hosting_method in ( + HostingMethod.PM2, + HostingMethod.SYSTEMD, + ): + embed = discord.Embed(title="Bot has been updated", color=self.main_color) + embed.set_footer( + text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" + ) + if self.config["update_notifications"]: + await channel.send(embed=embed) + else: + embed = discord.Embed( + title="Bot has been updated and is logging out.", + description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", + color=self.main_color, + ) + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + if self.config["update_notifications"]: + await channel.send(embed=embed) + return await self.close() + + @autoupdate.before_loop + async def before_autoupdate(self): + await self.wait_for_connected() + logger.debug("Starting autoupdate loop") + + if self.config.get("disable_autoupdates"): + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + if self.hosting_method == HostingMethod.DOCKER: + logger.warning("Autoupdates disabled as using Docker.") + self.autoupdate.cancel() + return + + if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: + logger.warning("GitHub access token not found.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + if not self.check_local_git(): + logger.warning("Bot not installed via git.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + @tasks.loop(hours=1, reconnect=False) + async def log_expiry(self): + log_expire_after = self.config.get("log_expiration") + if log_expire_after == isodate.Duration(): + return self.log_expiry.stop() + + now = discord.utils.utcnow() + expiration_datetime = now - log_expire_after + # WARNING: comparison is done lexicographically, not by date. + # This is fine as long as the date is in zero-padded ISO format, which it should be. + expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) + + logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") + + def format_channel_name(self, author, exclude_channel=None, force_null=False): + """Sanitises a username for use with text channel names + + Placed in main bot class to be extendable to plugins""" + guild = self.modmail_guild + + if force_null: + name = new_name = "null" + else: + if self.config["use_random_channel_name"]: + to_hash = self.token.split(".")[-1] + str(author.id) + digest = hashlib.md5(to_hash.encode("utf8"), usedforsecurity=False) + name = new_name = digest.hexdigest()[-8:] + elif self.config["use_user_id_channel_name"]: + name = new_name = str(author.id) + elif self.config["use_timestamp_channel_name"]: + name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") + else: + if self.config["use_nickname_channel_name"]: + author_member = self.guild.get_member(author.id) + name = author_member.display_name.lower() + else: + name = author.name.lower() + + if force_null: + name = "null" + + name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" + if author.discriminator != "0": + name += f"-{author.discriminator}" + new_name = name + + counter = 1 + existed = set(c.name for c in guild.text_channels if c != exclude_channel) + while new_name in existed: + new_name = f"{name}_{counter}" # multiple channels with same name + counter += 1 + + return new_name def main(): try: # noinspection PyUnresolvedReferences - import uvloop + import uvloop # type: ignore logger.debug("Setting up with uvloop.") uvloop.install() except ImportError: pass + try: + import cairosvg # noqa: F401 + except OSError: + if os.name == "nt": + if struct.calcsize("P") * 8 != 64: + logger.error( + "Unable to import cairosvg, ensure your Python is a 64-bit version: https://www.python.org/downloads/" + ) + else: + logger.error( + "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" + ) + else: + if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): + logger.error( + "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + else: + logger.error( + "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + sys.exit(0) + + # check discord version + discord_version = "2.6.3" + if discord.__version__ != discord_version: + logger.error( + "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", + discord_version, + discord.__version__, + ) + sys.exit(0) + bot = ModmailBot() bot.run() diff --git a/cogs/modmail.py b/cogs/modmail.py index 6531e61e70..0e39da920c 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,19 +1,22 @@ import asyncio import re -from datetime import datetime +from datetime import datetime, timezone, timedelta from itertools import zip_longest -from typing import Optional, Union -from types import SimpleNamespace +from typing import Optional, Union, List, Tuple, Literal +import logging import discord from discord.ext import commands +from discord.ext import tasks +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 PermissionLevel, getLogger +from core.models import DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger from core.paginator import EmbedPaginatorSession from core.thread import Thread from core.time import UserFriendlyTime, human_timedelta @@ -27,6 +30,87 @@ class Modmail(commands.Cog): def __init__(self, bot): self.bot = bot + self._snoozed_cache = [] + self._auto_unsnooze_task = self.bot.loop.create_task(self.auto_unsnooze_task()) + + async def auto_unsnooze_task(self): + await self.bot.wait_until_ready() + last_db_query = 0 + while not self.bot.is_closed(): + now = datetime.now(timezone.utc) + try: + # Query DB every 2 minutes + if (now.timestamp() - last_db_query) > 120: + snoozed_threads = await self.bot.api.logs.find( + {"snooze_until": {"$gte": now.isoformat()}} + ).to_list(None) + self._snoozed_cache = snoozed_threads or [] + last_db_query = now.timestamp() + # Check cache every 10 seconds + to_unsnooze = [] + for thread_data in list(self._snoozed_cache): + snooze_until = thread_data.get("snooze_until") + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + if snooze_until: + try: + dt = parser.isoparse(snooze_until) + except Exception: + continue + if now >= dt: + to_unsnooze.append(thread_data) + for thread_data in to_unsnooze: + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + thread = self.bot.threads.cache.get(thread_id) or await self.bot.threads.find( + id=thread_id + ) + if thread and thread.snoozed: + await thread.restore_from_snooze() + logging.info(f"[AUTO-UNSNOOZE] Thread {thread_id} auto-unsnoozed.") + try: + channel = thread.channel + if channel: + await channel.send("⏰ This thread has been automatically unsnoozed.") + except Exception as e: + logger.info( + "Failed to notify channel after auto-unsnooze: %s", + e, + ) + self._snoozed_cache.remove(thread_data) + except Exception as e: + logging.error(f"Error in auto_unsnooze_task: {e}") + await asyncio.sleep(10) + + def _resolve_user(self, user_str): + """Helper to resolve a user from mention, ID, or username.""" + import re + + if not user_str: + return None + if user_str.isdigit(): + return int(user_str) + match = re.match(r"<@!?(\d+)>", user_str) + if match: + return int(match.group(1)) + return None + + def _resolve_user(self, user_str): + """Helper to resolve a user from mention, ID, or username.""" + import re + + if not user_str: + return None + if user_str.isdigit(): + return int(user_str) + match = re.match(r"<@!?(\d+)>", user_str) + if match: + return int(match.group(1)) + return None @commands.command() @trigger_typing @@ -40,9 +124,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.") @@ -77,15 +159,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", @@ -98,8 +176,8 @@ 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 " - "feeling extra generous, buy us coffee on [Patreon](https://patreon.com/kyber) :heart:!", + "[repo a star](https://github.com/modmail-dev/modmail) :star: and if you are " + "feeling extra generous, buy us coffee on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev) :heart:!", ) embed.set_footer(text=f'Type "{self.bot.prefix}help" for a complete list of commands.') @@ -120,8 +198,8 @@ async def setup(self, ctx): if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: await self.bot.update_perms(PermissionLevel.REGULAR, -1) - for owner_ids in self.bot.owner_ids: - await self.bot.update_perms(PermissionLevel.OWNER, owner_ids) + for owner_id in self.bot.bot_owner_ids: + await self.bot.update_perms(PermissionLevel.OWNER, owner_id) @commands.group(aliases=["snippets"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -148,30 +226,54 @@ async def snippet(self, ctx, *, name: str.lower = None): """ if name is not None: - val = self.bot.snippets.get(name) - if val is None: + if name == "compact": + embeds = [] + + 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=self.bot.get_guild_icon(guild=ctx.guild, size=128) + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + return + + 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) if not self.bot.snippets: embed = discord.Embed( - color=self.bot.error_color, description="You dont have any snippets at the moment." + 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 = [] + embeds = [discord.Embed(color=self.bot.main_color) for _ in range((len(self.bot.snippets) // 10) + 1)] + for embed in embeds: + embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) - 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) - embeds.append(embed) + for i, snippet in enumerate(sorted(self.bot.snippets.items())): + embeds[i // 10].add_field( + name=snippet[0], value=return_or_truncate(snippet[1], 350), inline=False + ) session = EmbedPaginatorSession(ctx, *embeds) await session.run() @@ -182,20 +284,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): """ @@ -210,7 +312,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, @@ -244,16 +353,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() @@ -284,59 +480,159 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) - @commands.command() + @snippet.command(name="rename") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_rename(self, ctx, name: str.lower, *, value): + """ + Rename a snippet. + + To rename a multi-word snippet name, use quotes: ``` + {prefix}snippet rename "two word" this is a new two word snippet. + ``` + """ + if name in self.bot.snippets: + if self.bot.get_command(value): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{value}`.", + ) + return await ctx.send(embed=embed) + elif value in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Snippet `{value}` already exists.", + ) + return await ctx.send(embed=embed) + + if value in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"An alias that shares the same name exists: `{value}`.", + ) + return await ctx.send(embed=embed) + + if len(value) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Snippet names cannot be longer than 120 characters.", + ) + return await ctx.send(embed=embed) + old_snippet_value = self.bot.snippets[name] + self.bot.snippets.pop(name) + self.bot.snippets[value] = old_snippet_value + await self.bot.config.update() + + embed = discord.Embed( + title="Renamed snippet", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) + else: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + await ctx.send(embed=embed) + + @commands.command(usage=" [options]") @checks.has_permissions(PermissionLevel.MODERATOR) @checks.thread_only() - async def move(self, ctx, category: discord.CategoryChannel, *, specifics: str = None): + async def move(self, ctx, *, arguments): """ Move a thread to another category. `category` may be a category ID, mention, or name. - `specifics` is a string which takes in arguments on how to perform the move. Ex: "silently" + `options` is a string which takes in arguments on how to perform the move. Ex: "silently" """ + split_args = arguments.strip('"').split(" ") + category = None + + # manually parse arguments, consumes as much of args as possible for category + for i in range(len(split_args)): + try: + if i == 0: + fmt = arguments + else: + fmt = " ".join(split_args[:-i]) + + category = await SimilarCategoryConverter().convert(ctx, fmt) + except commands.BadArgument: + if i == len(split_args) - 1: + # last one + raise + pass + else: + break + + if not category: + raise commands.ChannelNotFound(arguments) + + options = " ".join(arguments.split(" ")[-i:]) + thread = ctx.thread silent = False - if specifics: + if options: silent_words = ["silent", "silently"] - silent = any(word in silent_words for word in specifics.split()) + 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( - title="Thread Moved", + title=self.bot.config["thread_move_title"], description=self.bot.config["thread_move_response"], color=self.bot.main_color, ) await thread.recipient.send(embed=embed) + if self.bot.config["thread_move_notify_mods"]: + mention = self.bot.config["mention"] + if mention is not None: + msg = f"{mention}, thread has been moved." + else: + msg = "Thread has been moved." + await thread.channel.send(msg) + sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) async def send_scheduled_close_message(self, ctx, after, silent=False): - human_delta = human_timedelta(after.dt) - - silent = "*silently* " if silent else "" + """Send a scheduled close notice only to the staff thread channel. + Uses Discord relative timestamp formatting for better UX. + """ + ts = int((after.dt if after.dt.tzinfo else after.dt.replace(tzinfo=timezone.utc)).timestamp()) 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 .", color=self.bot.error_color, ) - if after.arg and not silent: embed.add_field(name="Message", value=after.arg) - embed.set_footer(text="Closing will be cancelled if a thread message is sent.") embed.timestamp = after.dt - await ctx.send(embed=embed) + thread = getattr(ctx, "thread", None) + if thread and ctx.channel == thread.channel: + await thread.channel.send(embed=embed) @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. @@ -350,7 +646,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` @@ -358,19 +654,16 @@ 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( - color=self.bot.error_color, description="Scheduled close has been cancelled." + color=self.bot.error_color, + description="Scheduled close has been cancelled.", ) else: embed = discord.Embed( @@ -380,7 +673,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) @@ -399,9 +696,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. @@ -439,9 +734,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. @@ -469,16 +762,15 @@ async def unnotify( mentions.remove(mention) await self.bot.config.update() embed = discord.Embed( - color=self.bot.main_color, description=f"{mention} will no longer be notified." + color=self.bot.main_color, + description=f"{mention} will no longer be notified.", ) return await ctx.send(embed=embed) @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. @@ -516,9 +808,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. @@ -540,7 +830,7 @@ async def unsubscribe( if mention not in mentions: embed = discord.Embed( color=self.bot.error_color, - description=f"{mention} is not already subscribed to this thread.", + description=f"{mention} is not subscribed to this thread.", ) else: mentions.remove(mention) @@ -569,6 +859,28 @@ async def sfw(self, ctx): sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def msglink(self, ctx, message_id: int): + """Retrieves the link to a message in the current thread.""" + found = False + for recipient in ctx.thread.recipients: + try: + message = await recipient.fetch_message(message_id) + found = True + break + except discord.NotFound: + continue + if not found: + embed = discord.Embed( + color=self.bot.error_color, + description="Message not found or no longer exists.", + ) + else: + embed = discord.Embed(color=self.bot.main_color, description=message.jump_url) + await ctx.send(embed=embed) + @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @@ -583,20 +895,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" @@ -607,6 +922,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: @@ -620,6 +938,406 @@ def format_log_embeds(self, logs, avatar_url): embeds.append(embed) return embeds + @commands.command(cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def title(self, ctx, *, name: str): + """Sets title for a thread""" + await ctx.thread.set_title(name) + sent_emoji, _ = await self.bot.retrieve_emoji() + await ctx.message.pin() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.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 if ctx.author.display_avatar else None, + ) + 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 if users[0].display_avatar else None + ) + + 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 if ctx.author.display_avatar else None, + ) + 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 if users[0].display_avatar else None + ) + + 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 = "Anonymous" + 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 if avatar_url else None) + + 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 if users[0].display_avatar else None + ) + + 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 = "Anonymous" + 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 if avatar_url else None) + + 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 if users[0].display_avatar else None + ) + + 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): @@ -631,13 +1349,14 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.trigger_typing() + async with safe_typing(ctx): + pass if not user: thread = ctx.thread if not thread: - raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) - user = thread.recipient + 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) @@ -669,21 +1388,38 @@ async def logs_closed_by(self, ctx, *, user: User = None): """ user = user if user is not None else ctx.author - query = {"guild_id": str(self.bot.guild_id), "open": False, "closer.id": str(user.id)} + entries = await self.bot.api.search_closed_by(user.id) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) - projection = {"messages": {"$slice": 5}} + if not embeds: + embed = discord.Embed( + color=self.bot.error_color, + description="No log entries have been found for that query.", + ) + return await ctx.send(embed=embed) - entries = await self.bot.db.logs.find(query, projection).to_list(None) + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + @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 - if not embeds: + logs = await self.bot.api.find_log_entry(key) + + if not logs: embed = discord.Embed( color=self.bot.error_color, - description="No log entries have been found for that query.", + 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() @@ -725,7 +1461,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( @@ -746,19 +1482,12 @@ 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() - - query = { - "guild_id": str(self.bot.guild_id), - "open": False, - "$text": {"$search": f'"{query}"'}, - } - - projection = {"messages": {"$slice": 5}} + async with safe_typing(ctx): + pass - entries = await self.bot.db.logs.find(query, projection).to_list(limit) + 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( @@ -780,9 +1509,11 @@ async def reply(self, ctx, *, msg: str = ""): Supports attachments and images as well as automatically embedding image URLs. """ + + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) @commands.command(aliases=["formatreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -800,11 +1531,93 @@ async def freply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) + + @commands.command(aliases=["formatanonreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fareply(self, ctx, *, msg: str = ""): + """ + Anonymously reply to a Modmail thread with variables. + + 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, ) + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, 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, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, 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, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) @commands.command(aliases=["anonreply", "anonymousreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -819,11 +1632,42 @@ async def areply(self, ctx, *, msg: str = ""): Edit the `anon_username`, `anon_avatar_url` and `anon_tag` config variables to do so. """ + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message, anonymous=True) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True) - @commands.command() + @commands.command(aliases=["plainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def preply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, plain=True) + + @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def pareply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message and anonymously. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) + + @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() async def note(self, ctx, *, msg: str = ""): @@ -833,9 +1677,36 @@ async def note(self, ctx, *, msg: str = ""): Useful for noting context. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): msg = await ctx.thread.note(ctx.message) await msg.pin() + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound): + pass + + @note.command(name="persistent", aliases=["persist"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def note_persistent(self, ctx, *, msg: str = ""): + """ + Take a persistent note about the current user. + """ + ctx.message.content = msg + async with safe_typing(ctx): + 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) + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound) as e: + logger.debug(f"Failed to delete note command message: {e}") @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -857,7 +1728,7 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to edit.", + description="Cannot find a message to edit. Plain messages are not supported.", color=self.bot.error_color, ) ) @@ -866,13 +1737,50 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): await self.bot.add_reaction(ctx.message, sent_emoji) @commands.command() + @checks.has_permissions(PermissionLevel.REGULAR) + async def selfcontact(self, ctx): + """Creates a thread with yourself""" + # Check if user already has a thread + existing_thread = await self.bot.threads.find(recipient=ctx.author) + if existing_thread: + if existing_thread.snoozed: + # Unsnooze the thread + msg = await ctx.send("ℹ️ You had a snoozed thread. Unsnoozing now...") + await existing_thread.restore_from_snooze() + self.bot.threads.cache[existing_thread.id] = existing_thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound): + pass + return + else: + # Thread already exists and is active + embed = discord.Embed( + title="Thread not created", + description=f"A thread for you already exists in {existing_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed, delete_after=10) + return + + 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: discord.CategoryChannel = None, + category: SimilarCategoryConverter = None, + manual_trigger=True, ): """ Create a thread with a specified member. @@ -881,40 +1789,163 @@ 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. - """ + `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 = 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): + category = category.split() + + 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) + + snoozed_users = [] + for u in list(users): + exists = await self.bot.threads.find(recipient=u) + if exists: + # Check if thread is snoozed + if exists.snoozed: + snoozed_users.append(u) + continue + 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) + + # Handle snoozed users - unsnooze them and return early + if snoozed_users: + for u in snoozed_users: + thread = await self.bot.threads.find(recipient=u) + if thread and thread.snoozed: + msg = await ctx.send(f"ℹ️ {u.mention} had a snoozed thread. Unsnoozing now...") + await thread.restore_from_snooze() + self.bot.threads.cache[thread.id] = thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound) as e: + logger.debug( + f"Failed to delete message (likely already deleted or lacking permissions): {e}" + ) + # Don't try to create a new thread - we just unsnoozed existing ones + return - 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) + if len(users) > 5: + errors.append("Group conversations only support 5 users.") + users = [] - 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) + if errors or not users: + if not users: + # no users left + title = "Thread not created" + else: + title = None - else: - thread = await self.bot.threads.create(user, creator=ctx.author, category=category) - if self.bot.config["dm_disabled"] >= 1: - logger.info("Contacting user %s when Modmail DM is disabled.", user) + 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) - embed = discord.Embed( - title="Created Thread", - description=f"Thread started by {ctx.author.mention} for {user.mention}.", + if not users: + return + + creator = ctx.author if manual_trigger else users[0] + + thread = await self.bot.threads.create( + recipient=users[0], + creator=creator, + category=category, + manual_trigger=manual_trigger, + # The minimum character check is enforced in ThreadManager.create + ) + + 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 + ) + + 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 if creator.display_avatar else None + ) + + 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(3) - await ctx.message.delete() + try: + await ctx.message.delete(delay=5) + except (discord.Forbidden, discord.NotFound): + pass @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) @@ -922,39 +1953,95 @@ 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 + + blocked_users = list(self.bot.blocked_users.items()) + for id_, reason in blocked_users: + # parse "reason" and check if block is expired + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue - for id_, reason in self.bot.blocked_users.items(): - 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)) + if end_time is not None: + if after <= 0: + # No longer blocked + self.bot.blocked_users.pop(str(id_)) + logger.debug("No longer blocked, user %s.", id_) + continue + 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." + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue + + if end_time is not None: + if after <= 0: + # No longer blocked + self.bot.blocked_roles.pop(str(id_)) + logger.debug("No longer blocked, role %s.", id_) + continue + + role = self.bot.guild.get_role(int(id_)) + 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." + + 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 = 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", + color=self.bot.main_color, + description=line, + ) + role_embeds.append(embed) + else: + embed.description += line + else: + role_embeds[-1].description = "Currently there are no blocked roles." + + 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) - session = EmbedPaginatorSession(ctx, *embeds) await session.run() @blocked.command(name="whitelist") @@ -1015,9 +2102,15 @@ async def blocked_whitelist(self, ctx, *, user: User = None): @commands.command(usage="[user] [duration] [reason]") @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTime = None): + async def block( + self, + ctx, + user_or_role: Optional[Union[User, discord.Role]] = None, + *, + after: UserFriendlyTime = None, + ): """ - Block a user from using Modmail. + Block a user or role from using Modmail. You may choose to set a time as to when the user will automatically be unblocked. @@ -1027,18 +2120,21 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi `duration` may be a simple "human-readable" time text. See `{prefix}help close` for examples. """ - if user is None: + if user_or_role is None: thread = ctx.thread if thread: - user = thread.recipient + user_or_role = thread.recipient elif after is None: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) else: - raise commands.BadArgument(f'User "{after.arg}" not found.') + raise commands.BadArgument(f'User or role "{after.arg}" not found.') - mention = getattr(user, "mention", f"`{user.id}`") + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") - if str(user.id) in self.bot.blocked_whitelisted_users: + if ( + not isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_whitelisted_users + ): embed = discord.Embed( title="Error", description=f"Cannot block {mention}, user is whitelisted.", @@ -1046,23 +2142,30 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi ) 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 += "." - msg = self.bot.blocked_users.get(str(user.id)) + if isinstance(user_or_role, discord.Role): + msg = self.bot.blocked_roles.get(str(user_or_role.id)) + else: + msg = self.bot.blocked_users.get(str(user_or_role.id)) + if msg is None: msg = "" - if str(user.id) in self.bot.blocked_users and msg: + if msg: old_reason = msg.strip().rstrip(".") embed = discord.Embed( title="Success", @@ -1076,7 +2179,11 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi color=self.bot.main_color, description=f"{mention} is now blocked {reason}", ) - self.bot.blocked_users[str(user.id)] = reason + + if isinstance(user_or_role, discord.Role): + self.bot.blocked_roles[str(user_or_role.id)] = reason + else: + self.bot.blocked_users[str(user_or_role.id)] = reason await self.bot.config.update() return await ctx.send(embed=embed) @@ -1084,7 +2191,7 @@ async def block(self, ctx, user: Optional[User] = None, *, after: UserFriendlyTi @commands.command() @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - async def unblock(self, ctx, *, user: User = None): + async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): """ Unblock a user from using Modmail. @@ -1093,18 +2200,18 @@ async def unblock(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - if user is None: + if user_or_role is None: thread = ctx.thread if thread: - user = thread.recipient + user_or_role = thread.recipient else: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) - mention = getattr(user, "mention", f"`{user.id}`") - name = getattr(user, "name", f"`{user.id}`") + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") + name = getattr(user_or_role, "name", f"`{user_or_role.id}`") - if str(user.id) in self.bot.blocked_users: - msg = self.bot.blocked_users.pop(str(user.id)) or "" + if not isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_users: + msg = self.bot.blocked_users.pop(str(user_or_role.id)) or "" await self.bot.config.update() if msg.startswith("System Message: "): @@ -1120,7 +2227,7 @@ async def unblock(self, ctx, *, user: User = None): embed.set_footer( text="However, if the original system block reason still applies, " f"{name} will be automatically blocked again. " - f'Use "{self.bot.prefix}blocked whitelist {user.id}" to whitelist the user.' + f'Use "{self.bot.prefix}blocked whitelist {user_or_role.id}" to whitelist the user.' ) else: embed = discord.Embed( @@ -1128,9 +2235,20 @@ async def unblock(self, ctx, *, user: User = None): color=self.bot.main_color, description=f"{mention} is no longer blocked.", ) + elif isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_roles: + msg = self.bot.blocked_roles.pop(str(user_or_role.id)) or "" + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"{mention} is no longer blocked.", + ) else: embed = discord.Embed( - title="Error", description=f"{mention} is not blocked.", color=self.bot.error_color + title="Error", + description=f"{mention} is not blocked.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -1150,12 +2268,13 @@ async def delete(self, ctx, message_id: int = None): thread = ctx.thread try: - await thread.delete_message(message_id) - except ValueError: + await thread.delete_message(message_id, note=True) + except ValueError as e: + logger.warning("Failed to delete message: %s.", e) return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to delete.", + description="Cannot find a message to delete. Plain messages are not supported.", color=self.bot.error_color, ) ) @@ -1199,24 +2318,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: @@ -1224,20 +2343,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( - user, self.bot.modmail_guild, 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] @@ -1255,18 +2372,25 @@ 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.") await ctx.channel.edit( - reason="Fix broken Modmail thread", name=name, topic=f"User ID: {user.id}" + reason="Fix broken Modmail thread", + name=name, + topic=f"User ID: {user.id}", ) return await self.bot.add_reaction(ctx.message, sent_emoji) @@ -1288,8 +2412,8 @@ async def enable(self, ctx): color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] != 0: - self.bot.config["dm_disabled"] = 0 + if self.bot.config["dm_disabled"] != DMDisabled.NONE: + self.bot.config["dm_disabled"] = DMDisabled.NONE await self.bot.config.update() return await ctx.send(embed=embed) @@ -1319,8 +2443,8 @@ async def disable_new(self, ctx): description="Modmail will not create any new threads.", color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] < 1: - self.bot.config["dm_disabled"] = 1 + if self.bot.config["dm_disabled"] != DMDisabled.NEW_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.NEW_THREADS await self.bot.config.update() return await ctx.send(embed=embed) @@ -1339,8 +2463,8 @@ async def disable_all(self, ctx): color=self.bot.main_color, ) - if self.bot.config["dm_disabled"] != 2: - self.bot.config["dm_disabled"] = 2 + if self.bot.config["dm_disabled"] != DMDisabled.ALL_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.ALL_THREADS await self.bot.config.update() return await ctx.send(embed=embed) @@ -1352,13 +2476,13 @@ async def isenable(self, ctx): Check if the DM functionalities of Modmail is enabled. """ - if self.bot.config["dm_disabled"] == 1: + if self.bot.config["dm_disabled"] == DMDisabled.NEW_THREADS: embed = discord.Embed( title="New Threads Disabled", description="Modmail is not creating new threads.", color=self.bot.error_color, ) - elif self.bot.config["dm_disabled"] == 2: + elif self.bot.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( title="All DM Disabled", description="Modmail is not accepting any DM messages for new and existing threads.", @@ -1367,12 +2491,350 @@ async def isenable(self, ctx): else: embed = discord.Embed( title="Enabled", - description="Modmail is accepting all DM messages.", + description="Modmail now is accepting all DM messages.", color=self.bot.main_color, ) return await ctx.send(embed=embed) + @commands.command(usage="[duration]") + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def snooze(self, ctx, *, duration: UserFriendlyTime = None): + """ + Snooze this thread. Behavior depends on config: + - delete (default): deletes the channel and restores it later + - move: moves the channel to the configured snoozed category + Optionally specify a duration, e.g. 'snooze 2d' for 2 days. + Uses config: snooze_default_duration, snooze_title, snooze_text + """ + thread = ctx.thread + if thread.snoozed: + await ctx.send("This thread is already snoozed.") + logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.") + return + # Default snooze duration with safe fallback + try: + default_snooze = int(self.bot.config.get("snooze_default_duration", 604800)) + except (ValueError, TypeError): + default_snooze = 604800 + if duration: + snooze_for = int((duration.dt - duration.now).total_seconds()) + snooze_for = min(snooze_for, default_snooze) + else: + snooze_for = default_snooze + + # Capacity pre-check: if behavior is move, ensure snoozed category has room (<49 channels) + behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + if behavior == "move": + snoozed_cat_id = self.bot.config.get("snoozed_category_id") + target_category = None + if snoozed_cat_id: + try: + target_category = self.bot.modmail_guild.get_channel(int(snoozed_cat_id)) + except Exception: + target_category = None + # Auto-create snoozed category if missing + if not isinstance(target_category, discord.CategoryChannel): + try: + logging.info("Auto-creating snoozed category for move-based snoozing.") + # Hide category by default; only bot can view/manage + overwrites = { + self.bot.modmail_guild.default_role: discord.PermissionOverwrite(view_channel=False) + } + bot_member = self.bot.modmail_guild.me + if bot_member is not None: + overwrites[bot_member] = discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + manage_channels=True, + manage_messages=True, + attach_files=True, + embed_links=True, + add_reactions=True, + ) + target_category = await self.bot.modmail_guild.create_category( + name="Snoozed Threads", + overwrites=overwrites, + reason="Auto-created snoozed category for move-based snoozing", + ) + try: + await self.bot.config.set("snoozed_category_id", target_category.id) + await self.bot.config.update() + except Exception as e: + logging.warning("Failed to persist snoozed_category_id: %s", e) + try: + await ctx.send( + "⚠️ Created snoozed category but failed to save it to config. Please set `snoozed_category_id` manually." + ) + except Exception as e: + logging.info( + "Failed to notify about snoozed category persistence issue: %s", + e, + ) + await ctx.send( + embed=discord.Embed( + title="Snoozed category created", + description=( + f"Created category {target_category.mention if hasattr(target_category, 'mention') else target_category.name} " + "and set it as `snoozed_category_id`." + ), + color=self.bot.main_color, + ) + ) + except Exception as e: + await ctx.send( + embed=discord.Embed( + title="Could not create snoozed category", + description=( + "I couldn't create a category automatically. Please ensure I have Manage Channels " + "permission, or set `snoozed_category_id` manually." + ), + color=self.bot.error_color, + ) + ) + logging.warning("Failed to auto-create snoozed category: %s", e) + # Capacity check after ensuring category exists + if isinstance(target_category, discord.CategoryChannel): + try: + if len(target_category.channels) >= 49: + await ctx.send( + embed=discord.Embed( + title="Snooze unavailable", + description=( + "The configured snoozed category is full (49 channels). " + "Unsnooze or move some channels out before snoozing more." + ), + color=self.bot.error_color, + ) + ) + return + except Exception as e: + logging.debug("Failed to check snoozed category channel count: %s", e) + + # Store snooze_until timestamp for reliable auto-unsnooze + now = datetime.now(timezone.utc) + snooze_until = now + timedelta(seconds=snooze_for) + await self.bot.api.logs.update_one( + {"recipient.id": str(thread.id)}, + { + "$set": { + "snooze_start": now.isoformat(), + "snooze_for": snooze_for, + "snooze_until": snooze_until.isoformat(), + } + }, + ) + embed = discord.Embed( + title=self.bot.config.get("snooze_title") or "Thread Snoozed", + description=self.bot.config.get("snooze_text") or "This thread has been snoozed.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + ok = await thread.snooze(moderator=ctx.author, snooze_for=snooze_for) + if ok: + logging.info( + f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} snoozed for {snooze_for}s." + ) + self.bot.threads.cache[thread.id] = thread + else: + await ctx.send("Failed to snooze this thread.") + logging.error(f"[SNOOZE] Failed to snooze thread for {getattr(thread.recipient, 'id', None)}.") + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def unsnooze(self, ctx, *, user: str = None): + """ + Unsnooze a thread: restores the channel and replays messages. + You can specify a user by mention or ID, or run in a thread channel to unsnooze that thread. + Uses config: unsnooze_text + """ + import discord + + thread = None + user_obj = None + if user is not None: + user_id = self._resolve_user(user) + if user_id: + try: + user_obj = await self.bot.get_or_fetch_user(user_id) + except Exception: + logger.debug( + "Failed fetching user during unsnooze; falling back to partial object (%s).", + user_id, + exc_info=True, + ) + user_obj = discord.Object(user_id) + if user_obj: + thread = await self.bot.threads.find(recipient=user_obj) + if not thread: + await ctx.send(f"[DEBUG] No thread found for user {user} (obj: {user_obj}).") + logging.warning(f"[UNSNOOZE] No thread found for user {user} (obj: {user_obj})") + return + elif hasattr(ctx, "thread"): + thread = ctx.thread + else: + await ctx.send("This is not a Modmail thread.") + logging.warning("[UNSNOOZE] Not a Modmail thread context.") + return + if not thread.snoozed: + await ctx.send("This thread is not snoozed.") + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} is not snoozed.") + return + + # Manually fetch snooze_data if the thread object doesn't have it + if not thread.snooze_data: + log_entry = await self.bot.api.logs.find_one({"recipient.id": str(thread.id), "snoozed": True}) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + await ctx.send( + self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." + ) + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} unsnoozed.") + else: + await ctx.send("Failed to unsnooze this thread.") + logging.error( + f"[UNSNOOZE] Failed to unsnooze thread for {getattr(thread.recipient, 'id', None)}." + ) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snoozed(self, ctx): + """ + List all currently snoozed threads/users. + """ + snoozed_threads = [thread for thread in self.bot.threads.cache.values() if thread.snoozed] + if not snoozed_threads: + await ctx.send("No threads are currently snoozed.") + return + + lines = [] + now = datetime.now(timezone.utc) + for thread in snoozed_threads: + user = thread.recipient.name if thread.recipient else "Unknown" + user_id = thread.id + + since_str = "?" + until_str = "?" + + if thread.snooze_data: + since = thread.snooze_data.get("snooze_start") + duration = thread.snooze_data.get("snooze_for") + + if since: + try: + since_dt = datetime.fromisoformat(since) + since_str = f"" # Discord relative timestamp + except (ValueError, TypeError) as e: + logging.warning(f"[SNOOZED] Invalid snooze_start for {user_id}: {since} ({e})") + else: + logging.warning(f"[SNOOZED] Missing snooze_start for {user_id}") + + if duration and since_str != "?": + try: + until_dt = datetime.fromisoformat(since) + timedelta(seconds=int(duration)) + until_str = f"" + except (ValueError, TypeError) as e: + logging.warning( + f"[SNOOZED] Invalid until time for {user_id}: {since} + {duration} ({e})" + ) -def setup(bot): - bot.add_cog(Modmail(bot)) + lines.append(f"- {user} (`{user_id}`) since {since_str}, until {until_str}") + + await ctx.send("Snoozed threads:\n" + "\n".join(lines)) + + async def cog_load(self): + self.snooze_auto_unsnooze.start() + + @tasks.loop(seconds=10) + async def snooze_auto_unsnooze(self): + now = datetime.now(timezone.utc) + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + for entry in snoozed: + snooze_until = entry.get("snooze_until") + if snooze_until: + try: + until_dt = datetime.fromisoformat(snooze_until) + if now >= until_dt: + thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"])) + if thread and thread.snoozed: + await thread.restore_from_snooze() + except (ValueError, TypeError) as e: + logger.debug( + "Failed parsing snooze_until timestamp for auto-unsnooze loop: %s", + e, + ) + + @snooze_auto_unsnooze.before_loop + async def _snooze_auto_unsnooze_before(self): + await self.bot.wait_until_ready() + + async def process_dm_modmail(self, message: discord.Message) -> None: + # ... existing code ... + # Before processing, check if thread is snoozed and auto-unsnooze + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + # Ensure the thread object in the cache is updated with the new channel + self.threads.cache[thread.id] = thread + # ... rest of the method unchanged ... + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + async def clearsnoozed(self, ctx): + """ + List all snoozed threads and ask for confirmation before clearing (unsnoozing) all of them. + Only proceed if the user confirms. + """ + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + if not snoozed: + await ctx.send("No threads are currently snoozed.") + return + lines = [] + for entry in snoozed: + user = entry.get("recipient", {}).get("name", "Unknown") + user_id = entry.get("recipient", {}).get("id", "?") + lines.append(f"- {user} (`{user_id}`)") + msg = await ctx.send( + "The following threads are currently snoozed and will be unsnoozed if you confirm:\n" + + "\n".join(lines) + + "\n\nType `yes` to confirm, or anything else to cancel." + ) + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel + + try: + reply = await self.bot.wait_for("message", check=check, timeout=30) + except asyncio.TimeoutError: + await ctx.send("Timed out. No threads were unsnoozed.") + return + if reply.content.strip().lower() != "yes": + await ctx.send("Cancelled. No threads were unsnoozed.") + return + count = 0 + for entry in snoozed: + user_id = entry.get("recipient", {}).get("id") + if not user_id: + continue + user_obj = None + try: + user_obj = await self.bot.get_or_fetch_user(int(user_id)) + except Exception: + user_obj = discord.Object(int(user_id)) + thread = await self.bot.threads.find(recipient=user_obj) + if thread and thread.snoozed: + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + count += 1 + await ctx.send(f"Unsnoozed {count} threads.") + + +async def setup(bot): + await bot.add_cog(Modmail(bot)) diff --git a/cogs/plugins.py b/cogs/plugins.py index e4543d2f1e..a5cece7ab6 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -6,8 +6,8 @@ import sys import typing import zipfile -from importlib import invalidate_caches from difflib import get_close_matches +from importlib import invalidate_caches from pathlib import Path, PurePath from re import match from site import USER_SITE @@ -15,13 +15,12 @@ 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 from core.paginator import EmbedPaginatorSession -from core.utils import truncate, trigger_typing +from core.utils import trigger_typing, truncate, safe_typing logger = getLogger(__name__) @@ -31,16 +30,28 @@ class InvalidPluginError(commands.BadArgument): class Plugin: - def __init__(self, user, repo, name, branch=None): - self.user = user - self.repo = repo - self.name = name - self.branch = branch if branch is not None else "master" - self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" - self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" + def __init__(self, user, repo=None, name=None, branch=None): + if repo is None: + self.user = "@local" + self.repo = "@local" + self.name = user + self.local = True + self.branch = "@local" + self.url = f"@local/{user}" + self.link = f"@local/{user}" + else: + self.user = user + self.repo = repo + self.name = name + self.local = False + self.branch = branch if branch is not None else "master" + self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" + self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" @property def path(self): + if self.local: + return PurePath("plugins") / "@local" / self.name return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" @property @@ -49,6 +60,8 @@ def abs_path(self): @property def cache_path(self): + if self.local: + raise ValueError("No cache path for local plugins!") return ( Path(__file__).absolute().parent.parent / "temp" @@ -58,9 +71,13 @@ def cache_path(self): @property def ext_string(self): + if self.local: + return f"plugins.@local.{self.name}.{self.name}" return f"plugins.{self.user}.{self.repo}.{self.name}-{self.branch}.{self.name}" def __str__(self): + if self.local: + return f"@local/{self.name}" return f"{self.user}/{self.repo}/{self.name}@{self.branch}" def __lt__(self, other): @@ -68,10 +85,13 @@ def __lt__(self, other): @classmethod def from_string(cls, s, strict=False): - if not strict: - m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) - else: - m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) + m = match(r"^@?local/(.+)$", s) + if m is None: + if not strict: + m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) + else: + m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) + if m is not None: return Plugin(*m.groups()) raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple @@ -93,7 +113,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://docs.modmail.dev/usage-guide/plugins """ def __init__(self, bot): @@ -102,21 +122,22 @@ 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" - async with self.bot.session.get(url) as resp: - self.registry = json.loads(await resp.text()) + url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" + try: + async with self.bot.session.get(url) as resp: + self.registry = json.loads(await resp.text()) + except asyncio.TimeoutError: + logger.warning("Failed to fetch registry. Loading with empty registry") async def initial_load_plugins(self): - await self.bot.wait_for_connected() - for plugin_name in list(self.bot.config["plugins"]): try: plugin = Plugin.from_string(plugin_name, strict=True) @@ -136,33 +157,59 @@ async def initial_load_plugins(self): await self.download_plugin(plugin) await self.load_plugin(plugin) except Exception: - logger.error("Error when loading plugin %s.", plugin, exc_info=True) + self.bot.config["plugins"].remove(plugin_name) + logger.error( + "Error when loading plugin %s. Plugin removed from config.", + plugin, + exc_info=True, + ) continue logger.debug("Finished loading all plugins.") + + self.bot.dispatch("plugins_ready") + self._ready_event.set() await self.bot.config.update() async def download_plugin(self, plugin, force=False): - if plugin.abs_path.exists() and not force: + if plugin.abs_path.exists() and (not force or plugin.local): return + if plugin.local: + raise InvalidPluginError(f"Local plugin {plugin} not found!") + plugin.abs_path.mkdir(parents=True, exist_ok=True) if plugin.cache_path.exists() and not force: plugin_io = plugin.cache_path.open("rb") logger.debug("Loading cached %s.", plugin.cache_path) - else: - async with self.bot.session.get(plugin.url) as resp: + headers = {} + github_token = self.bot.config["github_token"] + if github_token is not None: + headers["Authorization"] = f"token {github_token}" + + async with self.bot.session.get(plugin.url, headers=headers) as resp: logger.debug("Downloading %s.", plugin.url) raw = await resp.read() - plugin_io = io.BytesIO(raw) - if not plugin.cache_path.parent.exists(): - plugin.cache_path.parent.mkdir(parents=True) - with plugin.cache_path.open("wb") as f: - f.write(raw) + try: + raw = await resp.text() + except UnicodeDecodeError: + pass + else: + if raw == "Not Found": + raise InvalidPluginError("Plugin not found") + else: + raise InvalidPluginError("Invalid download received, non-bytes object") + + plugin_io = io.BytesIO(raw) + if not plugin.cache_path.parent.exists(): + plugin.cache_path.parent.mkdir(parents=True) + + with plugin.cache_path.open("wb") as f: + f.write(raw) with zipfile.ZipFile(plugin_io) as zipf: for info in zipf.infolist(): @@ -187,10 +234,10 @@ async def load_plugin(self, plugin): if req_txt.exists(): # Install PIP requirements - venv = hasattr(sys, "real_prefix") # in a virtual env + venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env user_install = " --user" if not venv else "" proc = await asyncio.create_subprocess_shell( - f"{sys.executable} -m pip install --upgrade{user_install} -r {req_txt} -q -q", + f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', stderr=PIPE, stdout=PIPE, ) @@ -205,17 +252,17 @@ 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```" + "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) @@ -223,7 +270,25 @@ 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 unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + async def parse_user_input(self, ctx, plugin_name, check_version=False): + if not self.bot.config["enable_plugins"]: + embed = discord.Embed( + description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + return if not self._ready_event.is_set(): embed = discord.Embed( @@ -241,7 +306,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}`.", @@ -253,13 +318,21 @@ 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: embed = discord.Embed( description="Invalid plugin name, double check the plugin name " "or use one of the following formats: " - "username/repo/plugin, username/repo/plugin@branch.", + "username/repo/plugin-name, username/repo/plugin-name@branch, @local/plugin-name.", color=self.bot.error_color, ) await ctx.send(embed=embed) @@ -283,7 +356,8 @@ async def plugins_add(self, ctx, *, plugin_name: str): Install a new plugin for the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, - or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`). + or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) + or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) @@ -292,7 +366,8 @@ async def plugins_add(self, ctx, *, plugin_name: str): if str(plugin) in self.bot.config["plugins"]: embed = discord.Embed( - description="This plugin is already installed.", color=self.bot.error_color + description="This plugin is already installed.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -304,19 +379,25 @@ async def plugins_add(self, ctx, *, plugin_name: str): ) return await ctx.send(embed=embed) - embed = discord.Embed( - description=f"Starting to download plugin from {plugin.link}...", - color=self.bot.main_color, - ) + if plugin.local: + embed = discord.Embed( + description=f"Starting to load local plugin from {plugin.link}...", + color=self.bot.main_color, + ) + else: + embed = discord.Embed( + description=f"Starting to download plugin from {plugin.link}...", + color=self.bot.main_color, + ) msg = await ctx.send(embed=embed) try: await self.download_plugin(plugin, force=True) - except Exception: + except Exception as e: logger.warning("Unable to download plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description="Failed to download plugin, check logs for error.", + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -326,16 +407,15 @@ async def plugins_add(self, ctx, *, plugin_name: str): await self.bot.config.update() if self.bot.config.get("enable_plugins"): - invalidate_caches() try: await self.load_plugin(plugin) - except Exception: + except Exception as e: logger.warning("Unable to load plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description="Failed to download plugin, check logs for error.", + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -364,41 +444,41 @@ async def plugins_remove(self, ctx, *, plugin_name: str): Remove an installed plugin of the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`). + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name) if plugin is None: return if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed( - description="Plugin is not installed.", color=self.bot.error_color - ) + 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.") self.bot.config["plugins"].remove(str(plugin)) await self.bot.config.update() - shutil.rmtree( - plugin.abs_path, - onerror=lambda *args: logger.warning( - "Failed to remove plugin files %s: %s", plugin, str(args[2]) - ), - ) - try: - plugin.abs_path.parent.rmdir() - plugin.abs_path.parent.parent.rmdir() - except OSError: - pass # dir not empty + if not plugin.local: + shutil.rmtree( + plugin.abs_path, + onerror=lambda *args: logger.warning( + "Failed to remove plugin files %s: %s", plugin, str(args[2]) + ), + ) + try: + plugin.abs_path.parent.rmdir() + plugin.abs_path.parent.parent.rmdir() + except OSError: + pass # dir not empty embed = discord.Embed( - description="The plugin is successfully uninstalled.", color=self.bot.main_color + description="The plugin is successfully uninstalled.", + color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -409,23 +489,34 @@ 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(): + async with safe_typing(ctx): + embed = discord.Embed( + description=f"Successfully updated {plugin.name}.", + color=self.bot.main_color, + ) await self.download_plugin(plugin, force=True) if self.bot.config.get("enable_plugins"): try: - self.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) except commands.ExtensionError: logger.warning("Plugin unload fail.", exc_info=True) - await self.load_plugin(plugin) - logger.debug("Updated %s.", plugin_name) - embed = discord.Embed( - description=f"Successfully updated {plugin.name}.", color=self.bot.main_color - ) + + try: + await self.load_plugin(plugin) + except Exception: + embed = discord.Embed( + description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", + color=self.bot.error_color, + ) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) + else: + logger.debug("Updated %s.", plugin) + else: + logger.debug("Updated %s.", plugin) return await ctx.send(embed=embed) @plugins.command(name="update") @@ -435,18 +526,64 @@ async def plugins_update(self, ctx, *, plugin_name: str = None): Update a plugin for the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`). + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. To update all plugins, do `{prefix}plugins update`. """ if plugin_name is None: # pylint: disable=redefined-argument-from-local - for plugin_name in self.bot.config["plugins"]: + for plugin_name in list(self.bot.config["plugins"]): await self.update_plugin(ctx, plugin_name) else: await self.update_plugin(ctx, plugin_name) + @plugins.command(name="reset") + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_reset(self, ctx): + """ + Reset all plugins for the bot. + + Deletes all cache and plugins from config and unloads from the bot. + """ + logger.warning("Purging plugins.") + for ext in list(self.bot.extensions): + if not ext.startswith("plugins."): + continue + logger.error("Unloading plugin: %s.", ext) + try: + plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) + if plugin: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + else: + await self.bot.unload_extension(ext) + except Exception: + logger.error("Failed to unload plugin: %s.", ext) + + for module in list(sys.modules.keys()): + if module.startswith("plugins."): + del sys.modules[module] + + self.bot.config["plugins"].clear() + await self.bot.config.update() + + cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" + if cache_path.exists(): + logger.warning("Removing cache path.") + shutil.rmtree(cache_path) + + for entry in os.scandir(Path(__file__).absolute().parent.parent / "plugins"): + if entry.is_dir() and entry.name != "@local": + shutil.rmtree(entry.path) + logger.warning("Removing %s.", entry.name) + + embed = discord.Embed( + description="Successfully purged all plugins from the bot.", + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + @plugins.command(name="loaded", aliases=["enabled", "installed"]) @checks.has_permissions(PermissionLevel.OWNER) async def plugins_loaded(self, ctx): @@ -471,7 +608,8 @@ async def plugins_loaded(self, ctx): if not self.loaded_plugins: embed = discord.Embed( - description="There are no plugins currently loaded.", color=self.bot.error_color + description="There are no plugins currently loaded.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -490,9 +628,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() @@ -515,6 +651,14 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N registry = sorted(self.registry.items(), key=lambda elem: elem[0]) + if not registry: + embed = discord.Embed( + color=self.bot.error_color, + description="Registry is empty. This could be because it failed to load.", + ) + await ctx.send(embed=embed) + return + if isinstance(plugin_name, int): index = plugin_name - 1 if index < 0: @@ -534,7 +678,8 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N if matches: embed.add_field( - name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches) + name="Perhaps you meant:", + value="\n".join(f"`{m}`" for m in matches), ) return await ctx.send(embed=embed) @@ -553,13 +698,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")) @@ -571,7 +712,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}." @@ -632,12 +773,15 @@ 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 if self.bot.user.display_avatar else None, + ) 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/threadmenu.py b/cogs/threadmenu.py new file mode 100644 index 0000000000..4f4f1985ae --- /dev/null +++ b/cogs/threadmenu.py @@ -0,0 +1,846 @@ +import json +import asyncio +from copy import copy as _copy + +import discord +from discord.ext import commands + +from core import checks +from core.models import PermissionLevel + + +class ThreadCreationMenuCore(commands.Cog): + """Core-integrated thread menu configuration and management. + + This Cog exposes the same commands as the legacy plugin to manage menu options, + but stores settings in core config (no plugin DB). + """ + + def __init__(self, bot): + self.bot = bot + + # ----- helpers ----- + def _get_conf(self) -> dict: + return { + "enabled": bool(self.bot.config.get("thread_creation_menu_enabled")), + "options": self.bot.config.get("thread_creation_menu_options") or {}, + "submenus": self.bot.config.get("thread_creation_menu_submenus") or {}, + "timeout": int(self.bot.config.get("thread_creation_menu_timeout") or 20), + "close_on_timeout": bool(self.bot.config.get("thread_creation_menu_close_on_timeout")), + "anonymous_menu": bool(self.bot.config.get("thread_creation_menu_anonymous_menu")), + "embed_text": self.bot.config.get("thread_creation_menu_embed_text") + or "Please select an option.", + "dropdown_placeholder": self.bot.config.get("thread_creation_menu_dropdown_placeholder") + or "Select an option to contact the staff team.", + "embed_title": self.bot.config.get("thread_creation_menu_embed_title"), + "embed_footer": self.bot.config.get("thread_creation_menu_embed_footer"), + "embed_thumbnail_url": self.bot.config.get("thread_creation_menu_embed_thumbnail_url"), + "embed_footer_icon_url": self.bot.config.get("thread_creation_menu_embed_footer_icon_url"), + "embed_color": self.bot.config.get("thread_creation_menu_embed_color"), + } + + async def _save_conf(self, conf: dict): + await self.bot.config.set("thread_creation_menu_enabled", conf.get("enabled", False)) + await self.bot.config.set("thread_creation_menu_options", conf.get("options", {}), convert=False) + await self.bot.config.set("thread_creation_menu_submenus", conf.get("submenus", {}), convert=False) + await self.bot.config.set("thread_creation_menu_timeout", conf.get("timeout", 20)) + await self.bot.config.set( + "thread_creation_menu_close_on_timeout", conf.get("close_on_timeout", False) + ) + await self.bot.config.set("thread_creation_menu_anonymous_menu", conf.get("anonymous_menu", False)) + await self.bot.config.set( + "thread_creation_menu_embed_text", conf.get("embed_text", "Please select an option.") + ) + await self.bot.config.set( + "thread_creation_menu_dropdown_placeholder", + conf.get("dropdown_placeholder", "Select an option to contact the staff team."), + ) + await self.bot.config.set("thread_creation_menu_embed_title", conf.get("embed_title")) + await self.bot.config.set("thread_creation_menu_embed_footer", conf.get("embed_footer")) + await self.bot.config.set("thread_creation_menu_embed_thumbnail_url", conf.get("embed_thumbnail_url")) + await self.bot.config.set( + "thread_creation_menu_embed_footer_icon_url", conf.get("embed_footer_icon_url") + ) + if conf.get("embed_color"): + try: + await self.bot.config.set("thread_creation_menu_embed_color", conf.get("embed_color")) + except Exception: + pass + await self.bot.config.update() + + # ----- commands ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @commands.group(invoke_without_command=True) + async def threadmenu(self, ctx): + """Thread-creation menu settings (core).""" + await ctx.send_help(ctx.command) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="toggle") + async def threadmenu_toggle(self, ctx): + """Enable or disable the thread-creation menu. + + Toggles the global on/off state. When disabled, users won't see + or be able to use the interactive thread creation select menu. + """ + conf = self._get_conf() + conf["enabled"] = not conf["enabled"] + await self._save_conf(conf) + await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.") + advancedmenu_plugin = self.bot.get_cog("AdvancedMenu") + if ( + advancedmenu_plugin + and hasattr(advancedmenu_plugin, "config") + and advancedmenu_plugin.config.get("enabled") + and advancedmenu_plugin.config["enabled"] is True + and conf["enabled"] + ): + await ctx.send( + "**Warning:** You are using both the core threadmenu feature and the advancedmenu plugin.\n" + "It is recommended to disable/uninstall the advancedmenu plugin to avoid interruption.\n" + "Migration guide can be found at: " + ) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="show") + async def threadmenu_show(self, ctx): + """Show all current main-menu options. + + Lists every option (label + description) configured in the root + (non-submenu) select menu so you can review what users will see. + """ + conf = self._get_conf() + if not conf["options"]: + return await ctx.send("There are no options in the main menu.") + embed = discord.Embed(title="Main menu", color=discord.Color.blurple()) + for v in conf["options"].values(): + embed.add_field(name=v["label"], value=v["description"], inline=False) + await ctx.send(embed=embed) + + # ----- options ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.group(name="option", invoke_without_command=True) + async def threadmenu_option(self, ctx): + """Manage main-menu options (add/remove/edit/show). + + Use subcommands: + - add: interactive wizard to create an option + - remove