diff --git a/.bandit_baseline.json b/.bandit_baseline.json index 28a4e47b9c..8a61d4006f 100644 --- a/.bandit_baseline.json +++ b/.bandit_baseline.json @@ -1,295 +1,326 @@ { "errors": [], - "generated_at": "2020-11-26T11:00:36Z", + "generated_at": "2022-09-06T16:19:31Z", "metrics": { "./bot.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1321, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1507, + "nosec": 0, + "skipped_tests": 0 }, "./cogs/modmail.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1273, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1837, + "nosec": 0, + "skipped_tests": 0 }, "./cogs/plugins.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 578, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 597, + "nosec": 0, + "skipped_tests": 0 }, "./cogs/utility.py": { - "CONFIDENCE.HIGH": 2.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 1755, - "nosec": 0 + "CONFIDENCE.HIGH": 2, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 1794, + "nosec": 0, + "skipped_tests": 0 }, "./core/_color_data.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, "loc": 1166, - "nosec": 0 + "nosec": 0, + "skipped_tests": 0 }, "./core/changelog.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 155, - "nosec": 0 + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 159, + "nosec": 0, + "skipped_tests": 0 }, "./core/checks.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 90, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 105, + "nosec": 0, + "skipped_tests": 0 }, "./core/clients.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 587, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 644, + "nosec": 0, + "skipped_tests": 0 }, "./core/config.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 352, - "nosec": 0 - }, - "./core/decorators.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 9, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 388, + "nosec": 0, + "skipped_tests": 0 }, "./core/models.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 202, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 210, + "nosec": 0, + "skipped_tests": 0 }, "./core/paginator.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 209, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 312, + "nosec": 0, + "skipped_tests": 0 }, "./core/thread.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 996, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1184, + "nosec": 0, + "skipped_tests": 0 }, "./core/time.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 158, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 265, + "nosec": 0, + "skipped_tests": 0 }, "./core/utils.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 282, - "nosec": 0 + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 396, + "nosec": 0, + "skipped_tests": 0 }, - "./plugins/kyb3r/modmail-plugins/profanity-filter-master/profanity-filter.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 81, - "nosec": 0 + "./plugins/Cordila/cord/jishaku-migration/jishaku.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 2, + "nosec": 0, + "skipped_tests": 0 }, "_totals": { - "CONFIDENCE.HIGH": 5.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 5.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 9214, - "nosec": 0 + "CONFIDENCE.HIGH": 5, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 5, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 10566, + "nosec": 0, + "skipped_tests": 0 } }, "results": [ { - "code": "11 from datetime import datetime\n12 from subprocess import PIPE\n13 from types import SimpleNamespace\n", + "code": "14 from datetime import datetime, timezone\n15 from subprocess import PIPE\n16 from types import SimpleNamespace\n", + "col_offset": 0, "filename": "./bot.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", - "line_number": 12, + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 15, "line_range": [ - 12 + 15 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { "code": "13 from site import USER_SITE\n14 from subprocess import PIPE\n15 \n16 import discord\n", + "col_offset": 0, "filename": "./cogs/plugins.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", + "issue_text": "Consider possible security implications associated with the subprocess module.", "line_number": 14, "line_range": [ 14, 15 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "13 from json import JSONDecodeError, loads\n14 from subprocess import PIPE\n15 from textwrap import indent\n", + "code": "11 from json import JSONDecodeError, loads\n12 from subprocess import PIPE\n13 from textwrap import indent\n", + "col_offset": 0, "filename": "./cogs/utility.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", - "line_number": 14, + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 12, "line_range": [ - 14 + 12 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "2039 try:\n2040 exec(to_compile, env) # pylint: disable=exec-used\n2041 except Exception as exc:\n", + "code": "2093 try:\n2094 exec(to_compile, env) # pylint: disable=exec-used\n2095 except Exception as exc:\n", + "col_offset": 12, "filename": "./cogs/utility.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "MEDIUM", "issue_text": "Use of exec detected.", - "line_number": 2040, + "line_number": 2094, "line_range": [ - 2040 + 2094 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b102_exec_used.html", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b102_exec_used.html", "test_id": "B102", "test_name": "exec_used" }, { "code": "2 import re\n3 from subprocess import PIPE\n4 from typing import List\n", + "col_offset": 0, "filename": "./core/changelog.py", "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, "issue_severity": "LOW", - "issue_text": "Consider possible security implications associated with PIPE module.", + "issue_text": "Consider possible security implications associated with the subprocess module.", "line_number": 3, "line_range": [ 3 ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_imports.html#b404-import-subprocess", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", "test_id": "B404", "test_name": "blacklist" }, { - "code": "67 \n68 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n69 self.bot = bot\n70 self.session = bot.session\n71 self.headers: dict = None\n72 self.access_token = access_token\n73 self.username = username\n74 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n75 self.url: str = kwargs.pop(\"url\", \"\")\n76 if self.access_token:\n77 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n78 \n79 @property\n80 def BRANCH(self):\n", + "code": "70 \n71 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n72 self.bot = bot\n73 self.session = bot.session\n74 self.headers: Optional[dict] = None\n75 self.access_token = access_token\n76 self.username = username\n77 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n78 self.url: str = kwargs.pop(\"url\", \"\")\n79 if self.access_token:\n80 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n81 \n82 @property\n83 def BRANCH(self) -> str:\n", + "col_offset": 4, "filename": "./core/clients.py", "issue_confidence": "MEDIUM", + "issue_cwe": { + "id": 259, + "link": "https://cwe.mitre.org/data/definitions/259.html" + }, "issue_severity": "LOW", "issue_text": "Possible hardcoded password: ''", - "line_number": 68, + "line_number": 71, "line_range": [ - 68, - 69, - 70, 71, 72, 73, @@ -298,9 +329,12 @@ 76, 77, 78, - 79 + 79, + 80, + 81, + 82 ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b107_hardcoded_password_default.html", "test_id": "B107", "test_name": "hardcoded_password_default" } diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 568e5f2175..b1309b6402 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -33,6 +33,17 @@ In short, when you submit code changes, your submissions are understood to be un ## 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! +## Find pre-existing issues to tackle +Check out our [unstaged issue tracker](https://github.com/kyb3r/modmail/issues?q=is%3Aissue+is%3Aopen+-label%3Astaged) and start helping out! + +Ways to help out: +- Help out new members +- Highlight invalid bugs/unsupported use cases +- Code review of pull requests +- Add on new use cases or reproduction steps +- Point out duplicate issues and guide them to the right direction +- Create a pull request to resolve the issue! + ## Write bug reports with detail, background, and sample code **Great Bug Reports** tend to have: @@ -43,7 +54,6 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new Issue] - What *actually* happens - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - ## Use a Consistent Coding Style We use [black](https://github.com/python/black) for a unified code style. diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml index 145a83d489..21bf39368e 100644 --- a/.github/workflows/lints.yml +++ b/.github/workflows/lints.yml @@ -4,27 +4,28 @@ on: [push, pull_request] jobs: code-style: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + python-version: ['3.8', '3.9', '3.10'] -# 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 ${{ matrix.os }} - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 with: - python-version: 3.9 + python-version: ${{ matrix.python-version }} + architecture: x64 - name: Install dependencies run: | python -m pip install --upgrade pip pipenv pipenv install --dev --system - - name: Bandit syntax check - run: bandit -r . -b .bandit_baseline.json + # 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 --exit-zero -r y continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index af6e0cc407..20403c41a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,97 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. +# 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/kyb3r/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/kyb3r/modmail/pull/3093)) +- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/kyb3r/modmail/issues/3107)) +- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/kyb3r/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/kyb3r/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/kyb3r/modmail/issues/3143)) +- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/kyb3r/modmail/issues/3122)) +- Select menus for certain paginators. +- `Title` field in `?logs`. ([GH #3142](https://github.com/kyb3r/modmail/issues/3142)) +- Snippets can be used in aliases. ([GH #3108](https://github.com/kyb3r/modmail/issues/3108), [PR #3124](https://github.com/kyb3r/modmail/pull/3124)) +- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/kyb3r/modmail/issues/3173), [PR #3174](https://github.com/kyb3r/modmail/pull/3174)) + +### Improved + +- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/kyb3r/modmail/issues/3048)) +- Use discord relative timedeltas. ([GH #3046](https://github.com/kyb3r/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/kyb3r/modmail/pull/3095), [PR #3116](https://github.com/kyb3r/modmail/pull/3116)) +- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/kyb3r/modmail/pull/3109)) +- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/kyb3r/modmail/issues/3091), [PR #3092](https://github.com/kyb3r/modmail/pull/3092)) +- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/kyb3r/modmail/issues/3104), [PR #3105](https://github.com/kyb3r/modmail/pull/3105)) +- Stickers now work in Modmail. +- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/kyb3r/modmail/issues/3088)) +- Attachments now work on plain replies. ([GH #3102](https://github.com/kyb3r/modmail/issues/3102)) +- Support LOTTIE stickers. ([GH #3119](https://github.com/kyb3r/modmail/issues/3119)) +- Editing notes now work. ([GH #3094](https://github.com/kyb3r/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/kyb3r/modmail/issues/3131)) +- Delete channel auto close functionality now works. +- Improved error handling for autoupdate. ([PR #3161](https://github.com/kyb3r/modmail/pull/3161)) +- Skip loading of already-loaded cog. ([PR #3172](https://github.com/kyb3r/modmail/pull/3172)) +- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/kyb3r/modmail/issues/3170), [PR #3178](https://github.com/kyb3r/modmail/pull/3178)) +- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/kyb3r/modmail/issues/3179)) + +### Internal + +- Improve regex parsing of channel topics. ([GH #3114](https://github.com/kyb3r/modmail/issues/3114), [PR #3111](https://github.com/kyb3r/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/kyb3r/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. diff --git a/Dockerfile b/Dockerfile index 34aba25ce6..2906f4508f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.9-slim as py +FROM python:3.9 as py FROM py as build -RUN apt update && apt install -y g++ +RUN apt update && apt install -y g++ git COPY requirements.txt / RUN pip install --prefix=/inst -U -r /requirements.txt diff --git a/Pipfile b/Pipfile index 4ed01f5b70..573f06cb76 100644 --- a/Pipfile +++ b/Pipfile @@ -5,25 +5,24 @@ verify_ssl = true [dev-packages] bandit = "~=1.7.0" -black = "==21.6b0" +black = "==22.3.0" pylint = "~=2.9.3" +typing-extensions = "==4.2.0" [packages] -aiohttp = "==3.7.4.post0" -colorama = "~=0.4.4" # Doesn't officially support Python 3.9 yet, v0.4.5 will support 3.9 -"discord.py" = "==1.7.3" -emoji = "~=1.2.0" +aiohttp = "==3.8.1" +colorama = "~=0.4.5" +"discord.py" = "==2.0.1" +emoji = "==1.7.0" isodate = "~=0.6.0" -motor = "~=2.4.0" +motor = "==2.5.1" natural = "~=0.2.0" parsedatetime = "~=2.6" pymongo = {extras = ["srv"], version = "*"} # Required by motor python-dateutil = "~=2.8.1" -python-dotenv = "~=0.18.0" +python-dotenv = "==0.20.0" uvloop = {version = ">=0.15.2", markers = "sys_platform != 'win32'"} +lottie = {version = "==0.6.11", extras = ["pdf"]} [scripts] bot = "python bot.py" - -[requires] -python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index a9f820119e..9657e2b273 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,12 +1,10 @@ { "_meta": { "hash": { - "sha256": "0e726213f83b90d7c4e90a04cea6636dbdc5be2ad82049c96820535e5cc3d1ad" + "sha256": "c074bd9564d93aac78141ccfec1138ffeb561dfa0956f60ee5b10322d156d20f" }, "pipfile-spec": 6, - "requires": { - "python_version": "3.9" - }, + "requires": {}, "sources": [ { "name": "pypi", @@ -18,169 +16,407 @@ "default": { "aiohttp": { "hashes": [ - "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", - "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", - "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", - "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", - "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", - "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", - "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", - "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", - "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", - "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", - "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", - "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", - "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", - "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", - "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", - "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", - "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", - "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", - "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", - "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", - "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", - "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", - "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", - "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", - "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", - "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", - "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", - "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", - "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", - "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", - "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", - "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", - "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", - "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", - "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", - "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", - "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" + "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3", + "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782", + "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75", + "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf", + "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7", + "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675", + "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1", + "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785", + "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4", + "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf", + "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5", + "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15", + "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca", + "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8", + "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac", + "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8", + "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef", + "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516", + "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700", + "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2", + "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8", + "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0", + "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676", + "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad", + "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155", + "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db", + "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd", + "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091", + "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602", + "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411", + "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93", + "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd", + "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec", + "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51", + "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7", + "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17", + "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d", + "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00", + "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923", + "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440", + "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32", + "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e", + "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1", + "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724", + "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a", + "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8", + "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2", + "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33", + "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b", + "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2", + "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632", + "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b", + "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2", + "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316", + "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74", + "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96", + "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866", + "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44", + "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950", + "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa", + "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c", + "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a", + "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd", + "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd", + "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9", + "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421", + "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2", + "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922", + "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4", + "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237", + "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642", + "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578" ], "index": "pypi", - "version": "==3.7.4.post0" + "version": "==3.8.1" }, - "async-timeout": { + "aiosignal": { "hashes": [ - "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", - "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" + "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a", + "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2" ], - "markers": "python_full_version >= '3.5.3'", - "version": "==3.0.1" + "markers": "python_version >= '3.6'", + "version": "==1.2.0" }, - "attrs": { + "async-timeout": { "hashes": [ - "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", - "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" + "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", + "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==21.2.0" + "markers": "python_version >= '3.6'", + "version": "==4.0.2" }, - "chardet": { + "attrs": { "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", + "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" + "markers": "python_version >= '3.5'", + "version": "==22.1.0" + }, + "cairocffi": { + "hashes": [ + "sha256:108a3a7cb09e203bdd8501d9baad91d786d204561bd71e9364e8b34897c47b91" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0" + }, + "cairosvg": { + "hashes": [ + "sha256:98c276b7e4f0caf01e5c7176765c104ffa1aa1461d63b2053b04ab663cf7052b", + "sha256:b0b9929cf5dba005178d746a8036fcf0025550f498ca54db61873322384783bc" + ], + "version": "==2.5.2" + }, + "cffi": { + "hashes": [ + "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", + "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", + "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", + "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", + "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", + "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", + "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", + "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", + "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", + "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", + "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", + "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", + "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", + "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", + "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", + "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", + "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", + "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", + "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", + "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", + "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", + "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", + "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", + "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", + "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", + "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", + "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", + "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", + "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", + "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", + "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", + "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", + "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", + "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", + "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", + "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", + "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", + "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", + "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", + "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", + "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", + "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", + "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", + "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", + "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", + "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", + "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", + "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", + "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", + "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", + "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", + "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", + "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", + "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", + "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", + "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", + "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", + "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", + "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", + "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", + "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", + "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", + "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", + "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" + ], + "version": "==1.15.1" + }, + "charset-normalizer": { + "hashes": [ + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" + ], + "markers": "python_version >= '3.6'", + "version": "==2.1.1" }, "colorama": { "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da", + "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4" ], "index": "pypi", - "version": "==0.4.4" + "version": "==0.4.5" + }, + "cssselect2": { + "hashes": [ + "sha256:3a83b2a68370c69c9cd3fcb88bbfaebe9d22edeef2c22d1ff3e1ed9c7fa45ed8", + "sha256:5b5d6dea81a5eb0c9ca39f116c8578dd413778060c94c1f51196371618909325" + ], + "markers": "python_version >= '3.7'", + "version": "==0.6.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": { "hashes": [ - "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408", - "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c" + "sha256:309146476e986cb8faf038cd5d604d4b3834ef15c2d34df697ce5064bf5cd779", + "sha256:aeb186348bf011708b085b2715cf92bbb72c692eb4f59c4c0b488130cc4c4b7e" ], "index": "pypi", - "version": "==1.7.3" + "version": "==2.0.1" }, "dnspython": { "hashes": [ - "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", - "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d" + "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e", + "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "version": "==2.2.1" }, "emoji": { "hashes": [ - "sha256:496f432058567985838c13d67dde84ca081614a8286c0b9cdc7d63dfa89d51a3", - "sha256:6b19b65da8d6f30551eead1705539cc0eadcd9e33a6ecbc421a29b87f96287eb" + "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1" ], "index": "pypi", - "version": "==1.2.0" + "version": "==1.7.0" + }, + "frozenlist": { + "hashes": [ + "sha256:022178b277cb9277d7d3b3f2762d294f15e85cd2534047e68a118c2bb0058f3e", + "sha256:086ca1ac0a40e722d6833d4ce74f5bf1aba2c77cbfdc0cd83722ffea6da52a04", + "sha256:0bc75692fb3770cf2b5856a6c2c9de967ca744863c5e89595df64e252e4b3944", + "sha256:0dde791b9b97f189874d654c55c24bf7b6782343e14909c84beebd28b7217845", + "sha256:12607804084d2244a7bd4685c9d0dca5df17a6a926d4f1967aa7978b1028f89f", + "sha256:19127f8dcbc157ccb14c30e6f00392f372ddb64a6ffa7106b26ff2196477ee9f", + "sha256:1b51eb355e7f813bcda00276b0114c4172872dc5fb30e3fea059b9367c18fbcb", + "sha256:1e1cf7bc8cbbe6ce3881863671bac258b7d6bfc3706c600008925fb799a256e2", + "sha256:219a9676e2eae91cb5cc695a78b4cb43d8123e4160441d2b6ce8d2c70c60e2f3", + "sha256:2743bb63095ef306041c8f8ea22bd6e4d91adabf41887b1ad7886c4c1eb43d5f", + "sha256:2af6f7a4e93f5d08ee3f9152bce41a6015b5cf87546cb63872cc19b45476e98a", + "sha256:31b44f1feb3630146cffe56344704b730c33e042ffc78d21f2125a6a91168131", + "sha256:31bf9539284f39ff9398deabf5561c2b0da5bb475590b4e13dd8b268d7a3c5c1", + "sha256:35c3d79b81908579beb1fb4e7fcd802b7b4921f1b66055af2578ff7734711cfa", + "sha256:3a735e4211a04ccfa3f4833547acdf5d2f863bfeb01cfd3edaffbc251f15cec8", + "sha256:42719a8bd3792744c9b523674b752091a7962d0d2d117f0b417a3eba97d1164b", + "sha256:49459f193324fbd6413e8e03bd65789e5198a9fa3095e03f3620dee2f2dabff2", + "sha256:4c0c99e31491a1d92cde8648f2e7ccad0e9abb181f6ac3ddb9fc48b63301808e", + "sha256:52137f0aea43e1993264a5180c467a08a3e372ca9d378244c2d86133f948b26b", + "sha256:526d5f20e954d103b1d47232e3839f3453c02077b74203e43407b962ab131e7b", + "sha256:53b2b45052e7149ee8b96067793db8ecc1ae1111f2f96fe1f88ea5ad5fd92d10", + "sha256:572ce381e9fe027ad5e055f143763637dcbac2542cfe27f1d688846baeef5170", + "sha256:58fb94a01414cddcdc6839807db77ae8057d02ddafc94a42faee6004e46c9ba8", + "sha256:5e77a8bd41e54b05e4fb2708dc6ce28ee70325f8c6f50f3df86a44ecb1d7a19b", + "sha256:5f271c93f001748fc26ddea409241312a75e13466b06c94798d1a341cf0e6989", + "sha256:5f63c308f82a7954bf8263a6e6de0adc67c48a8b484fab18ff87f349af356efd", + "sha256:61d7857950a3139bce035ad0b0945f839532987dfb4c06cfe160254f4d19df03", + "sha256:61e8cb51fba9f1f33887e22488bad1e28dd8325b72425f04517a4d285a04c519", + "sha256:625d8472c67f2d96f9a4302a947f92a7adbc1e20bedb6aff8dbc8ff039ca6189", + "sha256:6e19add867cebfb249b4e7beac382d33215d6d54476bb6be46b01f8cafb4878b", + "sha256:717470bfafbb9d9be624da7780c4296aa7935294bd43a075139c3d55659038ca", + "sha256:74140933d45271c1a1283f708c35187f94e1256079b3c43f0c2267f9db5845ff", + "sha256:74e6b2b456f21fc93ce1aff2b9728049f1464428ee2c9752a4b4f61e98c4db96", + "sha256:9494122bf39da6422b0972c4579e248867b6b1b50c9b05df7e04a3f30b9a413d", + "sha256:94e680aeedc7fd3b892b6fa8395b7b7cc4b344046c065ed4e7a1e390084e8cb5", + "sha256:97d9e00f3ac7c18e685320601f91468ec06c58acc185d18bb8e511f196c8d4b2", + "sha256:9c6ef8014b842f01f5d2b55315f1af5cbfde284eb184075c189fd657c2fd8204", + "sha256:a027f8f723d07c3f21963caa7d585dcc9b089335565dabe9c814b5f70c52705a", + "sha256:a718b427ff781c4f4e975525edb092ee2cdef6a9e7bc49e15063b088961806f8", + "sha256:ab386503f53bbbc64d1ad4b6865bf001414930841a870fc97f1546d4d133f141", + "sha256:ab6fa8c7871877810e1b4e9392c187a60611fbf0226a9e0b11b7b92f5ac72792", + "sha256:b47d64cdd973aede3dd71a9364742c542587db214e63b7529fbb487ed67cddd9", + "sha256:b499c6abe62a7a8d023e2c4b2834fce78a6115856ae95522f2f974139814538c", + "sha256:bbb1a71b1784e68870800b1bc9f3313918edc63dbb8f29fbd2e767ce5821696c", + "sha256:c3b31180b82c519b8926e629bf9f19952c743e089c41380ddca5db556817b221", + "sha256:c56c299602c70bc1bb5d1e75f7d8c007ca40c9d7aebaf6e4ba52925d88ef826d", + "sha256:c92deb5d9acce226a501b77307b3b60b264ca21862bd7d3e0c1f3594022f01bc", + "sha256:cc2f3e368ee5242a2cbe28323a866656006382872c40869b49b265add546703f", + "sha256:d82bed73544e91fb081ab93e3725e45dd8515c675c0e9926b4e1f420a93a6ab9", + "sha256:da1cdfa96425cbe51f8afa43e392366ed0b36ce398f08b60de6b97e3ed4affef", + "sha256:da5ba7b59d954f1f214d352308d1d86994d713b13edd4b24a556bcc43d2ddbc3", + "sha256:e0c8c803f2f8db7217898d11657cb6042b9b0553a997c4a0601f48a691480fab", + "sha256:ee4c5120ddf7d4dd1eaf079af3af7102b56d919fa13ad55600a4e0ebe532779b", + "sha256:eee0c5ecb58296580fc495ac99b003f64f82a74f9576a244d04978a7e97166db", + "sha256:f5abc8b4d0c5b556ed8cd41490b606fe99293175a82b98e652c3f2711b452988", + "sha256:f810e764617b0748b49a731ffaa525d9bb36ff38332411704c2400125af859a6", + "sha256:f89139662cc4e65a4813f4babb9ca9544e42bddb823d2ec434e18dad582543bc", + "sha256:fa47319a10e0a076709644a0efbcaab9e91902c8bd8ef74c6adb19d320f69b83", + "sha256:fabb953ab913dadc1ff9dcc3a7a7d3dc6a92efab3a0373989b8063347f8705be" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.1" }, "idna": { "hashes": [ - "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", - "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" ], "markers": "python_version >= '3.5'", - "version": "==3.2" + "version": "==3.3" }, "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:d53e96265887aa9187c7c707fd612b3d52f38da64c81ea82297783efb47f7e3f" + ], + "index": "pypi", + "version": "==0.6.11" }, "motor": { "hashes": [ - "sha256:1196db507142ef8f00d953efa2f37b39335ef2d72af6ce4fbccfd870b65c5e9f", - "sha256:839c11a43897dbec8e5ba0e87a9c9b877239803126877b2efa5cef89aa6b687a" + "sha256:663473f4498f955d35db7b6f25651cb165514c247136f368b84419cb7635f6b8", + "sha256:961fdceacaae2c7236c939166f66415be81be8bbb762da528386738de3a0f509" ], "index": "pypi", - "version": "==2.4.0" + "version": "==2.5.1" }, "multidict": { "hashes": [ - "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", - "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", - "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", - "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", - "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", - "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", - "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", - "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", - "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", - "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", - "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", - "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", - "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", - "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", - "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", - "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", - "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", - "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", - "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", - "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", - "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", - "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", - "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", - "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", - "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", - "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", - "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", - "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", - "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", - "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", - "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", - "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", - "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", - "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", - "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", - "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", - "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" - ], - "markers": "python_version >= '3.6'", - "version": "==5.1.0" + "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60", + "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c", + "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672", + "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51", + "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032", + "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2", + "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b", + "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80", + "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88", + "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a", + "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d", + "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389", + "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c", + "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9", + "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c", + "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516", + "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b", + "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43", + "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee", + "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227", + "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d", + "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae", + "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7", + "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4", + "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9", + "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f", + "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013", + "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9", + "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e", + "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693", + "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a", + "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15", + "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb", + "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96", + "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87", + "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376", + "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658", + "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0", + "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071", + "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360", + "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc", + "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3", + "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba", + "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8", + "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9", + "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2", + "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3", + "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68", + "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8", + "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d", + "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49", + "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608", + "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57", + "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86", + "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20", + "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293", + "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849", + "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937", + "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.2" }, "natural": { "hashes": [ @@ -197,112 +433,192 @@ "index": "pypi", "version": "==2.6" }, + "pillow": { + "hashes": [ + "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927", + "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14", + "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc", + "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58", + "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60", + "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76", + "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c", + "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac", + "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490", + "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1", + "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f", + "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d", + "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f", + "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069", + "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402", + "sha256:336b9036127eab855beec9662ac3ea13a4544a523ae273cbf108b228ecac8437", + "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885", + "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e", + "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be", + "sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8", + "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff", + "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da", + "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004", + "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f", + "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20", + "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d", + "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c", + "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544", + "sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9", + "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3", + "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04", + "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c", + "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5", + "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4", + "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb", + "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4", + "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c", + "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467", + "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e", + "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421", + "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b", + "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8", + "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb", + "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3", + "sha256:adabc0bce035467fb537ef3e5e74f2847c8af217ee0be0455d4fec8adc0462fc", + "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf", + "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1", + "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a", + "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28", + "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0", + "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1", + "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8", + "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd", + "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4", + "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8", + "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f", + "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013", + "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59", + "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc", + "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4" + ], + "markers": "python_version >= '3.7'", + "version": "==9.2.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, "pymongo": { - "extras": [ - "srv" - ], - "hashes": [ - "sha256:02dc0b0f48ed3cd06c13b7e31b066bf91e00dac5f8147b0a0a45f9009bfab857", - "sha256:053b4ebf91c7395d1fcd2ce6a9edff0024575b7b2de6781554a4114448a8adc9", - "sha256:070a4ef689c9438a999ec3830e69b208ff0d12251846e064d947f97d819d1d05", - "sha256:072ba7cb65c8aa4d5c5659bf6722ee85781c9d7816dc00679b8b6f3dff1ddafc", - "sha256:0b6055e0ef451ff73c93d0348d122a0750dddf323b9361de5835dac2f6cf7fc1", - "sha256:11f9e0cfc84ade088a38df2708d0b958bb76360181df1b2e1e1a41beaa57952b", - "sha256:18290649759f9db660972442aa606f845c368db9b08c4c73770f6da14113569b", - "sha256:186104a94d39b8412f8e3de385acd990a628346a4402d4f3a288a82b8660bd22", - "sha256:1970cfe2aec1bf74b40cf30c130ad10cd968941694630386db33e1d044c22a2e", - "sha256:19d4bd0fc29aa405bb1781456c9cfff9fceabb68543741eb17234952dbc2bbb0", - "sha256:1bab889ae7640eba739f67fcbf8eff252dddc60d4495e6ddd3a87cd9a95fdb52", - "sha256:1bc6fe7279ff40c6818db002bf5284aa03ec181ea1b1ceaeee33c289d412afa7", - "sha256:208debdcf76ed39ebf24f38509f50dc1c100e31e8653817fedb8e1f867850a13", - "sha256:2399a85b54f68008e483b2871f4a458b4c980469c7fe921595ede073e4844f1e", - "sha256:246ec420e4c8744fceb4e259f906211b9c198e1f345e6158dcd7cbad3737e11e", - "sha256:24f8aeec4d6b894a6128844e50ff423dd02462ee83addf503c598ee3a80ddf3d", - "sha256:255a35bf29185f44b412e31a927d9dcedda7c2c380127ecc4fbf2f61b72fa978", - "sha256:2dbfbbded947a83a3dffc2bd1ec4750c17e40904692186e2c55a3ad314ca0222", - "sha256:2e92aa32300a0b5e4175caec7769f482b292769807024a86d674b3f19b8e3755", - "sha256:316c1b8723afa9870567cd6dff35d440b2afeda53aa13da6c5ab85f98ed6f5ca", - "sha256:333bfad77aa9cd11711febfb75eed0bb537a1d022e1c252714dad38993590240", - "sha256:39dafa2eaf577d1969f289dc9a44501859a1897eb45bd589e93ce843fc610800", - "sha256:3ce83f17f641a62a4dfb0ba1b8a3c1ced7c842f511b5450d90c030c7828e3693", - "sha256:46d5ec90276f71af3a29917b30f2aec2315a2759b5f8d45b3b63a07ca8a070a3", - "sha256:48d5bc80ab0af6b60c4163c5617f5cd23f2f880d7600940870ea5055816af024", - "sha256:4ba0def4abef058c0e5101e05e3d5266e6fffb9795bbf8be0fe912a7361a0209", - "sha256:5af390fa9faf56c93252dab09ea57cd020c9123aa921b63a0ed51832fdb492e7", - "sha256:5e574664f1468872cd40f74e4811e22b1aa4de9399d6bcfdf1ee6ea94c017fcf", - "sha256:625befa3bc9b40746a749115cc6a15bf20b9bd7597ca55d646205b479a2c99c7", - "sha256:6261bee7c5abadeac7497f8f1c43e521da78dd13b0a2439f526a7b0fc3788824", - "sha256:657ad80de8ec9ed656f28844efc801a0802961e8c6a85038d97ff6f555ef4919", - "sha256:6b89dc51206e4971c5568c797991eaaef5dc2a6118d67165858ad11752dba055", - "sha256:6e66780f14c2efaf989cd3ac613b03ee6a8e3a0ba7b96c0bb14adca71a427e55", - "sha256:6fb3f85870ae26896bb44e67db94045f2ebf00c5d41e6b66cdcbb5afd644fc18", - "sha256:701e08457183da70ed96b35a6b43e6ba1df0b47c837b063cde39a1fbe1aeda81", - "sha256:70761fd3c576b027eec882b43ee0a8e5b22ff9c20cdf4d0400e104bc29e53e34", - "sha256:73b400fdc22de84bae0dbf1a22613928a41612ec0a3d6ed47caf7ad4d3d0f2ff", - "sha256:7412a36798966624dc4c57d64aa43c2d1100b348abd98daaac8e99e57d87e1d7", - "sha256:78ecb8d42f50d393af912bfb1fb1dcc9aabe9967973efb49ee577e8f1cea494c", - "sha256:7c6a9948916a7bbcc6d3a9f6fb75db1acb5546078023bfb3db6efabcd5a67527", - "sha256:7c72d08acdf573455b2b9d2b75b8237654841d63a48bc2327dc102c6ee89b75a", - "sha256:7d98ce3c42921bb91566121b658e0d9d59a9082a9bd6f473190607ff25ab637f", - "sha256:845a8b83798b2fb11b09928413cb32692866bfbc28830a433d9fa4c8c3720dd0", - "sha256:94d38eba4d1b5eb3e6bfece0651b855a35c44f32fd91f512ab4ba41b8c0d3e66", - "sha256:9a13661681d17e43009bb3e85e837aa1ec5feeea1e3654682a01b8821940f8b3", - "sha256:a0e5dff6701fa615f165306e642709e1c1550d5b237c5a7a6ea299886828bd50", - "sha256:a2239556ff7241584ce57be1facf25081669bb457a9e5cbe68cce4aae6567aa1", - "sha256:a325600c83e61e3c9cebc0c2b1c8c4140fa887f789085075e8f44c8ff2547eb9", - "sha256:a3566acfbcde46911c52810374ecc0354fdb841284a3efef6ff7105bc007e9a8", - "sha256:a634a4730ce0b0934ed75e45beba730968e12b4dafbb22f69b3b2f616d9e644e", - "sha256:a6d055f01b83b1a4df8bb0c61983d3bdffa913764488910af3620e5c2450bf83", - "sha256:a752ecd1a26000a6d67be7c9a2e93801994a8b3f866ac95b672fbc00225ca91a", - "sha256:a9ba2a63777027b06b116e1ea8248e66fd1bedc2c644f93124b81a91ddbf6d88", - "sha256:aaa038eafb7186a4abbb311fcf20724be9363645882bbce540bef4797e812a7a", - "sha256:af586e85144023686fb0af09c8cdf672484ea182f352e7ceead3d832de381e1b", - "sha256:b0a0cf39f589e52d801fdef418305562bc030cdf8929217463c8433c65fd5c2f", - "sha256:b1c4874331ab960429caca81acb9d2932170d66d6d6f87e65dc4507a85aca152", - "sha256:b3b5b3cbc3fdf4fcfa292529df2a85b5d9c7053913a739d3069af1e12e12219f", - "sha256:b542d56ed1b8d5cf3bb36326f814bd2fbe8812dfd2582b80a15689ea433c0e35", - "sha256:b6ea08758b6673610b3c5bdf47189286cf9c58b1077558706a2f6f8744922527", - "sha256:b754240daafecd9d5fce426b0fbaaed03f4ebb130745c8a4ae9231fffb8d75e5", - "sha256:b772bab31cbd9cb911e41e1a611ebc9497f9a32a7348e2747c38210f75c00f41", - "sha256:b88d1742159bc93a078733f9789f563cef26f5e370eba810476a71aa98e5fbc2", - "sha256:b8bf42d3b32f586f4c9e37541769993783a534ad35531ce8a4379f6fa664fba9", - "sha256:bc9ac81e73573516070d24ce15da91281922811f385645df32bd3c8a45ab4684", - "sha256:c188db6cf9e14dbbb42f5254292be96f05374a35e7dfa087cc2140f0ff4f10f6", - "sha256:c55782a55f4a013a78ac5b6ee4b8731a192dea7ab09f1b6b3044c96d5128edd4", - "sha256:c5cab230e7cabdae9ff23c12271231283efefb944c1b79bed79a91beb65ba547", - "sha256:cbf8672edeb7b7128c4a939274801f0e32bbf5159987815e3d1eace625264a46", - "sha256:cc2894fe91f31a513860238ede69fe47fada21f9e7ddfe73f7f9fef93a971e41", - "sha256:cda9e628b1315beec8341e8c04aac9a0b910650b05e0751e42e399d5694aeacb", - "sha256:ceae3ab9e11a27aaab42878f1d203600dfd24f0e43678b47298219a0f10c0d30", - "sha256:ced944dcdd561476deef7cb7bfd4987c69fffbfeff6d02ca4d5d4fd592d559b7", - "sha256:d04ca462cb99077e6c059e97c072957caf2918e6e4191e3161c01c439e0193de", - "sha256:d1131562ddc2ea8a446f66c2648d7dabec2b3816fc818528eb978a75a6d23b2e", - "sha256:d1740776b70367277323fafb76bcf09753a5cc9824f5d705bac22a34ff3668ea", - "sha256:d6e11ffd43184d529d6752d6dcb62b994f903038a17ea2168ef1910c96324d26", - "sha256:d73e10772152605f6648ba4410318594f1043bbfe36d2fadee7c4b8912eff7c5", - "sha256:da8288bc4a7807c6715416deed1c57d94d5e03e93537889e002bf985be503f1a", - "sha256:db93608a246da44d728842b8fa9e45aa9782db76955f634a707739a8d53ff544", - "sha256:dcd3d0009fbb6e454d729f8b22d0063bd9171c31a55e0f0271119bd4f2700023", - "sha256:dd1f49f949a658c4e8f81ed73f9aad25fcc7d4f62f767f591e749e30038c4e1d", - "sha256:dd6ff2192f34bd622883c745a56f492b1c9ccd44e14953e8051c33024a2947d5", - "sha256:e018a4921657c2d3f89c720b7b90b9182e277178a04a7e9542cc79d7d787ca51", - "sha256:e2b7670c0c8c6b501464150dd49dd0d6be6cb7f049e064124911cec5514fa19e", - "sha256:e7a33322e08021c37e89cae8ff06327503e8a1719e97c69f32c31cbf6c30d72c", - "sha256:e8a82e35d52ad6f867e88096a1a2b9bdc7ec4d5e65c7b4976a248bf2d1a32a93", - "sha256:e9faf8d4712d5ea301d74abfcf6dafe4b7f4af7936e91f283b0ad7bf69ed3e3a", - "sha256:ec5ca7c0007ce268048bbe0ffc6846ed1616cf3d8628b136e81d5e64ff3f52a2", - "sha256:eee42a1cc06565f6b21caa1f504ec15e07de7ebfd520ab57f8cb3308bc118e22", - "sha256:f2acf9bbcd514e901f82c4ca6926bbd2ae61716728f110b4343eb0a69612d018", - "sha256:f55c1ddcc1f6050b07d468ce594f55dbf6107b459e16f735d26818d7be1e9538", - "sha256:f6977a520bd96e097c8a37a8cbb9faa1ea99d21bf84190195056e25f688af73d", - "sha256:f94c7d22fb36b184734dded7345a04ec5f95130421c775b8b0c65044ef073f34", - "sha256:fa8957e9a1b202cb45e6b839c241cd986c897be1e722b81d2f32e9c6aeee80b0", - "sha256:fd3854148005c808c485c754a184c71116372263709958b42aefbef2e5dd373a", - "sha256:fe5872ce6f9627deac8314bdffd3862624227c3de4c17ef0cc78bbf0402999eb", - "sha256:ffbae429ba9e42d0582d3ac63fdb410338892468a2107d8ff68228ec9a39a0ed" + "extras": [], + "hashes": [ + "sha256:06b64cdf5121f86b78a84e61b8f899b6988732a8d304b503ea1f94a676221c06", + "sha256:07398d8a03545b98282f459f2603a6bb271f4448d484ed7f411121a519a7ea48", + "sha256:0a02313e71b7c370c43056f6b16c45effbb2d29a44d24403a3d5ba6ed322fa3f", + "sha256:0a89cadc0062a5e53664dde043f6c097172b8c1c5f0094490095282ff9995a5f", + "sha256:0be605bfb8461384a4cb81e80f51eb5ca1b89851f2d0e69a75458c788a7263a4", + "sha256:0d52a70350ec3dfc39b513df12b03b7f4c8f8ec6873bbf958299999db7b05eb1", + "sha256:0e7a5d0b9077e8c3e57727f797ee8adf12e1d5e7534642230d98980d160d1320", + "sha256:145d78c345a38011497e55aff22c0f8edd40ee676a6810f7e69563d68a125e83", + "sha256:14dee106a10b77224bba5efeeb6aee025aabe88eb87a2b850c46d3ee55bdab4a", + "sha256:176fdca18391e1206c32fb1d8265628a84d28333c20ad19468d91e3e98312cd1", + "sha256:1b4c535f524c9d8c86c3afd71d199025daa070859a2bdaf94a298120b0de16db", + "sha256:1b5cb75d2642ff7db823f509641f143f752c0d1ab03166cafea1e42e50469834", + "sha256:1c6c71e198b36f0f0dfe354f06d3655ecfa30d69493a1da125a9a54668aad652", + "sha256:1c771f1a8b3cd2d697baaf57e9cfa4ae42371cacfbea42ea01d9577c06d92f96", + "sha256:208a61db8b8b647fb5b1ff3b52b4ed6dbced01eac3b61009958adb203596ee99", + "sha256:2157d68f85c28688e8b723bbe70c8013e0aba5570e08c48b3562f74d33fc05c4", + "sha256:2301051701b27aff2cbdf83fae22b7ca883c9563dfd088033267291b46196643", + "sha256:2567885ff0c8c7c0887ba6cefe4ae4af96364a66a7069f924ce0cd12eb971d04", + "sha256:2577b8161eeae4dd376d13100b2137d883c10bb457dd08935f60c9f9d4b5c5f6", + "sha256:27e5ea64332385385b75414888ce9d1a9806be8616d7cef4ef409f4f256c6d06", + "sha256:28bfd5244d32faf3e49b5a8d1fab0631e922c26e8add089312e4be19fb05af50", + "sha256:295a5beaecb7bf054c1c6a28749ed72b19f4d4b61edcd8a0815d892424baf780", + "sha256:2c46a0afef69d61938a6fe32c3afd75b91dec3ab3056085dc72abbeedcc94166", + "sha256:3100a2352bdded6232b385ceda0c0a4624598c517d52c2d8cf014b7abbebd84d", + "sha256:320a1fe403dd83a35709fcf01083d14bc1462e9789b711201349a9158db3a87e", + "sha256:320f8734553c50cffe8a8e1ae36dfc7d7be1941c047489db20a814d2a170d7b5", + "sha256:33ab8c031f788609924e329003088831045f683931932a52a361d4a955b7dce2", + "sha256:3492ae1f97209c66af70e863e6420e6301cecb0a51a5efa701058aa73a8ca29e", + "sha256:351a2efe1c9566c348ad0076f4bf541f4905a0ebe2d271f112f60852575f3c16", + "sha256:3f0ac6e0203bd88863649e6ed9c7cfe53afab304bc8225f2597c4c0a74e4d1f0", + "sha256:3fedad05147b40ff8a93fcd016c421e6c159f149a2a481cfa0b94bfa3e473bab", + "sha256:4294f2c1cd069b793e31c2e6d7ac44b121cf7cedccd03ebcc30f3fc3417b314a", + "sha256:463b974b7f49d65a16ca1435bc1c25a681bb7d630509dd23b2e819ed36da0b7f", + "sha256:4e0a3ea7fd01cf0a36509f320226bd8491e0f448f00b8cb89f601c109f6874e1", + "sha256:514e78d20d8382d5b97f32b20c83d1d0452c302c9a135f0a9022236eb9940fda", + "sha256:517b09b1dd842390a965a896d1327c55dfe78199c9f5840595d40facbcd81854", + "sha256:51d1d061df3995c2332ae78f036492cc188cb3da8ef122caeab3631a67bb477e", + "sha256:5296669bff390135528001b4e48d33a7acaffcd361d98659628ece7f282f11aa", + "sha256:5296e5e69243ffd76bd919854c4da6630ae52e46175c804bc4c0e050d937b705", + "sha256:58db209da08a502ce6948841d522dcec80921d714024354153d00b054571993c", + "sha256:5b779e87300635b8075e8d5cfd4fdf7f46078cd7610c381d956bca5556bb8f97", + "sha256:5cf113a46d81cff0559d57aa66ffa473d57d1a9496f97426318b6b5b14fdec1c", + "sha256:5d20072d81cbfdd8e15e6a0c91fc7e3a4948c71e0adebfc67d3b4bcbe8602711", + "sha256:5d67dbc8da2dac1644d71c1839d12d12aa333e266a9964d5b1a49feed036bc94", + "sha256:5f530f35e1a57d4360eddcbed6945aecdaee2a491cd3f17025e7b5f2eea88ee7", + "sha256:5fdffb0cfeb4dc8646a5381d32ec981ae8472f29c695bf09e8f7a8edb2db12ca", + "sha256:602284e652bb56ca8760f8e88a5280636c5b63d7946fca1c2fe0f83c37dffc64", + "sha256:648fcfd8e019b122b7be0e26830a3a2224d57c3e934f19c1e53a77b8380e6675", + "sha256:64b9122be1c404ce4eb367ad609b590394587a676d84bfed8e03c3ce76d70560", + "sha256:6526933760ee1e6090db808f1690a111ec409699c1990efc96f134d26925c37f", + "sha256:6632b1c63d58cddc72f43ab9f17267354ddce563dd5e11eadabd222dcc808808", + "sha256:6f93dbfa5a461107bc3f5026e0d5180499e13379e9404f07a9f79eb5e9e1303d", + "sha256:71c0db2c313ea8a80825fb61b7826b8015874aec29ee6364ade5cb774fe4511b", + "sha256:71c5c200fd37a5322706080b09c3ec8907cf01c377a7187f354fc9e9e13abc73", + "sha256:7738147cd9dbd6d18d5593b3491b4620e13b61de975fd737283e4ad6c255c273", + "sha256:7a6e4dccae8ef5dd76052647d78f02d5d0ffaff1856277d951666c54aeba3ad2", + "sha256:7b4a9fcd95e978cd3c96cdc2096aa54705266551422cf0883c12a4044def31c6", + "sha256:80710d7591d579442c67a3bc7ae9dcba9ff95ea8414ac98001198d894fc4ff46", + "sha256:81a3ebc33b1367f301d1c8eda57eec4868e951504986d5d3fe437479dcdac5b2", + "sha256:8455176fd1b86de97d859fed4ae0ef867bf998581f584c7a1a591246dfec330f", + "sha256:845b178bd127bb074835d2eac635b980c58ec5e700ebadc8355062df708d5a71", + "sha256:87e18f29bac4a6be76a30e74de9c9005475e27100acf0830679420ce1fd9a6fd", + "sha256:89d7baa847383b9814de640c6f1a8553d125ec65e2761ad146ea2e75a7ad197c", + "sha256:8c7ad5cab282f53b9d78d51504330d1c88c83fbe187e472c07e6908a0293142e", + "sha256:8d92c6bb9174d47c2257528f64645a00bbc6324a9ff45a626192797aff01dc14", + "sha256:9252c991e8176b5a2fa574c5ab9a841679e315f6e576eb7cf0bd958f3e39b0ad", + "sha256:93111fd4e08fa889c126aa8baf5c009a941880a539c87672e04583286517450a", + "sha256:95d15cf81cd2fb926f2a6151a9f94c7aacc102b415e72bc0e040e29332b6731c", + "sha256:9d5b66d457d2c5739c184a777455c8fde7ab3600a56d8bbebecf64f7c55169e1", + "sha256:a055d29f1302892a9389a382bed10a3f77708bcf3e49bfb76f7712fa5f391cc6", + "sha256:a1ba93be779a9b8e5e44f5c133dc1db4313661cead8a2fd27661e6cb8d942ee9", + "sha256:a283425e6a474facd73072d8968812d1d9058490a5781e022ccf8895500b83ce", + "sha256:a351986d6c9006308f163c359ced40f80b6cffb42069f3e569b979829951038d", + "sha256:a766157b195a897c64945d4ff87b050bb0e763bb78f3964e996378621c703b00", + "sha256:a8a3540e21213cb8ce232e68a7d0ee49cdd35194856c50b8bd87eeb572fadd42", + "sha256:a8e0a086dbbee406cc6f603931dfe54d1cb2fba585758e06a2de01037784b737", + "sha256:ab23b0545ec71ea346bf50a5d376d674f56205b729980eaa62cdb7871805014b", + "sha256:b0db9a4691074c347f5d7ee830ab3529bc5ad860939de21c1f9c403daf1eda9a", + "sha256:b1b5be40ebf52c3c67ee547e2c4435ed5bc6352f38d23e394520b686641a6be4", + "sha256:b3e08aef4ea05afbc0a70cd23c13684e7f5e074f02450964ec5cfa1c759d33d2", + "sha256:b7df0d99e189b7027d417d4bfd9b8c53c9c7ed5a0a1495d26a6f547d820eca88", + "sha256:be1f10145f7ea76e3e836fdc5c8429c605675bdcddb0bca9725ee6e26874c00c", + "sha256:bf254a1a95e95fdf4eaa25faa1ea450a6533ed7a997f9f8e49ab971b61ea514d", + "sha256:bfc2d763d05ec7211313a06e8571236017d3e61d5fef97fcf34ec4b36c0b6556", + "sha256:c164eda0be9048f83c24b9b2656900041e069ddf72de81c17d874d0c32f6079f", + "sha256:c22591cff80188dd8543be0b559d0c807f7288bd353dc0bcfe539b4588b3a5cd", + "sha256:c5f83bb59d0ff60c6fdb1f8a7b0288fbc4640b1f0fd56f5ae2387749c35d34e3", + "sha256:c7e8221278e5f9e2b6d3893cfc3a3e46c017161a57bb0e6f244826e4cee97916", + "sha256:c8d6bf6fcd42cde2f02efb8126812a010c297eacefcd090a609639d2aeda6185", + "sha256:c8f7dd025cb0bf19e2f60a64dfc24b513c8330e0cfe4a34ccf941eafd6194d9e", + "sha256:c9d212e2af72d5c8d082775a43eb726520e95bf1c84826440f74225843975136", + "sha256:cebb3d8bcac4a6b48be65ebbc5c9881ed4a738e27bb96c86d9d7580a1fb09e05", + "sha256:d3082e5c4d7b388792124f5e805b469109e58f1ab1eb1fbd8b998e8ab766ffb7", + "sha256:d81047341ab56061aa4b6823c54d4632579c3b16e675089e8f520e9b918a133b", + "sha256:d81299f63dc33cc172c26faf59cc54dd795fc6dd5821a7676cca112a5ee8bbd6", + "sha256:dfa217bf8cf3ff6b30c8e6a89014e0c0e7b50941af787b970060ae5ba04a4ce5", + "sha256:dfec57f15f53d677b8e4535695ff3f37df7f8fe431f2efa8c3c8c4025b53d1eb", + "sha256:e099b79ccf7c40f18b149a64d3d10639980035f9ceb223169dd806ff1bb0d9cc", + "sha256:e1fc4d3985868860b6585376e511bb32403c5ffb58b0ed913496c27fd791deea", + "sha256:e2b4c95c47fb81b19ea77dc1c50d23af3eba87c9628fcc2e03d44124a3d336ea", + "sha256:e4e5d163e6644c2bc84dd9f67bfa89288c23af26983d08fefcc2cbc22f6e57e6", + "sha256:e66b3c9f8b89d4fd58a59c04fdbf10602a17c914fbaaa5e6ea593f1d54b06362", + "sha256:ed7d11330e443aeecab23866055e08a5a536c95d2c25333aeb441af2dbac38d2", + "sha256:f340a2a908644ea6cccd399be0fb308c66e05d2800107345f9f0f0d59e1731c4", + "sha256:f38b35ecd2628bf0267761ed659e48af7e620a7fcccfccf5774e7308fb18325c", + "sha256:f6d5443104f89a840250087863c91484a72f254574848e951d1bdd7d8b2ce7c9", + "sha256:fc2048d13ff427605fea328cbe5369dce549b8c7657b0e22051a5b8831170af6" ], "index": "pypi", - "version": "==3.12.0" + "version": "==3.12.3" }, "python-dateutil": { "hashes": [ @@ -314,11 +630,11 @@ }, "python-dotenv": { "hashes": [ - "sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d", - "sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d" + "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f", + "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938" ], "index": "pypi", - "version": "==0.18.0" + "version": "==0.20.0" }, "six": { "hashes": [ @@ -328,173 +644,237 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, - "typing-extensions": { + "tinycss2": { "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" + "sha256:b2e44dd8883c360c35dd0d1b5aad0b610e5156c2cb3b33434634e539ead9d8bf", + "sha256:fe794ceaadfe3cf3e686b22155d0da5780dd0e273471a51846d0a02bc204fec8" ], - "version": "==3.10.0.0" + "markers": "python_version >= '3.6'", + "version": "==1.1.1" }, "uvloop": { "hashes": [ - "sha256:0de811931e90ae2da9e19ce70ffad73047ab0c1dba7c6e74f9ae1a3aabeb89bd", - "sha256:1ff05116ede1ebdd81802df339e5b1d4cab1dfbd99295bf27e90b4cec64d70e9", - "sha256:2d8ffe44ae709f839c54bacf14ed283f41bee90430c3b398e521e10f8d117b3a", - "sha256:5cda65fc60a645470b8525ce014516b120b7057b576fa876cdfdd5e60ab1efbb", - "sha256:63a3288abbc9c8ee979d7e34c34e780b2fbab3e7e53d00b6c80271119f277399", - "sha256:7522df4e45e4f25b50adbbbeb5bb9847495c438a628177099d2721f2751ff825", - "sha256:7f4b8a905df909a407c5791fb582f6c03b0d3b491ecdc1cdceaefbc9bf9e08f6", - "sha256:905f0adb0c09c9f44222ee02f6b96fd88b493478fffb7a345287f9444e926030", - "sha256:ae2b325c0f6d748027f7463077e457006b4fdb35a8788f01754aadba825285ee", - "sha256:e71fb9038bfcd7646ca126c5ef19b17e48d4af9e838b2bcfda7a9f55a6552a32" + "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450", + "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897", + "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861", + "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c", + "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805", + "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d", + "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464", + "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f", + "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9", + "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab", + "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f", + "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638", + "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64", + "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee", + "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382", + "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228" ], "markers": "sys_platform != 'win32'", - "version": "==0.15.3" + "version": "==0.16.0" }, - "yarl": { + "webencodings": { "hashes": [ - "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", - "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", - "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", - "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", - "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", - "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", - "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", - "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", - "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", - "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", - "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", - "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", - "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", - "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", - "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", - "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", - "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", - "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", - "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", - "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", - "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", - "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", - "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", - "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", - "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", - "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", - "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", - "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", - "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", - "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", - "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", - "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", - "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", - "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", - "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", - "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", - "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" ], - "markers": "python_version >= '3.6'", - "version": "==1.6.3" + "version": "==0.5.1" + }, + "yarl": { + "hashes": [ + "sha256:076eede537ab978b605f41db79a56cad2e7efeea2aa6e0fa8f05a26c24a034fb", + "sha256:07b21e274de4c637f3e3b7104694e53260b5fc10d51fb3ec5fed1da8e0f754e3", + "sha256:0ab5a138211c1c366404d912824bdcf5545ccba5b3ff52c42c4af4cbdc2c5035", + "sha256:0c03f456522d1ec815893d85fccb5def01ffaa74c1b16ff30f8aaa03eb21e453", + "sha256:12768232751689c1a89b0376a96a32bc7633c08da45ad985d0c49ede691f5c0d", + "sha256:19cd801d6f983918a3f3a39f3a45b553c015c5aac92ccd1fac619bd74beece4a", + "sha256:1ca7e596c55bd675432b11320b4eacc62310c2145d6801a1f8e9ad160685a231", + "sha256:1e4808f996ca39a6463f45182e2af2fae55e2560be586d447ce8016f389f626f", + "sha256:205904cffd69ae972a1707a1bd3ea7cded594b1d773a0ce66714edf17833cdae", + "sha256:20df6ff4089bc86e4a66e3b1380460f864df3dd9dccaf88d6b3385d24405893b", + "sha256:21ac44b763e0eec15746a3d440f5e09ad2ecc8b5f6dcd3ea8cb4773d6d4703e3", + "sha256:29e256649f42771829974e742061c3501cc50cf16e63f91ed8d1bf98242e5507", + "sha256:2d800b9c2eaf0684c08be5f50e52bfa2aa920e7163c2ea43f4f431e829b4f0fd", + "sha256:2d93a049d29df172f48bcb09acf9226318e712ce67374f893b460b42cc1380ae", + "sha256:31a9a04ecccd6b03e2b0e12e82131f1488dea5555a13a4d32f064e22a6003cfe", + "sha256:3d1a50e461615747dd93c099f297c1994d472b0f4d2db8a64e55b1edf704ec1c", + "sha256:449c957ffc6bc2309e1fbe67ab7d2c1efca89d3f4912baeb8ead207bb3cc1cd4", + "sha256:4a88510731cd8d4befaba5fbd734a7dd914de5ab8132a5b3dde0bbd6c9476c64", + "sha256:4c322cbaa4ed78a8aac89b2174a6df398faf50e5fc12c4c191c40c59d5e28357", + "sha256:5395da939ffa959974577eff2cbfc24b004a2fb6c346918f39966a5786874e54", + "sha256:5587bba41399854703212b87071c6d8638fa6e61656385875f8c6dff92b2e461", + "sha256:56c11efb0a89700987d05597b08a1efcd78d74c52febe530126785e1b1a285f4", + "sha256:5999c4662631cb798496535afbd837a102859568adc67d75d2045e31ec3ac497", + "sha256:59ddd85a1214862ce7c7c66457f05543b6a275b70a65de366030d56159a979f0", + "sha256:6347f1a58e658b97b0a0d1ff7658a03cb79bdbda0331603bed24dd7054a6dea1", + "sha256:6628d750041550c5d9da50bb40b5cf28a2e63b9388bac10fedd4f19236ef4957", + "sha256:6afb336e23a793cd3b6476c30f030a0d4c7539cd81649683b5e0c1b0ab0bf350", + "sha256:6c8148e0b52bf9535c40c48faebb00cb294ee577ca069d21bd5c48d302a83780", + "sha256:76577f13333b4fe345c3704811ac7509b31499132ff0181f25ee26619de2c843", + "sha256:7c0da7e44d0c9108d8b98469338705e07f4bb7dab96dbd8fa4e91b337db42548", + "sha256:7de89c8456525650ffa2bb56a3eee6af891e98f498babd43ae307bd42dca98f6", + "sha256:7ec362167e2c9fd178f82f252b6d97669d7245695dc057ee182118042026da40", + "sha256:7fce6cbc6c170ede0221cc8c91b285f7f3c8b9fe28283b51885ff621bbe0f8ee", + "sha256:85cba594433915d5c9a0d14b24cfba0339f57a2fff203a5d4fd070e593307d0b", + "sha256:8b0af1cf36b93cee99a31a545fe91d08223e64390c5ecc5e94c39511832a4bb6", + "sha256:9130ddf1ae9978abe63808b6b60a897e41fccb834408cde79522feb37fb72fb0", + "sha256:99449cd5366fe4608e7226c6cae80873296dfa0cde45d9b498fefa1de315a09e", + "sha256:9de955d98e02fab288c7718662afb33aab64212ecb368c5dc866d9a57bf48880", + "sha256:a0fb2cb4204ddb456a8e32381f9a90000429489a25f64e817e6ff94879d432fc", + "sha256:a165442348c211b5dea67c0206fc61366212d7082ba8118c8c5c1c853ea4d82e", + "sha256:ab2a60d57ca88e1d4ca34a10e9fb4ab2ac5ad315543351de3a612bbb0560bead", + "sha256:abc06b97407868ef38f3d172762f4069323de52f2b70d133d096a48d72215d28", + "sha256:af887845b8c2e060eb5605ff72b6f2dd2aab7a761379373fd89d314f4752abbf", + "sha256:b19255dde4b4f4c32e012038f2c169bb72e7f081552bea4641cab4d88bc409dd", + "sha256:b3ded839a5c5608eec8b6f9ae9a62cb22cd037ea97c627f38ae0841a48f09eae", + "sha256:c1445a0c562ed561d06d8cbc5c8916c6008a31c60bc3655cdd2de1d3bf5174a0", + "sha256:d0272228fabe78ce00a3365ffffd6f643f57a91043e119c289aaba202f4095b0", + "sha256:d0b51530877d3ad7a8d47b2fff0c8df3b8f3b8deddf057379ba50b13df2a5eae", + "sha256:d0f77539733e0ec2475ddcd4e26777d08996f8cd55d2aef82ec4d3896687abda", + "sha256:d2b8f245dad9e331540c350285910b20dd913dc86d4ee410c11d48523c4fd546", + "sha256:dd032e8422a52e5a4860e062eb84ac94ea08861d334a4bcaf142a63ce8ad4802", + "sha256:de49d77e968de6626ba7ef4472323f9d2e5a56c1d85b7c0e2a190b2173d3b9be", + "sha256:de839c3a1826a909fdbfe05f6fe2167c4ab033f1133757b5936efe2f84904c07", + "sha256:e80ed5a9939ceb6fda42811542f31c8602be336b1fb977bccb012e83da7e4936", + "sha256:ea30a42dc94d42f2ba4d0f7c0ffb4f4f9baa1b23045910c0c32df9c9902cb272", + "sha256:ea513a25976d21733bff523e0ca836ef1679630ef4ad22d46987d04b372d57fc", + "sha256:ed19b74e81b10b592084a5ad1e70f845f0aacb57577018d31de064e71ffa267a", + "sha256:f5af52738e225fcc526ae64071b7e5342abe03f42e0e8918227b38c9aa711e28", + "sha256:fae37373155f5ef9b403ab48af5136ae9851151f7aacd9926251ab26b953118b" + ], + "markers": "python_version >= '3.7'", + "version": "==1.8.1" } }, "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, "astroid": { "hashes": [ - "sha256:7b963d1c590d490f60d2973e57437115978d3a2529843f160b5003b721e1e925", - "sha256:83e494b02d75d07d4e347b27c066fd791c0c74fc96c613d1ea3de0c82c48168f" + "sha256:3975a0bd5373bdce166e60c851cfcbaf21ee96de80ec518c1f4cb3e94c3fb334", + "sha256:ab7f36e8a78b8e54a62028ba6beef7561db4cdb6f2a5009ecc44a6f42b5697ef" ], "markers": "python_version ~= '3.6'", - "version": "==2.6.5" + "version": "==2.6.6" }, "bandit": { "hashes": [ - "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07", - "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608" + "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2", + "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a" ], "index": "pypi", - "version": "==1.7.0" + "version": "==1.7.4" }, "black": { "hashes": [ - "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04", - "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7" + "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b", + "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176", + "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09", + "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a", + "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015", + "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79", + "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb", + "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20", + "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464", + "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968", + "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82", + "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21", + "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0", + "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265", + "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b", + "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a", + "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72", + "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce", + "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0", + "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a", + "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163", + "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad", + "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d" ], "index": "pypi", - "version": "==21.6b0" + "version": "==22.3.0" }, "click": { "hashes": [ - "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", - "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" ], - "markers": "python_version >= '3.6'", - "version": "==8.0.1" + "markers": "python_version >= '3.7'", + "version": "==8.1.3" }, "colorama": { "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da", + "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4" ], "index": "pypi", - "version": "==0.4.4" + "version": "==0.4.5" }, "gitdb": { "hashes": [ - "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", - "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" + "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd", + "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa" ], - "markers": "python_version >= '3.4'", - "version": "==4.0.7" + "markers": "python_version >= '3.6'", + "version": "==4.0.9" }, "gitpython": { "hashes": [ - "sha256:b838a895977b45ab6f0cc926a9045c8d1c44e2b653c1fcc39fe91f42c6e8f05b", - "sha256:fce760879cd2aebd2991b3542876dc5c4a909b30c9d69dfc488e504a8db37ee8" + "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704", + "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d" ], - "markers": "python_version >= '3.6'", - "version": "==3.1.18" + "markers": "python_version >= '3.7'", + "version": "==3.1.27" }, "isort": { "hashes": [ - "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813", - "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e" + "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", + "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" ], "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", - "version": "==5.9.2" + "version": "==5.10.1" }, "lazy-object-proxy": { "hashes": [ - "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", - "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", - "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", - "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", - "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", - "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", - "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", - "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", - "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", - "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", - "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", - "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", - "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", - "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", - "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", - "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", - "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", - "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", - "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", - "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", - "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", - "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.6.0" + "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7", + "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a", + "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c", + "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc", + "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f", + "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09", + "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442", + "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e", + "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029", + "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61", + "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb", + "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0", + "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35", + "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42", + "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1", + "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad", + "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443", + "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd", + "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9", + "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148", + "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38", + "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55", + "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36", + "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a", + "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b", + "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44", + "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6", + "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69", + "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4", + "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84", + "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de", + "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28", + "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c", + "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1", + "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8", + "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b", + "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb" + ], + "markers": "python_version >= '3.6'", + "version": "==1.7.1" }, "mccabe": { "hashes": [ @@ -512,131 +892,98 @@ }, "pathspec": { "hashes": [ - "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", - "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" + "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93", + "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d" ], - "version": "==0.9.0" + "markers": "python_version >= '3.7'", + "version": "==0.10.1" }, "pbr": { "hashes": [ - "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", - "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" + "sha256:cfcc4ff8e698256fc17ea3ff796478b050852585aa5bae79ecd05b2ab7b39b9a", + "sha256:da3e18aac0a3c003e9eea1a81bd23e5a3a75d745670dcf736317b7d966887fdf" ], "markers": "python_version >= '2.6'", - "version": "==5.6.0" + "version": "==5.10.0" + }, + "platformdirs": { + "hashes": [ + "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788", + "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19" + ], + "markers": "python_version >= '3.7'", + "version": "==2.5.2" }, "pylint": { "hashes": [ - "sha256:1f333dc72ef7f5ea166b3230936ebcfb1f3b722e76c980cb9fe6b9f95e8d3172", - "sha256:748f81e5776d6273a6619506e08f1b48ff9bcb8198366a56821cf11aac14fc87" + "sha256:2e1a0eb2e8ab41d6b5dbada87f066492bb1557b12b76c47c2ee8aa8a11186594", + "sha256:8b838c8983ee1904b2de66cce9d0b96649a91901350e956d78f289c3bc87b48e" ], "index": "pypi", - "version": "==2.9.5" + "version": "==2.9.6" }, "pyyaml": { "hashes": [ - "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", - "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", - "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", - "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", - "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", - "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", - "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", - "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", - "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", - "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", - "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", - "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", - "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", - "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", - "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", - "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", - "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", - "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", - "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", - "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", - "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", - "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", - "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", - "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", - "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", - "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", - "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", - "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==5.4.1" - }, - "regex": { - "hashes": [ - "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f", - "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad", - "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a", - "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf", - "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59", - "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d", - "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895", - "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4", - "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3", - "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222", - "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0", - "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c", - "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417", - "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d", - "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d", - "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761", - "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0", - "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026", - "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854", - "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb", - "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d", - "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068", - "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde", - "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d", - "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec", - "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa", - "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd", - "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b", - "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26", - "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2", - "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f", - "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694", - "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0", - "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407", - "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874", - "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035", - "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d", - "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c", - "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5", - "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985", - "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58" - ], - "version": "==2021.7.6" + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0" }, - "six": { + "setuptools": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82", + "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "markers": "python_version >= '3.7'", + "version": "==65.3.0" }, "smmap": { "hashes": [ - "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", - "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" + "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94", + "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936" ], - "markers": "python_version >= '3.5'", - "version": "==4.0.0" + "markers": "python_version >= '3.6'", + "version": "==5.0.0" }, "stevedore": { "hashes": [ - "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", - "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" + "sha256:87e4d27fe96d0d7e4fc24f0cbe3463baae4ec51e81d95fbe60d2474636e0c7d8", + "sha256:f82cc99a1ff552310d19c379827c2c64dd9f85a38bcd5559db2470161867b786" ], - "markers": "python_version >= '3.6'", - "version": "==3.3.0" + "markers": "python_version >= '3.8'", + "version": "==4.0.0" }, "toml": { "hashes": [ @@ -646,6 +993,22 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708", + "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376" + ], + "index": "pypi", + "version": "==4.2.0" + }, "wrapt": { "hashes": [ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" diff --git a/README.md b/README.md index f4b8e8d2ff..ace61fb903 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
- +
@@ -24,11 +24,11 @@ - Python 3.7 + Python 3.8 - Made with Python 3.7 + Made with Python 3.8 @@ -108,7 +108,7 @@ If you don't want to go through the trouble of setting up your very own Modmail ### Locally -Local hosting of Modmail is also possible. First, you will need [`Python 3.7`](https://www.python.org/downloads/release/python-376/). +Local hosting of Modmail is also possible. First, you will need at least [`Python 3.8`](https://www.python.org/downloads/release/python-376/). Follow the [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) and disregard deploying the Heroku bot application. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/etJNHCQ) for help and support. @@ -162,13 +162,6 @@ $ docker run --env-file .env kyb3rr/modmail Special thanks to our sponsors for supporting the project. -Kingdom Gaming Discord: -
-
- - -
-
SirReddit:
@@ -190,12 +183,20 @@ Real Madrid:

+Advertise Your Server: +
+ + + +
+
Discord Advice Center:
+ Become a sponsor on [Patreon](https://patreon.com/kyber). ## Plugins diff --git a/SPONSORS.json b/SPONSORS.json index 30081e24e5..ce34dc3f9d 100644 --- a/SPONSORS.json +++ b/SPONSORS.json @@ -90,5 +90,43 @@ } ] } + }, + { + "embed": { + "title": "Advertise Your Server", + "description": "Advertise Your Server is the leading advertising and growth Discord Server. With over 60,000 members we can help grow your community with our range of services.\n\n__**Advertise Your Server offers everything you need to grow and find servers:**__\n\n:chart_with_upwards_trend: **Discord Growth Experts** to give you advice on how to __grow your server.__ (server/advert reviews, growth tips)\n:dividers: Over 40 different channels for **different server categories.**\n:robot: Our own __custom__ **bump bot.** (Liam)\n:bar_chart: Currently the __BIGGEST__ advertising server on Discord.\n:computer: Our own server __Listing Site__!\n:ticket: Small Servers Program for servers with less than 300 members.\n:dvd: Weekly Podcast, Blog, Email Newsletter and YouTube Tutorials. \n\nhttps://discord.gg/zP8KcF4VQz\nhttps://aysdiscord.com", + "author": { + "name": "Advertise Your Server", + "icon_url": "https://cdn.discordapp.com/attachments/563522692418895872/907067815486427176/logo4.png" + }, + "color": 431075, + "footer": { + "text": "Grow Your Discord Server" + }, + "image": { + "url": "https://cdn.discordapp.com/attachments/472811257913933834/907068966311166043/unknown_2.png" + } + } + }, + { + "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" + } } ] diff --git a/bot.py b/bot.py index 7d4248173c..9f13e3e04e 100644 --- a/bot.py +++ b/bot.py @@ -1,29 +1,28 @@ -__version__ = "3.10.2" +__version__ = "4.0.0" import asyncio import copy +import hashlib import logging import os import re -import signal import string +import struct import sys 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 +from aiohttp import ClientSession, ClientResponseError from discord.ext import commands, tasks from discord.ext.commands.view import StringView from emoji import UNICODE_EMOJI from pkg_resources import parse_version -from core.utils import tryint - try: # noinspection PyUnresolvedReferences @@ -48,10 +47,11 @@ ) from core.thread import ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, truncate +from core.utils import extract_block_timestamp, normalize_alias, parse_alias, truncate, tryint logger = getLogger(__name__) + temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") if not os.path.exists(temp_dir): os.mkdir(temp_dir) @@ -67,14 +67,12 @@ class ModmailBot(commands.Bot): def __init__(self): intents = discord.Intents.all() super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` - self._session = None + self.session = None self._api = None - self.metadata_loop = None - self.autoupdate_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] - self._connected = asyncio.Event() - self.start_time = datetime.utcnow() + self._connected = None + self.start_time = discord.utils.utcnow() self._started = False self.config = ConfigManager(self) @@ -88,9 +86,33 @@ def __init__(self): self.plugin_db = PluginDatabaseClient(self) # Deprecated self.startup() + 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) @@ -133,10 +155,13 @@ def startup(self): 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) @@ -169,12 +194,6 @@ def _configure_logging(self): 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 - @property def api(self) -> ApiClient: if self._api is None: @@ -194,106 +213,87 @@ async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def run(self): - loop = self.loop - - try: - loop.add_signal_handler(signal.SIGINT, lambda: loop.stop()) - loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop()) - except NotImplementedError: - pass - async def runner(): - try: - retry_intents = False + async with self: + self._connected = asyncio.Event() + self.session = ClientSession(loop=self.loop) try: - await self.start(self.token) + retry_intents = False + try: + await self.start(self.token) + except discord.PrivilegedIntentsRequired: + retry_intents = True + if retry_intents: + await self.http.close() + if self.ws is not None and self.ws.open: + await self.ws.close(code=1000) + self._ready.clear() + + intents = discord.Intents.default() + intents.members = True + intents.message_content = True + # Try again with members intent + self._connection._intents = intents + logger.warning( + "Attempting to login with only the server members and message content privileged intent. Some plugins might not work correctly." + ) + await self.start(self.token) except discord.PrivilegedIntentsRequired: - retry_intents = True - if retry_intents: - await self.http.close() - if self.ws is not None and self.ws.open: - await self.ws.close(code=1000) - self._ready.clear() - intents = discord.Intents.default() - intents.members = True - # Try again with members intent - self._connection._intents = intents - logger.warning( - "Attempting to login with only the server members privileged intent. Some plugins might not work correctly." + logger.critical( + "Privileged intents are not explicitly granted in the discord developers dashboard." ) - await self.start(self.token) - except discord.PrivilegedIntentsRequired: - logger.critical( - "Privileged intents are not explicitly granted in the discord developers dashboard." - ) - except discord.LoginFailure: - logger.critical("Invalid token") - except Exception: - logger.critical("Fatal exception", exc_info=True) - finally: - if not self.is_closed(): - await self.close() - if self._session: - await self._session.close() - - # noinspection PyUnusedLocal - def stop_loop_on_completion(f): - loop.stop() - - def _cancel_tasks(): - if sys.version_info < (3, 8): - task_retriever = asyncio.Task.all_tasks - else: + except discord.LoginFailure: + logger.critical("Invalid token") + except Exception: + logger.critical("Fatal exception", exc_info=True) + finally: + if self.session: + await self.session.close() + if not self.is_closed(): + await self.close() + + async def _cancel_tasks(): + async with self: task_retriever = asyncio.all_tasks + loop = self.loop + tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} - tasks = {t for t in task_retriever(loop=loop) if not t.done()} - - if not tasks: - return + if not tasks: + return - logger.info("Cleaning up after %d tasks.", len(tasks)) - for task in tasks: - task.cancel() - - loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) - logger.info("All tasks finished cancelling.") - - for task in tasks: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "Unhandled exception during Client.run shutdown.", - "exception": task.exception(), - "task": task, - } - ) + logger.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("All tasks finished cancelling.") + + for task in tasks: + try: + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + except (asyncio.InvalidStateError, asyncio.CancelledError): + pass - future = asyncio.ensure_future(runner(), loop=loop) - future.add_done_callback(stop_loop_on_completion) try: - loop.run_forever() - except KeyboardInterrupt: + asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) + except (KeyboardInterrupt, SystemExit): logger.info("Received signal to terminate bot and event loop.") finally: - future.remove_done_callback(stop_loop_on_completion) logger.info("Cleaning up tasks.") try: - _cancel_tasks() - if sys.version_info >= (3, 6): - loop.run_until_complete(loop.shutdown_asyncgens()) + cancel_tasks_coro = _cancel_tasks() + asyncio.run(cancel_tasks_coro) finally: logger.info("Closing the event loop.") - if not future.cancelled(): - try: - return future.result() - except KeyboardInterrupt: - # I am unsure why this gets raised here but suppress it anyway - return None - @property def bot_owner_ids(self): owner_ids = self.config["owners"] @@ -523,6 +523,7 @@ async def on_connect(self): logger.debug("Connected to gateway.") await self.config.refresh() await self.api.setup_indexes() + await self.load_extensions() self._connected.set() async def on_ready(self): @@ -557,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 @@ -565,7 +573,9 @@ 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 @@ -582,7 +592,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"], @@ -598,13 +608,13 @@ async def on_ready(self): { "open": False, "title": None, - "closed_at": str(datetime.utcnow()), + "closed_at": str(discord.utils.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { "id": str(self.user.id), "name": self.user.name, "discriminator": self.user.discriminator, - "avatar_url": str(self.user.avatar_url), + "avatar_url": self.user.display_avatar.url, "mod": True, }, }, @@ -614,34 +624,17 @@ async def on_ready(self): else: logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) - if self.config.get("data_collection"): - self.metadata_loop = tasks.Loop( - self.post_metadata, - seconds=0, - minutes=0, - hours=1, - count=None, - reconnect=True, - loop=None, - ) - self.metadata_loop.before_loop(self.before_post_metadata) - self.metadata_loop.start() - - self.autoupdate_loop = tasks.Loop( - self.autoupdate, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None - ) - self.autoupdate_loop.before_loop(self.before_autoupdate) - self.autoupdate_loop.start() - other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] if any(other_guilds): logger.warning( "The bot is in more servers other than the main and staff server. " "This may cause data compromise (%s).", - ", ".join(guild.name for guild in other_guilds), + ", ".join(str(guild.name) for guild in other_guilds), ) logger.warning("If the external servers are valid, you may ignore this message.") + self.post_metadata.start() + self.autoupdate.start() self._started = True async def convert_emoji(self, name: str) -> str: @@ -656,6 +649,15 @@ async def convert_emoji(self, name: str) -> str: raise return name + async def get_or_fetch_user(self, id: int) -> discord.User: + """ + Retrieve a User based on their ID. + + This tries getting the user from the cache and falls back to making + an API call if they're not found in the cache. + """ + return self.get_user(id) or await self.fetch_user(id) + async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] @@ -681,7 +683,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 @@ -695,7 +697,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 @@ -703,7 +705,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) @@ -721,7 +723,7 @@ def check_guild_age(self, author: discord.Member) -> bool: logger.debug("Blocked due to guild age, user %s.", author.name) if str(author.id) not in self.blocked_users: - new_reason = f"System Message: Recently Joined. Required to wait for {delta}." + new_reason = f"System Message: Recently Joined. User can try again {delta}." self.blocked_users[str(author.id)] = new_reason return False @@ -733,21 +735,13 @@ def check_manual_blocked_roles(self, author: discord.Member) -> bool: if str(r.id) in self.blocked_roles: blocked_reason = self.blocked_roles.get(str(r.id)) or "" - now = datetime.utcnow() - - # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", blocked_reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", blocked_reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for role %s, block and unblock again to update.", - r.name, - ) + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.blocked_roles.pop(str(r.id)) @@ -763,26 +757,19 @@ def check_manual_blocked(self, author: discord.Member) -> bool: return True blocked_reason = self.blocked_users.get(str(author.id)) or "" - now = datetime.utcnow() if blocked_reason.startswith("System Message:"): # Met the limits already, otherwise it would've been caught by the previous checks logger.debug("No longer internally blocked, user %s.", author.name) self.blocked_users.pop(str(author.id)) return True - # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", blocked_reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", blocked_reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for user %s, block and unblock again to update.", - author.name, - ) + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.blocked_users.pop(str(author.id)) @@ -852,7 +839,7 @@ async def is_blocked( async def get_thread_cooldown(self, author: discord.Member): thread_cooldown = self.config.get("thread_cooldown") - now = datetime.utcnow() + now = discord.utils.utcnow() if thread_cooldown == isodate.Duration(): return @@ -889,7 +876,7 @@ async def add_reaction( if reaction != "disable": try: await msg.add_reaction(reaction) - except (discord.HTTPException, discord.InvalidArgument) as e: + except (discord.HTTPException, discord.BadArgument) as e: logger.warning("Failed to add reaction %s: %s.", reaction, e) return False return True @@ -923,7 +910,7 @@ async def process_dm_modmail(self, message: discord.Message) -> None: 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) + embed.set_footer(text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon.url) logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -938,7 +925,7 @@ 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.guild.icon.url, ) logger.info("A message was blocked from %s due to disabled Modmail.", message.author) await self.add_reaction(message, blocked_emoji) @@ -963,6 +950,16 @@ async def process_dm_modmail(self, message: discord.Message) -> None: await self.add_reaction(message, sent_emoji) self.dispatch("thread_reply", thread, False, message, False, False) + def _get_snippet_command(self) -> commands.Command: + """Get the correct reply command based on the snippet config""" + modifiers = "f" + if self.config["plain_snippets"]: + modifiers += "p" + if self.config["anonymous_snippets"]: + modifiers += "a" + + return self.get_command(f"{modifiers}reply") + async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. @@ -973,7 +970,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() @@ -984,9 +981,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: @@ -994,18 +1000,36 @@ async def get_contexts(self, message, *, cls=commands.Context): self.aliases.pop(invoker) for alias in aliases: - view = StringView(invoked_prefix + alias) + command = None + try: + snippet_text = self.snippets[alias] + except KeyError: + command_invocation_text = alias + else: + command = self._get_snippet_command() + command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" + view = StringView(invoked_prefix + command_invocation_text) ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() - ctx_.command = self.all_commands.get(ctx_.invoked_with) + ctx_.command = command or self.all_commands.get(ctx_.invoked_with) ctxs += [ctx_] return ctxs ctx.thread = thread - ctx.invoked_with = invoker - ctx.command = self.all_commands.get(invoker) + + if snippet_text is not None: + # Process snippets + ctx.command = self._get_snippet_command() + reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") + discord.utils.find(reply_view.skip_string, prefixes) + ctx.invoked_with = reply_view.get_word().lower() + ctx.view = reply_view + else: + ctx.command = self.all_commands.get(invoker) + ctx.invoked_with = invoker + return [ctx] async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): @@ -1066,7 +1090,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) @@ -1130,7 +1154,7 @@ async def on_message(self, message): color=self.main_color, ) if self.config["show_timestamp"]: - em.timestamp = datetime.utcnow() + em.timestamp = discord.utils.utcnow() if not self.config["silent_alert_on_mention"]: content = self.config["mention"] @@ -1147,18 +1171,6 @@ async def process_commands(self, message): if isinstance(message.channel, discord.DMChannel): return await self.process_dm_modmail(message) - if message.content.startswith(self.prefix): - cmd = message.content[len(self.prefix) :].strip() - - # Process snippets - cmd = cmd.lower() - if cmd in self.snippets: - snippet = self.snippets[cmd] - if self.config["anonymous_snippets"]: - message.content = f"{self.prefix}fareply {snippet}" - else: - message.content = f"{self.prefix}freply {snippet}" - ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: @@ -1206,7 +1218,7 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(recipient=user) if thread: - await thread.channel.trigger_typing() + await thread.channel.typing() else: if not self.config.get("mod_typing"): return @@ -1216,7 +1228,7 @@ async def on_typing(self, channel, user, _): for user in thread.recipients: if await self.is_blocked(user): continue - await user.trigger_typing() + await user.typing() async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) @@ -1289,7 +1301,7 @@ async def handle_reaction_events(self, payload): for msg in linked_messages: await msg.remove_reaction(reaction, self.user) await message.remove_reaction(reaction, self.user) - except (discord.HTTPException, discord.InvalidArgument) as e: + except (discord.HTTPException, discord.BadArgument) as e: logger.warning("Failed to remove reaction: %s", e) async def handle_react_to_contact(self, payload): @@ -1320,7 +1332,7 @@ async def handle_react_to_contact(self, payload): ) embed.set_footer( text=self.config["disabled_new_thread_footer"], - icon_url=self.guild.icon_url, + icon_url=self.guild.icon.url, ) logger.info( "A new thread using react to contact was blocked from %s due to disabled Modmail.", @@ -1362,9 +1374,13 @@ async def on_guild_channel_delete(self, channel): return audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) - entry = await audit_logs.find(lambda a: int(a.target.id) == channel.id) + found_entry = False + async for entry in audit_logs: + if int(entry.target.id) == channel.id: + found_entry = True + break - if entry is None: + if not found_entry: logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) return @@ -1422,7 +1438,13 @@ async def on_message_delete(self, message): 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 @@ -1475,9 +1497,19 @@ 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): + async def on_command_error( + self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False + ) -> None: + if not unhandled_by_cog: + command = context.command + if command and command.has_error_handler(): + return + cog = context.cog + if cog and cog.has_error_handler(): + return + if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): - await context.trigger_typing() + await context.typing() await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) @@ -1512,21 +1544,23 @@ async def on_command_error(self, context, exception): else: logger.error("Unexpected exception:", exc_info=exception) + @tasks.loop(hours=1) async def post_metadata(self): info = await self.application_info() + delta = discord.utils.utcnow() - self.start_time data = { "bot_id": self.user.id, "bot_name": str(self.user), - "avatar_url": str(self.user.avatar_url), + "avatar_url": self.user.display_avatar.url, "guild_id": self.guild_id, "guild_name": self.guild.name, "member_count": len(self.guild.members), - "uptime": (datetime.utcnow() - self.start_time).total_seconds(), + "uptime": delta.total_seconds(), "latency": f"{self.ws.latency * 1000:.4f}", "version": str(self.version), "selfhosted": True, - "last_updated": str(datetime.utcnow()), + "last_updated": str(discord.utils.utcnow()), } if info.team is not None: @@ -1543,56 +1577,71 @@ async def post_metadata(self): async with self.session.post("https://api.modmail.dev/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") + @post_metadata.before_loop async def before_post_metadata(self): await self.wait_for_connected() + if not self.config.get("data_collection") or not self.guild: + self.post_metadata.cancel() + return + logger.debug("Starting metadata loop.") logger.line("debug") - if not self.guild: - self.metadata_loop.cancel() + @tasks.loop(hours=1) async def autoupdate(self): changelog = await Changelog.from_url(self) latest = changelog.latest_version if self.version < parse_version(latest.version): - if self.hosting_method == HostingMethod.HEROKU: + error = None + data = {} + try: + # update fork if gh_token exists data = await self.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + if self.hosting_method == HostingMethod.HEROKU: + if error is not None: + logger.error(f"Autoupdate failed! Status: {error.status}.") + logger.error(f"Error message: {error.message}") + self.autoupdate.cancel() + return - embed = discord.Embed(color=self.main_color) + commit_data = data.get("data") + if not commit_data: + return + + logger.info("Bot has been updated.") - commit_data = data["data"] + if not self.config["update_notifications"]: + return + + embed = discord.Embed(color=self.main_color) + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] user = data["user"] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", + ) embed.set_author( name=user["username"] + " - Updating Bot", icon_url=user["avatar_url"], url=user["url"], ) - embed.set_footer(text=f"Updating Modmail v{self.version} " f"-> v{latest.version}") + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") embed.description = latest.description for name, value in latest.fields.items(): embed.add_field(name=name, value=value) - if commit_data: - message = commit_data["commit"]["message"] - html_url = commit_data["html_url"] - short_sha = commit_data["sha"][:6] - embed.add_field( - name="Merge Commit", - value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", - ) - logger.info("Bot has been updated.") - channel = self.log_channel - if self.config["update_notifications"]: - await channel.send(embed=embed) + channel = self.update_channel + await channel.send(embed=embed) else: - try: - # update fork if gh_token exists - await self.api.update_repository() - except InvalidConfigError: - pass - command = "git pull" proc = await asyncio.create_subprocess_shell( command, @@ -1606,44 +1655,61 @@ async def autoupdate(self): if err and not res: logger.warning(f"Autoupdate failed: {err}") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() return elif res != "Already up to date.": + if os.getenv("PIPENV_ACTIVE"): + # Update pipenv if possible + await asyncio.create_subprocess_shell( + "pipenv sync", + stderr=PIPE, + stdout=PIPE, + ) + message = "" + else: + message = "\n\nDo manually update dependencies if your bot has crashed." + logger.info("Bot has been updated.") channel = self.update_channel if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): embed = discord.Embed(title="Bot has been updated", color=self.main_color) - embed.set_footer(text=f"Updating Modmail v{self.version} " f"-> v{latest.version}") + 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="If you do not have an auto-restart setup, please manually start the bot.", + description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", color=self.main_color, ) - embed.set_footer(text=f"Updating Modmail v{self.version} " f"-> v{latest.version}") + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") if self.config["update_notifications"]: await channel.send(embed=embed) return await self.close() + @autoupdate.before_loop async def before_autoupdate(self): await self.wait_for_connected() logger.debug("Starting autoupdate loop") if self.config.get("disable_autoupdates"): logger.warning("Autoupdates disabled.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return if self.hosting_method == HostingMethod.DOCKER: logger.warning("Autoupdates disabled as using Docker.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: logger.warning("GitHub access token not found.") logger.warning("Autoupdates disabled.") - self.autoupdate_loop.cancel() + self.autoupdate.cancel() + return def format_channel_name(self, author, exclude_channel=None, force_null=False): """Sanitises a username for use with text channel names @@ -1654,12 +1720,21 @@ def format_channel_name(self, author, exclude_channel=None, force_null=False): if force_null: name = new_name = "null" else: - if self.config["use_user_id_channel_name"]: + 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: - name = author.name.lower() + 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" @@ -1679,21 +1754,51 @@ def format_channel_name(self, author, exclude_channel=None, force_null=False): 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: + logger.error( + "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + sys.exit(0) + # check discord version - if discord.__version__ != "1.7.3": + discord_version = "2.0.1" + if discord.__version__ != discord_version: logger.error( - "Dependencies are not updated, run pipenv install. discord.py version expected 1.7.3, received %s", + "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", + discord_version, discord.__version__, ) sys.exit(0) + # Set up discord.py internal logging + if os.environ.get("LOG_DISCORD"): + logger.debug(f"Discord logging enabled: {os.environ['LOG_DISCORD'].upper()}") + d_logger = logging.getLogger("discord") + + d_logger.setLevel(os.environ["LOG_DISCORD"].upper()) + handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w") + handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) + d_logger.addHandler(handler) + bot = ModmailBot() bot.run() diff --git a/cogs/modmail.py b/cogs/modmail.py index bb3860bfde..b6ebe81baf 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,18 +1,18 @@ import asyncio import re -from datetime import datetime +from datetime import datetime, timezone from itertools import zip_longest -from typing import Optional, Union +from typing import Optional, Union, List, Tuple, Literal from types import SimpleNamespace import discord from discord.ext import commands +from discord.ext.commands.view import StringView from discord.ext.commands.cooldowns import BucketType from discord.role import Role from discord.utils import escape_markdown from dateutil import parser -from natural.date import duration from core import checks from core.models import DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger @@ -144,12 +144,14 @@ async def snippet(self, ctx, *, name: str.lower = None): """ if name is not None: - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: + val = self.bot.snippets[snippet_name] embed = discord.Embed( - title=f'Snippet - "{name}":', description=val, color=self.bot.main_color + title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color ) return await ctx.send(embed=embed) @@ -158,7 +160,7 @@ async def snippet(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any snippets at the moment." ) embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + embed.set_author(name="Snippets", icon_url=ctx.guild.icon.url) return await ctx.send(embed=embed) embeds = [] @@ -166,7 +168,7 @@ async def snippet(self, ctx, *, name: str.lower = None): for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): description = format_description(i, names) embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author(name="Snippets", icon_url=ctx.guild.icon_url) + embed.set_author(name="Snippets", icon_url=ctx.guild.icon.url) embeds.append(embed) session = EmbedPaginatorSession(ctx, *embeds) @@ -178,20 +180,20 @@ async def snippet_raw(self, ctx, *, name: str.lower): """ View the raw content of a snippet. """ - val = self.bot.snippets.get(name) - if val is None: + snippet_name = self.bot._resolve_snippet(name) + if snippet_name is None: embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") else: - val = truncate(escape_code_block(val), 2048 - 7) + val = truncate(escape_code_block(self.bot.snippets[snippet_name]), 2048 - 7) embed = discord.Embed( - title=f'Raw snippet - "{name}":', + title=f'Raw snippet - "{snippet_name}":', description=f"```\n{val}```", color=self.bot.main_color, ) return await ctx.send(embed=embed) - @snippet.command(name="add") + @snippet.command(name="add", aliases=["create", "make"]) @checks.has_permissions(PermissionLevel.SUPPORTER) async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content): """ @@ -212,6 +214,7 @@ async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_conte 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", @@ -246,16 +249,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() @@ -358,7 +448,7 @@ async def send_scheduled_close_message(self, ctx, after, silent=False): embed = discord.Embed( title="Scheduled close", - description=f"This thread will close {silent}in {human_delta}.", + description=f"This thread will close {silent}{human_delta}.", color=self.bot.error_color, ) @@ -373,7 +463,13 @@ async def send_scheduled_close_message(self, ctx, after, silent=False): @commands.command(usage="[after] [close message]") @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def close(self, ctx, *, after: UserFriendlyTime = None): + async def close( + self, + ctx, + option: Optional[Literal["silent", "silently", "cancel"]] = "", + *, + after: UserFriendlyTime = None, + ): """ Close the current thread. @@ -395,15 +491,11 @@ async def close(self, ctx, *, after: UserFriendlyTime = None): thread = ctx.thread - now = datetime.utcnow() - - close_after = (after.dt - now).total_seconds() if after else 0 - message = after.arg if after else None - silent = str(message).lower() in {"silent", "silently"} - cancel = str(message).lower() == "cancel" + close_after = (after.dt - after.now).total_seconds() if after else 0 + silent = any(x == option for x in {"silent", "silently"}) + cancel = option == "cancel" if cancel: - if thread.close_task is not None or thread.auto_close_task is not None: await thread.cancel_closure(all=True) embed = discord.Embed( @@ -417,7 +509,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) @@ -627,7 +723,7 @@ 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": @@ -642,7 +738,7 @@ def format_log_embeds(self, logs, avatar_url): 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" @@ -653,6 +749,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["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: @@ -734,6 +833,7 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str 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 @@ -744,10 +844,10 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) for u in users: - await u.send(embed=em) + to_exec.append(u.send(embed=em)) description = self.bot.formatter.format( self.bot.config["public_added_to_group_response"], @@ -760,14 +860,17 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) for i in ctx.thread.recipients: if i not in users: - await i.send(embed=em) + 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) @@ -813,6 +916,17 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, 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 @@ -823,10 +937,10 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=str(ctx.author), icon_url=ctx.author.display_avatar.url) for u in users: - await u.send(embed=em) + to_exec.append(u.send(embed=em)) description = self.bot.formatter.format( self.bot.config["public_removed_from_group_response"], @@ -839,14 +953,17 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) for i in ctx.thread.recipients: if i not in users: - await i.send(embed=em) + 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) @@ -896,6 +1013,7 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ctx.command.reset_cooldown(ctx) return + to_exec = [] if not silent: em = discord.Embed( title=self.bot.config["private_added_to_group_title"], @@ -903,21 +1021,21 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() + em.timestamp = discord.utils.utcnow() tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_hoisted_role(ctx.author)) + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: name = tag avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: - avatar_url = self.bot.guild.icon_url + avatar_url = self.bot.guild.icon.url em.set_footer(text=name, icon_url=avatar_url) for u in users: - await u.send(embed=em) + to_exec.append(u.send(embed=em)) description = self.bot.formatter.format( self.bot.config["public_added_to_group_description_anon"], @@ -929,14 +1047,17 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) for i in ctx.thread.recipients: if i not in users: - await i.send(embed=em) + 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) @@ -981,6 +1102,7 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro ctx.command.reset_cooldown(ctx) return + to_exec = [] if not silent: em = discord.Embed( title=self.bot.config["private_removed_from_group_title"], @@ -988,21 +1110,21 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() + em.timestamp = discord.utils.utcnow() tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_hoisted_role(ctx.author)) + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: name = tag avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: - avatar_url = self.bot.guild.icon_url + avatar_url = self.bot.guild.icon.url em.set_footer(text=name, icon_url=avatar_url) for u in users: - await u.send(embed=em) + to_exec.append(u.send(embed=em)) description = self.bot.formatter.format( self.bot.config["public_removed_from_group_description_anon"], @@ -1014,14 +1136,17 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=f"{users[0]}", icon_url=users[0].avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{users[0]}", icon_url=users[0].display_avatar.url) for i in ctx.thread.recipients: if i not in users: - await i.send(embed=em) + 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) @@ -1036,13 +1161,13 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.trigger_typing() + await ctx.typing() if not user: thread = ctx.thread if not thread: raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) - user = thread.recipient or await self.bot.fetch_user(thread.id) + 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) @@ -1075,7 +1200,7 @@ async def logs_closed_by(self, ctx, *, user: User = None): user = user if user is not None else ctx.author entries = await self.bot.api.search_closed_by(user.id) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon.url) if not embeds: embed = discord.Embed( @@ -1125,7 +1250,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.guild.icon.url) if not embeds: embed = discord.Embed( @@ -1146,11 +1271,11 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): Provide a `limit` to specify the maximum number of logs the bot should find. """ - await ctx.trigger_typing() + await ctx.typing() entries = await self.bot.api.search_by_text(query, limit) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon_url) + embeds = self.format_log_embeds(entries, avatar_url=self.bot.guild.icon.url) if not embeds: embed = discord.Embed( @@ -1222,6 +1347,50 @@ async def fareply(self, ctx, *, msg: str = ""): async with ctx.typing(): await ctx.thread.reply(ctx.message, anonymous=True) + @commands.command(aliases=["formatplainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpreply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + ) + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, plain=True) + + @commands.command(aliases=["formatplainanonreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpareply(self, ctx, *, msg: str = ""): + """ + Anonymously reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + ) + ctx.message.content = msg + async with ctx.typing(): + await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + @commands.command(aliases=["anonreply", "anonymousreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @@ -1333,9 +1502,11 @@ async def selfcontact(self, ctx): async def contact( self, ctx, - users: commands.Greedy[Union[discord.Member, discord.User, discord.Role]], + users: commands.Greedy[ + Union[Literal["silent", "silently"], discord.Member, discord.User, discord.Role] + ], *, - category: Union[SimilarCategoryConverter, str] = None, + category: SimilarCategoryConverter = None, manual_trigger=True, ): """ @@ -1349,11 +1520,23 @@ async def contact( A maximum of 5 users are allowed. `options` can be `silent` or `silently`. """ - silent = False + silent = any(x in users for x in ("silent", "silently")) + if silent: + try: + users.remove("silent") + except ValueError: + pass + + try: + users.remove("silently") + except ValueError: + pass + if isinstance(category, str): - if "silent" in category or "silently" in category: - silent = True - category = category.strip("silently").strip("silent").strip() + category = category.split() + + category = " ".join(category) + if category: try: category = await SimilarCategoryConverter().convert( ctx, category @@ -1434,8 +1617,8 @@ async def contact( color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: - em.timestamp = datetime.utcnow() - em.set_footer(text=f"{creator}", icon_url=creator.avatar_url) + em.timestamp = discord.utils.utcnow() + em.set_footer(text=f"{creator}", icon_url=creator.display_avatar.url) for u in users: await u.send(embed=em) @@ -1464,8 +1647,6 @@ async def contact( async def blocked(self, ctx): """Retrieve a list of blocked users.""" - embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] - roles = [] users = [] now = ctx.message.created_at @@ -1473,48 +1654,33 @@ async def blocked(self, ctx): blocked_users = list(self.bot.blocked_users.items()) for id_, reason in blocked_users: # parse "reason" and check if block is expired - # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for user %s, block and unblock again to update.", - id_, - ) + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() if after <= 0: # No longer blocked self.bot.blocked_users.pop(str(id_)) logger.debug("No longer blocked, user %s.", id_) continue - user = self.bot.get_user(int(id_)) - if user: - users.append((user.mention, reason)) + try: + user = await self.bot.get_or_fetch_user(int(id_)) + except discord.NotFound: + users.append((id_, reason)) else: - try: - user = await self.bot.fetch_user(id_) - users.append((user.mention, reason)) - except discord.NotFound: - users.append((id_, reason)) + users.append((user.mention, reason)) blocked_roles = list(self.bot.blocked_roles.items()) for id_, reason in blocked_roles: # parse "reason" and check if block is expired # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for role %s, block and unblock again to update.", - id_, - ) + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue if end_time is not None: after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() @@ -1528,43 +1694,54 @@ async def blocked(self, ctx): if role: roles.append((role.mention, reason)) + user_embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] + if users: - embed = embeds[0] + embed = user_embeds[0] for mention, reason in users: line = mention + f" - {reason or 'No Reason Provided'}\n" if len(embed.description) + len(line) > 2048: embed = discord.Embed( - title="Blocked Users (Continued)", + title="Blocked Users", color=self.bot.main_color, description=line, ) - embeds.append(embed) + user_embeds.append(embed) else: embed.description += line else: - embeds[0].description = "Currently there are no blocked users." + user_embeds[0].description = "Currently there are no blocked users." - embeds.append(discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="")) + if len(user_embeds) > 1: + for n, em in enumerate(user_embeds): + em.title = f"{em.title} [{n + 1}]" + + role_embeds = [discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="")] if roles: - embed = embeds[-1] + embed = role_embeds[-1] for mention, reason in roles: line = mention + f" - {reason or 'No Reason Provided'}\n" if len(embed.description) + len(line) > 2048: + role_embeds[-1].set_author() embed = discord.Embed( - title="Blocked Roles (Continued)", + title="Blocked Roles", color=self.bot.main_color, description=line, ) - embeds.append(embed) + role_embeds.append(embed) else: embed.description += line else: - embeds[-1].description = "Currently there are no blocked roles." + role_embeds[-1].description = "Currently there are no blocked roles." - session = EmbedPaginatorSession(ctx, *embeds) + if len(role_embeds) > 1: + for n, em in enumerate(role_embeds): + em.title = f"{em.title} [{n + 1}]" + + session = EmbedPaginatorSession(ctx, *user_embeds, *role_embeds) await session.run() @@ -1671,10 +1848,13 @@ async def block( 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 += "." @@ -1837,10 +2017,10 @@ 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] = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + other_recipients[n] = await self.bot.get_or_fetch_user(uid) if user_id != -1: recipient = self.bot.get_user(user_id) @@ -1893,7 +2073,7 @@ async def repair(self, ctx): other_recipients = match_other_recipients(ctx.channel.topic) for n, uid in enumerate(other_recipients): - other_recipients[n] = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + other_recipients[n] = await self.bot.get_or_fetch_user(uid) if recipient is None: self.bot.threads.cache[user.id] = thread = Thread( @@ -2014,5 +2194,5 @@ async def isenable(self, ctx): return await ctx.send(embed=embed) -def setup(bot): - bot.add_cog(Modmail(bot)) +async def setup(bot): + await bot.add_cog(Modmail(bot)) diff --git a/cogs/plugins.py b/cogs/plugins.py index 4cdd0aba3f..2bfac509af 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -123,10 +123,10 @@ 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.") @@ -136,8 +136,6 @@ async def populate_registry(self): self.registry = json.loads(await resp.text()) async def initial_load_plugins(self): - await self.bot.wait_for_connected() - for plugin_name in list(self.bot.config["plugins"]): try: plugin = Plugin.from_string(plugin_name, strict=True) @@ -258,7 +256,7 @@ async def load_plugin(self, plugin): 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) @@ -432,7 +430,7 @@ async def plugins_remove(self, ctx, *, plugin_name: str): if self.bot.config.get("enable_plugins"): try: - self.bot.unload_extension(plugin.ext_string) + await self.bot.unload_extension(plugin.ext_string) self.loaded_plugins.remove(plugin) except (commands.ExtensionNotLoaded, KeyError): logger.warning("Plugin was never loaded.") @@ -474,7 +472,7 @@ async def update_plugin(self, ctx, plugin_name): await self.download_plugin(plugin, force=True) if self.bot.config.get("enable_plugins"): try: - self.bot.unload_extension(plugin.ext_string) + await self.bot.unload_extension(plugin.ext_string) except commands.ExtensionError: logger.warning("Plugin unload fail.", exc_info=True) try: @@ -525,7 +523,7 @@ async def plugins_reset(self, ctx): continue try: logger.error("Unloading plugin: %s.", ext) - self.bot.unload_extension(ext) + await self.bot.unload_extension(ext) except Exception: logger.error("Failed to unload plugin: %s.", ext) else: @@ -730,12 +728,12 @@ async def plugins_registry_compact(self, ctx): for page in pages: embed = discord.Embed(color=self.bot.main_color, description=page) - embed.set_author(name="Plugin Registry", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Plugin Registry", icon_url=self.bot.user.display_avatar.url) embeds.append(embed) paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() -def setup(bot): - bot.add_cog(Plugins(bot)) +async def setup(bot): + await bot.add_cog(Plugins(bot)) diff --git a/cogs/utility.py b/cogs/utility.py index bc813e9d6e..af0e70beec 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -5,7 +5,6 @@ import re import traceback from contextlib import redirect_stdout -from datetime import datetime from difflib import get_close_matches from io import BytesIO, StringIO from itertools import takewhile, zip_longest @@ -40,7 +39,7 @@ class ModmailHelpCommand(commands.HelpCommand): async def command_callback(self, ctx, *, command=None): - """Ovrwrites original command_callback to ensure `help` without any arguments + """Overwrites original command_callback to ensure `help` without any arguments returns with checks, `help all` returns without checks""" if command is None: self.verify_checks = True @@ -54,7 +53,7 @@ async def command_callback(self, ctx, *, command=None): async def format_cog_help(self, cog, *, no_cog=False): bot = self.context.bot - prefix = self.clean_prefix + prefix = self.context.clean_prefix formats = [""] for cmd in await self.filter_commands( @@ -90,19 +89,23 @@ async def format_cog_help(self, cog, *, no_cog=False): embed.add_field(name="Commands", value=format_ or "No commands.") - continued = " (Continued)" if embeds else "" name = cog.qualified_name + " - Help" if not no_cog else "Miscellaneous Commands" - embed.set_author(name=name + continued, icon_url=bot.user.avatar_url) + embed.set_author(name=name, icon_url=bot.user.display_avatar.url) embed.set_footer( text=f'Type "{prefix}{self.command_attrs["name"]} command" ' "for more info on a specific command." ) embeds.append(embed) + + if len(embeds) > 1: + for n, em in enumerate(embeds): + em.set_author(name=f"{em.author.name} [{n + 1}]", icon_url=em.author.icon_url) + return embeds def process_help_msg(self, help_: str): - return help_.format(prefix=self.clean_prefix) if help_ else "No help message." + return help_.format(prefix=self.context.clean_prefix) if help_ else "No help message." async def send_bot_help(self, mapping): embeds = [] @@ -174,7 +177,7 @@ async def send_group_help(self, group): embed.add_field(name="Sub Command(s)", value=format_[:1024], inline=False) embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} command" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} command" ' "for more info on a command." ) @@ -185,7 +188,18 @@ async def send_error_message(self, error): val = self.context.bot.snippets.get(command) if val is not None: embed = discord.Embed(title=f"{command} is a snippet.", color=self.context.bot.main_color) - embed.add_field(name=f"`{command}` will send:", value=val) + embed.add_field(name=f"`{command}` will send:", value=val, inline=False) + + snippet_aliases = [] + for alias in self.context.bot.aliases: + if self.context.bot._resolve_snippet(alias) == command: + snippet_aliases.append(f"`{alias}`") + + if snippet_aliases: + embed.add_field( + name="Aliases to this snippet:", value=",".join(snippet_aliases), inline=False + ) + return await self.get_destination().send(embed=embed) val = self.context.bot.aliases.get(command) @@ -216,7 +230,7 @@ async def send_error_message(self, error): embed.add_field(name=f"Step {i}:", value=val) embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]} alias" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]} alias" ' "for more details on aliases." ) return await self.get_destination().send(embed=embed) @@ -238,7 +252,7 @@ async def send_error_message(self, error): else: embed.title = "Cannot find command or category" embed.set_footer( - text=f'Type "{self.clean_prefix}{self.command_attrs["name"]}" ' + text=f'Type "{self.context.clean_prefix}{self.command_attrs["name"]}" ' "for a list of all available commands." ) await self.get_destination().send(embed=embed) @@ -257,11 +271,13 @@ def __init__(self, bot): }, ) self.bot.help_command.cog = self - self.loop_presence.start() # pylint: disable=no-member if not self.bot.config.get("enable_eval"): self.eval_.enabled = False logger.info("Eval disabled. enable_eval=False") + async def cog_load(self): + self.loop_presence.start() # pylint: disable=no-member + def cog_unload(self): self.bot.help_command = self._original_help_command @@ -303,13 +319,13 @@ async def changelog(self, ctx, version: str.lower = ""): @utils.trigger_typing async def about(self, ctx): """Shows information about this bot.""" - embed = discord.Embed(color=self.bot.main_color, timestamp=datetime.utcnow()) + embed = discord.Embed(color=self.bot.main_color, timestamp=discord.utils.utcnow()) embed.set_author( name="Modmail - About", - icon_url=self.bot.user.avatar_url, + icon_url=self.bot.user.display_avatar.url, url="https://discord.gg/F34cRU8", ) - embed.set_thumbnail(url=self.bot.user.avatar_url) + embed.set_thumbnail(url=self.bot.user.display_avatar.url) desc = "This is an open source Discord bot that serves as a means for " desc += "members to easily communicate with server administrators in " @@ -848,7 +864,7 @@ async def config_get(self, ctx, *, key: str.lower = None): if key in keys: desc = f"`{key}` is set to `{self.bot.config[key]}`" embed = discord.Embed(color=self.bot.main_color, description=desc) - embed.set_author(name="Config variable", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Config variable", icon_url=self.bot.user.display_avatar.url) else: embed = discord.Embed( @@ -865,7 +881,7 @@ async def config_get(self, ctx, *, key: str.lower = None): color=self.bot.main_color, description="Here is a list of currently set configuration variable(s).", ) - embed.set_author(name="Current config(s):", icon_url=self.bot.user.avatar_url) + embed.set_author(name="Current config(s):", icon_url=self.bot.user.display_avatar.url) config = self.bot.config.filter_default(self.bot.config) for name, value in config.items(): @@ -913,9 +929,7 @@ def fmt(val): for i, (current_key, info) in enumerate(config_help.items()): if current_key == key: index = i - embed = discord.Embed( - title=f"Configuration description on {current_key}:", color=self.bot.main_color - ) + embed = discord.Embed(title=f"{current_key}", color=self.bot.main_color) embed.add_field(name="Default:", value=fmt(info["default"]), inline=False) embed.add_field(name="Information:", value=fmt(info["description"]), inline=False) if info["examples"]: @@ -1006,7 +1020,7 @@ async def alias(self, ctx, *, name: str.lower = None): color=self.bot.error_color, description="You dont have any aliases at the moment." ) embed.set_footer(text=f'Do "{self.bot.prefix}help alias" for more commands.') - embed.set_author(name="Aliases", icon_url=ctx.guild.icon_url) + embed.set_author(name="Aliases", icon_url=ctx.guild.icon.url) return await ctx.send(embed=embed) embeds = [] @@ -1014,7 +1028,7 @@ async def alias(self, ctx, *, name: str.lower = None): for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.aliases)),) * 15)): description = utils.format_description(i, names) embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon_url) + embed.set_author(name="Command Aliases", icon_url=ctx.guild.icon.url) embeds.append(embed) session = EmbedPaginatorSession(ctx, *embeds) @@ -1071,7 +1085,9 @@ async def make_alias(self, name, value, action): linked_command = view.get_word().lower() message = view.read_rest() - if not self.bot.get_command(linked_command): + is_snippet = val in self.bot.snippets + + if not self.bot.get_command(linked_command) and not is_snippet: alias_command = self.bot.aliases.get(linked_command) if alias_command is not None: save_aliases.extend(utils.normalize_alias(alias_command, message)) @@ -1595,7 +1611,7 @@ async def permissions_get( for name, level in takewhile(lambda x: x is not None, items) ) embed = discord.Embed(color=self.bot.main_color, description=description) - embed.set_author(name="Permission Overrides", icon_url=ctx.guild.icon_url) + embed.set_author(name="Permission Overrides", icon_url=ctx.guild.icon.url) embeds.append(embed) session = EmbedPaginatorSession(ctx, *embeds) @@ -1915,7 +1931,7 @@ async def github(self, ctx): async def update(self, ctx, *, flag: str = ""): """ Update Modmail. - To stay up-to-date with the latest commit rom GitHub, specify "force" as the flag. + To stay up-to-date with the latest commit from GitHub, specify "force" as the flag. """ changelog = await Changelog.from_url(self.bot) @@ -1923,7 +1939,7 @@ async def update(self, ctx, *, flag: str = ""): desc = ( f"The latest version is [`{self.bot.version}`]" - "(https://github.com/kyb3r/modmail/blob/master/bot.py#L25)" + "(https://github.com/kyb3r/modmail/blob/master/bot.py#L1)" ) if self.bot.version >= parse_version(latest.version) and flag.lower() != "force": @@ -1935,16 +1951,39 @@ async def update(self, ctx, *, flag: str = ""): embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: - if self.bot.hosting_method == HostingMethod.HEROKU: + error = None + data = {} + try: + # update fork if gh_token exists data = await self.bot.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + + if self.bot.hosting_method == HostingMethod.HEROKU: + if error is not None: + embed = discord.Embed( + title="Update failed", + description=f"Error status: {error.status}.\nError message: {error.message}", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + if not data: + # invalid gh_token + embed = discord.Embed( + title="Update failed", + description="Invalid Github token.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) commit_data = data["data"] user = data["user"] - if commit_data and commit_data.get("html_url"): embed = discord.Embed(color=self.bot.main_color) - embed.set_footer(text=f"Updating Modmail v{self.bot.version} " f"-> v{latest.version}") + embed.set_footer(text=f"Updating Modmail v{self.bot.version} -> v{latest.version}") embed.set_author( name=user["username"] + " - Updating bot", @@ -1962,21 +2001,14 @@ async def update(self, ctx, *, flag: str = ""): else: embed = discord.Embed( title="Already up to date", - description="No further updates required", + description="No further updates required.", color=self.bot.main_color, ) embed.set_footer(text="Force update") embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: - # update fork if gh_token exists - try: - await self.bot.api.update_repository() - except InvalidConfigError: - pass - command = "git pull" - proc = await asyncio.create_subprocess_shell( command, stderr=PIPE, @@ -2100,5 +2132,5 @@ def paginate(text: str): await self.bot.add_reaction(ctx.message, "\u2705") -def setup(bot): - bot.add_cog(Utility(bot)) +async def setup(bot): + await bot.add_cog(Utility(bot)) diff --git a/core/changelog.py b/core/changelog.py index 7c9af2e1bb..a4f88ed323 100644 --- a/core/changelog.py +++ b/core/changelog.py @@ -90,14 +90,15 @@ def embed(self) -> Embed: embed = Embed(color=self.bot.main_color, description=self.description) embed.set_author( name=f"v{self.version} - Changelog", - icon_url=self.bot.user.avatar_url, + icon_url=self.bot.user.display_avatar.url, url=self.url, ) for name, value in self.fields.items(): embed.add_field(name=name, value=truncate(value, 1024), inline=False) embed.set_footer(text=f"Current version: v{self.bot.version}") - embed.set_thumbnail(url=self.bot.user.avatar_url) + + embed.set_thumbnail(url=self.bot.user.display_avatar.url) return embed diff --git a/core/clients.py b/core/clients.py index 489954e3e4..eebe3bcff6 100644 --- a/core/clients.py +++ b/core/clients.py @@ -1,9 +1,9 @@ import secrets import sys -from datetime import datetime from json import JSONDecodeError -from typing import Union, Optional +from typing import Any, Dict, Union, Optional +import discord from discord import Member, DMChannel, TextChannel, Message from discord.ext import commands @@ -19,6 +19,7 @@ class GitHub: """ The client for interacting with GitHub API. + Parameters ---------- bot : Bot @@ -31,6 +32,7 @@ class GitHub: URL to the avatar in GitHub. url : str, optional URL to the GitHub profile. + Attributes ---------- bot : Bot @@ -43,6 +45,7 @@ class GitHub: URL to the avatar in GitHub. url : str URL to the GitHub profile. + Class Attributes ---------------- BASE : str @@ -77,7 +80,7 @@ def __init__(self, bot, access_token: str = "", username: str = "", **kwargs): self.headers = {"Authorization": "token " + str(access_token)} @property - def BRANCH(self): + def BRANCH(self) -> str: return "master" if not self.bot.version.is_prerelease else "development" async def request( @@ -85,11 +88,13 @@ async def request( url: str, method: str = "GET", payload: dict = None, - return_response: bool = False, headers: dict = None, - ) -> Union[ClientResponse, dict, str]: + return_response: bool = False, + read_before_return: bool = False, + ) -> Union[ClientResponse, Dict[str, Any], str]: """ Makes a HTTP request. + Parameters ---------- url : str @@ -98,16 +103,20 @@ async def request( The HTTP method (POST, GET, PUT, DELETE, FETCH, etc.). payload : Dict[str, Any] The json payload to be sent along the request. - return_response : bool - Whether the `ClientResponse` object should be returned. headers : Dict[str, str] Additional headers to `headers`. + return_response : bool + Whether the `ClientResponse` object should be returned. + read_before_return : bool + Whether to perform `.read()` method before returning the `ClientResponse` object. + Only valid if `return_response` is set to `True`. + Returns ------- ClientResponse or Dict[str, Any] or List[Any] or str `ClientResponse` if `return_response` is `True`. - `dict` if the returned data is a json object. - `list` if the returned data is a json list. + `Dict[str, Any]` if the returned data is a json object. + `List[Any]` if the returned data is a json list. `str` if the returned data is not a valid json data, the raw response. """ @@ -117,19 +126,32 @@ async def request( headers = self.headers async with self.session.request(method, url, headers=headers, json=payload) as resp: if return_response: + if read_before_return: + await resp.read() return resp - try: - return await resp.json() - except (JSONDecodeError, ClientResponseError): - return await resp.text() - def filter_valid(self, data): + return await self._get_response_data(resp) + + @staticmethod + async def _get_response_data(response: ClientResponse) -> Union[Dict[str, Any], str]: + """ + Internal method to convert the response data to `dict` if the data is a + json object, or to `str` (raw response) if the data is not a valid json. + """ + try: + return await response.json() + except (JSONDecodeError, ClientResponseError): + return await response.text() + + def filter_valid(self, data) -> Dict[str, Any]: """ Filters configuration keys that are accepted. + Parameters ---------- data : Dict[str, Any] The data that needs to be cleaned. + Returns ------- Dict[str, Any] @@ -138,42 +160,79 @@ def filter_valid(self, data): valid_keys = self.bot.config.valid_keys.difference(self.bot.config.protected_keys) return {k: v for k, v in data.items() if k in valid_keys} - async def update_repository(self, sha: str = None) -> Optional[dict]: + async def update_repository(self, sha: str = None) -> Dict[str, Any]: """ Update the repository from Modmail main repo. + Parameters ---------- - sha : Optional[str], optional - The commit SHA to update the repository. + sha : Optional[str] + The commit SHA to update the repository. If `None`, the latest + commit SHA will be fetched. + Returns ------- - Optional[dict] - If the response is a dict. + Dict[str, Any] + A dictionary that contains response data. """ if not self.username: raise commands.CommandInvokeError("Username not found.") if sha is None: - resp: dict = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) + resp = await self.request(self.REPO + "/git/refs/heads/" + self.BRANCH) sha = resp["object"]["sha"] payload = {"base": self.BRANCH, "head": sha, "commit_message": "Updating bot"} merge_url = self.MERGE_URL.format(username=self.username) - resp = await self.request(merge_url, method="POST", payload=payload) - if isinstance(resp, dict): - return resp + resp = await self.request( + merge_url, + method="POST", + payload=payload, + return_response=True, + read_before_return=True, + ) + + repo_url = self.BASE + f"/repos/{self.username}/modmail" + status_map = { + 201: "Successful response.", + 204: "Already merged.", + 403: "Forbidden.", + 404: f"Repository '{repo_url}' not found.", + 409: "There is a merge conflict.", + 422: "Validation failed.", + } + # source https://docs.github.com/en/rest/branches/branches#merge-a-branch + + status = resp.status + data = await self._get_response_data(resp) + if status in (201, 204): + return data + + args = (resp.request_info, resp.history) + try: + # try to get the response error message if any + message = data.get("message") + except AttributeError: + message = None + kwargs = { + "status": status, + "message": message if message else status_map.get(status), + } + # just raise + raise ClientResponseError(*args, **kwargs) async def fork_repository(self) -> None: """ Forks Modmail's repository. """ - await self.request(self.FORK_URL, method="POST") + await self.request(self.FORK_URL, method="POST", return_response=True) async def has_starred(self) -> bool: """ Checks if shared Modmail. + Returns ------- bool @@ -187,23 +246,30 @@ async def star_repository(self) -> None: """ Stars Modmail's repository. """ - await self.request(self.STAR_URL, method="PUT", headers={"Content-Length": "0"}) + await self.request( + self.STAR_URL, + method="PUT", + headers={"Content-Length": "0"}, + return_response=True, + ) @classmethod async def login(cls, bot) -> "GitHub": """ Logs in to GitHub with configuration variable information. + Parameters ---------- bot : Bot The Modmail bot. + Returns ------- GitHub The newly created `GitHub` object. """ self = cls(bot, bot.config.get("github_token")) - resp: dict = await self.request("https://api.github.com/user") + resp: Dict[str, Any] = await self.request(self.BASE + "/user") if resp.get("login"): self.username = resp["login"] self.avatar_url = resp["avatar_url"] @@ -507,7 +573,7 @@ async def create_log_entry(self, recipient: Member, channel: TextChannel, creato "_id": key, "key": key, "open": True, - "created_at": str(datetime.utcnow()), + "created_at": str(discord.utils.utcnow()), "closed_at": None, "channel_id": str(channel.id), "guild_id": str(self.bot.guild_id), @@ -516,14 +582,14 @@ async def create_log_entry(self, recipient: Member, channel: TextChannel, creato "id": str(recipient.id), "name": recipient.name, "discriminator": recipient.discriminator, - "avatar_url": str(recipient.avatar_url), + "avatar_url": recipient.display_avatar.url, "mod": False, }, "creator": { "id": str(creator.id), "name": creator.name, "discriminator": creator.discriminator, - "avatar_url": str(creator.avatar_url), + "avatar_url": creator.display_avatar.url, "mod": isinstance(creator, Member), }, "closer": None, @@ -585,7 +651,7 @@ async def append_log( "id": str(message.author.id), "name": message.author.name, "discriminator": message.author.discriminator, - "avatar_url": str(message.author.avatar_url), + "avatar_url": message.author.display_avatar.url, "mod": not isinstance(message.channel, DMChannel), }, "content": message.content, @@ -635,7 +701,7 @@ async def create_note(self, recipient: Member, message: Message, message_id: Uni "id": str(message.author.id), "name": message.author.name, "discriminator": message.author.discriminator, - "avatar_url": str(message.author.avatar_url), + "avatar_url": message.author.display_avatar.url, }, "message": message.content, "message_id": str(message_id), diff --git a/core/config.py b/core/config.py index d8593a02ef..dd76776092 100644 --- a/core/config.py +++ b/core/config.py @@ -13,7 +13,7 @@ from core._color_data import ALL_COLORS from core.models import DMDisabled, InvalidConfigError, Default, getLogger -from core.time import UserFriendlyTimeSync +from core.time import UserFriendlyTime from core.utils import strtobool logger = getLogger(__name__) @@ -52,6 +52,8 @@ class ConfigManager: "close_emoji": "\N{LOCK}", "use_user_id_channel_name": False, "use_timestamp_channel_name": False, + "use_nickname_channel_name": False, + "use_random_channel_name": False, "recipient_thread_close": False, "thread_show_roles": True, "thread_show_account_age": True, @@ -77,7 +79,7 @@ class ConfigManager: "thread_move_notify_mods": False, "thread_move_response": "This thread has been moved.", "cooldown_thread_title": "Message not sent!", - "cooldown_thread_response": "You must wait for {delta} before you can contact me again.", + "cooldown_thread_response": "Your cooldown ends {delta}. Try contacting me then.", "disabled_new_thread_title": "Not Delivered", "disabled_new_thread_response": "We are not accepting new threads.", "disabled_new_thread_footer": "Please try again later...", @@ -91,6 +93,9 @@ class ConfigManager: "silent_alert_on_mention": False, "show_timestamp": True, "anonymous_snippets": False, + "plain_snippets": False, + "require_close_reason": False, + "show_log_url_button": False, # group conversations "private_added_to_group_title": "New Thread (Group)", "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.", @@ -123,6 +128,7 @@ class ConfigManager: "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}", # regex "use_regex_autotrigger": False, + "use_hoisted_top_role": True, } private_keys = { @@ -181,11 +187,14 @@ class ConfigManager: booleans = { "use_user_id_channel_name", "use_timestamp_channel_name", + "use_nickname_channel_name", + "use_random_channel_name", "user_typing", "mod_typing", "reply_without_command", "anon_reply_without_command", "plain_reply_without_command", + "show_log_url_button", "recipient_thread_close", "thread_auto_close_silently", "thread_move_notify", @@ -205,10 +214,13 @@ class ConfigManager: "update_notifications", "thread_contact_silently", "anonymous_snippets", + "plain_snippets", + "require_close_reason", "recipient_thread_close", "thread_show_roles", "thread_show_account_age", "thread_show_join_age", + "use_hoisted_top_role", } enums = { @@ -388,7 +400,7 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: isodate.parse_duration(item) except isodate.ISO8601Error: try: - converter = UserFriendlyTimeSync() + converter = UserFriendlyTime() time = converter.convert(None, item) if time.arg: raise ValueError @@ -400,7 +412,8 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: "Unrecognized time, please use ISO-8601 duration format " 'string or a simpler "human readable" time.' ) - item = isodate.duration_isoformat(time.dt - converter.now) + now = discord.utils.utcnow() + item = isodate.duration_isoformat(time.dt - now) return self.__setitem__(key, item) if key in self.booleans: diff --git a/core/config_help.json b/core/config_help.json index ee91e3d37d..e7ebb9590d 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -106,8 +106,8 @@ ], "notes": [ "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_timestamp_channel_name`.", - "See also: `use_timestamp_channel_name`." + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." ] }, "use_timestamp_channel_name": { @@ -119,8 +119,34 @@ ], "notes": [ "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", - "This cannot be applied with `use_user_id_channel_name`.", - "See also: `use_user_id_channel_name`." + "This cannot be applied with `use_user_id_channel_name`, `use_random_channel_name` or `use_nickname_channel_name`.", + "See also: `use_user_id_channel_name`, `use_nickname_channel_name`, `use_random_channel_name`." + ] + }, + "use_nickname_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with the recipient's nickname instead of the recipient's name.", + "examples": [ + "`{prefix}config set use_nickname_channel_name yes`", + "`{prefix}config set use_nickname_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_random_channel_name` or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_random_channel_name`." + ] + }, + "use_random_channel_name": { + "default": "No", + "description": "When this is set to `yes`, new thread channels will be named with random characters tied to their user ID.", + "examples": [ + "`{prefix}config set use_random_channel_name yes`", + "`{prefix}config set use_random_channel_name no`" + ], + "notes": [ + "This config is suitable for servers in Server Discovery to comply with channel name restrictions.", + "This cannot be applied with `use_timestamp_channel_name`, `use_nickname_channel_name`, or `use_user_id_channel_name`.", + "See also: `use_timestamp_channel_name`, `use_user_id_channel_name`, `use_nickname_channel_name`." ] }, "mod_typing": { @@ -227,7 +253,7 @@ "default": "Yes", "description": "This is the channel where update notifications are sent to.", "examples": [ - "`{prefix}config set update_notifications no" + "`{prefix}config set update_notifications no`" ], "notes": [ "This has no effect unless `disable_autoupdates` is set to no.", @@ -426,7 +452,7 @@ "default": "\"You have opened a Modmail thread.\"", "description": "This is the message embed description sent to recipients when self-contacted.", "examples": [ - "`{prefix}config set thread_creation_contact_title You contacted yourself.`" + "`{prefix}config set thread_creation_self_contact_response You contacted yourself.`" ], "notes": [ "`thread_creation_contact_response` is used when contacted by another user.", @@ -558,7 +584,7 @@ ] }, "cooldown_thread_response": { - "default": "You must wait for {delta} before you can contact me again.", + "default": "Your cooldown ends {delta}. Try contacting me then.", "description": "The description of the message embed when the user has a cooldown before creating a new thread.", "examples": [ "`{prefix}config set cooldown_thread_response Be patient! You are on cooldown, wait {delta} more.`" @@ -794,9 +820,35 @@ "`{prefix}config set anonymous_snippets yes`" ], "notes": [ - "See also: `anon_avatar_url`, `anon_tag`." + "See also: `anon_avatar_url`, `anon_tag`, `plain_snippets`." ] }, + "plain_snippets": { + "default": "No", + "description": "Sends snippets with a plain interface.", + "examples":[ + "`{prefix}config set plain_snippets yes`" + ], + "notes": [ + "See also: `anonymous_snippets`." + ] + }, + "require_close_reason": { + "default" : "No", + "description": "Require a reason to close threads.", + "examples": [ + "`{prefix}config set require_close_reason yes`" + ], + "notes": [] + }, + "show_log_url_button": { + "default" : "No", + "description": "Shows the button to open the Log URL.", + "examples": [ + "`{prefix}config set show_log_url_button yes`" + ], + "notes": [] + }, "private_added_to_group_title": { "default": "New Thread (Group)", "description": "This is the message embed title sent to the recipient that is just added to a thread.", @@ -1121,5 +1173,16 @@ "notes": [ "This configuration can only to be set through `.env` file or environment (config) variables." ] + }, + "use_hoisted_top_role": { + "default": "Yes", + "description": "Controls if only hoisted roles are evaluated when finding top role.", + "examples": [ + ], + "notes": [ + "Top role is displayed in embeds when replying or adding/removing users to a thread in the case mod_tag and anon_username are not set.", + "If this configuration is enabled, only roles that are hoisted (displayed seperately in member list) will be used. If a user has no hoisted roles, it will return 'None'.", + "If you would like to display the top role of a user regardless of if it's hoisted or not, disable `use_hoisted_top_role`." + ] } } diff --git a/core/decorators.py b/core/decorators.py deleted file mode 100644 index 0107a6b2d6..0000000000 --- a/core/decorators.py +++ /dev/null @@ -1,12 +0,0 @@ -import warnings - -from core.utils import trigger_typing as _trigger_typing - - -def trigger_typing(func): - warnings.warn( - "trigger_typing has been moved to core.utils.trigger_typing, this will be removed.", - DeprecationWarning, - stacklevel=2, - ) - return _trigger_typing(func) diff --git a/core/models.py b/core/models.py index 445f7793ae..2eab1ceebb 100644 --- a/core/models.py +++ b/core/models.py @@ -2,6 +2,7 @@ import re import sys import os +from difflib import get_close_matches from enum import IntEnum from logging.handlers import RotatingFileHandler from string import Formatter @@ -202,13 +203,18 @@ async def convert(self, ctx, argument): return await super().convert(ctx, argument) except commands.ChannelNotFound: - def check(c): - return isinstance(c, discord.CategoryChannel) and c.name.lower().startswith(argument.lower()) - if guild: - result = discord.utils.find(check, guild.categories) + categories = {c.name.casefold(): c for c in guild.categories} else: - result = discord.utils.find(check, bot.get_all_channels()) + categories = { + c.name.casefold(): c + for c in bot.get_all_channels() + if isinstance(c, discord.CategoryChannel) + } + + result = get_close_matches(argument.casefold(), categories.keys(), n=1, cutoff=0.75) + if result: + result = categories[result[0]] if not isinstance(result, discord.CategoryChannel): raise commands.ChannelNotFound(argument) diff --git a/core/paginator.py b/core/paginator.py index 7ba1c98b60..5a6844f382 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -1,8 +1,8 @@ import typing -import asyncio -from discord import User, Reaction, Message, Embed -from discord import HTTPException, InvalidArgument +import discord +from discord import Message, Embed, ButtonStyle, Interaction +from discord.ui import View, Button, Select from discord.ext import commands @@ -33,8 +33,12 @@ class PaginatorSession: The `Message` of the `Embed`. current : int The current page number. - reaction_map : Dict[str, method] - A mapping for reaction to method. + callback_map : Dict[str, method] + A mapping for text to method. + view : PaginatorView + The view that is sent along with the base message. + select_menu : Select + A select menu that will be added to the View. """ def __init__(self, ctx: commands.Context, *pages, **options): @@ -45,40 +49,18 @@ def __init__(self, ctx: commands.Context, *pages, **options): self.current = 0 self.pages = list(pages) self.destination = options.get("destination", ctx) - self.reaction_map = { - "⏮": self.first_page, - "◀": self.previous_page, - "▶": self.next_page, - "⏭": self.last_page, - "🛑": self.close, + self.view = None + self.select_menu = None + + self.callback_map = { + "<<": self.first_page, + "<": self.previous_page, + ">": self.next_page, + ">>": self.last_page, } + self._buttons_map = {"<<": None, "<": None, ">": None, ">>": None} - def add_page(self, item) -> None: - """ - Add a page. - """ - raise NotImplementedError - - async def create_base(self, item) -> None: - """ - Create a base `Message`. - """ - await self._create_base(item) - - if len(self.pages) == 1: - self.running = False - return - - self.running = True - for reaction in self.reaction_map: - if len(self.pages) == 2 and reaction in "⏮⏭": - continue - await self.ctx.bot.add_reaction(self.base, reaction) - - async def _create_base(self, item) -> None: - raise NotImplementedError - - async def show_page(self, index: int) -> None: + async def show_page(self, index: int) -> typing.Optional[typing.Dict]: """ Show a page by page number. @@ -92,74 +74,96 @@ async def show_page(self, index: int) -> None: self.current = index page = self.pages[index] + result = None if self.running: - await self._show_page(page) + result = self._show_page(page) else: await self.create_base(page) - async def _show_page(self, page): - raise NotImplementedError + self.update_disabled_status() + return result - def react_check(self, reaction: Reaction, user: User) -> bool: - """ + def update_disabled_status(self): + if self.current == self.first_page(): + # disable << button + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = True - Parameters - ---------- - reaction : Reaction - The `Reaction` object of the reaction. - user : User - The `User` or `Member` object of who sent the reaction. + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = True + else: + if self._buttons_map["<<"] is not None: + self._buttons_map["<<"].disabled = False - Returns - ------- - bool + if self._buttons_map["<"] is not None: + self._buttons_map["<"].disabled = False + + if self.current == self.last_page(): + # disable >> button + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = True + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = True + else: + if self._buttons_map[">>"] is not None: + self._buttons_map[">>"].disabled = False + + if self._buttons_map[">"] is not None: + self._buttons_map[">"].disabled = False + + async def create_base(self, item) -> None: """ - return ( - reaction.message.id == self.base.id - and user.id == self.ctx.author.id - and reaction.emoji in self.reaction_map.keys() - ) + Create a base `Message`. + """ + if len(self.pages) == 1: + self.view = None + self.running = False + else: + self.view = PaginatorView(self, timeout=self.timeout) + self.update_disabled_status() + self.running = True + + await self._create_base(item, self.view) + + async def _create_base(self, item, view: View) -> None: + raise NotImplementedError + + def _show_page(self, page): + raise NotImplementedError + + def first_page(self): + """Returns the index of the first page""" + return 0 + + def next_page(self): + """Returns the index of the next page""" + return min(self.current + 1, self.last_page()) + + def previous_page(self): + """Returns the index of the previous page""" + return max(self.current - 1, self.first_page()) + + def last_page(self): + """Returns the index of the last page""" + return len(self.pages) - 1 async def run(self) -> typing.Optional[Message]: """ Starts the pagination session. - - Returns - ------- - Optional[Message] - If it's closed before running ends. """ if not self.running: await self.show_page(self.current) - while self.running: - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", check=self.react_check, timeout=self.timeout - ) - except asyncio.TimeoutError: - return await self.close(delete=False) - else: - action = self.reaction_map.get(reaction.emoji) - await action() - try: - await self.base.remove_reaction(reaction, user) - except (HTTPException, InvalidArgument): - pass - - async def previous_page(self) -> None: - """ - Go to the previous page. - """ - await self.show_page(self.current - 1) - async def next_page(self) -> None: - """ - Go to the next page. - """ - await self.show_page(self.current + 1) + if self.view is not None: + await self.view.wait() + + await self.close(delete=False) - async def close(self, delete: bool = True) -> typing.Optional[Message]: + async def close( + self, delete: bool = True, *, interaction: Interaction = None + ) -> typing.Optional[Message]: """ Closes the pagination session. @@ -174,30 +178,127 @@ async def close(self, delete: bool = True) -> typing.Optional[Message]: Optional[Message] If `delete` is `True`. """ - self.running = False + if self.running: + sent_emoji, _ = await self.ctx.bot.retrieve_emoji() + await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) - sent_emoji, _ = await self.ctx.bot.retrieve_emoji() - await self.ctx.bot.add_reaction(self.ctx.message, sent_emoji) + if interaction: + message = interaction.message + else: + message = self.base - if delete: - return await self.base.delete() + self.running = False - try: - await self.base.clear_reactions() - except HTTPException: - pass + if self.view is not None: + self.view.stop() + if delete: + await message.delete() + else: + self.view.clear_items() + await message.edit(view=self.view) - async def first_page(self) -> None: - """ - Go to the first page. - """ - await self.show_page(0) - async def last_page(self) -> None: - """ - Go to the last page. - """ - await self.show_page(len(self.pages) - 1) +class PaginatorView(View): + """ + View that is used for pagination. + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + timeout : float + How long to wait for before the session closes. + """ + + def __init__(self, handler: PaginatorSession, *args, **kwargs): + super().__init__(*args, **kwargs) + self.handler = handler + self.clear_items() # clear first so we can control the order + self.fill_items() + + @discord.ui.button(label="Stop", style=ButtonStyle.danger) + async def stop_button(self, interaction: Interaction, button: Button): + await self.handler.close(interaction=interaction) + + def fill_items(self): + if self.handler.select_menu is not None: + self.add_item(self.handler.select_menu) + + for label, callback in self.handler.callback_map.items(): + if len(self.handler.pages) == 2 and label in ("<<", ">>"): + continue + + if label in ("<<", ">>"): + style = ButtonStyle.secondary + else: + style = ButtonStyle.primary + + button = PageButton(self.handler, callback, label=label, style=style) + + self.handler._buttons_map[label] = button + self.add_item(button) + self.add_item(self.stop_button) + + async def interaction_check(self, interaction: Interaction): + """Only allow the message author to interact""" + if interaction.user != self.handler.ctx.author: + await interaction.response.send_message( + "Only the original author can control this!", ephemeral=True + ) + return False + return True + + +class PageButton(Button): + """ + A button that has a callback to jump to the next page + + Parameters + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + + Attributes + ---------- + handler : PaginatorSession + The paginator session that spawned this view. + page_callback : Callable + A callable that returns an int of the page to go to. + """ + + def __init__(self, handler, page_callback, **kwargs): + super().__init__(**kwargs) + self.handler = handler + self.page_callback = page_callback + + async def callback(self, interaction: Interaction): + kwargs = await self.handler.show_page(self.page_callback()) + await interaction.response.edit_message(**kwargs, view=self.view) + + +class PageSelect(Select): + def __init__(self, handler: PaginatorSession, pages: typing.List[typing.Tuple[str]]): + self.handler = handler + options = [] + for n, (label, description) in enumerate(pages): + options.append(discord.SelectOption(label=label, description=description, value=str(n))) + + options = options[:25] # max 25 options + super().__init__(placeholder="Select a page", min_values=1, max_values=1, options=options) + + async def callback(self, interaction: Interaction): + page = int(self.values[0]) + kwargs = await self.handler.show_page(page) + await interaction.response.edit_message(**kwargs, view=self.view) class EmbedPaginatorSession(PaginatorSession): @@ -205,11 +306,42 @@ def __init__(self, ctx: commands.Context, *embeds, **options): super().__init__(ctx, *embeds, **options) if len(self.pages) > 1: + select_options = [] + create_select = True for i, embed in enumerate(self.pages): footer_text = f"Page {i + 1} of {len(self.pages)}" if embed.footer.text: footer_text = footer_text + " • " + embed.footer.text - embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url) + + if embed.footer.icon: + icon_url = embed.footer.icon.url + else: + icon_url = None + embed.set_footer(text=footer_text, icon_url=icon_url) + + # select menu + if embed.author.name: + title = embed.author.name[:30].strip() + if len(embed.author.name) > 30: + title += "..." + else: + title = embed.title[:30].strip() + if len(embed.title) > 30: + title += "..." + if not title: + create_select = False + + if embed.description: + description = embed.description[:40].replace("*", "").replace("`", "").strip() + if len(embed.description) > 40: + description += "..." + else: + description = "" + select_options.append((title, description)) + + if create_select: + if len(set(x[0] for x in select_options)) != 1: # must have unique authors + self.select_menu = PageSelect(self, select_options) def add_page(self, item: Embed) -> None: if isinstance(item, Embed): @@ -217,11 +349,11 @@ def add_page(self, item: Embed) -> None: else: raise TypeError("Page must be an Embed object.") - async def _create_base(self, item: Embed) -> None: - self.base = await self.destination.send(embed=item) + async def _create_base(self, item: Embed, view: View) -> None: + self.base = await self.destination.send(embed=item, view=view) - async def _show_page(self, page): - await self.base.edit(embed=page) + def _show_page(self, page): + return dict(embed=page) class MessagePaginatorSession(PaginatorSession): @@ -241,12 +373,18 @@ def _set_footer(self): footer_text = f"Page {self.current+1} of {len(self.pages)}" if self.footer_text: footer_text = footer_text + " • " + self.footer_text - self.embed.set_footer(text=footer_text, icon_url=self.embed.footer.icon_url) - async def _create_base(self, item: str) -> None: + if self.embed.footer.icon: + icon_url = self.embed.footer.icon.url + else: + icon_url = None + + self.embed.set_footer(text=footer_text, icon_url=icon_url) + + async def _create_base(self, item: str, view: View) -> None: self._set_footer() - self.base = await self.ctx.send(content=item, embed=self.embed) + self.base = await self.ctx.send(content=item, embed=self.embed, view=view) - async def _show_page(self, page) -> None: + def _show_page(self, page) -> typing.Dict: self._set_footer() - await self.base.edit(content=page, embed=self.embed) + return dict(content=page, embed=self.embed) diff --git a/core/thread.py b/core/thread.py index 54509cdc36..c9d8ae5bcb 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1,27 +1,32 @@ import asyncio +import base64 import copy +import functools import io import re import time +import traceback import typing -from datetime import datetime, timedelta +import warnings +from datetime import timedelta from types import SimpleNamespace import isodate import discord from discord.ext.commands import MissingRequiredArgument, CommandError +from lottie.importers import importers as l_importers +from lottie.exporters import exporters as l_exporters from core.models import DMDisabled, DummyMessage, getLogger from core.time import human_timedelta from core.utils import ( is_image_url, - days, + parse_channel_topic, match_title, match_user_id, - match_other_recipients, truncate, - get_top_hoisted_role, + get_top_role, create_thread_channel, get_joint_id, ) @@ -51,7 +56,7 @@ def __init__( self._recipient = recipient self._other_recipients = other_recipients or [] self._channel = channel - self.genesis_message = None + self._genesis_message = None self._ready_event = asyncio.Event() self.wait_tasks = [] self.close_task = None @@ -69,7 +74,7 @@ def __eq__(self, other): async def wait_until_ready(self) -> None: """Blocks execution until the thread is fully set up.""" # timeout after 30 seconds - task = asyncio.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25)) + task = self.bot.loop.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25)) self.wait_tasks.append(task) try: await task @@ -119,19 +124,18 @@ def cancelled(self, flag: bool): @classmethod async def from_channel(cls, manager: "ThreadManager", channel: discord.TextChannel) -> "Thread": - recipient_id = match_user_id( - channel.topic - ) # there is a chance it grabs from another recipient's main thread + # there is a chance it grabs from another recipient's main thread + _, recipient_id, other_ids = parse_channel_topic(channel.topic) if recipient_id in manager.cache: thread = manager.cache[recipient_id] else: - recipient = manager.bot.get_user(recipient_id) or await manager.bot.fetch_user(recipient_id) + recipient = await manager.bot.get_or_fetch_user(recipient_id) other_recipients = [] - for uid in match_other_recipients(channel.topic): + for uid in other_ids: try: - other_recipient = manager.bot.get_user(uid) or await manager.bot.fetch_user(uid) + other_recipient = await manager.bot.get_or_fetch_user(uid) except discord.NotFound: continue other_recipients.append(other_recipient) @@ -140,6 +144,15 @@ async def from_channel(cls, manager: "ThreadManager", channel: discord.TextChann return thread + async def get_genesis_message(self) -> discord.Message: + if self._genesis_message is None: + async for m in self.channel.history(limit=5, oldest_first=True): + if m.author == self.bot.user: + if m.embeds and m.embeds[0].fields and m.embeds[0].fields[0].name == "Roles": + self._genesis_message = m + + return self._genesis_message + async def setup(self, *, creator=None, category=None, initial_message=None): """Create the thread channel and other io related initialisation tasks""" self.bot.dispatch("thread_initiate", self, creator, category, initial_message) @@ -151,7 +164,7 @@ async def setup(self, *, creator=None, category=None, initial_message=None): category = category or self.bot.main_category if category is not None: - overwrites = None + overwrites = {} try: channel = await create_thread_channel(self.bot, recipient, category, overwrites) @@ -182,7 +195,6 @@ async def setup(self, *, creator=None, category=None, initial_message=None): log_url = log_count = None # ensure core functionality still works - await channel.edit(topic=f"User ID: {recipient.id}") self.ready = True if creator is not None and creator != recipient: @@ -195,7 +207,7 @@ async def send_genesis_message(): try: msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) - self.genesis_message = msg + self._genesis_message = msg except Exception: logger.error("Failed unexpectedly:", exc_info=True) @@ -216,7 +228,7 @@ async def send_recipient_genesis_message(): else: footer = self.bot.config["thread_creation_footer"] - embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) + embed.set_footer(text=footer, icon_url=self.bot.guild.icon.url) embed.title = self.bot.config["thread_creation_title"] if creator is None or creator == recipient: @@ -242,7 +254,7 @@ class Author: name = author["name"] id = author["id"] discriminator = author["discriminator"] - avatar_url = author["avatar_url"] + display_avatar = SimpleNamespace(url=author["avatar_url"]) data = { "id": round(time.time() * 1000 - discord.utils.DISCORD_EPOCH) << 22, @@ -256,7 +268,7 @@ class Author: "content": note["message"], "author": Author(), } - message = discord.Message(state=State(), channel=None, data=data) + message = discord.Message(state=State(), channel=self.channel, data=data) ids[note["_id"]] = str((await self.note(message, persistent=True, thread_creation=True)).id) await self.bot.api.update_note_ids(ids) @@ -282,7 +294,7 @@ def _format_info_embed(self, user, log_url, log_count, color): """Get information about a member of a server supports users from the guild or not.""" member = self.bot.guild.get_member(user.id) - time = datetime.utcnow() + time = discord.utils.utcnow() # key = log_url.split('/')[-1] @@ -309,10 +321,10 @@ def _format_info_embed(self, user, log_url, log_count, color): role_names = separator.join(roles) - created = str((time - user.created_at).days) user_info = [] if self.bot.config["thread_show_account_age"]: - user_info.append(f"was created {days(created)}") + created = discord.utils.format_dt(user.created_at, "R") + user_info.append(f" was created {created}") embed = discord.Embed(color=color, description=user.mention, timestamp=time) @@ -321,14 +333,12 @@ def _format_info_embed(self, user, log_url, log_count, color): else: footer = f"User ID: {user.id}" - embed.set_author(name=str(user), icon_url=user.avatar_url, url=log_url) - # embed.set_thumbnail(url=avi) - if member is not None: - joined = str((time - member.joined_at).days) - # embed.add_field(name='Joined', value=joined + days(joined)) + embed.set_author(name=str(user), icon_url=member.display_avatar.url, url=log_url) + if self.bot.config["thread_show_join_age"]: - user_info.append(f"joined {days(joined)}") + joined = discord.utils.format_dt(member.joined_at, "R") + user_info.append(f"joined {joined}") if member.nick: embed.add_field(name="Nickname", value=member.nick, inline=True) @@ -336,6 +346,7 @@ def _format_info_embed(self, user, log_url, log_count, color): embed.add_field(name="Roles", value=role_names, inline=True) embed.set_footer(text=footer) else: + embed.set_author(name=str(user), icon_url=user.display_avatar.url, url=log_url) embed.set_footer(text=f"{footer} • (not in main server)") embed.description += ", ".join(user_info) @@ -353,7 +364,8 @@ def _format_info_embed(self, user, log_url, log_count, color): return embed - def _close_after(self, closer, silent, delete_channel, message): + async def _close_after(self, after, closer, silent, delete_channel, message): + await asyncio.sleep(after) return self.bot.loop.create_task(self._close(closer, silent, delete_channel, message, True)) async def close( @@ -374,7 +386,7 @@ async def close( if after > 0: # TODO: Add somewhere to clean up broken closures # (when channel is already deleted) - now = datetime.utcnow() + now = discord.utils.utcnow() items = { # 'initiation_time': now.isoformat(), "time": (now + timedelta(seconds=after)).isoformat(), @@ -387,7 +399,7 @@ async def close( self.bot.config["closures"][str(self.id)] = items await self.bot.config.update() - task = self.bot.loop.call_later(after, self._close_after, closer, silent, delete_channel, message) + task = asyncio.create_task(self._close_after(after, closer, silent, delete_channel, message)) if auto_close: self.auto_close_task = task @@ -417,14 +429,14 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, { "open": False, "title": match_title(self.channel.topic), - "closed_at": str(datetime.utcnow()), + "closed_at": str(discord.utils.utcnow()), "nsfw": self.channel.nsfw, "close_message": message, "closer": { "id": str(closer.id), "name": closer.name, "discriminator": closer.discriminator, - "avatar_url": str(closer.avatar_url), + "avatar_url": closer.display_avatar.url, "mod": True, }, }, @@ -475,13 +487,18 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, event = "Thread Closed as Scheduled" if scheduled else "Thread Closed" # embed.set_author(name=f"Event: {event}", url=log_url) - embed.set_footer(text=f"{event} by {_closer}", icon_url=closer.avatar_url) - embed.timestamp = datetime.utcnow() + embed.set_footer(text=f"{event} by {_closer}", icon_url=closer.display_avatar.url) + embed.timestamp = discord.utils.utcnow() tasks = [self.bot.config.update()] if self.bot.log_channel is not None and self.channel is not None: - tasks.append(self.bot.log_channel.send(embed=embed)) + if self.bot.config["show_log_url_button"]: + view = discord.ui.View() + view.add_item(discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url)) + else: + view = None + tasks.append(self.bot.log_channel.send(embed=embed, view=view)) # Thread closed message @@ -490,7 +507,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, color=self.bot.error_color, ) if self.bot.config["show_timestamp"]: - embed.timestamp = datetime.utcnow() + embed.timestamp = discord.utils.utcnow() if not message: if self.id == closer.id: @@ -504,7 +521,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, embed.description = message footer = self.bot.config["thread_close_footer"] - embed.set_footer(text=footer, icon_url=self.bot.guild.icon_url) + embed.set_footer(text=footer, icon_url=self.bot.guild.icon.url) if not silent: for user in self.recipients: @@ -550,8 +567,8 @@ async def _restart_close_timer(self): # Set timeout seconds seconds = timeout.total_seconds() # seconds = 20 # Uncomment to debug with just 20 seconds - reset_time = datetime.utcnow() + timedelta(seconds=seconds) - human_time = human_timedelta(dt=reset_time) + reset_time = discord.utils.utcnow() + timedelta(seconds=seconds) + human_time = human_timedelta(dt=reset_time, spec="manual") if self.bot.config.get("thread_auto_close_silently"): return await self.close(closer=self.bot.user, silent=True, after=int(seconds), auto_close=True) @@ -663,13 +680,14 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1.description = message tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)] - if message2 is not [None]: - for m2 in message2: - embed2 = m2.embeds[0] - embed2.description = message - tasks += [m2.edit(embed=embed2)] - elif message1.embeds[0].author.name.startswith("Persistent Note"): + if message1.embeds[0].author.name.startswith("Persistent Note"): tasks += [self.bot.api.edit_note(message1.id, message)] + else: + for m2 in message2: + if m2 is not None: + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks) @@ -796,7 +814,8 @@ async def note( async def reply( self, message: discord.Message, anonymous: bool = False, plain: bool = False - ) -> typing.Tuple[discord.Message, discord.Message]: + ) -> typing.Tuple[typing.List[discord.Message], discord.Message]: + """Returns List[user_dm_msg] and thread_channel_msg""" if not message.content and not message.attachments: raise MissingRequiredArgument(SimpleNamespace(name="msg")) if not any(g.get_member(self.id) for g in self.bot.guilds): @@ -826,6 +845,7 @@ async def reply( user_msg = await asyncio.gather(*user_msg_tasks) except Exception as e: logger.error("Message delivery failed:", exc_info=True) + user_msg = None if isinstance(e, discord.Forbidden): description = ( "Your message could not be delivered as " @@ -839,12 +859,10 @@ async def reply( "to an unknown error. Check `?debug` for " "more information" ) - tasks.append( - message.channel.send( - embed=discord.Embed( - color=self.bot.error_color, - description=description, - ) + msg = await message.channel.send( + embed=discord.Embed( + color=self.bot.error_color, + description=description, ) ) else: @@ -916,6 +934,11 @@ async def send( destination = destination or self.channel author = message.author + member = self.bot.guild.get_member(author.id) + if member: + avatar_url = member.display_avatar.url + else: + avatar_url = author.display_avatar.url embed = discord.Embed(description=message.content) if self.bot.config["show_timestamp"]: @@ -928,13 +951,13 @@ async def send( # Anonymously sending to the user. tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_hoisted_role(author)) + tag = str(get_top_role(author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: name = tag avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: - avatar_url = self.bot.guild.icon_url + avatar_url = self.bot.guild.icon.url embed.set_author( name=name, icon_url=avatar_url, @@ -943,7 +966,7 @@ async def send( else: # Normal message name = str(author) - avatar_url = author.avatar_url + avatar_url = avatar_url embed.set_author( name=name, icon_url=avatar_url, @@ -978,14 +1001,51 @@ async def send( if is_image_url(url, convert_size=False) ] images.extend(image_urls) - images.extend( - ( - str(i.image_url) if isinstance(i.image_url, discord.Asset) else i.image_url, - f"{i.name} Sticker", - True, - ) - for i in message.stickers - ) + + def lottie_to_png(data): + importer = l_importers.get("lottie") + exporter = l_exporters.get("png") + with io.BytesIO() as stream: + stream.write(data) + stream.seek(0) + an = importer.process(stream) + + with io.BytesIO() as stream: + exporter.process(an, stream) + stream.seek(0) + return stream.read() + + for i in message.stickers: + if i.format in (discord.StickerFormatType.png, discord.StickerFormatType.apng): + images.append((i.url, i.name, True)) + elif i.format == discord.StickerFormatType.lottie: + # save the json lottie representation + try: + async with self.bot.session.get(i.url) as resp: + data = await resp.read() + + # convert to a png + img_data = await self.bot.loop.run_in_executor( + None, functools.partial(lottie_to_png, data) + ) + b64_data = base64.b64encode(img_data).decode() + + # upload to imgur + async with self.bot.session.post( + "https://api.imgur.com/3/image", + headers={"Authorization": "Client-ID 50e96145ac5e085"}, + data={"image": b64_data}, + ) as resp: + result = await resp.json() + url = result["data"]["link"] + + except Exception: + traceback.print_exc() + images.append((None, i.name, True)) + else: + images.append((url, i.name, True)) + else: + images.append((None, i.name, True)) embedded_image = False @@ -1003,10 +1063,10 @@ async def send( if filename: if is_sticker: if url is None: - description = "Unable to retrieve sticker image" + description = f"{filename}: Unable to retrieve sticker image" else: - description = "\u200b" - embed.add_field(name=filename, value=description) + description = f"[{filename}]({url})" + embed.add_field(name="Sticker", value=description) else: embed.add_field(name="Image", value=f"[{filename}]({url})") embedded_image = True @@ -1045,7 +1105,7 @@ async def send( elif not anonymous: mod_tag = self.bot.config["mod_tag"] if mod_tag is None: - mod_tag = str(get_top_hoisted_role(message.author)) + mod_tag = str(get_top_role(message.author, self.bot.config["use_hoisted_top_role"])) embed.set_footer(text=mod_tag) # Normal messages else: embed.set_footer(text=self.bot.config["anon_tag"]) @@ -1071,30 +1131,32 @@ async def send( logger.info("Sending a message to %s when DM disabled is set.", self.recipient) try: - await destination.trigger_typing() + await destination.typing() except discord.NotFound: logger.warning("Channel not found.") raise if not from_mod and not note: - mentions = self.get_notifications() + mentions = await self.get_notifications() else: mentions = None if plain: if from_mod and not isinstance(destination, discord.TextChannel): # Plain to user + with warnings.catch_warnings(): + # Catch coroutines not awaited warning + warnings.simplefilter("ignore") + additional_images = [] + if embed.footer.text: plain_message = f"**({embed.footer.text}) " else: plain_message = "**" plain_message += f"{embed.author.name}:** {embed.description}" files = [] - for i in embed.fields: - if "Image" in i.name: - async with self.bot.session.get(i.field[i.field.find("http") : -1]) as resp: - stream = io.BytesIO(await resp.read()) - files.append(discord.File(stream)) + for i in message.attachments: + files.append(await i.to_file()) msg = await destination.send(plain_message, files=files) else: @@ -1112,7 +1174,7 @@ async def send( return msg - def get_notifications(self) -> str: + async def get_notifications(self) -> str: key = str(self.id) mentions = [] @@ -1126,27 +1188,72 @@ def get_notifications(self) -> str: return " ".join(set(mentions)) async def set_title(self, title: str) -> None: + topic = f"Title: {title}\n" + user_id = match_user_id(self.channel.topic) - ids = ",".join(i.id for i in self._other_recipients) + topic += f"User ID: {user_id}" + + if self._other_recipients: + ids = ",".join(str(i.id) for i in self._other_recipients) + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + + async def _update_users_genesis(self): + genesis_message = await self.get_genesis_message() + embed = genesis_message.embeds[0] + value = " ".join(x.mention for x in self._other_recipients) + index = None + for n, field in enumerate(embed.fields): + if field.name == "Other Recipients": + index = n + break + + if index is None and value: + embed.add_field(name="Other Recipients", value=value, inline=False) + else: + if value: + embed.set_field_at(index, name="Other Recipients", value=value, inline=False) + else: + embed.remove_field(index) - await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}") + await genesis_message.edit(embed=embed) async def add_users(self, users: typing.List[typing.Union[discord.Member, discord.User]]) -> None: - title = match_title(self.channel.topic) - user_id = match_user_id(self.channel.topic) + topic = "" + title, _, _ = parse_channel_topic(self.channel.topic) + if title is not None: + topic += f"Title: {title}\n" + + topic += f"User ID: {self._id}" + self._other_recipients += users + self._other_recipients = list(set(self._other_recipients)) ids = ",".join(str(i.id) for i in self._other_recipients) - await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}") + + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + await self._update_users_genesis() async def remove_users(self, users: typing.List[typing.Union[discord.Member, discord.User]]) -> None: - title = match_title(self.channel.topic) - user_id = match_user_id(self.channel.topic) + topic = "" + title, user_id, _ = parse_channel_topic(self.channel.topic) + if title is not None: + topic += f"Title: {title}\n" + + topic += f"User ID: {user_id}" + for u in users: self._other_recipients.remove(u) - ids = ",".join(str(i.id) for i in self._other_recipients) - await self.channel.edit(topic=f"Title: {title}\nUser ID: {user_id}\nOther Recipients: {ids}") + if self._other_recipients: + ids = ",".join(str(i.id) for i in self._other_recipients) + topic += f"\nOther Recipients: {ids}" + + await self.channel.edit(topic=topic) + await self._update_users_genesis() class ThreadManager: @@ -1177,7 +1284,7 @@ async def find( recipient_id: int = None, ) -> typing.Optional[Thread]: """Finds a thread from cache or from discord channel topics.""" - if recipient is None and channel is not None: + if recipient is None and channel is not None and isinstance(channel, discord.TextChannel): thread = await self._find_from_channel(channel) if thread is None: user_id, thread = next( @@ -1206,16 +1313,24 @@ async def find( await thread.close(closer=self.bot.user, silent=True, delete_channel=False) thread = None else: + + def check(topic): + _, user_id, other_ids = parse_channel_topic(topic) + return recipient_id == user_id or recipient_id in other_ids + channel = discord.utils.find( - lambda x: str(recipient_id) in x.topic if x.topic else False, + lambda x: (check(x.topic)) if x.topic else False, self.bot.modmail_guild.text_channels, ) if channel: thread = await Thread.from_channel(self, channel) if thread.recipient: - # only save if data is valid - self.cache[recipient_id] = thread + # only save if data is valid. + # also the recipient_id here could belong to other recipient, + # it would be wrong if we set it as the dict key, + # so we use the thread id instead + self.cache[thread.id] = thread thread.ready = True if thread and recipient_id not in [x.id for x in thread.recipients]: @@ -1231,10 +1346,11 @@ async def _find_from_channel(self, channel): searching channel history for genesis embed and extracts user_id from that. """ - user_id = -1 - if channel.topic: - user_id = match_user_id(channel.topic) + if not channel.topic: + return None + + _, user_id, other_ids = parse_channel_topic(channel.topic) if user_id == -1: return None @@ -1243,14 +1359,14 @@ async def _find_from_channel(self, channel): return self.cache[user_id] try: - recipient = self.bot.get_user(user_id) or await self.bot.fetch_user(user_id) + recipient = await self.bot.get_or_fetch_user(user_id) except discord.NotFound: recipient = None other_recipients = [] - for uid in match_other_recipients(channel.topic): + for uid in other_ids: try: - other_recipient = self.bot.get_user(uid) or await self.bot.fetch_user(uid) + other_recipient = await self.bot.get_or_fetch_user(uid) except discord.NotFound: continue other_recipients.append(other_recipient) diff --git a/core/time.py b/core/time.py index bdec8d2549..963eac5a6e 100644 --- a/core/time.py +++ b/core/time.py @@ -3,217 +3,357 @@ Source: https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/time.py """ -import re -from datetime import datetime - -from discord.ext.commands import BadArgument, Converter +from __future__ import annotations +import datetime +import discord +from typing import TYPE_CHECKING, Any, Optional, Union import parsedatetime as pdt from dateutil.relativedelta import relativedelta +from .utils import human_join +from discord.ext import commands +from discord import app_commands +import re + +# Monkey patch mins and secs into the units +units = pdt.pdtLocales["en_US"].units +units["minutes"].append("mins") +units["seconds"].append("secs") -from core.models import getLogger +if TYPE_CHECKING: + from discord.ext.commands import Context + from typing_extensions import Self -logger = getLogger(__name__) + +class plural: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L8-L18""" + + def __init__(self, value: int): + self.value: int = value + + def __format__(self, format_spec: str) -> str: + v = self.value + singular, sep, plural = format_spec.partition("|") + plural = plural or f"{singular}s" + if abs(v) != 1: + return f"{v} {plural}" + return f"{v} {singular}" class ShortTime: compiled = re.compile( - r""" - (?:(?P[0-9])(?:years?|y))? # e.g. 2y - (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 9mo - (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w - (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d - (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h - (?:(?P[0-9]{1,5})(?:min(?:ute)?s?|m))? # e.g. 10m - (?:(?P[0-9]{1,5})(?:sec(?:ond)?s?|s))? # e.g. 15s - """, + """ + (?:(?P[0-9])(?:years?|y))? # e.g. 2y + (?:(?P[0-9]{1,2})(?:months?|mo))? # e.g. 2months + (?:(?P[0-9]{1,4})(?:weeks?|w))? # e.g. 10w + (?:(?P[0-9]{1,5})(?:days?|d))? # e.g. 14d + (?:(?P[0-9]{1,5})(?:hours?|h))? # e.g. 12h + (?:(?P[0-9]{1,5})(?:minutes?|m))? # e.g. 10m + (?:(?P[0-9]{1,5})(?:seconds?|s))? # e.g. 15s + """, re.VERBOSE, ) - def __init__(self, argument): + discord_fmt = re.compile(r"[0-9]+)(?:\:?[RFfDdTt])?>") + + dt: datetime.datetime + + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): match = self.compiled.fullmatch(argument) if match is None or not match.group(0): - raise BadArgument("Invalid time provided.") - - data = {k: int(v) for k, v in match.groupdict(default="0").items()} - now = datetime.utcnow() + match = self.discord_fmt.fullmatch(argument) + if match is not None: + self.dt = datetime.datetime.utcfromtimestamp(int(match.group("ts")), tz=datetime.timezone.utc) + return + else: + raise commands.BadArgument("invalid time provided") + + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + now = now or datetime.datetime.now(datetime.timezone.utc) self.dt = now + relativedelta(**data) - -# Monkey patch mins and secs into the units -units = pdt.pdtLocales["en_US"].units -units["minutes"].append("mins") -units["seconds"].append("secs") + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) class HumanTime: calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) - def __init__(self, argument): - now = datetime.utcnow() + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + now = now or datetime.datetime.utcnow() dt, status = self.calendar.parseDT(argument, sourceTime=now) if not status.hasDateOrTime: - raise BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') if not status.hasTime: # replace it with the current time dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) - self.dt = dt - self._past = dt < now + self.dt: datetime.datetime = dt + self._past: bool = dt < now + + @classmethod + async def convert(cls, ctx: Context, argument: str) -> Self: + return cls(argument, now=ctx.message.created_at) class Time(HumanTime): - def __init__(self, argument): + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): try: - short_time = ShortTime(argument) + o = ShortTime(argument, now=now) except Exception: super().__init__(argument) else: - self.dt = short_time.dt + self.dt = o.dt self._past = False class FutureTime(Time): - def __init__(self, argument): - super().__init__(argument) + def __init__(self, argument: str, *, now: Optional[datetime.datetime] = None): + super().__init__(argument, now=now) if self._past: - raise BadArgument("The time is in the past.") + raise commands.BadArgument("this time is in the past") -class UserFriendlyTimeSync(Converter): - """That way quotes aren't absolutely necessary.""" +class BadTimeTransform(app_commands.AppCommandError): + pass - def __init__(self): - self.raw: str = None - self.dt: datetime = None - self.arg = None - self.now: datetime = None - def check_constraints(self, now, remaining): - if self.dt < now: - raise BadArgument("This time is in the past.") +class TimeTransformer(app_commands.Transformer): + async def transform(self, interaction, value: str) -> datetime.datetime: + now = interaction.created_at + try: + short = ShortTime(value, now=now) + except commands.BadArgument: + try: + human = FutureTime(value, now=now) + except commands.BadArgument as e: + raise BadTimeTransform(str(e)) from None + else: + return human.dt + else: + return short.dt - self.arg = remaining - return self - def convert(self, ctx, argument): - self.raw = argument - remaining = "" - try: - calendar = HumanTime.calendar - regex = ShortTime.compiled - self.dt = self.now = datetime.utcnow() +# CHANGE: Added now +class FriendlyTimeResult: + dt: datetime.datetime + now: datetime.datetime + arg: str - match = regex.match(argument) - if match is not None and match.group(0): - data = {k: int(v) for k, v in match.groupdict(default="0").items()} - remaining = argument[match.end() :].strip() - self.dt = self.now + relativedelta(**data) - return self.check_constraints(self.now, remaining) - - # apparently nlp does not like "from now" - # it likes "from x" in other cases though - # so let me handle the 'now' case - if argument.endswith(" from now"): - argument = argument[:-9].strip() - # handles "in xxx hours" - if argument.startswith("in "): - argument = argument[3:].strip() - - elements = calendar.nlp(argument, sourceTime=self.now) - if elements is None or not elements: - return self.check_constraints(self.now, argument) - - # handle the following cases: - # "date time" foo - # date time foo - # foo date time - - # first the first two cases: - dt, status, begin, end, _ = elements[0] - - if not status.hasDateOrTime: - return self.check_constraints(self.now, argument) - - if begin not in (0, 1) and end != len(argument): - raise BadArgument( - "Time is either in an inappropriate location, which must " - "be either at the end or beginning of your input, or I " - "just flat out did not understand what you meant. Sorry." - ) + __slots__ = ("dt", "arg", "now") - if not status.hasTime: - # replace it with the current time - dt = dt.replace( - hour=self.now.hour, - minute=self.now.minute, - second=self.now.second, - microsecond=self.now.microsecond, - ) + def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): + self.dt = dt + self.now = now - # if midnight is provided, just default to next day - if status.accuracy == pdt.pdtContext.ACU_HALFDAY: - dt = dt.replace(day=self.now.day + 1) + if now is None: + self.now = dt + else: + self.now = now - self.dt = dt + self.arg = "" - if begin in (0, 1): - if begin == 1: - # check if it's quoted: - if argument[0] != '"': - raise BadArgument("Expected quote before time input...") + async def ensure_constraints( + self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str + ) -> None: + if self.dt < now: + raise commands.BadArgument("This time is in the past.") - if not (end < len(argument) and argument[end] == '"'): - raise BadArgument("If the time is quoted, you must unquote it.") + # CHANGE + # if not remaining: + # if uft.default is None: + # raise commands.BadArgument("Missing argument after the time.") + # remaining = uft.default - remaining = argument[end + 1 :].lstrip(" ,.!") - else: - remaining = argument[end:].lstrip(" ,.!") - elif len(argument) == end: - remaining = argument[:begin].strip() + if uft.converter is not None: + self.arg = await uft.converter.convert(ctx, remaining) + else: + self.arg = remaining - return self.check_constraints(self.now, remaining) - except Exception: - logger.exception("Something went wrong while parsing the time.") - raise +class UserFriendlyTime(commands.Converter): + """That way quotes aren't absolutely necessary.""" + + def __init__( + self, + converter: Optional[Union[type[commands.Converter], commands.Converter]] = None, + *, + default: Any = None, + ): + if isinstance(converter, type) and issubclass(converter, commands.Converter): + converter = converter() + + if converter is not None and not isinstance(converter, commands.Converter): + raise TypeError("commands.Converter subclass necessary.") + + self.converter: commands.Converter = converter # type: ignore # It doesn't understand this narrowing + self.default: Any = default + + async def convert(self, ctx: Context, argument: str) -> FriendlyTimeResult: + calendar = HumanTime.calendar + regex = ShortTime.compiled + now = ctx.message.created_at + + match = regex.match(argument) + if match is not None and match.group(0): + data = {k: int(v) for k, v in match.groupdict(default=0).items()} + remaining = argument[match.end() :].strip() + result = FriendlyTimeResult(now + relativedelta(**data), now) + await result.ensure_constraints(ctx, self, now, remaining) + return result + + if match is None or not match.group(0): + match = ShortTime.discord_fmt.match(argument) + if match is not None: + result = FriendlyTimeResult( + datetime.datetime.utcfromtimestamp(int(match.group("ts")), now, tz=datetime.timezone.utc) + ) + remaining = argument[match.end() :].strip() + await result.ensure_constraints(ctx, self, now, remaining) + return result + + # apparently nlp does not like "from now" + # it likes "from x" in other cases though so let me handle the 'now' case + if argument.endswith("from now"): + argument = argument[:-8].strip() + + if argument[0:2] == "me": + # starts with "me to", "me in", or "me at " + if argument[0:6] in ("me to ", "me in ", "me at "): + argument = argument[6:] + + elements = calendar.nlp(argument, sourceTime=now) + if elements is None or len(elements) == 0: + # CHANGE + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + + # handle the following cases: + # "date time" foo + # date time foo + # foo date time + + # first the first two cases: + dt, status, begin, end, dt_string = elements[0] + + if not status.hasDateOrTime: + raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + + if begin not in (0, 1) and end != len(argument): + raise commands.BadArgument( + "Time is either in an inappropriate location, which " + "must be either at the end or beginning of your input, " + "or I just flat out did not understand what you meant. Sorry." + ) -class UserFriendlyTime(UserFriendlyTimeSync): - async def convert(self, ctx, argument): - return super().convert(ctx, argument) + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) + + # if midnight is provided, just default to next day + if status.accuracy == pdt.pdtContext.ACU_HALFDAY: + dt = dt.replace(day=now.day + 1) + result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) + remaining = "" -def human_timedelta(dt, *, source=None): - now = source or datetime.utcnow() + if begin in (0, 1): + if begin == 1: + # check if it's quoted: + if argument[0] != '"': + raise commands.BadArgument("Expected quote before time input...") + + if not (end < len(argument) and argument[end] == '"'): + raise commands.BadArgument("If the time is quoted, you must unquote it.") + + remaining = argument[end + 1 :].lstrip(" ,.!") + else: + remaining = argument[end:].lstrip(" ,.!") + elif len(argument) == end: + remaining = argument[:begin].strip() + + await result.ensure_constraints(ctx, self, now, remaining) + return result + + +def human_timedelta( + dt: datetime.datetime, + *, + source: Optional[datetime.datetime] = None, + accuracy: Optional[int] = 3, + brief: bool = False, + suffix: bool = True, +) -> str: + now = source or datetime.datetime.now(datetime.timezone.utc) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + + if now.tzinfo is None: + now = now.replace(tzinfo=datetime.timezone.utc) + + # Microsecond free zone + now = now.replace(microsecond=0) + dt = dt.replace(microsecond=0) + + # This implementation uses relativedelta instead of the much more obvious + # divmod approach with seconds because the seconds approach is not entirely + # accurate once you go over 1 week in terms of accuracy since you have to + # hardcode a month as 30 or 31 days. + # A query like "11 months" can be interpreted as "!1 months and 6 days" if dt > now: delta = relativedelta(dt, now) - suffix = "" + output_suffix = "" else: delta = relativedelta(now, dt) - suffix = " ago" - - if delta.microseconds and delta.seconds: - delta = delta + relativedelta(seconds=+1) + output_suffix = " ago" if suffix else "" - attrs = ["years", "months", "days", "hours", "minutes", "seconds"] + attrs = [ + ("year", "y"), + ("month", "mo"), + ("day", "d"), + ("hour", "h"), + ("minute", "m"), + ("second", "s"), + ] output = [] - for attr in attrs: - elem = getattr(delta, attr) + for attr, brief_attr in attrs: + elem = getattr(delta, attr + "s") if not elem: continue - if elem > 1: - output.append(f"{elem} {attr}") + if attr == "day": + weeks = delta.weeks + if weeks: + elem -= weeks * 7 + if not brief: + output.append(format(plural(weeks), "week")) + else: + output.append(f"{weeks}w") + + if elem <= 0: + continue + + if brief: + output.append(f"{elem}{brief_attr}") else: - output.append(f"{elem} {attr[:-1]}") + output.append(format(plural(elem), attr)) - if not output: + if accuracy is not None: + output = output[:accuracy] + + if len(output) == 0: return "now" - if len(output) == 1: - return output[0] + suffix - if len(output) == 2: - return f"{output[0]} and {output[1]}{suffix}" - return f"{output[0]}, {output[1]} and {output[2]}{suffix}" + else: + if not brief: + return human_join(output, final="and") + output_suffix + else: + return " ".join(output) + output_suffix + + +def format_relative(dt: datetime.datetime) -> str: + return discord.utils.format_dt(dt, "R") diff --git a/core/utils.py b/core/utils.py index 0fa74e457a..30130eb740 100644 --- a/core/utils.py +++ b/core/utils.py @@ -2,6 +2,7 @@ import functools import re import typing +from datetime import datetime, timezone from difflib import get_close_matches from distutils.util import strtobool as _stb # pylint: disable=import-error from itertools import takewhile, zip_longest @@ -10,6 +11,9 @@ import discord from discord.ext import commands +from core.models import getLogger + + __all__ = [ "strtobool", "User", @@ -20,9 +24,11 @@ "human_join", "days", "cleanup_code", + "parse_channel_topic", "match_title", "match_user_id", "match_other_recipients", + "create_thread_channel", "create_not_found_embed", "parse_alias", "normalize_alias", @@ -30,11 +36,15 @@ "trigger_typing", "escape_code_block", "tryint", - "get_top_hoisted_role", + "get_top_role", "get_joint_id", + "extract_block_timestamp", ] +logger = getLogger(__name__) + + def strtobool(val): if isinstance(val, bool): return val @@ -170,10 +180,19 @@ def parse_image_url(url: str, *, convert_size=True) -> str: return "" -def human_join(strings): - if len(strings) <= 2: - return " or ".join(strings) - return ", ".join(strings[: len(strings) - 1]) + " or " + strings[-1] +def human_join(seq: typing.Sequence[str], delim: str = ", ", final: str = "or") -> str: + """https://github.com/Rapptz/RoboDanny/blob/bf7d4226350dff26df4981dd53134eeb2aceeb87/cogs/utils/formats.py#L21-L32""" + size = len(seq) + if size == 0: + return "" + + if size == 1: + return seq[0] + + if size == 2: + return f"{seq[0]} {final} {seq[1]}" + + return delim.join(seq[:-1]) + f" {final} {seq[-1]}" def days(day: typing.Union[str, int]) -> str: @@ -218,9 +237,49 @@ def cleanup_code(content: str) -> str: return content.strip("` \n") -TOPIC_OTHER_RECIPIENTS_REGEX = re.compile(r"Other Recipients:\s*((?:\d{17,21},*)+)", flags=re.IGNORECASE) -TOPIC_TITLE_REGEX = re.compile(r"\bTitle: (.*)\n(?:User ID: )\b", flags=re.IGNORECASE | re.DOTALL) -TOPIC_UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) +TOPIC_REGEX = re.compile( + r"(?:\bTitle:\s*(?P.*)\n)?" + r"\bUser ID:\s*(?P<user_id>\d{17,21})\b" + r"(?:\nOther Recipients:\s*(?P<other_ids>\d{17,21}(?:(?:\s*,\s*)\d{17,21})*)\b)?", + flags=re.IGNORECASE | re.DOTALL, +) +UID_REGEX = re.compile(r"\bUser ID:\s*(\d{17,21})\b", flags=re.IGNORECASE) + + +def parse_channel_topic(text: str) -> typing.Tuple[typing.Optional[str], int, typing.List[int]]: + """ + A helper to parse channel topics and respectivefully returns all the required values + at once. + + Parameters + ---------- + text : str + The text of channel topic. + + Returns + ------- + Tuple[Optional[str], int, List[int]] + A tuple of title, user ID, and other recipients IDs. + """ + title, user_id, other_ids = None, -1, [] + if isinstance(text, str): + match = TOPIC_REGEX.search(text) + else: + match = None + + if match is not None: + groupdict = match.groupdict() + title = groupdict["title"] + + # user ID string is the required one in regex, so if match is found + # the value of this won't be None + user_id = int(groupdict["user_id"]) + + oth_ids = groupdict["other_ids"] + if oth_ids: + other_ids = list(map(int, oth_ids.split(","))) + + return title, user_id, other_ids def match_title(text: str) -> str: @@ -237,12 +296,10 @@ def match_title(text: str) -> str: Optional[str] The title if found. """ - match = TOPIC_TITLE_REGEX.search(text) - if match is not None: - return match.group(1) + return parse_channel_topic(text)[0] -def match_user_id(text: str) -> int: +def match_user_id(text: str, any_string: bool = False) -> int: """ Matches a user ID in the format of "User ID: 12345". @@ -250,16 +307,24 @@ def match_user_id(text: str) -> int: ---------- text : str The text of the user ID. + any_string: bool + Whether to search any string that matches the UID_REGEX, e.g. not from channel topic. + Defaults to False. Returns ------- int The user ID if found. Otherwise, -1. """ - match = TOPIC_UID_REGEX.search(text) - if match is not None: - return int(match.group(1)) - return -1 + user_id = -1 + if any_string: + match = UID_REGEX.search(text) + if match is not None: + user_id = int(match.group(1)) + else: + user_id = parse_channel_topic(text)[1] + + return user_id def match_other_recipients(text: str) -> typing.List[int]: @@ -276,10 +341,7 @@ def match_other_recipients(text: str) -> typing.List[int]: List[int] The list of other recipients IDs. """ - match = TOPIC_OTHER_RECIPIENTS_REGEX.search(text) - if match is not None: - return list(map(int, match.group(1).split(","))) - return [] + return parse_channel_topic(text)[2] def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discord.Embed: @@ -352,7 +414,7 @@ def format_description(i, names): def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - await ctx.trigger_typing() + await ctx.typing() return await func(self, ctx, *args, **kwargs) return wrapper @@ -369,20 +431,25 @@ def tryint(x): return x -def get_top_hoisted_role(member: discord.Member): +def get_top_role(member: discord.Member, hoisted=True): roles = sorted(member.roles, key=lambda r: r.position, reverse=True) for role in roles: + if not hoisted: + return role if role.hoist: return role -async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=[]): +async def create_thread_channel(bot, recipient, category, overwrites, *, name=None, errors_raised=None): name = name or bot.format_channel_name(recipient) + errors_raised = errors_raised or [] + try: channel = await bot.modmail_guild.create_text_channel( name=name, category=category, overwrites=overwrites, + topic=f"User ID: {recipient.id}", reason="Creating a thread channel.", ) except discord.HTTPException as e: @@ -393,19 +460,20 @@ async def create_thread_channel(bot, recipient, category, overwrites, *, name=No errors_raised.append((e.text, (category, name))) if "Maximum number of channels in category reached" in e.text: + fallback = None fallback_id = bot.config["fallback_category_id"] if fallback_id: fallback = discord.utils.get(category.guild.categories, id=int(fallback_id)) - if fallback and len(fallback.channels) < 49: - category = fallback + if fallback and len(fallback.channels) >= 49: + fallback = None - if not category: - category = await category.clone(name="Fallback Modmail") - bot.config.set("fallback_category_id", str(category.id)) + if not fallback: + fallback = await category.clone(name="Fallback Modmail") + bot.config.set("fallback_category_id", str(fallback.id)) await bot.config.update() return await create_thread_channel( - bot, recipient, category, overwrites, errors_raised=errors_raised + bot, recipient, fallback, overwrites, errors_raised=errors_raised ) if "Contains words not allowed" in e.text: @@ -444,3 +512,50 @@ def get_joint_id(message: discord.Message) -> typing.Optional[int]: except ValueError: pass return None + + +def extract_block_timestamp(reason, id_): + # etc "blah blah blah... until <t:XX:f>." + now = discord.utils.utcnow() + end_time = re.search(r"until <t:(\d+):(?:R|f)>.$", reason) + attempts = [ + # backwards compat + re.search(r"until ([^`]+?)\.$", reason), + re.search(r"%([^%]+?)%", reason), + ] + after = None + if end_time is None: + for i in attempts: + if i is not None: + end_time = i + break + + if end_time is not None: + # found a deprecated version + try: + after = ( + datetime.fromisoformat(end_time.group(1)).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise + logger.warning( + r"Deprecated time message for user %s, block and unblock again to update.", + id_, + ) + else: + try: + after = ( + datetime.utcfromtimestamp(int(end_time.group(1))).replace(tzinfo=timezone.utc) - now + ).total_seconds() + except ValueError: + logger.warning( + r"Broken block message for user %s, block and unblock again with a different message to prevent further issues", + id_, + ) + raise + + return end_time, after diff --git a/plugins/registry.json b/plugins/registry.json index b39ac345bd..494feebee4 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1,251 +1,11 @@ { - "close_message": { - "repository": "python-discord/modmail-plugins", - "branch": "main", - "description": "Add a ?closemessage command that will close the thread after 15 minutes with a default message.", - "bot_version": "2.20.1", - "title": "Close message", - "icon_url": "https://i.imgur.com/ev7BFMz.png", - "thumbnail_url": "https://i.imgur.com/ev7BFMz.png" - }, - "mdlink": { - "repository": "python-discord/modmail-plugins", - "branch": "main", - "description": "Generate a ready to paste link to the thread logs.", - "bot_version": "2.20.1", - "title": "MDLink", - "icon_url": "https://i.imgur.com/JA2E63R.png", - "thumbnail_url": "https://i.imgur.com/JA2E63R.png" - }, - "reply_cooldown": { - "repository": "python-discord/modmail-plugins", - "branch": "main", - "description": "Forbid you from sending the same message twice in ten seconds.", - "bot_version": "2.20.1", - "title": "Reply cooldown", - "icon_url": "https://i.imgur.com/FtRQveT.png", - "thumbnail_url": "https://i.imgur.com/FtRQveT.png" - }, - "dragory-migrate": { - "repository": "kyb3r/modmail-plugins", - "branch": "master", - "description": "Migrate your logs from Dragory's modmail bot to this one with a simple command. Added at the request of users.", - "bot_version": "2.20.1", - "title": "Dragory Logs Migration", - "icon_url": "https://cdn1.iconfinder.com/data/icons/web-hosting-2-4/52/200-512.png", - "thumbnail_url": "https://cdn1.iconfinder.com/data/icons/web-hosting-2-4/52/200-512.png" - }, - "music": { - "repository": "Taaku18/modmail-plugins", - "branch": "master", - "description": "Play wonderfull jams through your modmail!", - "bot_version": "2.20.1", - "title": "music", - "icon_url": "https://i.imgur.com/JmJPX5W.gif", - "thumbnail_url": "https://i.imgur.com/jrYL7F8.gif" - }, - "media-only": { - "repository": "lorenzo132/modmail-plugins", - "branch": "master", - "description": "Make a channel mediaonly, only the following mediatypes will be accepted `.png` / `.gif` / `.jpg` / `.mp4`/ `.jpeg`", - "bot_version": "2.20.1", - "title": "Media-only", - "icon_url": "https://i.imgur.com/ussAoIi.png", - "thumbnail_url": "https://i.imgur.com/ussAoIi.png" - }, - "anti-steal-close": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Don't let anyone steal ya close.", - "title": "Anti Steal Close", - "icon_url": "https://i.imgur.com/LovxyV3.png", - "thumbnail_url": "https://i.imgur.com/LovxyV3.png" - }, - "announcement": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Easily make announcements in your server!", - "bot_version": "2.20.1", - "title": "Announcement Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "dm-on-join": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "DM New Users when they join", - "bot_version": "2.20.1", - "title": "DM-on-join Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "giveaway": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Host giveaways on your server", - "bot_version": "2.20.1", - "title": "\uD83C\uDF89 Giveaway Plugin \uD83C\uDF89", - "icon_url": "https://i.imgur.com/qk85xdi.png", - "thumbnail_url": "https://i.imgur.com/gUHB91v.png" - }, - "hastebin": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Easily Upload Text To hastebin!", - "bot_version": "2.20.1", - "title": "Hastebin Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "leave-server": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Don't want your bot in a server? Did someone invite it without your permission? If so, this plugin is useful for you!", - "bot_version": "2.20.1", - "title": "Leave-server Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "welcomer": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Add messages to welcome new members! Allows for embedded messages as well. [Read more](https://github.com/fourjr/modmail-plugins/blob/master/welcomer/README.md)", - "bot_version": "2.20.1", - "title": "New member messages plugin", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" - }, - "tags": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Tag Management For Your Server", - "bot_version": "2.20.1", - "title": "Tags Plugin", - "icon_url": "https://images.ionadev.ml/b/ZIDUUsl.png", - "thumbnail_url": "https://images.ionadev.ml/b/ZIDUUsl.png" - }, - "backupdb": { - "repository": "officialpiyush/modmail-plugins", - "branch": "master", - "description": "Backup you're current Modmail DB with a single command!\n\n**Requires `BACKUP_MONGO_URI` in either config.json or environment variables**", - "bot_version": "2.20.1", - "title": "Backup Database (backupdb)", - "icon_url": "https://images.ionadev.ml/b/nKAlOC4.jpg", - "thumbnail_url": "https://images.ionadev.ml/b/nKAlOC4.jpg" - }, - "colors": { - "repository": "Taaku18/modmail-plugins", - "branch": "master", - "description": "Conversions between hex, RGB, and color names.", - "bot_version": "2.20.1", - "title": "Colors!!", - "icon_url": "https://cdn1.iconfinder.com/data/icons/weather-19/32/rainbow-512.png", - "thumbnail_url": "https://i.imgur.com/fSxnc9W.jpg" - }, - "fun": { - "repository": "TheKinG2149/modmail-plugins", - "branch": "master", - "description": "Some fun commands like 8ball, dadjokes", - "bot_version": "2.24.1", - "title": "Fun", - "icon_url": "https://cdn.discordapp.com/attachments/584692239893135362/591588754142265354/43880032.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/584692239893135362/591588754142265354/43880032.png" - }, - "stats": { - "repository": "KarateWumpus/modmail-plugins", - "branch": "master", - "description": "Get useful stats directly in an embed about either the Modmail bot, a user or the server.", - "bot_version": "2.24.1", - "title": "Get Stats", - "icon_url": "https://image.flaticon.com/icons/png/512/117/117761.png", - "thumbnail_url": "http://www.pngmart.com/files/7/Statistics-PNG-Clipart.png" - }, - "moderation": { - "repository": "Vincysuper07/modmail-plugins", - "branch": "main", - "description": "Moderate your server with Modmail, bring the Mod to Modmail!", - "bot_version": "3.6.2", - "title": "Moderate your server", - "icon_url": "https://cdn.discordapp.com/attachments/759829573654544454/773535811143598110/ad2e4d6e7b90ca6005a5038e22b099cc.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/759829573654544454/773535811143598110/ad2e4d6e7b90ca6005a5038e22b099cc.png" - }, - "serverstats": { - "repository": "dazvise/modmail-plugins", - "branch": "master", - "description": "Voice channels containing interesting and accurate statistics about your server such as Member Count.", - "bot_version": "2.20.1", - "title": "Server Stats", - "icon_url": "https://i.gyazo.com/fadb70740e83f2448b23ffe192a1f32d.png", - "thumbnail_url": "https://i.gyazo.com/fadb70740e83f2448b23ffe192a1f32d.png" - }, "suggest": { "repository": "realcyguy/modmail-plugins", - "branch": "master", + "branch": "v4", "description": "Send suggestions to a selected server! It has accepting, denying, and moderation-ing.", - "bot_version": "3.4.1", + "bot_version": "4.0.0", "title": "Suggest stuff.", "icon_url": "https://i.imgur.com/qtE7AH8.png", "thumbnail_url": "https://i.imgur.com/qtE7AH8.png" - }, - "githubstats": { - "repository": "mischievousdev/modmail-plugins", - "branch": "master", - "description": "Github statistics in discord", - "bot_version": "2.20.1", - "title": "Github Stats", - "icon_url": "https://raw.githubusercontent.com/mischievousdev/modmail-plugins/master/download%20(9).jpeg", - "thumbnail_url": "https://raw.githubusercontent.com/mischievousdev/modmail-plugins/master/download%20(9).jpeg" - }, - "slowmode": { - "repository": "teen1/modmail-plugins", - "branch": "master", - "description": "Configure slow mode for your channels with Modmail!", - "bot_version": "2.20.1", - "title": "Slow Mode", - "icon_url": "https://cdn.discordapp.com/attachments/717029057635549274/717033838966210601/Slow_mode_-_icon.png", - "thumbnail_url": "https://cdn.discordapp.com/attachments/717029057635549274/717029110907666482/Slow_mode_plugin_-_thumbnail.png" - }, - "translate": { - "repository": "WebKide/modmail-plugins", - "branch": "master", - "description": "(∩`-´)⊃━☆゚.*・。゚ translate text from one language to another (defaults to English)\n\nGet full list of available languages at: https://github.com/WebKide/modmail-plugins/blob/master/translate/langs.json\n\nThis command conflicts with Translator-plugin", - "bot_version": "3.5.0", - "title": "Translate", - "icon_url": "https://i.imgur.com/yeHFKgl.png", - "thumbnail_url": "https://i.imgur.com/yeHFKgl.png" - }, - "countdowns": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Setup a countdown voice channel in your server!", - "bot_version": "3.6.2", - "title": "Countdowns", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" - }, - "action": { - "repository": "6days9weeks/modmail-plugins", - "branch": "master", - "description": "Have fun with others by hugging them or giving them pats~!!", - "title": "Action", - "icon_url": "https://media.discordapp.net/attachments/720733784970100776/820933433579798528/689105042212388965.png", - "thumbnail_url": "https://data.whicdn.com/images/58526601/original.gif" - }, - "menu": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Adds reaction-based menus into thread creates. Check out `?configmenu`", - "title": "Menus", - "bot_version": "3.9.0", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" - }, - "claim": { - "repository": "fourjr/modmail-plugins", - "branch": "master", - "description": "Allows supporters to claim thread by sending ?claim in the thread channel", - "title": "Claim Thread", - "icon_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png", - "thumbnail_url": "https://cdn.discordapp.com/avatars/180314310298304512/7552e0089004079304cc9912d13ac81d.png" } -} +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4dc7d411a5..a19e436399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ extend-exclude = ''' [tool.poetry] name = 'Modmail' -version = '3.10.2' +version = '4.0.0' description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." license = 'AGPL-3.0-only' authors = [ diff --git a/requirements.txt b/requirements.txt index d802cc0c46..313426315e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,24 +6,35 @@ # -i https://pypi.org/simple -aiohttp==3.7.4.post0 -async-timeout==3.0.1; python_full_version >= '3.5.3' -attrs==21.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' -chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +aiohttp==3.8.1 +aiosignal==1.2.0; python_version >= '3.6' +async-timeout==4.0.2; python_version >= '3.6' +attrs==21.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +cairocffi==1.3.0; python_version >= '3.7' +cairosvg==2.5.2 +cffi==1.15.0 +charset-normalizer==2.0.12; python_version >= '3.5' colorama==0.4.4 -discord.py==1.7.3 -dnspython==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -emoji==1.2.0 -idna==3.2; python_version >= '3.5' -isodate==0.6.0 -motor==2.4.0 -multidict==5.1.0; python_version >= '3.6' +cssselect2==0.6.0; python_version >= '3.7' +defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +dnspython==2.2.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +emoji==1.7.0 +frozenlist==1.3.0; python_version >= '3.7' +discord.py==2.0.1 +idna==3.3; python_version >= '3.5' +isodate==0.6.1 +lottie[pdf]==0.6.11 +motor==2.5.1 +multidict==6.0.2; python_version >= '3.7' natural==0.2.0 parsedatetime==2.6 -pymongo[srv]==3.11.4 -python-dateutil==2.8.1 -python-dotenv==0.18.0 +pillow==9.1.0; python_version >= '3.7' +pycparser==2.21 +pymongo==3.12.3 +python-dateutil==2.8.2 +python-dotenv==0.20.0 six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -typing-extensions==3.10.0.0 -uvloop==0.15.2; sys_platform != 'win32' -yarl==1.6.3; python_version >= '3.6' +tinycss2==1.1.1; python_version >= '3.6' +uvloop==0.16.0; sys_platform != 'win32' +webencodings==0.5.1 +yarl==1.7.2; python_version >= '3.6' diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000000..30e81e3130 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.10.3