diff --git a/.bandit_baseline.json b/.bandit_baseline.json new file mode 100644 index 0000000000..8a61d4006f --- /dev/null +++ b/.bandit_baseline.json @@ -0,0 +1,342 @@ +{ + "errors": [], + "generated_at": "2022-09-06T16:19:31Z", + "metrics": { + "./bot.py": { + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1507, + "nosec": 0, + "skipped_tests": 0 + }, + "./cogs/modmail.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1837, + "nosec": 0, + "skipped_tests": 0 + }, + "./cogs/plugins.py": { + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 597, + "nosec": 0, + "skipped_tests": 0 + }, + "./cogs/utility.py": { + "CONFIDENCE.HIGH": 2, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 1794, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/_color_data.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1166, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/changelog.py": { + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 159, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/checks.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 105, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/clients.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 644, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/config.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 388, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/models.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 210, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/paginator.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 312, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/thread.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1184, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/time.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 265, + "nosec": 0, + "skipped_tests": 0 + }, + "./core/utils.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 396, + "nosec": 0, + "skipped_tests": 0 + }, + "./plugins/Cordila/cord/jishaku-migration/jishaku.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 2, + "nosec": 0, + "skipped_tests": 0 + }, + "_totals": { + "CONFIDENCE.HIGH": 5, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 1, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 5, + "SEVERITY.MEDIUM": 1, + "SEVERITY.UNDEFINED": 0, + "loc": 10566, + "nosec": 0, + "skipped_tests": 0 + } + }, + "results": [ + { + "code": "14 from 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 the subprocess module.", + "line_number": 15, + "line_range": [ + 15 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "13 from site import USER_SITE\n14 from subprocess import PIPE\n15 \n16 import discord\n", + "col_offset": 0, + "filename": "./cogs/plugins.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 14, + "line_range": [ + 14, + 15 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "11 from json import JSONDecodeError, loads\n12 from subprocess import PIPE\n13 from textwrap import indent\n", + "col_offset": 0, + "filename": "./cogs/utility.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 12, + "line_range": [ + 12 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "2093 try:\n2094 exec(to_compile, env) # pylint: disable=exec-used\n2095 except Exception as exc:\n", + "col_offset": 12, + "filename": "./cogs/utility.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Use of exec detected.", + "line_number": 2094, + "line_range": [ + 2094 + ], + "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 the subprocess module.", + "line_number": 3, + "line_range": [ + 3 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "70 \n71 def __init__(self, bot, access_token: str = \"\", username: str = \"\", **kwargs):\n72 self.bot = bot\n73 self.session = bot.session\n74 self.headers: Optional[dict] = None\n75 self.access_token = access_token\n76 self.username = username\n77 self.avatar_url: str = kwargs.pop(\"avatar_url\", \"\")\n78 self.url: str = kwargs.pop(\"url\", \"\")\n79 if self.access_token:\n80 self.headers = {\"Authorization\": \"token \" + str(access_token)}\n81 \n82 @property\n83 def BRANCH(self) -> str:\n", + "col_offset": 4, + "filename": "./core/clients.py", + "issue_confidence": "MEDIUM", + "issue_cwe": { + "id": 259, + "link": "https://cwe.mitre.org/data/definitions/259.html" + }, + "issue_severity": "LOW", + "issue_text": "Possible hardcoded password: ''", + "line_number": 71, + "line_range": [ + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82 + ], + "more_info": "https://bandit.readthedocs.io/en/1.7.4/plugins/b107_hardcoded_password_default.html", + "test_id": "B107", + "test_name": "hardcoded_password_default" + } + ] +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..a3de147db4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,154 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +venv2/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# PyCharm +.idea/ + +# MacOS +.DS_Store + +# VS Code +.vscode/ + +# Node +package-lock.json +node_modules/ + +# Modmail +config.json +plugins/ +!plugins/registry.json +!plugins/@local/ +temp/ +test.py + +# Other stuff +.dockerignore +.env.example +.git/ +.gitignore +.github/ +app.json +CHANGELOG.md +Dockerfile +docker-compose.yml +Procfile +pyproject.toml +README.md +Pipfile +Pipfile.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..972eca4517 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +TOKEN=MyBotToken +LOG_URL=https://logviewername.herokuapp.com/ +GUILD_ID=1234567890 +OWNERS=Owner1ID,Owner2ID,Owner3ID +CONNECTION_URI=mongodb+srv://mongodburi +DISABLE_AUTOUPDATES=true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..176a458f94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..990f13124e --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team on [discord](https://discord.gg/etJNHCQ). All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000000..6c6694b809 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to Modmail + +We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +If you are proposing new features, please discuss them with us in the [development server](https://discord.gg/etJNHCQ) before you start working on them! + +## We Develop with Github +We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. + +## We Use [Git Flow](https://atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) +![Simple Image Of A Git Flow Workflow](https://nvie.com/img/hotfix-branches@2x.png) +When contributing to this project, please make sure you follow this and name your branches appropriately! + +## All Code Changes Happen Through Pull Requests +Make sure you know how Git Flow works before contributing! +Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: + +1. Fork the repo and create your branch from `master` or `development` according to Git Flow. +2. Update the CHANGELOG. +3. If you've changed `core/*` or `bot.py`, mark changelog as "BREAKING" since plugins may break. +4. Make sure your code passes the lint checks. +5. Create Issues and pull requests! + +## Any contributions you make will be under the GNU Affero General Public License v3.0 +In short, when you submit code changes, your submissions are understood to be under the same [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using [Github Issues](https://github.com/modmail-dev/modmail/issues) +We use GitHub issues to track public bugs. Report a bug by [opening a new Issue](https://github.com/modmail-dev/modmail/issues/new); it's that easy! + +## Find pre-existing issues to tackle +Check out our [unstaged issue tracker](https://github.com/modmail-dev/modmail/issues?q=is%3Aissue+is%3Aopen+-label%3Astaged) and start helping out! + +Ways to help out: +- Help out new members +- Highlight invalid bugs/unsupported use cases +- Code review of pull requests +- Add on new use cases or reproduction steps +- Point out duplicate issues and guide them to the right direction +- Create a pull request to resolve the issue! + +## Write bug reports with detail, background, and sample code +**Great Bug Reports** tend to have: + +- A quick summary and background +- Steps to reproduce + - Be specific! +- What you expected would happen +- 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. + +## License +By contributing, you agree that your contributions will be licensed under its MIT License. + +## References +This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..8125c1756a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: modmaildev diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..35fc2bedb1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,55 @@ +name: Bug Report +description: File a bug report +title: "[BUG]: your bug report title" +labels: "maybe: bug" +body: + - type: input + id: bot-info-version + attributes: + label: Bot Version + description: Check it with `@modmail about` + placeholder: eg. v3.9.4 + validations: + required: true + - type: dropdown + id: bot-info-hosting + attributes: + label: How are you hosting Modmail? + description: You can check it with `@modmail about` if you are unsure + options: + - Heroku + - Systemd + - PM2 + - Buy Me A Coffee / Patreon + - Other + validations: + required: true + - type: input + id: logs + attributes: + label: Error Logs + placeholder: https://hastebin.cc/placeholder + description: + "If your Modmail bot is online, type `@modmail debug hastebin` and include the link here. + + If your Modmail bot is not online or the previous command did not generate a link, do the following: + + 1. Select your *bot* application at https://dashboard.heroku.com + + 2. [Restart your bot](https://i.imgur.com/3FcrlKz.png) + + 3. Reproduce the error to populate the error logs + + 4. [Copy and paste the logs](https://i.imgur.com/TTrhitm.png)" + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: "[optional] You may add screenshots to further explain your problem." + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: "[optional] You may provide additional context for us to better understand how this issue occured." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e9a2d9fcd4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discord Server + url: https://discord.gg/etJNHCQ + about: Please ask hosting-related questions here before creating an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..b6a4437f2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature Request +description: Suggest an idea for this project +title: "your feature request title" +labels: "feature request" +body: + - type: textarea + id: problem-relation + attributes: + label: Is your feature request related to a problem? Please elaborate. + description: A clear and concise description of what the problem is. + placeholder: eg. I'm always frustrated when... + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: checkboxes + id: complications + attributes: + label: Does your solution involve any of the following? + options: + - label: Logviewer + - label: New config option + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + - type: textarea + id: benefit + attributes: + label: Who will this benefit? + description: Does this feature apply to a great portion of users? + validations: + required: true + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: "[optional] You may provide additional context or screenshots for us to better understand the need of the feature." diff --git a/.github/pull.yml b/.github/pull.yml new file mode 100644 index 0000000000..8a0898a6b7 --- /dev/null +++ b/.github/pull.yml @@ -0,0 +1,8 @@ +version: "1" +rules: + - base: master + upstream: modmail-dev:master + mergeMethod: hardreset + - base: development + upstream: modmail-dev:development + mergeMethod: hardreset \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000000..6af8630b38 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,42 @@ + +name: Create and publish a Docker image + +on: + push: + branches: ['master'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/lints.yml b/.github/workflows/lints.yml new file mode 100644 index 0000000000..b283dff078 --- /dev/null +++ b/.github/workflows/lints.yml @@ -0,0 +1,33 @@ +name: Modmail Workflow + +on: [push, pull_request] + +jobs: + code-style: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11'] + + name: Python ${{ matrix.python-version }} on ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip pipenv + pipenv install --dev --system + # to refresh: bandit -f json -o .bandit_baseline.json -r . + # - name: Bandit syntax check + # run: bandit -r . -b .bandit_baseline.json + - name: Pylint + run: pylint ./bot.py cogs/*.py core/*.py --exit-zero -r y + continue-on-error: true + - name: Black + run: | + black . --diff --check diff --git a/.gitignore b/.gitignore index 001a767f8f..e165a06432 100644 --- a/.gitignore +++ b/.gitignore @@ -1,130 +1,95 @@ +#### Modmail +# Plugins +plugins/* +!plugins/registry.json +!plugins/@local + +# Config files +config.json +.env + +# Data +temp/ + +### Python # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - # pyenv .python-version -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - # Environments .env .venv env/ venv/ +venv2/ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +#### PyCharm +.idea/ -# Rope project settings -.ropeproject +#### VS Code +.vscode/ -# mkdocs documentation -/site +### Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# Dump file +*.stackdump -# Pyre type checker -.pyre/ +# Folder config file +[Dd]esktop.ini -# PyCharm -.idea/ +# Recycle Bin used on file shares +$RECYCLE.BIN/ -# MacOS -.DS_Store +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp -# VS Code -.vscode/ +# Windows shortcuts +*.lnk -# Modmail -config.json -plugins/ -temp/ \ No newline at end of file +### macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/.lint.py b/.lint.py deleted file mode 100644 index 768c10c143..0000000000 --- a/.lint.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -from os import listdir -from os.path import join - -from pylint.lint import Run - -THRESHOLD = 9.75 - -cogs = [join('cogs', c) for c in listdir('cogs') if c.endswith('.py')] -core = [join('core', c) for c in listdir('core') if c.endswith('.py')] - -results = Run(['bot.py', *cogs, *core], - do_exit=False) - -score = results.linter.stats['global_note'] -if score <= THRESHOLD: - sys.exit(1) diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index f3ed27c42a..0000000000 --- a/.pylintrc +++ /dev/null @@ -1,508 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Specify a configuration file. -#rcfile= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - metaclass-assignment, - missing-docstring, # No doc-string - no-name-in-module, # No name 'file' in module 'core' - fixme, - too-many-public-methods, - too-many-locals, - too-many-statements, - too-many-branches, - too-many-instance-attributes, - too-many-arguments, - too-few-public-methods, - too-many-lines - - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# Format style used to check logging format string. `old` means using % -# formatting, while `new` is for `{}` formatting. -logging-format-style=new - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package.. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - TODO, - BUG - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis. It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - _, - v, - id, - db, - f, - dt, - c - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=yes - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[IMPORTS] - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement. -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception". -overgeneral-exceptions=Exception diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4c9a94db6a..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python - -matrix: - include: - - python: '3.7' - dist: xenial - -install: - - pip install -r requirements.txt - - pip install pylint - -script: python .lint.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e3c71d25..536680ad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,54 +1,1173 @@ # Changelog + All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); +however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. + +# v4.2.1 + +### Added +* `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown). +* `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing. +* `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`. +* Thread-creation menu: Adds an interactive select step before a thread channel is created. + * Commands: + * `threadmenu toggle`: Enable/disable the menu. + * `threadmenu show`: List current top-level options. + * `threadmenu option add`: Interactive wizard to create an option. + * `threadmenu option edit/remove/show`: Manage or inspect an existing option. + * `threadmenu submenu create/delete/list/show`: Manage submenus. + * `threadmenu submenu option add/edit/remove`: Manage options inside a submenu. + * Configuration / Behavior: + * Per-option `category` targeting when creating a thread; falls back to `main_category_id` if invalid/missing. + * Optional selection logging (`thread_creation_menu_selection_log`) posts the chosen option in the new thread. + * Anonymous prompt support (`thread_creation_menu_anonymous_menu`). + +### Changed +- Renamed `max_snooze_time` to `snooze_default_duration`. The old config will be invalidated. +- When `snooze_behavior` is set to `move`, the snoozed category now has a hard limit of 49 channels. New snoozes are blocked once it’s full until space is freed. +- When switching `snooze_behavior` to `move` via `?config set`, the bot reminds admins to set `snoozed_category_id` if it’s missing. +- Thread-creation menu options & submenu options now support an optional per-option `category` target. The interactive wizards (`threadmenu option add` / `threadmenu submenu option add`) and edit commands allow specifying or updating a category. If the stored category is missing or invalid at selection time, channel creation automatically falls back to `main_category_id`. + + +# v4.2.0 + +Upgraded discord.py to version 2.6.3, added support for CV2. +Forwarded messages now properly show in threads, rather than showing as an empty embed. + +### Fixed +- Make Modmail keep working when typing is disabled due to an outage caused by Discord. +- Resolved an issue where forwarded messages appeared as empty embeds. +- Fixed internal message handling and restoration processes. +- Eliminated duplicate logs and notes. +- Addressed inconsistent use of `logkey` after ticket restoration. +- Fixed issues with identifying the user who sent internal messages. +- Solved an ancient bug where closing with words like `evening` wouldn't work. +- Fixed the command from being included in the reply in rare conditions. + +### Added +Commands: +* `snooze`: Initiates a snooze action. +* `snoozed`: Displays snoozed items. +* `unsnooze`: Reverses the snooze action. +* `clearsnoozed`: Clears all snoozed items. + +Configuration Options: +* `max_snooze_time`: Sets the maximum duration for snooze. +* `snooze_title`: Customizes the title for snooze notifications. +* `snooze_text`: Customizes the text for snooze notifications. +* `unsnooze_text`: Customizes the text for unsnooze notifications. +* `unsnooze_notify_channel`: Specifies the channel for unsnooze notifications. +* `thread_min_characters`: Minimum number of characters required. +* `thread_min_characters_title`: Title shown when the message is too short. +* `thread_min_characters_response`: Response shown to the user if their message is too short. +* `thread_min_characters_footer`: Footer displaying the minimum required characters. + +# v4.1.2 + +### Fixed +- Members not caching correctly for large servers. ([PR #3365](https://github.com/modmail-dev/Modmail/pull/3365)) + +# v4.1.1 + +### Fixed +- `?msglink` now supports threads with multiple recipients. ([PR #3341](https://github.com/modmail-dev/Modmail/pull/3341)) +- Fixed persistent notes not working due to discord.py internal change. ([PR #3324](https://github.com/modmail-dev/Modmail/pull/3324)) + +### Added +- Support for custom activities with `?activity custom ` ([PR #3352](https://github.com/modmail-dev/Modmail/pull/3352)) + +# v4.1.0 + +Drops support for Python 3.9. Python 3.10 and Python 3.11 are now the only supported versions. + +### Fixed +- GIF stickers no longer cause the bot to crash. +- `?alias make/create` as aliases to `?alias add`. This improves continuity between the bot and its command structure. ([PR #3195](https://github.com/kyb3r/modmail/pull/3195)) +- Loading the blocked list with the `?blocked` command takes a long time when the list is large. ([PR #3242](https://github.com/kyb3r/modmail/pull/3242)) +- Reply not being forwarded from DM. ([PR #3239](https://github.com/modmail-dev/modmail/pull/3239)) +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) +- Fixed a syntactic error in the close message when a thread is closed after a certain duration. ([PR #3233](https://github.com/modmail-dev/Modmail/pull/3233)) +- Removed an extra space in the help command title when the command has no parameters. ([PR #3271](https://github.com/modmail-dev/Modmail/pull/3271)) +- Corrected some incorrect config help descriptions. ([PR #3277](https://github.com/modmail-dev/Modmail/pull/3277)) +- Rate limit issue when fetch the messages due to reaction linking. ([PR #3306](https://github.com/modmail-dev/Modmail/pull/3306)) +- Update command fails when the plugin is invalid. ([PR #3295](https://github.com/modmail-dev/Modmail/pull/3295)) + +### Added +- `?log key ` to retrieve the log link and view a preview using a log key. ([PR #3196](https://github.com/modmail-dev/Modmail/pull/3196)) +- `REGISTRY_PLUGINS_ONLY`, environment variable, when set, restricts to only allow adding registry plugins. ([PR #3247](https://github.com/modmail-dev/modmail/pull/3247)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `STREAM_LOG_FORMAT` and `FILE_LOG_FORMAT` environment variable to set the log format of the stream and file handlers respectively. Possible options are `json` and `plain` (default). ([PR #3305](https://github.com/modmail-dev/Modmail/pull/3305)) +- `LOG_EXPIRATION` environment variable to set the expiration time of logs. ([PR #3257](https://github.com/modmail-dev/Modmail/pull/3257)) +- New registry plugins: [`autoreact`](https://github.com/martinbndr/kyb3r-modmail-plugins/tree/master/autoreact) and [`rename`](https://github.com/Nicklaus-s/modmail-plugins/tree/main/rename). +- Improved join/leave message for multiple servers. + +### Changed +- Repo moved to https://github.com/modmail-dev/modmail. +- Channel name no longer shows `-0` if the user has migrated to the new username system. +- `?note` and `?reply` now allows you to send a sticker without any message. +- Guild icons in embed footers and author urls now have a fixed size of 128. ([PR #3261](https://github.com/modmail-dev/modmail/pull/3261)) +- Discord.py internal logging is now enabled by default. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- The confirm-thread-creation dialog now uses buttons instead of reactions. ([PR #3273](https://github.com/modmail-dev/Modmail/pull/3273)) +- `?disable all` no longer overrides `?disable new`. ([PR #3278](https://github.com/modmail-dev/Modmail/pull/3278)) +- Dropped root privileges for Modmail running under Docker. ([PR #3284](https://github.com/modmail-dev/Modmail/pull/3284)) + +### Internal +- Renamed `Bot.log_file_name` to `Bot.log_file_path`. Log files are now created at `temp/logs/modmail.log`. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) +- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. +- Various dependencies have been updated to their latest versions. + +# v4.0.2 + +### Breaking + +- Presence intent is now by-default OFF. You can turn it on by setting `ENABLE_PRESENCE_INTENT=true` in the environment variables. + +### Fixed + +- Not having a guild icon no longer raises an exception. ([PR #3235](https://github.com/modmail-dev/modmail/pull/3235)) + - When no icon is set, use the default user icon. +- Resolved an issue where `?logs` doesn't work when the thread has no title. ([PR #3201](https://github.com/modmail-dev/modmail/pull/3201)) +- AttributeError raised when failing to forward a reaction. ([GH #3218](https://github.com/modmail-dev/modmail/issues/3218)) + +### Changed + +- Plain messages no longer forces `()` around the respondent text. ([PR #3234](https://github.com/modmail-dev/modmail/pull/3234)) +- Added workflow to automatically build Docker image ([PR #3232](https://github.com/modmail-dev/modmail/pull/3228)) +- Updated installation guide to reflect new preferred hosting methods + +# v4.0.1 + +This is a hotfix release. + +### Improved + +- Error Messages + +### Fixed + +- Thread cooldown + +# v4.0.0 + +### Breaking + +- Modmail now requires [`Message Content` privileged intent](https://support-dev.discord.com/hc/en-us/articles/4404772028055-Message-Content-Privileged-Intent-for-Verified-Bots). +- Upgraded to discord.py v2.0 ([internal changes](https://discordpy.readthedocs.io/en/latest/migrating.html), [GH #2990](https://github.com/modmail-dev/modmail/issues/2990)). +- Python 3.8 or higher is required. +- Asyncio changes ([gist](https://gist.github.com/Rapptz/6706e1c8f23ac27c98cee4dd985c8120)) +- Plugin registry is purged and all developers have to re-apply due to breaking changes. + +### Added + +- `use_hoisted_top_role` config to use change how default mod tags work, see `v3.10.0#Added` for details. ([PR #3093](https://github.com/modmail-dev/modmail/pull/3093)) +- `require_close_reason` config to require a reason to close a thread. ([GH #3107](https://github.com/modmail-dev/modmail/issues/3107)) +- `plain_snippets` config to force all snippets to be plain. ([GH #3083](https://github.com/modmail-dev/modmail/issues/3083)) +- `?fpareply` and `?fpreply` to reply to messages with variables plainly. +- `use_nickname_channel_name` config to use nicknames instead of usernames for channel names. ([GH #3112](https://github.com/modmail-dev/modmail/issues/3112)) +- `use_random_channel_name` config to use random nicknames vaguely tied to user ID. It is unable to be computed in reverse. ([GH #3143](https://github.com/modmail-dev/modmail/issues/3143)) +- `show_log_url_button` config to show Log URL button. ([GH #3122](https://github.com/modmail-dev/modmail/issues/3122)) +- Select menus for certain paginators. +- `Title` field in `?logs`. ([GH #3142](https://github.com/modmail-dev/modmail/issues/3142)) +- Snippets can be used in aliases. ([GH #3108](https://github.com/modmail-dev/modmail/issues/3108), [PR #3124](https://github.com/modmail-dev/modmail/pull/3124)) +- `?snippet make/create` as aliases to `?snippet add`. ([GH #3172](https://github.com/modmail-dev/modmail/issues/3173), [PR #3174](https://github.com/modmail-dev/modmail/pull/3174)) + +### Improved + +- Modmail now uses per-server avatars if applicable. ([GH #3048](https://github.com/modmail-dev/modmail/issues/3048)) +- Use discord relative timedeltas. ([GH #3046](https://github.com/modmail-dev/modmail/issues/3046)) +- Use discord native buttons for all paginator sessions. +- `?help` and `?blocked` paginator sessions now have better multi-page UI. +- Autoupdate now automatically updates pipenv dependencies if possible. + +### Fixed + +- Several minor typos. ([PR #3095](https://github.com/modmail-dev/modmail/pull/3095), [PR #3116](https://github.com/modmail-dev/modmail/pull/3116)) +- Certain cases where fallback categories were not working as intended. ([PR #3109](https://github.com/modmail-dev/modmail/pull/3109)) +- `?contact` would create in a random category in silent mode. ([GH #3091](https://github.com/modmail-dev/modmail/issues/3091), [PR #3092](https://github.com/modmail-dev/modmail/pull/3092)) +- Certain cases where `?close` would fail if closer isn't in cache. ([GH #3104](https://github.com/modmail-dev/modmail/issues/3104), [PR #3105](https://github.com/modmail-dev/modmail/pull/3105)) +- Stickers now work in Modmail. +- Large server sizes results in Guild.name == None. ([GH #3088](https://github.com/modmail-dev/modmail/issues/3088)) +- Attachments now work on plain replies. ([GH #3102](https://github.com/modmail-dev/modmail/issues/3102)) +- Support LOTTIE stickers. ([GH #3119](https://github.com/modmail-dev/modmail/issues/3119)) +- Editing notes now work. ([GH #3094](https://github.com/modmail-dev/modmail/issues/3094)) +- Commands now work in threads. +- Audit log searching now properly works. +- Old data causing `?blocked` to fail. ([GH #3131](https://github.com/modmail-dev/modmail/issues/3131)) +- Delete channel auto close functionality now works. +- Improved error handling for autoupdate. ([PR #3161](https://github.com/modmail-dev/modmail/pull/3161)) +- Skip loading of already-loaded cog. ([PR #3172](https://github.com/modmail-dev/modmail/pull/3172)) +- Respect plugin's `cog_command_error`. ([GH #3170](https://github.com/modmail-dev/modmail/issues/3170), [PR #3178](https://github.com/modmail-dev/modmail/pull/3178)) +- Use silent as a typing literal for contacting. ([GH #3179](https://github.com/modmail-dev/modmail/issues/3179)) + +### Internal + +- Improve regex parsing of channel topics. ([GH #3114](https://github.com/modmail-dev/modmail/issues/3114), [PR #3111](https://github.com/modmail-dev/modmail/pull/3111)) +- Add warning if deploying on a developmental version. +- Extensions are now loaded `on_connect`. +- MongoDB v5.0 clients are now supported. ([GH #3126](https://github.com/modmail-dev/modmail/issues/3126)) +- Bump python-dotenv to v0.20.0, support for python 3.10 +- Bump emoji to v1.7.0 +- Bump aiohttp to v3.8.1 +- Bump lottie to v0.6.11 +- Remove deprecated `core/decorators.py` from v3.3.0 + +# v3.10.5 + +### Internal + +- Locked plugin registry version impending v4 release. + +# v3.10.4 + +### Improved + +- Thread genesis message now shows other recipients. + +### Fixed + +- `?snippet add` now properly blocks command names. + +### Internal + +- Set `LOG_DISCORD` environment variable to the logger level and log discord events. + +# v3.10.3 +This is a hotfix for contact command. + +### Fixed + +- Fixed a bug where contacting with no category argument defaults to the top category. + +# v3.10.2 +This is a hotfix for react to contact. + +### Fixed + +- React to contact now works properly. + +# v3.10.1 + +This is a hotfix for the edit command. + +### Fixed + +- `?edit` now works properly. + +# v3.10.0 + +v3.10 adds group conversations while resolving other bugs and QOL changes. It is potentially breaking to some plugins that adds functionality to threads. + +### Breaking + +- `Thread.recipient` (`str`) is now `Thread.recipients` (`List[str]`). +- `Thread.reply` now returns `mod_message, user_message1, user_message2`... It is no longer limited at a size 2 tuple. + +### Added + +- Ability to have group conversations with up to 5 users. ([GH #143](https://github.com/modmail-dev/modmail/issues/143)) +- Snippets are invoked case insensitively. ([GH #3077](https://github.com/modmail-dev/modmail/issues/3077), [PR #3080](https://github.com/modmail-dev/modmail/pull/3080)) +- Default tags now use top hoisted role. ([GH #3014](https://github.com/modmail-dev/modmail/issues/3014)) +- New thread-related config - `thread_show_roles`, `thread_show_account_age`, `thread_show_join_age`, `thread_cancelled`, `thread_creation_contact_title`, `thread_creation_self_contact_response`, `thread_creation_contact_response`. ([GH #3072](https://github.com/modmail-dev/modmail/issues/3072)) +- `use_timestamp_channel_name` config to create thread channels by timestamp. + +### Improved + +- `?contact` now accepts a role or multiple users (creates a group conversation). ([GH #3082](https://github.com/modmail-dev/modmail/issues/3082)) +- Aliases are now supported in autotrigger. ([GH #3081](https://github.com/modmail-dev/modmail/pull/3081)) + +### Fixed + +- Certain situations where the internal thread cache breaks and spams new channels. ([GH #3022](https://github.com/modmail-dev/modmail/issues/3022), [PR #3028](https://github.com/modmail-dev/modmail/pull/3028)) +- Blocked users are now no longer allowed to use `?contact` and react to contact. ([COMMENT #819004157](https://github.com/modmail-dev/modmail/issues/2969#issuecomment-819004157), [PR #3027](https://github.com/modmail-dev/modmail/pull/3027)) +- UnicodeEncodeError will no longer be raised on Windows. ([PR #3043](https://github.com/modmail-dev/modmail/pull/3043)) +- Notifications are no longer duplicated when using both `?notify` and `subscribe`. ([PR #3015](https://github.com/modmail-dev/modmail/pull/3015)) +- `?contact` now works properly with both category and silent. ([GH #3076](https://github.com/modmail-dev/modmail/issues/3076)) +- `close_on_leave_reason` now works properly when `close_on_leave` is enabled. ([GH #3075](https://github.com/modmail-dev/modmail/issues/3075)) +- Invalid arguments are now properly catched and a proper error message is sent. +- Update database after resetting/purging all plugins. ([GH #3011](https://github.com/modmail-dev/modmail/pull/3011)) +- `thread_auto_close` timer now only resets on non-note and replies from mods. ([GH #3030](https://github.com/modmail-dev/modmail/issues/3030)) +- Deleted messages are now deleted on both ends. ([GH #3041](https://github.com/modmail-dev/modmail/issues/3041), [@JerrieAries](https://github.com/modmail-dev/modmail/commit/20b31f8e8b5497943513997fef788d72ae668438)) +- Persistent notes are now properly deleted from the database. ([GH #3013](https://github.com/modmail-dev/modmail/issues/3013)) +- Modmail Bot is now recognized to have `OWNER` permission level. This affects what can be run in autotriggers. + +### Internal + +- Fix return types, type hints and unresolved references ([PR #3009](https://github.com/modmail-dev/modmail/pull/3009)) +- Reload thread cache only when it's the first on_ready trigger. ([GH #3037](https://github.com/modmail-dev/modmail/issues/3037)) +- `format_channel_name` is now extendable to plugins. Modify `Bot.format_channel_name(bot, author, exclude_channel=None, force_null=False):`. ([GH #2982](https://github.com/modmail-dev/modmail/issues/2982)) + +# v3.9.5 + +### Internal + +- Bumped discord.py to v1.7.3, updated all other packages to latest. +- More debug log files are now kept. +- Resolve SSL errors by retrying without SSL + +# v3.9.4 + +### Fixed + +- Certain cases where fallback categories were not working as intended. ([GH #3002](https://github.com/modmail-dev/modmail/issues/3002), [PR #3003](https://github.com/modmail-dev/modmail/pull/3003)) +- There is now a proper message when trying to contact a bot. + +### Improved + +- `?mention` can now be disabled with `?mention disable`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) +- `?mention` now allows vague entries such as `everyone` or `all`. ([PR #2993](https://github.com/modmail-dev/modmail/pull/2993/files)) + +### Internal + +- Change heroku python version to 3.9.4 ([PR #3001](https://github.com/modmail-dev/modmail/pull/3001)) + +# v3.9.3 + +### Added + +- New config: `use_user_id_channel_name`, when set to TRUE, channel names would get created with the recipient's ID instead of their name and discriminator. + - This is now an option to better suit the needs of servers in Server Discovery + +### Internal + +- Signature of `format_channel_name` in core/util.py changed to: + - `format_channel_name(bot, author, exclude_channel=None, force_null=False)` + + +# v3.9.2 + +### Improved + +- Additional HostingMethods (i.e. DOCKER, PM2, SCREEN). Autoupdates are now disabled on all docker instances. ([GH #2977](https://github.com/modmail-dev/modmail/issues/2977), [PR #2988](https://github.com/modmail-dev/modmail/pull/2988)) + +### Fixed + +- `user_typing` default in the config help is now correct. + +# v3.9.1 + +### Internal + +- `bot.run` now more gracefully handles closing, similar to how discord.py handles it. + - No longer displays `PrivilegedIntentsRequired` exception when exiting when presence intent is disabled. + +# v3.9.0 + +### Breaking + +- `on_thread_initiate` and `on_thread_ready` events now have `thread, creator, category, initial_message` as additional arguments. + +### Fixed + +- `confirm_thread_creation` now properly works when a user opens a thread using react to contact. ([GH #2930](https://github.com/modmail-dev/modmail/issues/2930), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- `?disable all/new` now disables react to contact threads. ([GH #2969](https://github.com/modmail-dev/modmail/issues/2969), [PR #2971](https://github.com/modmail-dev/modmail/pull/2971)) +- Ghost errors are no longer raised when threads are created using non-organic methods. + +### Internal + +- `thread.reply` now returns (msg_to_user, msg_to_thread). Can be useful in plugins. + +# v3.8.6 + +### Added + +- Ability to install local plugins without relying on git / external sources + - Simply add your extension to plugins/@local, and use `?plugin add local/plugin-name` to load the plugin as normal +- Updated deps for requirements.min.txt and pyproject.toml + +# v3.8.5 + +### Added + +- `?msglink `, allows you to obtain channel + message ID for T&S reports. ([GH #2963](https://github.com/modmail-dev/modmail/issues/2963), [PR #2964](https://github.com/modmail-dev/modmail/pull/2964)) +- `?mention disable/reset`, disables or resets mention on thread creation. ([PR #2951](https://github.com/modmail-dev/modmail/pull/2951)) + +### Fixed + +- Non-master/development branch deployments no longer cause errors to be raised. +- Autotriggers now can search for roles/channels in guild context. ([GH #2961](https://github.com/modmail-dev/modmail/issues/2961)) + +# v3.8.4 + +This update is a quick hotfix for a weird behaviour experienced on 1 Feb 2021 where users were not properly cached. + +### Fixed + +- Corrupted data is no longer saved to thread cache. + +# v3.8.2 + +### Fixed + +- Retry with `null-discrim` if channel could not be created. ([GH #2934](https://github.com/modmail-dev/modmail/issues/2934)) +- Fix update notifications. +- Retrieve user from Discord API if user has left the server, resolving issues in `?block`. ([GH #2935](https://github.com/modmail-dev/modmail/issues/2935), [PR #2936](https://github.com/modmail-dev/modmail/pull/2936)) +- IDs in `` commands work now. + +# v3.8.1 + +### Fixed + +- Additional image uploads now render properly. ([PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- `confirm_thread_creation` no longer raises unnecessary errors. ([GH #2931](https://github.com/modmail-dev/modmail/issues/2931), [PR #2933](https://github.com/modmail-dev/modmail/pull/2933)) +- Autotriggers no longer sends attachments back. ([GH #2932](https://github.com/modmail-dev/modmail/issues/2932)) + +# v3.8.0 + +### Added + +- `update_notifications` configuration option to toggle bot autoupdate notifications. ([GH #2896](https://github.com/modmail-dev/modmail/issues/2896)) +- `?fareply`, anonymously reply with variables. +- `anonymous_snippets` config variable to toggle if snippets should be anonymous. ([GH #2905](https://github.com/modmail-dev/modmail/issues/2905)) +- `disable_updates` config variable to control if the update command should be disabled or not. +- `silent_alert_on_mention` to alert mods silently. ([GH #2907](https://github.com/modmail-dev/modmail/issues/2907)) +- Support for only the "Server Members" intent. + +### Improved + +- Added command validation to `autotrigger add/edit`. +- `GITHUB_TOKEN` is now no longer required in Heroku setups. +- Clearer error messages on reply fails. + +### Fixed + +- Mentioned `competing` as an activity type. ([PR #2902](https://github.com/modmail-dev/modmail/pull/2902)) +- Level permissions were not checked if command permissions were set. +- Regex autotriggers were not working if term was in the middle of strings. +- `?blocked` now no longers show blocks that have expired. +- Blocked roles will no longer trigger an error during unblock. +- Custom emojis are now supported in `confirm_thread_creation_deny`. ([GH #2916](https://github.com/modmail-dev/modmail/issues/2916)) +- Finding linked messages in replies work now. ([GH #2920](https://github.com/modmail-dev/modmail/issues/2920), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2920#issuecomment-751530495)) +- Sending files in threads (non-images) now work. ([GH #2926](https://github.com/modmail-dev/modmail/issues/2926)) +- Deleting messages no longer shows a false error. ([GH #2910](https://github.com/modmail-dev/modmail/issues/2910), [Jerrie-Aries](https://github.com/modmail-dev/modmail/issues/2910#issuecomment-753557313)) +- Display an error on [Lottie](https://airbnb.io/lottie/#/) stickers, instead of failing the send. +- `?perms get` now shows role/user names. ([PR #2927](https://github.com/modmail-dev/modmail/pull/2927)) + +### Internal + +- Make use of `git branch --show-current` to retrieve branch instead of using prerelease version check. +- Use discord.py 1.6.0 from PyPi instead of the development clone. + +# v3.7.13 + +### Fixed + +- Notes in config help are now optional. + +# v3.7.12 + +### Fixed + +- Bot was not responding to union errors. +- `?block [reason]` now works in threads. + +# v3.7.11 + +### Improved + +- Role block will now work better with seperate server setups. + +### Fixed + +- Bot not restarting after autoupdate on PM2. + +### Internal + +- Removed unnecessary loggings. + +# v3.7.10 + +### Added + +- Added `update_channel_id` to specify which channel autoupdate notifications were being sent to. +- Added `show_timestamp` to specify if timestamps should be displayed in message embeds. ([GH #2885](https://github.com/modmail-dev/modmail/issues/2885)) + +# v3.7.9 + +### Fixed + +- `perms add/remove` with permission levels should now work again. ([GH #2892](https://github.com/modmail-dev/modmail/issues/2892), [PR #2893](https://github.com/modmail-dev/modmail/pull/2893)) + +### Improved + +- Clearer plugin debug messages when plugins are disabled + +# v3.7.8 + +### Added + +- Added `thread_contact_silently` to allow opening threads silently by default. ([PR #2887](https://github.com/modmail-dev/modmail/pull/2887)) + +### Fixed +- Permission levels were not respected. +- `perms remove` was not working. +- `logs` and `block` would not recognise users in a seperate server setup. +- Custom emojis were not working with `confirm_thread_creation`. + +### Internal +- Optimised `perms get`, bot should respond faster now. + +# v3.7.7 + +### Added + +- Added updating github fork if GITHUB_TOKEN was provided + +### Fixed + +- Skip blocked roles check if user is not in main guild. + +# v3.7.6 + +### Fixed + +- Autoupdate persists despite errors. +- Mention when normal thread created was not working. ([GH #2883](https://github.com/modmail-dev/modmail/issues/2883)) + +# v3.7.5 + +### Fixed + +- Close on emoji was not working. + +# v3.7.3 + +### Fixed + +- React to contact threads were treated like normal contact threads. ([GH #2881](https://github.com/modmail-dev/modmail/issues/2881)) + +# v3.7.2 + +### Added + +- Added `mention_channel_id` to specify which channel `alert_on_mention` was being sent to. ([GH #2880](https://github.com/modmail-dev/modmail/issues/2880)) + +### Fixed + +- `?config set` would not respond if an invalid key was provided. + +# v3.7.1 + +### Fixed + +- Bot will now leave a reaction on the react to contact message. +- Added docstring to selfcontact + +# v3.7.0 + +### Added + +- Plain replies functionality. Added commands `preply`, `pareply` and config `plain_reply_without_command`. ([GH #2872](https://github.com/modmail-dev/modmail/issues/2872)) +- Added `react_to_contact_message`, `react_to_contact_emoji` to allow users to create threads by reacting to a message. +- Added `thread_move_notify_mods` to mention all mods again after moving thread. ([GH #215](https://github.com/modmail-dev/modmail/issues/215)) +- Added `transfer_reactions` to link reactions between mods and users. ([GH #2763](https://github.com/modmail-dev/modmail/issues/2763)) +- Added `close_on_leave`, `close_on_leave_reason` to automatically close threads upon recipient leaving the server. ([GH #2757](https://github.com/modmail-dev/modmail/issues/2757)) +- Added `alert_on_mention` to mention mods upon a bot mention. ([GH #2833](https://github.com/modmail-dev/modmail/issues/2833)) +- Added `confirm_thread_creation`, `confirm_thread_creation_title`, `confirm_thread_response`, `confirm_thread_creation_accept`, `confirm_thread_creation_deny` to allow users to confirm that they indeed want to create a new thread. ([GH #2773](https://github.com/modmail-dev/modmail/issues/2773)) +- Support Gyazo image links in message embeds. ([GH #282](https://github.com/modmail-dev/modmail/issues/282)) +- Added `silent` argument to `?contact` to restore old behaviour. +- Added new functionality: If `?help` is sent, bot does checks on every command, `?help all` restores old behaviour. ([GH #2847](https://github.com/modmail-dev/modmail/issues/2847)) +- Added a way to block roles. ([GH #2753](https://github.com/modmail-dev/modmail/issues/2753)) +- Added `cooldown_thread_title`, `cooldown_thread_response` to customise message sent when user is on a creating thread cooldown. ([GH #2865](https://github.com/modmail-dev/modmail/issues/2865)) +- Added `?selfcontact` to allow users to open a thread. ([GH #2762](https://github.com/modmail-dev/modmail/issues/2762)) +- Support stickers and reject non-messages. (i.e. pin_add) +- Added support for thread titles, `?title`. ([GH #2838](https://github.com/modmail-dev/modmail/issues/2838)) +- Added `data_collection` to specify if bot metadata should be collected by Modmail developers. +- Added `?autotrigger`, `use_regex_autotrigger` config to specify keywords to trigger commands. ([GH #130](https://github.com/modmail-dev/modmail/issues/130), [GH #649](https://github.com/modmail-dev/modmail/issues/649)) +- Added `?note persistent` that creates notes that are persistent for a user. ([GH #2842](https://github.com/modmail-dev/modmail/issues/2842), [PR #2878](https://github.com/modmail-dev/modmail/pull/2878)) +- Autoupdates and `?update` which was removed in v3.0.0 + +### Fixed + +- `?contact` now sends members a DM. +- `level_permissions` and `command_permissions` would sometimes be reset. ([GH #2856](https://github.com/modmail-dev/modmail/issues/2856)) +- Command truncated after && in alias. ([GH #2870](https://github.com/modmail-dev/modmail/issues/2870)) +- `on_plugins_ready` event for plugins works now. + +### Improved + +- Plugins installations have clearer error messages. +- `?move` now does not require exact category names, accepts case-insensitive and startswith names. + +### Internal +- Use enums in config. ([GH #2821](https://github.com/modmail-dev/modmail/issues/2821)) +- `on_thread_close` event for plugins. +- `on_thread_reply` event for plugins. + +# v3.6.2 + +### Fixed + +- Plugins downloading requirements in virtual environments. + + +# v3.6.1 + +### Added + +- Proper error message if privileged intents not explicitly granted to bot. + + +# v3.6.0 + +### Added + +- Added `thread_move_title` to specify title of thread moved embed. +- Mark NSFW logs in log message. ([GH #2792](https://github.com/modmail-dev/modmail/issues/2792)) +- Icon for moderator that closed the thread in log message. ([GH #2828](https://github.com/modmail-dev/modmail/issues/2828)) +- Ability to set mentions via user/role ID. ([GH #2796](https://github.com/modmail-dev/modmail/issues/2796)) + +### Changed + +- `?move` now consumes rest in category name, which means `?move Long Category Name` works without quotes! +- `?help` shows "No command description" if no description provided. ([PR #2845](https://github.com/modmail-dev/modmail/pull/2845)) + +### Fixed +- Unicode errors raised during windows selfhosting + +### Internal + +- Bump discord.py version to 1.5.1 +- Explicitly state intents used for connection +- Use `--diff` for black CI instead of `--check` ([GH #2816](https://github.com/modmail-dev/modmail/issues/2816)) + + +# v3.5.0 + +Fixed discord.py issue. + +### Added + +- A confirmation when you manually delete a thread message embed. +- Config var `enable_eval` defaults true, set `enable_eval=no` to disable the eval command. ([GH #2803](https://github.com/modmail-dev/modmail/issues/2803)) +- Added `?plugins reset` command to completely reset everything related to plugins. This will fix some problems caused by broken plugins in the file system. +- Support private GitHub repos for plugins (thanks to @officialpiyush pr#2767) + +### Changed + +- Bump discord.py version to v1.3.3. +- Renamed `bot.owner_ids` to `bot.bot_owner_ids` as the attribute is now defined internally for team support. +- Deleting channel manually will now close the thread. +- Deleting messages will no longer cause the bot to produce warnings. +- Plugins will automatically be removed when it fails to load. +- Moved all database-related activities to clients.py under MongoDBClient, with possible future hook for additional database support. +- `bot.db` is deprecated in favour of `bot.api.db` and will be removed in the future. +- Deprecated `bot.plugin_db.get_partition` in favour of `bot.api.get_plugin_partition` (not final). +- Deprecated `MONGO_URI` config var (but will keep support in the future) in favour of `CONNECTION_URI` and `DATABASE_TYPE`. Right now there is one supported database - "mongodb", which is the default. + +### Fixed + +- Plugins not loading in Windows OS. Now uses proactor event loop for asyncio which should fix this. + + +# v3.4.1 + +### Fixed + +- Masked a bunch of noise errors when deleting messages. +- Added more checks for deleting messages. + +### Breaking + +- `thread_initiate` will be dispatched at the beginning of the setup process. +- `thread_create` is dispatched when the thread is registered as a thread by Modmail (i.e., when channel topic is edited). +- `thread_ready` is dispatched when a thread finishes its setup steps. + + +# v3.4.0 + +### Added + +- Thread cooldown! + - Set via the new config var `thread_cooldown`. + - Specify a time for the recipient to wait before allowed to create another thread. +- Fallback Category (thanks to DAzVise PR#636) + - Automatically created upon reaching the 50 channels limit. + - Manually set fallback category with the config var `fallback_category_id`. +- "enable" and "disable" support for yes or no config vars. +- Added "perhaps you meant" section to `?config help`. +- Multi-command alias is now more stable. With support for a single quote escape `\"`. +- New command `?freply`, which behaves exactly like `?reply` with the addition that you can substitute `{channel}`, `{recipient}`, and `{author}` to be their respective values. +- New command `?repair`, repair any broken Modmail thread (with help from @officialpiyush). +- Recipients get feedback when they edit their messages. +- Chained delete for DMs now comes with a message. +- poetry (in case someone needs it). + +### Changed + +- The look of alias and snippet when previewing. +- The database now saves the message ID of the thread embed, instead of the original message. +- Swapped the position of user and category for `?contact`. +- The log file will no longer grow infinitely large. +- A hard limit of a maximum of 25 steps for aliases. +- `?disable` is now `?disable new`. + +### Fixed + +- Setting config vars using human time wasn't working. +- Fixed some bugs with aliases. +- Fixed many issues with `?edit` and `?delete` and recipient message edit. +- Masked the error: "AttributeError: 'int' object has no attribute 'name'" + - Channel delete event will not be checked until discord.py fixes this issue. +- Chained reaction add/remove. +- Chained delete for thread channels. + +### Internal + +- Commit to black format line width max = 99, consistent with PyLint. +- No longer requires shlex for alias parsing. +- New checks with thread create / find. +- No more flake8 and Travis. + +# v3.3.2 + +### Fixed + +- An oversight with the permission system. + +# v3.3.1 + +### Emergency Patch + +- Fixed a recent issue with an animation KeyError due to Discord API update. + +# v3.3.0 + +### Important + +- Recommend all users to unblock and re-block all blocked users upon updating to this release. + +### Added + +- Three new config vars: + - `enable_plugins` (yes/no default yes) + - When set to no, Modmail will not load plugins. + - `error_color` (color format, defaults discord red) + - The color of error messages. + - `anon_reply_without_command` (yes/no default no) (Thanks to papiersnipper PR#288) + - When set, all non-command messages sent to thread channels are forwarded to the recipient anonymously without the need of `?anonreply`. + - This config takes precedence over `reply_without_command`. +- `?logs responded [user]` command. It will show all the logs that the user has sent a reply. (Thanks to papiersnipper PR#288) + - `user` when not provided, defaults to the user who ran the command. +- Open threads in limbo now auto-close if Modmail cannot find the channel. Modmail does this check every time the bot restarts. +- Ability to disable new threads from getting created. + - `?disable`. +- Ability to fully disable Modmail DM. + - `?disable all`. +- To re-enable DM: `?enable`, and to see the current status: `?isenable`. +- This disabled Modmail interface is customizable with the following config vars: + - `disabled_new_thread_title` + - `disabled_new_thread_response` + - `disabled_new_thread_footer` + - `disabled_current_thread_title` + - `disabled_current_thread_response` + - `disabled_current_thread_footer` +- Ability to delete notes when providing their ID. (Thanks to papiersnipper PR#402) +- Ability to delete log entries. (Thanks to papiersnipper PR#402) + +### Changed + +- `?contact` no longer send the "thread created" message to where the command was run, instead, it's now sent to the newly created thread channel. (Thanks to DAzVise) +- Automatically delete notes command `?note` when there're no attachments attached. +- Embed author links used to be inaccessible in many cases, now: + - `?anonreply`, `?reply`, and `?note` in the thread channel will link to the sender's profile. + - `?reply` and the recipient's DM will also link the sender's profile. + - `?anonreply` in DM channel will link to the first channel of the main guild. +- Plugins update (mostly internal). + - `git` is no longer used to install plugins; it now downloads through zip files. + - `?plugins enabled` renamed to `?plugins loaded` while `enabled` is still an alias to that command. + - Reorganized plugins folder structure. + - Logging / plugin-related messages change. + - Updating one plugin will not update other plugins; repositories no longer separate plugins, but the plugin name itself. +- The help command is in alphabetical order grouped by permissions. +- Notes are no longer always blurple; it's set to `MAIN_COLOR` now. +- Added `?plugins update` for updating all installed plugins. +- Reintroduce flake8 and use bandit for security issues detection. +- Add Travis checks for 3.6 in Linux and 3.7 for macOS and Windows. +- Debug logs not logs eval commands. +- Presence updates 30 minutes instead of 45 now. +- Fixed an assortment of problems to do with `?block`. +- Existing aliases can be used when creating new aliases. (Thanks to papiersnipper PR#402) + +### Internal + +- Reworked `config.get` and `config.set`, it feeds through the converters before setting/getting. + - To get/set the raw value, access through `config[]`. +- The prerelease naming scheme is now `x.x.x-devN`. +- `trigger_typing` has been moved to `core.utils.trigger_typing`, the original location is deprecated. +- Simpler status and activity logic. +- New logging logic. + +# v3.2.2 + +Security update! + +### Important + +- Supporter permission users used to be able to "hack" snippets to reveal all your config vars, including your token and MongoURI. +- Implemented some changes to address this bug: + - All customizable variables used in snippets, close messages, etc., using the `{}` syntax, now forbids chaining two or more attributes and attributes that start with `_`. +- We advise you to update to this version. +- If you felt your credentials had been leaked, consider changing your bot token / MongoURI. + +# v3.2.1 + +### Fixed + +- Can't set hex for main_color, recipient_color, etc. + +### Added + +- Discord colors by default when addressing them by names. + +# v3.2.0 + +### Added + +- Ability to change permission levels of individual commands. + - See `?permissions override` for more information. +- `thread_move_notify` and `thread_move_response` to notify recipients if a thread is moved. (Thanks to Flufster PR#360) +- IDs of messages sent to Modmail are now viewable. (Thanks to Flufster PR#360) + +### Fixed + +- `?help `, will return `Perhaps you meant: `, now it's fixed. + - For example, `?help add` used to return `Perhaps you meant: add`, now it wouldn't do this. +- Aliases and Permissions command names are always saved lowercase now. +- An improved Dockerfile. + +### Internal + +- Use regex to parse Changes, Added, Fixed, etc. and description. +- Adds `PermissionLevel.INVALID` when commands don't have a permission level. + +# v3.1.1 + +### Fixed + +- An issue when reading `config_help.json` for Windows users due to an encoding problem. + +# v3.1.0 + +### Breaking + +- `disable_recipient_thread_close` is removed, a new configuration variable `recipient_thread_close` replaces it which defaults to False. +- Truthy and falsy values for binary configuration variables are now interpreted respectfully. +- `LOG_URL_PREFIX` cannot be set to "NONE" to specify no additional path in the future, "/" is the new method. + +### Added + +- `?sfw`, mark a thread as "safe for work", undos `?nsfw`. +- New config variable, `thread_auto_close_silently`, when set to a truthy value, no message will be sent when a thread is auto-closed. +- New configuration variable `thread_self_closable_creation_footer` — the footer when `recipient_thread_close` is enabled. +- Added a minimalistic version of requirements.txt (named requirements.min.txt) that contains only the absolute minimum of Modmail. + - For users having trouble with pipenv or any other reason. +- Multi-step alias, see `?help alias add`. Public beta testing might be unstable. +- Misc commands without cogs are now displayed in `?help`. +- `?help` works for alias and snippets. +- `?config help ` shows a help embed for the configuration. +- Support setting permissions for subcommands. +- Support numbers (1-5) as substitutes for Permission Level REGULAR - OWNER in `?perms` subcommands. + +### Changes + +- `thread_auto_close_response` has a configurable variable `{timeout}`. +- `?snippet` is now the default command name instead of `?snippets` (`?snippets` is still usable). This is to make this consistent with `?alias`/`?aliases`. +- `colorama` is no longer a necessity; this is due to some unsupported OS. +- Changelog command can now take a version argument to jump straight to the specified version. +- `?plugin enabled` results are now sorted alphabetically. +- `?plugin registry` results are now sorted alphabetically, helps users find plugins more easily. +- `?plugin registry page-number` plugin registry can specify a page number for quick access. +- A reworked interface for `?snippet` and `?alias`. + - Add an `?snippet raw ` command for viewing the raw content of a snippet (escaped markdown). + - Add an `?alias raw ` command for displaying the raw content of an alias (escaped markdown). +- The placeholder channel for the streaming status changed to https://www.twitch.tv/discordmodmail/. +- Removed unclear `rm` alias for some `remove` commands. +- Paginate `?config options`. +- All users configured with a permission level higher than REGULAR has access to the main Modmail category. + - Category overrides also changes when a level is removed or added to a user or role. +- `@everyone` is now accepted for `?perms add`. + +### Fixes + +- `?notify` no longer carries over to the next thread. +- `discord.NotFound` errors for `on_raw_reaction_add`. +- `mod_typing` ~~and `user_typing`~~ (`user_typing` is now by-design to show) will no longer show when the user is blocked. +- Better `?block` usage message. +- Resolved errors when mods sent messages after a thread is closed somehow. +- Recipient join/leave server messages are limited to only the guild set by `GUILD_ID`. +- When creating snippets and aliases, it now checks if other snippets/aliases with the same name exist. +- Modmail looked for `config.json` in the wrong directory. + +### Internal + +- Removed supporting code for GitHub interaction. +- All default config values moved to `core/config.py`. +- `config.cache` is no longer accessible, use `config['key']` for getting, `config['key'] = value` for setting, `config.remove('key')` for removing. +- Dynamic attribute for configs are removed, must use `config['key']` or `config.get('key')`. +- Removed helper functions `info()` and `error()` for formatting logging, it's formatted automatically now. +- Bumped discord.py version to 1.2.3. +- Use discord tasks for metadata loop. +- More debug based logging. +- Reduce redundancies in `?perms` sub commands. +- paginator been split into `EmbedPaginatorSession` and `MessagePaginatorSession`, both subclassing `PaginatorSession`. + +# v3.0.3 + +### Added + +- New commands, `?alias edit ` and `?snippets edit `. + - They can be used to edit aliases and snippets, respectively. + +# v3.0.2 + +### Added + +- A new command, `?blocked whitelist `, this command prevents users from getting blocked by any means. + +### Changed + +- Removed some aliases from `?oauth`. + +# v3.0.1 + +### Fixed + +- Many bugs with `thread_auto_close`. + +# v3.0.0 + +### Added + +- `?sponsors` command will list sponsors. +- An alert will now be sent to the log channel if a thread channel fails to create. This could be due to a variety of problems such as insufficient permissions, or the category channel limit is met. +- Threads will close automatically after some time when `thread_auto_close` is set. +- Custom closing messages can be configured with `thread_auto_close_response`. + +### Breaking Changes + +- Removed auto-update functionality and the `?update` command in favor of the [Pull app](https://github.com/apps/pull). + +Read more about updating your bot [here](https://github.com/modmail-dev/modmail/wiki/updating) + +### Changed +- Channel names now can contain Unicode characters. +- Debug logs are now located in a different file for each bot. (Internal change) +- Default cogs always appear first in the help command now. + +### Fixed +- Editing notes now work, minor bug with edit command is fixed. +- Bug in the `?oauth` command where the response message fails to send when an ID is provided. +- Plugin requirement installation now works in virtual environments + + +# v2.24.1 + +### Fixed + +Fixed a bug with branches and `?plugin update`. + +# v2.24.0 + +### Added + +Branch support for `?plugin add` and in the registry. Typically for developers. + +# v2.23.0 + +### Added + +Added a "Mutual servers" field to the genesis embed if: +a) The user is not in the main guild. +b) The user shares more than one server with the bot. + +### Changed + +Notes with the `?note` command are now automatically pinned within the thread channel. + +# v2.22.0 + +### Added + +Added a 🛑 reaction to the paginators to delete the embed. + +### Fixed + +`?blocked` is now paginated using reactions. This fixes [#249](https://github.com/modmail-dev/modmail/issues/249) + +# v2.21.0 + +### Added + +New `?plugin registry compact` command which shows a more compact view of all plugins. + +# v2.20.2 + +### Plugin Registry + +Plugin developers can now make a PR to include their plugin in the `plugin registry` command. +Add your plugin in the `plugins/registry.json` file in the main repository. + +### Changed + +`?debug` command now shows the most recent logs first. (Starts at the last page) + +# v2.20.1 + +### What's new? + + - New error message when using thread-only commands outside of threads. + - `?unnotify`, ability to undo `?notify`. + - `?notify` and `?subscribe` now accepts other users. + +### Changes + +This update contains mostly internal changes. + - Implemented support for the new discord.py v1.1.1. + - Improved help text for most commands. + - Completely revamped help command, few users changes. + - Removed ABC (internal). + +# v2.20.0 + +### What's new? + +New `?oauth whitelist` command, which allows you to whitelist users so they can log in via discord to view logs. To set up oauth login for your logviewer app, check the logviewer [repo](https://github.com/modmail-dev/logviewer). + +# v2.19.1 + +### Changed + +- Ability to force an update despite having the same version number. Helpful to keep up-to-date with the latest GitHub commit. + - `?update force`. +- Plugin developers now have a new event called `on_plugin_ready`; this is a coroutine and is awaited when all plugins are loaded. Use `on_plugin_ready` instead of `on_ready` since `on_ready` will not get called in plugins. + +# v2.19.0 + +### What's new? + +- New config variable `guild_age`, similar to `account_age`, `guild_age` sets a limit as to how long a user has to wait after they joined the server to message Modmail. +- `guild_age` can be set the same way as `account_age`. + +# v2.18.5 + +Fix help command bug when using external plugins. + +# v2.18.4 + +Fix the teams permission bug. + +# v2.18.2 + +### Changed + +Commands now have better error messages. Instead of sending the help message for a command when an argument fails to be converted, the bot now says like "User 'bob' not found" instead. + +# v2.18.1 + +Un-deprecated the `OWNERS` config variable to support Discord developer team accounts. + +# v2.18.0 + +### New Permissions System + +- A brand new permission system! Replaced the old guild-based permissions (i.e., manage channels, manage messages), with the new system enables you to customize your desired permission level specific to a command or a group of commands for a role or user. +- There are five permission levels: + - Owner [5] + - Administrator [4] + - Moderator [3] + - Supporter [2] + - Regular [1] + +### Usage + +You may add a role or user to a permission group through any of the following methods: +- `?permissions add level owner @role` +- `?permissions add level supporter member-name` +- `?permissions add level moderator everyone` +- `?permissions add level moderator @member#1234` +- `?permissions add level administrator 78912384930291853` + +The same applies to individual commands permissions: +- `?permissions add command command-name @member#1234` +- and the other methods listed above. + +To revoke permission, use `remove` instead of `add`. + +To view all roles and users with permission for a permission level or command do: +- `?permissions get command command-name` +- `?permissions get level owner` + +By default, all newly set up Modmail will have `OWNER` set to the owner of the bot, and `REGULAR` set to @everyone. + +### Breaking + +When updating to this version, all prior permission settings with guild-based permissions will be invalidated. You will need to convert to the above system. +`OWNERS` will also get removed; you will need to set owners through `?permissions add level owner 212931293123129` or any way listed above. + +### New Command + +- A `?delete` command, which is an alternative to manually deleting a message. This command is created to no longer require "manage messages" permission to recall thread messages. + +### Changed + +- The help message no longer conceals inaccessible commands due to check failures. # v2.17.2 ### Changed - Logs search command will search through log keys as well now. - -e.g. `?logs search e7499e82f8ff` +- For example, `?logs search e7499e82f8ff`. # v2.17.1 -### Changed +### What's new? -- Stricter fallback genesis embed search. This update shouldn't affect anyone. +Stricter fallback genesis embed search. -### Info -How modmail checks if a channel is a thread: +### Changed -1. First the bot checks if the channel topic is in the format `User ID: xxxx`, this means it is a thread. -2. If a channel topic is not found, the bot searches through message history of a channel to find the thread creation embed. This step should never yield a thread for a normal user, but in the case of a another bot messing up the channel topic (happened to a user before) this extra step was added. +How Modmail checks if a channel is a thread: +1. The bot first checks if the channel topic is in the format `User ID: XXXX`, this means it is a thread. +2. If a channel topic is not found, the bot searches through the message history of a channel to find the thread creation embed. This step should never yield a thread for an average user. Still, in the case of another bot messing up the channel topic (happened to a user before), this extra step was added. # v2.17.0 ### What's new? -Added a config option `reply_without_command` which when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) +Added a config option `reply_without_command`, which, when present, enables the bot to forward any message sent in a thread channel to the recipient. (Replying without using a command) To enable this functionality, do `?config set reply_without_command true` and to disable it, use `?config del reply_without_command`. - ### Changed -The `move` command now only requires `manage_messages` perms instead of `manage_channels` +The `move` command now only requires `manage_messages` perms instead of `manage_channels`. # v2.16.1 ### Fixed -An issue where a scheduled close would not execute over a long period of time if the recipient no shares any servers with the bot. +An issue where a scheduled close would not execute over a long time if the recipient no shares any servers with the bot. # v2.16.0 -### What's new? +### Changed + +All support for Modmail API (api.modmail.tk) has terminated. +If you're still using api.modmail.tk, you will need to migrate to the self-hosted database +option ASAP. Your bot will not work unless you switch to the self-hosted option. Refer to the installation tutorial for information regarding self-hosted Modmail. -If a member leaves/joins (again) while they are a recipient of a thread, a message will be sent to notify you that this has occured. +If a member leaves/joins (again) while they are a recipient of a thread, a message will be sent to notify you that this has occurred. + +# v2.15.1 + +### Fixed + +Emergency patch of a SyntaxError. # v2.15.0 @@ -62,8 +1181,8 @@ Added the ability to change the default close message via the introduction of tw They will be provided by string variables that you can incorporate into them: - `closer` - the user object that closed the thread. -- `logkey` - the key for the thread logs e.g. (`5219ccc82ad4`) -- `loglink` - the full link to the thread logs e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) +- `logkey` - the key for the thread logs, e.g. (`5219ccc82ad4`) +- `loglink` - the full link to the thread logs, e.g. (`https://logwebsite.com/logs/5219ccc82ad4`) Example usage would be: ``?config set thread_close_message {closer.mention} closed the thread, here is the link to your logs: [**`{logkey}`**]({loglink})`` @@ -92,17 +1211,17 @@ You now have complete control of the look of the thread creation and close embed ### What's new? -Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages modmail. +Added the ability to disable the `sent_emoji` and `blocked_emoji` when a user messages Modmail. -You can do this via `?config set sent_emoji disable` +You can do this via `?config set sent_emoji disable`. ### Fixed The bot now handles having too many roles to show in the thread created embed. # v2.13.12 -### What's new? +### What's new? Added image link in title in case discord fails to embed an image. # v2.13.11 @@ -111,17 +1230,17 @@ Added image link in title in case discord fails to embed an image. - Introduced a new configuration variable `account_age` for setting a minimum account creation age. - Users blocked by this reason will be stored in `blocked` along with other reasons for being blocked. - `account_age` needs to be an ISO-8601 Duration Format (examples: `P12DT3H` 12 days and 3 hours, `P3Y5M` 3 years and 5 months `PT4H14M999S` 4 hours 14 minutes and 999 seconds). https://en.wikipedia.org/wiki/ISO_8601#Durations. - - You can set `account_age` using `config set account_age time` where "time" can be a simple human readable time string or an ISO-8601 Duration Format string. + - You can set `account_age` using `config set account_age time` where "time" can be a simple human-readable time string or an ISO-8601 Duration Format string. ### Changed -- `block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. -- `block`, like `close`, now support a block duration (temp blocking). +- `?block` reason cannot start with `System Message: ` as it is now reserved for internal user blocking. +- `?block`, like `?close`, now supports a block duration (temp blocking). # v2.13.10 ### Fixed -- Fixed an issue where status and activity does not work if they were modified wrongly in the database. - - This was especially an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. +- Fixed an issue where status and activity do not work if they were modified wrongly in the database. + - This was primarily an issue for older Modmail users, as the old `status` configuration variable clashes with the new `status` variable. # v2.13.9 @@ -140,7 +1259,7 @@ Added image link in title in case discord fails to embed an image. ### What's new? - The ability to enable typing interactions. - - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. + - If you want the bot to type in the thread channel if the user is also typing, add the config variable `user_typing` and set it to "yes" or "true". Use `config del` to disable the functionality. The same thing in reverse is also possible if you want the user to see the bot type when someone is typing in the thread channel add the `mod_typing` config variable. - New `status` command, change the bot's status to `online`, `idle`, `dnd`, `invisible`, or `offline`. - To remove the status (change it back to default), use `status clear`. - This also introduces a new internal configuration variable: `status`. Possible values are `online`, `idle`, `dnd`, `invisible`, and `offline`. @@ -157,15 +1276,15 @@ Added image link in title in case discord fails to embed an image. ### What's new? - You will no longer need to view your bot debug logs from Heroku. `debug` will show you the recent logs within 24h through a series of embeds. - - If you don't mind your data (may or may not be limited to: user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. + - If you don't mind your data (may or may not be limited to user ID, guild ID, bot name) be on the internet, `debug hastebin` will upload a formatted logs file to https://hasteb.in. - `debug clear` will clear the locally cached logs. - - Local logs are automatically cleared at least once every 27h for bots hosted on Heroku. + - Local logs are automatically erased at least once every 27h for bots hosted on Heroku. ### Fixed -- Will no longer show `Unclosed client session` and `Task was destroyed but it is pending!` when the bot terminates. +- Will no longer show `Unclosed client session` and `Task was destroyed, but it is pending!` when the bot terminates. - `thread.create` is now synchronous so that the first message sent can be queued to be sent as soon as a thread is created. - This fixes a problem where if multiple messages are sent in quick succession, the first message sent (which triggers the thread creation) is not sent in order. -- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled and the bot will send a message saying so. +- Trying to reply to someone who has DMs disabled or has blocked the bot is now handled, and the bot will send a message saying so. ### Changed - `print` is replaced by logging. @@ -199,19 +1318,19 @@ Added image link in title in case discord fails to embed an image. ### What's new? - Plugins: - - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit from? Plugins are your go-to! - - [Creating Plugins Documentation](https://github.com/kyb3r/modmail/wiki/Plugins). + - Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it. Add a welcome message to Modmail, or moderation commands? It's all up to your imagination! Have a niche feature request that you think only your server would benefit? Plugins are your go-to! + - [Creating Plugins Documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). # v2.12.5 ### Fixed -- `config del` command will now work properly on self-hosted db bots. +- `config del` command will now work correctly on self-hosted DB bots. # v2.12.4 ### What's new? -- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/kyb3r/modmail/blob/master/core/_color_data.py). +- Named colors are now supported! Over 900 different common color names are recognized. A list of color names can be found in [core/_color_data.py](https://github.com/modmail-dev/modmail/blob/master/core/_color_data.py). - Named colors can be set the same way as hex. But this can only be done through `config set`, which means database modifications will not work. - For example: `config set main_color yellowish green`. - New config var `main_color` allows you to customize the main Modmail color (as requested by many). Defaults to Discord `blurple`. @@ -220,14 +1339,14 @@ Added image link in title in case discord fails to embed an image. ### Fixed - Patched a bug where `logs` sub-commands were accessible by anyone. -- Patched a bug where an error was raised if there was an open thread where the recipient had left the server. +- Patched a bug where an error was raised when a thread is open where the recipient left the server. Huge thanks to Sasiko for reporting these issues. # v2.12.2 ### Fixed -- Fixed a bug in self-hosted `update` command. +- Fixed a bug in self-hosted `?update` command. # v2.12.1 @@ -237,13 +1356,13 @@ Huge thanks to Sasiko for reporting these issues. # v2.12.0 -### Important -**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is due to the fact that we are providing a free service without getting anything in return, and thus we do not have the resources to scale to accommodate for more users. +### Important +**In the future, the Modmail API (https://modmail.tk) will be deprecated. This is because we are providing free service without getting anything in return. Thus we do not have the resources to scale to accommodate more users. We recommend using your own database for logs. In the future you will soon get a `backup` command so you can download all your pre-existing data and migrate to your own database.** ### Changed - A lot of painful code cleanup, which is good for us (the developers), but shouldn't affect you. -- The appearance of the `logs` command. Should be clearer with better info now. +- The appearance of the `?logs` command. It should be clearer with better info now. - Bot owners get access to all commands regardless of server permissions. - Blocked users no longer receive a message, only the blocked emoji will be sent. @@ -251,10 +1370,10 @@ We recommend using your own database for logs. In the future you will soon get a - **Note:** The following commands only work if you are self-hosting your logs. We recommend you to use your own database. - Log search queries, in the form of two new commands. - `logs search [query]` - this searches all log messages for a query string. -- `logs closed-by [user]` this returns all logs closed by a certain user +- `logs closed-by [user]` this returns all logs closed by a particular user ### Fixed -- `activity listening to music` no longer result in two "to"s ("listening to to music"). +- `activity listening to music` no longer results in two "to"s ("listening to to music"). - This may require you to change your activity message to accommodate this fix. - A problem where `main_category_id` and `log_channel_id` weren't updated when their corresponding channel or category get deleted. @@ -277,7 +1396,7 @@ We recommend using your own database for logs. In the future you will soon get a ### What's new? - `anonreply` command to anonymously reply to the recipient. -The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message). The avatar defaults the guild icon URL. However you can change both of these via the `anon_username`, `anon_avatar_url` and `anon_tag` config variables. +The username of the anonymous user defaults to the `mod_tag` (the footer text of a mod reply message) — the avatar defaults to the guild icon URL. However, you can change both of these via the `anon_username`, `anon_avatar_url`, and `anon_tag` config variables. ### Changed - Your bot now logs all messages sent in a thread channel, including discussions that take place. You can now toggle to view them in the log viewer app. @@ -302,7 +1421,7 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of - All commands are now blurple instead of green. ### Fixed -- Bug where the close command wouldn't work if you didnt configure a log channel. +- Bug where the close command wouldn't work if you didn't configure a log channel. ### What's new? - Ability to set your own custom `mod_color` and `recipient_color` for the thread message embeds. @@ -319,13 +1438,13 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of # v2.9.0 ### What's new? -- New command `note` will add a system message to your thread logs. This is useful for noting the context of a conversation. +- New command `note` will add a system message to your thread logs. - - This is useful for noting the context of a conversation. # v2.8.1 ### Fixed - Fixed bug where thread logs were getting duplicated when using the `contact` command. -- Fixed bug where the wrong key was used for logs which caused some `log` command log links to point to an HTTP 404 Not Found. +- Fixed bug where the wrong key was used for logs, which caused some `log` command log links to point to an HTTP 404 Not Found. - A minor oversight from commit 1ba74d9. # v2.8.0 @@ -342,7 +1461,7 @@ The username of the anonymous user defaults to the `mod_tag` (the footer text of ### Security Thread channels will now default to being private (`@everyone`'s read message perms set to `false`). - If the thread creation category could not be resolved. - - This will save you from some trouble if for whatever reason your configuration gets messed up. + - This will save you from some trouble if, for whatever reason, your configuration gets messed up. # v2.7.1 @@ -354,20 +1473,20 @@ Thread channels will now default to being private (`@everyone`'s read message pe ### Note -- If your Modmail bot was set up a long time ago, you may experience an issue where messages were sent outside of the category. +- If your Modmail bot was set up a long time ago, you might experience an issue where messages were sent outside of the category. - To fix this, set `main_category_id` to the ID of the Modmail category. # v2.7.0 ### Changed -- `move` command now syncs thread channel permissions with the category that it was moved to. +- `move` command now syncs thread channel permissions with the destination category. - `contact` command now supports an optional category argument (where the thread channel will be created). # v2.6.3 ### Fixes -- Fixed small issue with finding thread. +- Fixed small issue with finding threads. # v2.6.2 @@ -387,8 +1506,8 @@ Thread channels will now default to being private (`@everyone`'s read message pe ### Changed - Log URLs are moved to their own collection. - Log URLs are now `https://logs.modmail.tk/LOGKEY`, no more numbers before the log key. -- We still support the numbers so as to not break everyone's URLs so quickly but both work at the moment. -- This is a huge change to the backend logging and there might be migration errors. If so, please contact us in our [discord server](https://discord.gg/2fMbf2N). +- We still support the numbers to not break everyone's URLs so quickly, but both work at the moment. +- This is a huge change to the backend logging, and there might be migration errors. If so, please contact us in our [Discord server](https://discord.gg/2fMbf2N). # v2.5.2 @@ -398,14 +1517,14 @@ Thread channels will now default to being private (`@everyone`'s read message pe # v2.5.1 ### Fixes -- Emergency patch to save config. +- Emergency patch to save configs. # v2.5.0 ### Background - Bots hosted by Heroku restart at least once every 27 hours. -- During this period, local caches are deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. -- [PR #135](https://github.com/kyb3r/modmail/pull/135) +- During this period, local caches will be deleted, which results in the inability to set the scheduled close time to longer than 24 hours. This update resolves this issue. +- [PR #135](https://github.com/modmail-dev/modmail/pull/135) ### Changed - Created a new internal config var: `closures`. @@ -422,7 +1541,7 @@ Fixed activity setting due to flawed logic in `config.get()` function. # v2.4.4 ### Fixed -Fixed a bug in activity command where it would fail to set the activity on bot restart if the activity type was `playing`. +Fixed a bug in the `?activity` command where it would fail to set the activity on bot restart if the activity type was `playing`. # v2.4.3 @@ -437,17 +1556,17 @@ Fixed a bug in activity command where it would fail to set the activity on bot r # v2.4.1 ### Fixed -- Small bug in `activity` command. +- Small bug in `?activity` command. # v2.4.0 ### What's new? -- Added the `activity` command for setting the activity -- [PR #131](https://github.com/kyb3r/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening` and `streaming`). +- Added the `?activity` command for setting the activity +- [PR #131](https://github.com/modmail-dev/modmail/pull/131#issue-244686818) this supports multiple activity types (`playing`, `watching`, `listening`, and `streaming`). ### Removed - Removed the deprecated `status` command. -- This also means you will have to reset your bot status with the `activity` command, as `status` command is removed. +- This also means you will have to reset your bot status with the `?activity` command, as the `?status` command was removed. # v2.3.0 @@ -468,7 +1587,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### What's new? - Notify command `notify [role]`. - Notify a given role or yourself to the next thread message received. - - Once a thread message is received you will be pinged once only. + - Once a thread message is received, you will be pinged once only. - Subscribe command `sub [role]` / `unsub [role]`. - Subscribes yourself or a given role to be notified when thread messages are received. @@ -485,15 +1604,15 @@ Fixed a bug in activity command where it would fail to set the activity on bot r # v2.1.0 ### What's new? -- Ability to set a custom thread creation response message. +- Ability to set a custom thread-creation-response message. - Via `config set thread_creation_response [message]`. ### Changed -- Improve logs command format. -- Improve thread log channel message to have more relevant info. +- Improve `?logs` command format. +- Improve thread log channel messages to have more relevant info. - Improve close command. - - You now can close the thread after a delay and use a custom thread close message. - - You also now have the ability to close a thread silently. + - You can now close the thread after a delay and use a custom thread close message. + - You also now can close a thread silently. # v2.0.10 @@ -508,7 +1627,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Fixes - Support multiple images and file attachments in one message. -- This is only possible on mobile so its good to handle it in code. +- This is only possible on mobile, so its good to handle it in code. # v2.0.8 @@ -519,7 +1638,7 @@ Fixed a bug in activity command where it would fail to set the activity on bot r - You can do this via the `config set main_category_id ` command. ### Changed -- You now have the ability to supply a reason when blocking a user. +- You can now supply a reason when blocking a user. - Blocked users are now stored in the database instead of in the channel topic. - This means you can delete the top channel in the Modmail category now (after migrating the currently blocked users). @@ -530,8 +1649,8 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - `update` command now shows the latest changes directly from CHANGELOG.md. -- Auto update messages also show the latest changes from the GitHub repo. -- Removed "latest changes" section from the `about` command. +- Auto-update messages also show the latest changes from the GitHub repo. +- Removed the "latest changes" section from the `about` command. # v2.0.6 @@ -544,20 +1663,20 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - `alias` command now checks if you are adding a valid alias-command combo. -- Deleting a channel manually will now correctly close the thread and post logs. +- Manually deleting a channel will now correctly close the thread and post logs. # v2.0.4 ### Fixed -- Fixed a one-off bug where the channel topic disappears, but Modmail operations should still continue. +- Fixed a one-off bug where the channel topic disappears, but Modmail operations should continue. - Fixed `linked_message_id` issues. # v2.0.3 ### Fixed -- Thread creation embed now shows the correct number of past logs. +- The thread creation embed now shows the correct number of past logs. - If using a separate server setup, roles in the info embed now are shown as names instead of mentions. - - This is due to the fact that you can't mention roles across servers. + - This is because you can't mention roles across servers. # v2.0.2 @@ -569,16 +1688,16 @@ Fixed a bug in activity command where it would fail to set the activity on bot r ### Changed - Improved `block` / `unblock` commands. - - They now take a wider range of arguments: usernames, nicknames, mentions and user IDs. + - They now take a more comprehensive range of arguments: usernames, nicknames, mentions, and user IDs. ### Fixed - Setup command now configures permissions correctly so that the bot will always be able to see the main operations category. # v2.0.0 -This release introduces the use of our centralized [API service](https://github.com/kyb3r/webserver) to enable dynamic configuration, auto-updates, and thread logs. -To use this release you must acquire an API token from https://modmail.tk. -Read the updated installation guide [here](https://github.com/kyb3r/modmail/wiki/installation). +This release introduces the use of our centralized [API service](https://github.com/modmail-dev/webserver) to enable dynamic configuration, auto-updates, and thread logs. +To use this release, you must acquire an API token from https://modmail.tk. +Read the updated installation guide [here](https://github.com/modmail-dev/modmail/wiki/installation). ### Changed - Stability improvements through synchronization primitives. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..246d3cf7a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.11-slim-bookworm as base + +RUN apt-get update && \ + apt-get install --no-install-recommends -y \ + # Install CairoSVG dependencies. + libcairo2 && \ + # Cleanup APT. + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + # Create a non-root user. + useradd --shell /usr/sbin/nologin --create-home -d /opt/modmail modmail + +FROM base as builder + +COPY requirements.txt . + +RUN pip install --root-user-action=ignore --no-cache-dir --upgrade pip wheel && \ + python -m venv /opt/modmail/.venv && \ + . /opt/modmail/.venv/bin/activate && \ + pip install --no-cache-dir --upgrade -r requirements.txt + +FROM base + +# Copy the entire venv. +COPY --from=builder --chown=modmail:modmail /opt/modmail/.venv /opt/modmail/.venv + +# Copy repository files. +WORKDIR /opt/modmail +USER modmail:modmail +COPY --chown=modmail:modmail . . + +# This sets some Python runtime variables and disables the internal auto-update. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH=/opt/modmail/.venv/bin:$PATH \ + USING_DOCKER=yes + +CMD ["python", "bot.py"] diff --git a/LICENSE b/LICENSE index 2a09c00236..7579c25e0a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,661 @@ -MIT License - -Copyright (c) 2017-2019 kyb3r - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Modmail for Discord. + Copyright (C) 2018-2019 kyb3r + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000000..b4283e3660 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,122 @@ +# Privacy Statement + +Hey, we are the lead developers of Modmail bot. This is a look into the data we collect, the data you collect, the data other parties collect, and what can be done about any of this data. +> **Disclaimer**: None of us are lawyers. We are just trying to be more transparent + +### TL;DR + +Yes, we collect some data to keep us happy. You collect some data to keep the bot functioning. External services also collect some data that is out of our control. + +## Interpretation + +- Modmail: This application that has been made open-source. +- Modmail Team: Lead developers, namely kyb3r, fourjr and taku. +- Bot: Your instance of the Modmail bot. +- Bot owner: The person managing the bot. +- Guild: A [server](https://discord.com/developers/docs/resources/guild#guild-resource), an isolated collection of users and channels, within Discord +- User: The end user, or server members, that interface with the bot. +- Database: A location where data is stored, hosted by the bot owner. The following types of database are currently supported: [MongoDB](#MongoDB). +- Logviewer: A webserver hosted by the bot owner. + +## The Data We Collect + +No data is being collected unless someone decides to host the bot and the bot is kept online. + +The Modmail Team collect some metadata to keep us updated on the number of instances that are making use of the bot and know what features we should focus on. The following is a list of data that we collect: +- Bot ID +- Bot username and discriminator +- Bot avatar URL +- Main guild ID +- Main guild name +- Main guild member count +- Bot uptime +- Bot latency +- Bot version +- Whether the bot is selfhosted + +No tokens/passwords/private data is ever being collected or sent to our servers. + +This metadata is sent to our centralised servers every hour that the bot is up and can be viewed in the bot logs when the `log_level` is set to `DEBUG`. + +As our bot is completely open-source, the part that details this behaviour is located in `bot.py > ModmailBot > post_metadata`. + +We assure you that the data is not being sold to anybody. + +### Opting out + +The bot owner can opt out of this data collection by setting `data_collection` to `off` within the configuration variables or the `.env` file. + +### Data deletion + +Data can be deleted with a request in a DM to our [support server](https://discord.gg/etJNHCQ)'s Modmail bot. + +## The Data You Collect + +When using the bot, the bot can collect various bits of user data to ensure that the bot can run smoothly. +This data is stored in a database instance that is hosted by the bot owner (more details below). + +When a thread is created, the bot saves the following data: +- Timestamp +- Log Key +- Channel ID +- Guild ID +- Bot ID +- Recipient ID +- Recipient Username and Discriminator +- Recipient Avatar URL +- Whether the recipient is a moderator + +When a message is sent in a thread, the bot saves the following data: +- Timestamp +- Message ID +- Message author ID +- Message author username and discriminator +- Message author avatar URL +- Whether the message author is a moderator +- Message content +- All attachment urls in the message + +This data is essential to have live logs for the web logviewer to function. +The Modmail team does not track any data by users. + +### Opting out + +There is no way for users or moderators to opt out from this data collection. + +### Data deletion + +Logs can be deleted using the `?logs delete ` command. This will remove all data from that specific log entry from the database permenantly. + +## The Data Other Parties Collect + +Plugins form a large part of the Modmail experience. Although we do not have any control over the data plugins collect, including plugins within our registry, all plugins are open-sourced by design. Some plugin devs may collect data beyond our control, and it is the bot owner's responsibility to check with the various plugin developers involved. + +We recommend 4 external services to be used when setting up the Modmail bot. +We have no control over the data external parties collect and it is up to the bot owner's choice as to which external service they choose to employ when using Modmail. +If you wish to opt out of any of this data collection, please view their own privacy policies and data collection information. We will not provide support for such a procedure. + +### Discord + +- [Discord Privacy Policy](https://discord.com/privacy) + +### Heroku + +- [Heroku Security](https://www.heroku.com/policy/security) +- [Salesforce Privacy Policy](https://www.salesforce.com/company/privacy/). + +### MongoDB + +- [MongoDB Privacy Policy](https://www.mongodb.com/legal/privacy-policy). + +### Github + +- [Github Privacy Statement](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-privacy-statement) + +## Maximum Privacy Setup + +For a maximum privacy setup, we recommend the following hosting procedure. We have included links to various help articles for each relevant step. We will not provide support for such a procedure. +- [Creating a local mongodb instance](https://zellwk.com/blog/local-mongodb/) +- [Hosting Modmail on your personal computer](https://taaku18.github.io/modmail/local-hosting/) +- Ensuring `data_collection` is set to `no` in the `.env` file. +- [Opt out of discord data collection](https://support.discord.com/hc/en-us/articles/360004109911-Data-Privacy-Controls) +- Do not use any plugins, setting `enable_plugins` to `no`. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000000..daa0e60698 --- /dev/null +++ b/Pipfile @@ -0,0 +1,34 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +bandit = ">=1.7.5" +black = "==23.11.0" +pylint = "==3.0.2" +tomli = "==2.2.1" # Needed for black on Python < 3.11 + +[packages] +aiohttp = "==3.13.2" +async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} # Required by aiohttp +typing-extensions = ">=4.12.2" # Required by aiohttp +colorama = "==0.4.6" +"discord.py" = {version = "==2.6.3", extras = ["speed"]} +emoji = "==2.8.0" +isodate = "==0.6.1" +motor = "==3.7.1" +natural = "==0.2.0" # Why is this needed? +packaging = "==23.2" +parsedatetime = "==2.6" +dnspython = ">=2.8,<3" # Required by pymongo +pymongo = ">=4.9,<5" # Required by motor +python-dateutil = "==2.8.2" +python-dotenv = "==1.0.0" +uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} +lottie = {version = "==0.7.2", extras = ["pdf"]} +setuptools = "*" # Needed for lottie +requests = "==2.31.0" + +[scripts] +bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000000..39cd6c33e2 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,2137 @@ +{ + "_meta": { + "hash": { + "sha256": "b9e47a4bb95c39f0d11eeffe03c9229ef1751eec0e412c1a9b4c1f6dc47ed754" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "aiodns": { + "hashes": [ + "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310", + "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5" + ], + "markers": "python_version >= '3.9'", + "version": "==3.5.0" + }, + "aiohappyeyeballs": { + "hashes": [ + "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", + "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" + ], + "markers": "python_version >= '3.9'", + "version": "==2.6.1" + }, + "aiohttp": { + "hashes": [ + "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329", + "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", + "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7", + "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", + "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", + "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", + "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", + "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5", + "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", + "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", + "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca", + "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", + "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", + "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", + "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", + "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", + "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6", + "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", + "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", + "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", + "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b", + "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa", + "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", + "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", + "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", + "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", + "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", + "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07", + "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", + "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", + "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", + "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", + "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", + "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d", + "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", + "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", + "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", + "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", + "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", + "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", + "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", + "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", + "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", + "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", + "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", + "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", + "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", + "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", + "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", + "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d", + "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989", + "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", + "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", + "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", + "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", + "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", + "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac", + "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", + "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", + "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", + "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", + "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", + "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", + "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", + "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", + "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", + "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", + "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5", + "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", + "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", + "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", + "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084", + "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", + "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", + "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", + "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", + "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", + "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", + "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", + "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", + "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", + "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", + "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", + "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", + "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", + "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", + "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", + "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", + "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", + "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", + "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700", + "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", + "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901", + "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2", + "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", + "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", + "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", + "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", + "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", + "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", + "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", + "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", + "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", + "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", + "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", + "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", + "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", + "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", + "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", + "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", + "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", + "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", + "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", + "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", + "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", + "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", + "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", + "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", + "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", + "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==3.13.2" + }, + "aiosignal": { + "hashes": [ + "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", + "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" + ], + "markers": "python_version >= '3.9'", + "version": "==1.4.0" + }, + "async-timeout": { + "hashes": [ + "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", + "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" + ], + "markers": "python_version < '3.11'", + "version": "==5.0.1" + }, + "attrs": { + "hashes": [ + "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", + "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" + ], + "markers": "python_version >= '3.9'", + "version": "==25.4.0" + }, + "audioop-lts": { + "hashes": [ + "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", + "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", + "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", + "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", + "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", + "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", + "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", + "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", + "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", + "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", + "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", + "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", + "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", + "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", + "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", + "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", + "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", + "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", + "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", + "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", + "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", + "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", + "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", + "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", + "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", + "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", + "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", + "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", + "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", + "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", + "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", + "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", + "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", + "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", + "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", + "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", + "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", + "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", + "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", + "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", + "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", + "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", + "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", + "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", + "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", + "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", + "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", + "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", + "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800" + ], + "markers": "python_version >= '3.13'", + "version": "==0.2.2" + }, + "brotli": { + "hashes": [ + "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", + "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", + "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4", + "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", + "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", + "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470", + "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", + "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", + "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", + "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502", + "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937", + "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7", + "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", + "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", + "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17", + "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc", + "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", + "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971", + "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", + "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d", + "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", + "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", + "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", + "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", + "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", + "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a", + "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", + "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", + "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0", + "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46", + "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", + "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8", + "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", + "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3", + "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a", + "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6", + "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64", + "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", + "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", + "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", + "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5", + "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a", + "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", + "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", + "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", + "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982", + "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f", + "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b", + "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", + "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518", + "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", + "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", + "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16", + "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a", + "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", + "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1", + "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190", + "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", + "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e", + "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", + "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea", + "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8", + "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", + "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", + "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526", + "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", + "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92", + "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12", + "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", + "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8", + "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", + "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", + "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", + "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", + "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", + "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", + "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb", + "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533", + "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8", + "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2", + "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69", + "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96", + "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49", + "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", + "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", + "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f", + "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", + "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7", + "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", + "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", + "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8", + "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990", + "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e", + "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", + "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675", + "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", + "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c", + "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13", + "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", + "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d" + ], + "version": "==1.2.0" + }, + "cairocffi": { + "hashes": [ + "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", + "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f" + ], + "markers": "python_version >= '3.8'", + "version": "==1.7.1" + }, + "cairosvg": { + "hashes": [ + "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", + "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5" + ], + "markers": "python_version >= '3.9'", + "version": "==2.8.2" + }, + "certifi": { + "hashes": [ + "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", + "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.10.5" + }, + "cffi": { + "hashes": [ + "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", + "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", + "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", + "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", + "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", + "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", + "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", + "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", + "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", + "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", + "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", + "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", + "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", + "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", + "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", + "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", + "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", + "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", + "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", + "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", + "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", + "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", + "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", + "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", + "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", + "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", + "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", + "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", + "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", + "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", + "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", + "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", + "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", + "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", + "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", + "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", + "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", + "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", + "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", + "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", + "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", + "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", + "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", + "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", + "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", + "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", + "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", + "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", + "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", + "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", + "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", + "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", + "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", + "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", + "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", + "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", + "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", + "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", + "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", + "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", + "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", + "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", + "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", + "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", + "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", + "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", + "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", + "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", + "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", + "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", + "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", + "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", + "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", + "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", + "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", + "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", + "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", + "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", + "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", + "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", + "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", + "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", + "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", + "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" + ], + "markers": "python_version >= '3.9'", + "version": "==2.0.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "cssselect2": { + "hashes": [ + "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", + "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a" + ], + "markers": "python_version >= '3.9'", + "version": "==0.8.0" + }, + "defusedxml": { + "hashes": [ + "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", + "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.7.1" + }, + "discord.py": { + "extras": [ + "speed" + ], + "hashes": [ + "sha256:69835269d73d9889a2f0efff4c91264a18998db0fdc4295a3c886fe9196dea4e", + "sha256:92bb3ef9dbe08525803be1e357bc0191f59ae16956690fc96c34f40bcd02c649" + ], + "markers": "python_version >= '3.8'", + "version": "==2.6.3" + }, + "dnspython": { + "hashes": [ + "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==2.8.0" + }, + "emoji": { + "hashes": [ + "sha256:8d8b5dec3c507444b58890e598fc895fcec022b3f5acb49497c6ccc5208b8b00", + "sha256:a8468fd836b7ecb6d1eac054c9a591701ce0ccd6c6f7779ad71b66f76664df90" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.0" + }, + "frozenlist": { + "hashes": [ + "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", + "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", + "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", + "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", + "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", + "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", + "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", + "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", + "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", + "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", + "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", + "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", + "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", + "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", + "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", + "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", + "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", + "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", + "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", + "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", + "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", + "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", + "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", + "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", + "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", + "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", + "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", + "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", + "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", + "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", + "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", + "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", + "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", + "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", + "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", + "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", + "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", + "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", + "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", + "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", + "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", + "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", + "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", + "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", + "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", + "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", + "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", + "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", + "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", + "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", + "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", + "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", + "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", + "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", + "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", + "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", + "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", + "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", + "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", + "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", + "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", + "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", + "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", + "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", + "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", + "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", + "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", + "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", + "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", + "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", + "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", + "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", + "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", + "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", + "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", + "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", + "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", + "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", + "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", + "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", + "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", + "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", + "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", + "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", + "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", + "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", + "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", + "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", + "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", + "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", + "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", + "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", + "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", + "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", + "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", + "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", + "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", + "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", + "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", + "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", + "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", + "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", + "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", + "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", + "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", + "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", + "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", + "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", + "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", + "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", + "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", + "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", + "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", + "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", + "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", + "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", + "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", + "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", + "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", + "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", + "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", + "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", + "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", + "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", + "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", + "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", + "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", + "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", + "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", + "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" + ], + "markers": "python_version >= '3.9'", + "version": "==1.8.0" + }, + "idna": { + "hashes": [ + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + ], + "markers": "python_version >= '3.8'", + "version": "==3.11" + }, + "isodate": { + "hashes": [ + "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", + "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9" + ], + "index": "pypi", + "version": "==0.6.1" + }, + "lottie": { + "extras": [ + "pdf" + ], + "hashes": [ + "sha256:85304ac152cd042c9fe98a527b5d136a31c6fe2aacd859ce1c58189bb946d127" + ], + "markers": "python_version >= '3'", + "version": "==0.7.2" + }, + "motor": { + "hashes": [ + "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", + "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==3.7.1" + }, + "multidict": { + "hashes": [ + "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", + "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", + "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", + "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", + "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", + "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", + "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", + "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", + "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", + "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", + "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", + "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", + "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", + "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", + "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", + "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", + "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", + "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", + "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", + "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", + "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", + "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", + "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", + "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", + "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", + "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", + "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", + "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", + "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", + "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", + "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", + "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c", + "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", + "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6", + "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", + "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", + "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84", + "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", + "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", + "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", + "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", + "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", + "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", + "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", + "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", + "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", + "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", + "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", + "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", + "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", + "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", + "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", + "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", + "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4", + "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", + "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", + "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", + "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", + "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", + "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", + "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c", + "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", + "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546", + "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", + "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", + "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", + "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", + "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", + "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", + "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", + "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f", + "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", + "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e", + "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", + "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", + "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", + "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", + "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851", + "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb", + "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32", + "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", + "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", + "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", + "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", + "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", + "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", + "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", + "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5", + "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", + "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", + "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", + "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", + "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", + "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", + "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", + "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", + "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", + "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", + "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", + "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", + "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", + "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", + "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", + "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", + "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", + "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", + "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", + "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", + "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", + "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329", + "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", + "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", + "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", + "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", + "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", + "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", + "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", + "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", + "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", + "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", + "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", + "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91", + "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", + "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", + "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", + "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40", + "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", + "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", + "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", + "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", + "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", + "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", + "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", + "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", + "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", + "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e", + "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", + "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", + "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", + "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", + "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", + "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", + "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687", + "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e", + "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", + "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7" + ], + "markers": "python_version >= '3.9'", + "version": "==6.7.0" + }, + "natural": { + "hashes": [ + "sha256:18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2" + ], + "index": "pypi", + "version": "==0.2.0" + }, + "orjson": { + "hashes": [ + "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9", + "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", + "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", + "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", + "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", + "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10", + "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350", + "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", + "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", + "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13", + "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", + "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc", + "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", + "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac", + "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", + "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c", + "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", + "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", + "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905", + "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907", + "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a", + "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a", + "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b", + "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b", + "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", + "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", + "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", + "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d", + "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", + "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63", + "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39", + "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", + "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc", + "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", + "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", + "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23", + "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", + "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045", + "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", + "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9", + "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", + "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", + "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", + "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", + "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19", + "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549", + "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c", + "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040", + "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", + "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", + "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827", + "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be", + "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", + "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", + "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", + "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea", + "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44", + "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", + "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", + "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", + "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7", + "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f", + "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", + "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", + "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", + "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d", + "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", + "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", + "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", + "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919", + "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", + "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", + "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79", + "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", + "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", + "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175", + "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba", + "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6", + "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a", + "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", + "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1", + "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", + "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", + "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", + "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3", + "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1", + "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff" + ], + "markers": "python_version >= '3.9'", + "version": "==3.11.4" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "parsedatetime": { + "hashes": [ + "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", + "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" + ], + "index": "pypi", + "version": "==2.6" + }, + "pillow": { + "hashes": [ + "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", + "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", + "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", + "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", + "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", + "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", + "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", + "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", + "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", + "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", + "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", + "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", + "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", + "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", + "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", + "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", + "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", + "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", + "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", + "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", + "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", + "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", + "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", + "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", + "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", + "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", + "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", + "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", + "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", + "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", + "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", + "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", + "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", + "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", + "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", + "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", + "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", + "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", + "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", + "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", + "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", + "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", + "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", + "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", + "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", + "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", + "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", + "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", + "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", + "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", + "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", + "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", + "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", + "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", + "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", + "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", + "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", + "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", + "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", + "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", + "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", + "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", + "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", + "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", + "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", + "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", + "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", + "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", + "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", + "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", + "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", + "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", + "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", + "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", + "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", + "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", + "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", + "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", + "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", + "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", + "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", + "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", + "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", + "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", + "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", + "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", + "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", + "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", + "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", + "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", + "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1" + ], + "markers": "python_version >= '3.10'", + "version": "==12.0.0" + }, + "propcache": { + "hashes": [ + "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", + "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", + "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", + "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", + "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", + "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", + "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", + "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", + "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", + "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", + "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", + "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", + "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", + "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", + "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", + "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", + "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", + "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", + "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", + "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", + "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", + "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", + "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", + "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", + "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", + "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", + "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", + "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", + "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", + "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", + "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", + "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", + "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", + "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", + "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", + "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", + "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", + "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", + "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", + "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", + "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", + "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", + "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", + "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", + "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", + "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", + "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", + "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", + "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", + "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", + "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", + "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", + "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", + "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", + "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", + "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", + "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", + "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", + "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", + "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", + "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", + "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", + "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", + "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", + "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", + "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", + "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", + "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", + "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", + "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", + "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", + "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", + "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", + "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", + "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", + "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", + "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", + "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", + "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", + "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", + "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", + "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", + "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", + "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", + "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", + "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", + "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", + "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", + "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", + "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", + "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", + "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", + "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", + "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", + "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", + "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", + "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", + "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", + "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", + "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", + "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", + "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", + "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", + "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", + "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", + "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", + "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", + "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", + "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", + "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", + "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", + "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", + "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", + "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", + "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", + "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", + "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", + "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", + "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", + "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", + "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", + "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781" + ], + "markers": "python_version >= '3.9'", + "version": "==0.4.1" + }, + "pycares": { + "hashes": [ + "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c", + "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84", + "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b", + "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4", + "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf", + "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c", + "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a", + "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266", + "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759", + "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84", + "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642", + "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4", + "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199", + "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21", + "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a", + "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8", + "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6", + "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9", + "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad", + "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00", + "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287", + "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3", + "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3", + "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689", + "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e", + "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad", + "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4", + "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605", + "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e", + "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a", + "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4", + "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34", + "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4", + "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c", + "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26", + "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae", + "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d", + "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47", + "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca", + "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80", + "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa", + "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb", + "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd", + "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766", + "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80", + "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739", + "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197", + "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b", + "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b", + "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461", + "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e", + "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf", + "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b", + "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807", + "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d", + "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83", + "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690", + "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba", + "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566", + "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0", + "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a", + "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", + "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8", + "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36", + "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87", + "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078", + "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240", + "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f", + "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557", + "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed", + "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49", + "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232", + "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401", + "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d", + "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01", + "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0", + "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c", + "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c", + "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5", + "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35", + "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8", + "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28", + "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7", + "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502", + "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85", + "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134", + "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a", + "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2", + "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468", + "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e", + "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d", + "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7" + ], + "markers": "python_version >= '3.9'", + "version": "==4.11.0" + }, + "pycparser": { + "hashes": [ + "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", + "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934" + ], + "markers": "python_version >= '3.8'", + "version": "==2.23" + }, + "pymongo": { + "hashes": [ + "sha256:07bcc36d11252f24fe671e7e64044d39a13d997b0502c6401161f28cc144f584", + "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1", + "sha256:1246a82fa6dd73ac2c63aa7e463752d5d1ca91e0c7a23396b78f21273befd3a7", + "sha256:17d13458baf4a6a9f2e787d95adf8ec50d412accb9926a044bd1c41029c323b2", + "sha256:17fc94d1e067556b122eeb09e25c003268e8c0ea1f2f78e745b33bb59a1209c4", + "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834", + "sha256:21c0a95a4db72562fd0805e2f76496bf432ba2e27a5651f4b9c670466260c258", + "sha256:292fd5a3f045751a823a54cdea75809b2216a62cc5f74a1a96b337db613d46a8", + "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b", + "sha256:2f3d66f7c495efc3cfffa611b36075efe86da1860a7df75522a6fe499ee10383", + "sha256:2fd3b99520f2bb013960ac29dece1b43f2f1b6d94351ca33ba1b1211ecf79a09", + "sha256:300eaf83ad053e51966be1839324341b08eaf880d3dc63ada7942d5912e09c49", + "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e", + "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68", + "sha256:39a13d8f7141294404ce46dfbabb2f2d17e9b1192456651ae831fa351f86fbeb", + "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3", + "sha256:45aebbd369ca79b7c46eaea5b04d2e4afca4eda117b68965a07a9da05d774e4d", + "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259", + "sha256:482ca9b775747562ce1589df10c97a0e62a604ce5addf933e5819dd967c5e23c", + "sha256:49fd6e158cf75771b2685a8a221a40ab96010ae34dd116abd06371dc6c38ab60", + "sha256:4a0a054e9937ec8fdb465835509b176f6b032851c8648f6a5d1b19932d0eacd6", + "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8", + "sha256:58d0f4123855f05c0649f9b8ee083acc5b26e7f4afde137cd7b8dc03e9107ff3", + "sha256:5bf879a6ed70264574d4d8fb5a467c2a64dc76ecd72c0cb467c4464f849c8c77", + "sha256:5c78237e878e0296130e398151b0d4aa6c9eaf82e38fb6e0aaae2029bc7ef0ce", + "sha256:5c85a4c72b7965033f95c94c42dac27d886c01dbc23fe337ccb14f052a0ccc29", + "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08", + "sha256:6a054d282dd922ac400b6f47ea3ef58d8b940968d76d855da831dc739b7a04de", + "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a", + "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53", + "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f", + "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5", + "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385", + "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48", + "sha256:82a490f1ade4ec6a72068e3676b04c126e3043e69b38ec474a87c6444cf79098", + "sha256:86b1b5b63f4355adffc329733733a9b71fdad88f37a9dc41e163aed2130f9abc", + "sha256:89e45d7fa987f4e246cdf43ff001e3f911f73eb19ba9dabc2a6d80df5c97883b", + "sha256:8bd6dd736f5d07a825caf52c38916d5452edc0fac7aee43ec67aba6f61c2dbb7", + "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289", + "sha256:90ad56bd1d769d2f44af74f0fd0c276512361644a3c636350447994412cbc9a1", + "sha256:9483521c03f6017336f54445652ead3145154e8d3ea06418e52cea57fee43292", + "sha256:959ef69c5e687b6b749fbf2140c7062abdb4804df013ae0507caabf30cba6875", + "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8", + "sha256:982107c667921e896292f4be09c057e2f1a40c645c9bfc724af5dd5fb8398094", + "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b", + "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298", + "sha256:9bc9f99e7702fdb0dcc3ff1dd490adc5d20b3941ad41e58f887d4998b9922a14", + "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340", + "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010", + "sha256:b33d59bf6fa1ca1d7d96d4fccff51e41312358194190d53ef70a84c070f5287e", + "sha256:b3a0ec660d61efb91c16a5962ec937011fe3572c4338216831f102e53d294e5c", + "sha256:b63bac343b79bd209e830aac1f5d9d552ff415f23a924d3e51abbe3041265436", + "sha256:bd0497c564b0ae34fb816464ffc09986dd9ca29e2772a0f7af989e472fecc2ad", + "sha256:c4fdd8e6eab8ff77c1c8041792b5f760d48508623cd10b50d5639e73f1eec049", + "sha256:c57dad9f289d72af1d7c47a444c4d9fa401f951cedbbcc54c7dd0c2107d6d786", + "sha256:c7eb497519f42ac89c30919a51f80e68a070cfc2f3b0543cac74833cd45a6b9c", + "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b", + "sha256:d09d895c7f08bcbed4d2e96a00e52e9e545ae5a37b32d2dc10099b205a21fc6d", + "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37", + "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992", + "sha256:dc583a1130e2516440b93bb2ecb55cfdac6d5373615ae472a9d1f26801f58749", + "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4", + "sha256:de3bc878c3be54ae41c2cabc9e9407549ed4fec41f4e279c04e840dddd7c630c", + "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca", + "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7", + "sha256:f6b0513e5765fdde39f36e6a29a36c67071122b5efa748940ae51075beb5e4bc", + "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95", + "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d", + "sha256:fe4bcb8acfb288e238190397d4a699aeb4adb70e8545a6f4e44f99d4e8096ab1", + "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6", + "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.3" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==2.8.2" + }, + "python-dotenv": { + "hashes": [ + "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", + "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.0.0" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "setuptools": { + "hashes": [ + "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", + "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==80.9.0" + }, + "six": { + "hashes": [ + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" + }, + "tinycss2": { + "hashes": [ + "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", + "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + }, + "uvloop": { + "hashes": [ + "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772", + "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", + "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743", + "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54", + "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", + "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659", + "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", + "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", + "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7", + "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", + "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", + "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", + "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", + "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", + "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", + "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193", + "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", + "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", + "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", + "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", + "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", + "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242", + "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", + "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", + "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6", + "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", + "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", + "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", + "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", + "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", + "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", + "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa", + "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", + "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", + "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", + "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", + "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4", + "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", + "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", + "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", + "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", + "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", + "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820", + "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", + "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", + "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", + "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c", + "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", + "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" + ], + "markers": "sys_platform != 'win32'", + "version": "==0.22.1" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "yarl": { + "hashes": [ + "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", + "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", + "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", + "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", + "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", + "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", + "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", + "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", + "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", + "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", + "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", + "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", + "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", + "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", + "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", + "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", + "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", + "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", + "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", + "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", + "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", + "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", + "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", + "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", + "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", + "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", + "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", + "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", + "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", + "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", + "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", + "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", + "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", + "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", + "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", + "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", + "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", + "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", + "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", + "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", + "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", + "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", + "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", + "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", + "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", + "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", + "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", + "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", + "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", + "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", + "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", + "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", + "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", + "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", + "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", + "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", + "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", + "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", + "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", + "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", + "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", + "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", + "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", + "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", + "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", + "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", + "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", + "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", + "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", + "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", + "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", + "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", + "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", + "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", + "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", + "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", + "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", + "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", + "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", + "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", + "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", + "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", + "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", + "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", + "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", + "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", + "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", + "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", + "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", + "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", + "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", + "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", + "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", + "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", + "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", + "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", + "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", + "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", + "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", + "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", + "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", + "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", + "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", + "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", + "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", + "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", + "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", + "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", + "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", + "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", + "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", + "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", + "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", + "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", + "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", + "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", + "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", + "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", + "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", + "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", + "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", + "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", + "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", + "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", + "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", + "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", + "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", + "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", + "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", + "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" + ], + "markers": "python_version >= '3.9'", + "version": "==1.22.0" + }, + "zstandard": { + "hashes": [ + "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", + "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", + "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3", + "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", + "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", + "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936", + "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", + "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", + "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", + "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", + "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", + "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", + "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", + "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", + "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362", + "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649", + "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb", + "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", + "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", + "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", + "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", + "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", + "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", + "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", + "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", + "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", + "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860", + "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", + "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53", + "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", + "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", + "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", + "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", + "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2", + "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", + "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", + "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", + "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388", + "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530", + "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577", + "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", + "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", + "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", + "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", + "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", + "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", + "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", + "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", + "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", + "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", + "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", + "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", + "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", + "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", + "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", + "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", + "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27", + "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", + "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", + "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", + "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", + "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", + "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", + "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", + "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", + "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", + "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", + "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", + "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df", + "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", + "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", + "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", + "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", + "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0", + "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", + "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", + "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", + "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7", + "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", + "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", + "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", + "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751", + "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", + "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", + "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", + "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", + "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", + "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", + "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", + "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", + "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", + "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", + "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", + "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", + "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", + "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", + "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", + "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", + "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01" + ], + "markers": "python_version >= '3.9'", + "version": "==0.25.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:4148645659b08b70d72460ed1921158027a9e53ae8b7234149b1400eddacbb93", + "sha256:92fcf218b89f449cdf9f7b39a269f8d5d617b27be68434912e11e79203963a17" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.3" + }, + "bandit": { + "hashes": [ + "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0", + "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.8.6" + }, + "black": { + "hashes": [ + "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", + "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", + "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", + "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", + "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", + "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", + "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", + "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", + "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", + "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", + "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", + "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", + "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", + "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", + "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", + "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", + "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", + "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==23.11.0" + }, + "click": { + "hashes": [ + "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", + "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + ], + "markers": "python_version >= '3.10'", + "version": "==8.3.0" + }, + "dill": { + "hashes": [ + "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", + "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049" + ], + "markers": "python_version >= '3.8'", + "version": "==0.4.0" + }, + "isort": { + "hashes": [ + "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", + "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.13.2" + }, + "markdown-it-py": { + "hashes": [ + "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", + "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "mypy-extensions": { + "hashes": [ + "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", + "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" + ], + "markers": "python_version >= '3.8'", + "version": "==1.1.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", + "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3" + ], + "markers": "python_version >= '3.10'", + "version": "==4.5.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pylint": { + "hashes": [ + "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496", + "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.2" + }, + "pyyaml": { + "hashes": [ + "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", + "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", + "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", + "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", + "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", + "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", + "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", + "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", + "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", + "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", + "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", + "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", + "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", + "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", + "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", + "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", + "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", + "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", + "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", + "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", + "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", + "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", + "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", + "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", + "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", + "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", + "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", + "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", + "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", + "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", + "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", + "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", + "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", + "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", + "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", + "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", + "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", + "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", + "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", + "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", + "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", + "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", + "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", + "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", + "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", + "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", + "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", + "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", + "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", + "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", + "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", + "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", + "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", + "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", + "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", + "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", + "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", + "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", + "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", + "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", + "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", + "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", + "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", + "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", + "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", + "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", + "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", + "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", + "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", + "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", + "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", + "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", + "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" + ], + "markers": "python_version >= '3.8'", + "version": "==6.0.3" + }, + "rich": { + "hashes": [ + "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", + "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==14.2.0" + }, + "stevedore": { + "hashes": [ + "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", + "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73" + ], + "markers": "python_version >= '3.9'", + "version": "==5.5.0" + }, + "tomli": { + "hashes": [ + "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", + "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", + "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", + "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", + "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", + "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", + "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", + "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", + "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", + "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", + "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", + "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", + "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", + "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", + "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", + "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", + "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", + "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", + "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", + "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", + "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", + "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", + "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", + "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", + "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", + "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", + "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", + "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", + "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", + "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", + "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", + "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.2.1" + }, + "tomlkit": { + "hashes": [ + "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", + "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0" + ], + "markers": "python_version >= '3.8'", + "version": "==0.13.3" + } + } +} diff --git a/README.md b/README.md index f8e6b07e98..8f78406ea9 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,168 @@

- A simple and functional Modmail bot for Discord. + A feature-rich Modmail bot for Discord.

- - - + + + + +
- - Support + + - - - Bot instances + + + Bot instances - - - Python 3.7 + + + Support - - - MIT License + + + Buy Me A Coffee + + + Made with Python 3.10 + + + + + + + + MIT License + + +
+
---- -## How Does Modmail Work? +## What is Modmail? + +Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way. - +This bot is free for everyone and always will be. If you like this project and would like to show your appreciation, you can support us on **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**, cool benefits included! -When a user sends a direct message to the bot, a channel is created within an isolated category. This channel is where messages will be relayed. To reply to the message, simply use the command `?reply` in the channel. A full list of commands can be found in the [wiki](https://github.com/kyb3r/modmail/wiki) or by using the `?help` command. +For up-to-date setup instructions, please visit our [**documentation**](https://docs.modmail.dev/installation) page. + +## How does it work? + +When a member sends a direct message to the bot, Modmail will create a channel or "thread" into a designated category. All further DM messages will automatically relay to that channel; any available staff can respond within the channel. + +Our Logviewer will save the threads so you can view previous threads through their corresponding log link. ~~Here is an [**example**](https://logs.modmail.dev/example)~~ (demo not available at the moment). + +## Features + +* **Highly Customisable:** + * Bot activity, prefix, category, log channel, etc. + * Command permission system. + * Interface elements (color, responses, reactions, etc.). + * Snippets and *command aliases*. + * Minimum duration for accounts to be created before allowed to contact Modmail (`account_age`). + * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). + +* **Advanced Logging Functionality:** + * When you close a thread, Modmail will generate a log link and post it to your log channel. + * Native Discord dark-mode feel. + * Markdown/formatting support. + * Login via Discord to protect your logs ([premium feature](https://buymeacoffee.com/modmaildev/membership)). + * See past logs of a user with `?logs`. + * Searchable by text queries using `?logs search`. + +* **Robust implementation:** + * Schedule tasks in human time, e.g. `?close in 2 hours silently`. + * Editing and deleting messages are synced. + * Support for the diverse range of message contents (multiple images, files). + * Paginated commands interfaces via reactions. + +This list is ever-growing thanks to active development and our exceptional contributors. See a full list of documented commands by using the `?help` command. ## Installation -Currently, the easiest and fastest way to set up the bot is by using Heroku, which is a service that offers a free plan for hosting applications. If you choose to install the bot using Heroku, you will not need to download anything. The [**installation guide**](https://github.com/kyb3r/modmail/wiki/Installation) will guide you through the entire installation process. If you run into any problems, join our [Discord server](https://discord.gg/etJNHCQ) for help and support. Even if you don't have any issues, you should come and check out our awesome Discord community! :wink: +There are a number of options for hosting your very own dedicated Modmail bot. + +Visit our [**documentation**](https://docs.modmail.dev/installation) page for detailed guidance on how to deploy your Modmail bot. + +### Paid Hosting + +If you don't want the trouble of renting and configuring your server to host Modmail, we got a solution for you! We offer hosting and maintenance of your own, private Modmail bot (including a Logviewer) through [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). + +## FAQ + +**Q: Where can I find the Modmail bot invite link?** + +**A:** Unfortunately, due to how this bot functions, it cannot be invited. The lack of an invite link is to ensure an individuality to your server and grant you full control over your bot and data. Nonetheless, you can quickly obtain a free copy of Modmail for your server by following our [**documentation**](https://docs.modmail.dev/installation) steps or subscribe to [**Buy Me A Coffee**](https://buymeacoffee.com/modmaildev/membership). + +**Q: Where can I find out more info about Modmail?** + +**A:** You can find more info about Modmail on our [**documentation**](https://docs.modmail.dev) page. If you run into any problems, join our [Modmail Discord Server](https://discord.gg/cnUpwrnpYb) for help and support. + +## Plugins -Interactive installation: [**https://taaku18.github.io/modmail/installation**](https://taaku18.github.io/modmail/installation) +Modmail supports the use of third-party plugins to extend or add functionalities to the bot. +Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. ---- +You can find a list of third-party plugins using the `?plugins registry` command or visit the [Unofficial List of Plugins](https://github.com/modmail-dev/modmail/wiki/Unofficial-List-of-Plugins) for a list of plugins contributed by the community. -# Notable Features +To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). -## Customizability +Plugins requests and support are available in our [Modmail Support Server](https://discord.gg/cnUpwrnpYb). -Modmail has a range of configuration variables that you can dynamically alter with the `?config` command. You can use them to change the different aspects of the bot, for example, the embed color, responses, reactions, status, etc. Snippets and custom command aliases are also supported. Snippets are shortcuts for predefined messages that you can send. Add or remove snippets with the `?snippets` command. The level of customization is ever growing thanks to our exceptional contributors. +## Sponsors -## Linked Messages +Special thanks to our sponsors for supporting the project. - +SirReddit: +
+ + + +
+
+Prime Servers Inc: +
+ + + +
+
+Real Madrid: +
+ + + +
+
+Advertise Your Server: +
+ + + +
+
+Discord Advice Center: +
+ + + -Have you sent something with the `?reply` command by accident? Don't fret, you can delete your original message, and the bot will automatically delete the corresponding message sent to the recipient of the thread! You can also use the `?edit` command to edit a message you sent. +Become a sponsor on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev/membership). -## Thread Logs +## Contributing -Thread conversations are automatically logged with a generated viewable website of the complete thread. Logs are rendered with styled HTML and presented in an aesthetically pleasing way—it blends seamlessly with the mobile version of Discord. An example of a logged conversation: https://logs.modmail.tk/02032d65a6f3 +Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free to make the change. Check out our [contributing guidelines](https://github.com/modmail-dev/modmail/blob/master/.github/CONTRIBUTING.md) before you get started. +If you like this project and would like to show your appreciation, support us on **[Buy Me A Coffee](https://buymeacoffee.com/modmaildev)**! -# Contributing +## Beta Testing -This project is licenced under MIT. If you have any new ideas, create an issue or a pull request. Contributions to Modmail are always welcome, whether it be improvements to the documentation or new functionality, please feel free make the change. +Our [development](https://github.com/modmail-dev/modmail/tree/development) branch is where most of our features are tested before public release. Be warned that there could be bugs in various commands so keep it away from any large servers you manage. -If you use Modmail and love it, consider supporting me on **[Patreon](https://www.patreon.com/kyber)** :heart: +If you wish to test the new features and play around with them, feel free to join our [Public Test Server](https://discord.gg/v5hTjKC). Bugs can be raised within that server or in our Github issues (state that you are using the development branch though). diff --git a/SPONSORS.json b/SPONSORS.json new file mode 100644 index 0000000000..cdaf3621ac --- /dev/null +++ b/SPONSORS.json @@ -0,0 +1,135 @@ +[ + { + "embed": { + "description": "This is a youtube channel that provides high quality reddit content. Videos are uploaded regularly so you are never out of funny, interesting or even creepy content!", + "color": 15114050, + "footer": { + "icon_url": "https://i.imgur.com/fvNKUks.png", + "text": "I am a robot, son of Daniel (UK)" + }, + "thumbnail": { + "url": "https://i.imgur.com/WyzaPKY.png" + }, + "author": { + "name": "Sir Reddit", + "url": "https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1", + "icon_url": "https://i.imgur.com/WyzaPKY.png" + }, + "fields": [ + { + "name": "Subscribe!", + "value": "[**Click Here**](https://www.youtube.com/channel/UCgSmBJD9imASmJRleycTCwQ?sub_confirmation=1)" + }, + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/V8ErqHb)" + } + ] + } + }, + { + "embed": { + "title": "Berkand Karadere", + "description": "Berkand Karadere is an German Community Manager who integrated new systems into the game industry. He also is hosting and developing web servers and game servers. He also plays American Football for the Dortmund Giants and his journey has just begun.", + "color": 2968248, + "thumbnail": { + "url": "https://i.imgur.com/cs2QEcp.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click here**](https://discord.gg/BanCwptMJV)" + } + ] + } + }, + { + "embed": { + "description": "Quality Hosting at Prices You Deserve!", + "color": 3137203, + "footer": { + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png", + "text": "Prime Servers, Inc." + }, + "thumbnail": { + "url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" + }, + "author": { + "name": "Prime Servers, Inc.", + "url": "https://primeserversinc.com", + "icon_url": "https://primeserversinc.com/images/Prime_Logo_P_Sassy.png" + }, + "fields": [ + { + "name": "Twitter", + "value": "[**Click Here**](https://twitter.com/PrimeServersInc)" + }, + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/cYM6Urn)" + } + ] + } + }, + { + "embed": { + "description": "──── 《𝐃𝐢𝐬𝐜𝐨𝐫𝐝 𝐀𝐝𝐯𝐢𝐜𝐞 𝐂𝐞𝐧𝐭𝐞𝐫 》 ────\n\n◈ We are a server aimed to meet your discord needs. We have tools, tricks and tips to grow your server and advertise your server. We offer professional server reviews and suggestions how to run it successfully as a part of our courtesy. Join the server and get the chance to add our very own BUMP BOT called DAC Advertise where you can advertise your server to other servers!\n", + "color": 53380, + "author": { + "name": "Discord Advice Center", + "url": "https://discord.gg/nkMDQfuK", + "icon_url": "https://i.imgur.com/cjVtRw5.jpg" + }, + "image": { + "url": "https://i.imgur.com/1hrjcHd.png" + }, + "fields": [ + { + "name": "Discord Server", + "value": "[**Click Here**](https://discord.gg/zmwZy5fd9v)" + } + ] + } + }, + { + "embed": { + "footer": { + "text": "Join noch heute!" + }, + "thumbnail": { + "url": "https://i.imgur.com/bp0xfyK.png" + }, + "fields": [ + { + "inline": false, + "name": "Viele Verschiedene Talks", + "value": "Gro\u00dfe Community\nGewinnspiele" + } + ], + "color": 61532, + "description": "Die etwas andere Community", + "url": "https://discord.gg/uncommon", + "title": "uncommon community" + } + }, + { + "embed": { + "description": "> Be apart of our community as we start to grow! and embark on a long journey.\n——————————————————-\n**What we offer?**\n\n➺〚🖌️〛Custom Liveries \n➺〚❤️〛Friendly and Growing community.\n➺〚🤝〛Partnerships.\n➺〚🎮〛Daily SSUs. \n➺〚🚨〛Great roleplays.\n➺〚💬〛Kind and Professional staff\n➺〚🎉〛Giveaways!!! \n——————————————————-\n**Emergency Services**\n\n➺〚🚔〛NY Police Force\n➺〚🚒〛Fire & Emergency NY\n➺〚🚧〛NY department of transportation \n\n——————————————————-\n**Whitelisted**\nComing soon!\n——————————————————-\n**What are we looking for!**\n\n➺〚💬〛More members\n➺〚⭐〛Staff Members - **WE'RE HIRING!**\n➺〚🤝〛Partnerships\n➺〚💎〛Boosters\n——————————————————\n\n**[Join now](https://discord.com/invite/qt62qSnKVa)**", + "author": { + "name": "New York Roleplay", + "icon_url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + }, + "color": 431075, + "thumbnail": { + "url": "https://cdn.discordapp.com/icons/1172553254882775111/648d5bc50393a21216527a1aaa61286d.webp" + } + } + }, + { + "embed": { + "title": "CityStore PLC", + "description": "*Your Retail Journey*\n*\"Better choice and better value in food, fashion & homewares.\"*\n\n\n**------------------------------------------**\n*__About us__*\nSupermarket, CityStore PLC! Attend a training to become staff!\n\nThis game is currently in V3\n\nWe have a training Centre and applications center!\n\n**------------------------------------------**\n\n> *❤️ Don't hesitate! Dive into the excitement today by joining our vibrant community on Discord. Experience our unique perspective and become an integral part of our group. Your **journey** with us promises to be unforgettable no regrets, only great memories await! ❤️*\n\n*We hope to see you. *\n\n*Signed,*\n**CityStore PLC**\n> Discord: https://discord.gg/yjFQb5mrSk\n> Roblox Group: https://www.roblox.com/groups/32819373/CityStore-PLC#!/about\n\nJoin us now and become apart of Citystore PLC community! 🎉", + "color": 15523550 + } + } +] diff --git a/app.json b/app.json index 8d16cd75c2..decd58695c 100644 --- a/app.json +++ b/app.json @@ -1,31 +1,39 @@ { - "name": "Modmail", - "description": "An easy to install Modmail bot for Discord - DM to contact mods!", - "repository": "https://github.com/kyb3r/modmail", - "env": { - "TOKEN": { - "description": "Your discord bot's token.", - "required": true - }, - "GUILD_ID": { - "description": "The id for the server you are hosting this bot for.", - "required": true - }, - "OWNERS": { - "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval and update).", - "required": true - }, - "GITHUB_ACCESS_TOKEN": { - "description": "Your personal access token for GitHub, adding this gives you the ability to use the 'update' command, which will sync your fork with the main repo.", - "required": false - }, - "MONGO_URI": { - "description": "Mongo DB connection URI for self-hosting your data.", - "required": true - }, - "LOG_URL": { - "description": "The url of the log viewer app for viewing self-hosted logs.", - "required": true + "name": "Modmail", + "description": "An easy to install Modmail bot for Discord - DM to contact mods!", + "repository": "https://github.com/modmail-dev/modmail", + "env": { + "TOKEN": { + "description": "Your discord bot's token.", + "required": true + }, + "GUILD_ID": { + "description": "The id for the server you are hosting this bot for.", + "required": true + }, + "OWNERS": { + "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", + "required": true + }, + "CONNECTION_URI": { + "description": "The connection URI for your database.", + "required": true + }, + "DATABASE_TYPE": { + "description": "The type of your database. There is only one supported database at the moment - MongoDB (default).", + "required": false + }, + "LOG_URL": { + "description": "The url of the log viewer app for viewing self-hosted logs.", + "required": true + }, + "GITHUB_TOKEN": { + "description": "A github personal access token with the repo scope.", + "required": false + }, + "REGISTRY_PLUGINS_ONLY": { + "description": "If set to true, only plugins that are in the registry can be loaded.", + "required": false + } } - } } diff --git a/bot.py b/bot.py index 56502badac..6176ac5824 100644 --- a/bot.py +++ b/bot.py @@ -1,268 +1,413 @@ -""" -MIT License +__version__ = "4.2.1" -Copyright (c) 2017-2019 kyb3r - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -__version__ = '2.17.2' import asyncio -import logging +import copy +import hashlib import os import re +import string +import struct import sys - -from datetime import datetime +import platform +import typing +from datetime import datetime, timezone +from subprocess import PIPE from types import SimpleNamespace import discord -from discord.ext import commands +import isodate +from aiohttp import ClientSession, ClientResponseError +from discord.ext import commands, tasks from discord.ext.commands.view import StringView +from emoji import is_emoji +from packaging.version import Version -import isodate -from aiohttp import ClientSession -from colorama import init, Fore, Style -from emoji import UNICODE_EMOJI -from motor.motor_asyncio import AsyncIOMotorClient +try: + # noinspection PyUnresolvedReferences + from colorama import init + init() +except ImportError: + pass + +from core import checks from core.changelog import Changelog -from core.clients import ModmailApiClient, SelfHostedClient, PluginDatabaseClient +from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient from core.config import ConfigManager -from core.utils import info, error -from core.models import Bot +from core.models import ( + DMDisabled, + HostingMethod, + InvalidConfigError, + PermissionLevel, + SafeFormatter, + configure_logging, + getLogger, +) from core.thread import ThreadManager from core.time import human_timedelta +from core.utils import ( + extract_block_timestamp, + normalize_alias, + parse_alias, + truncate, + tryint, + human_join, + extract_forwarded_content, +) +logger = getLogger(__name__) -init() - -logger = logging.getLogger('Modmail') -logger.setLevel(logging.INFO) - -ch = logging.StreamHandler(stream=sys.stdout) -ch.setLevel(logging.INFO) -formatter = logging.Formatter('%(filename)s - %(levelname)s: %(message)s') -ch.setFormatter(formatter) -logger.addHandler(ch) - - -class FileFormatter(logging.Formatter): - ansi_escape = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]') - - def format(self, record): - record.msg = self.ansi_escape.sub('', record.msg) - return super().format(record) - - -temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp') +temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") if not os.path.exists(temp_dir): os.mkdir(temp_dir) -ch_debug = logging.FileHandler( - os.path.join(temp_dir, 'logs.log'), - mode='a+' -) - -ch_debug.setLevel(logging.DEBUG) -formatter_debug = FileFormatter('%(asctime)s %(filename)s - ' - '%(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S') -ch_debug.setFormatter(formatter_debug) -logger.addHandler(ch_debug) - -LINE = Fore.BLACK + Style.BRIGHT + '-------------------------' + \ - Style.RESET_ALL - +if sys.platform == "win32": + try: + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + except AttributeError: + logger.error("Failed to use WindowsProactorEventLoopPolicy.", exc_info=True) -class ModmailBot(Bot): +class ModmailBot(commands.Bot): def __init__(self): - super().__init__(command_prefix=None) # implemented in `get_prefix` - self._threads = None - self._session = None - self._config = None - self._db = None + self.config = ConfigManager(self) + self.config.populate_cache() + + intents = discord.Intents.all() + if not self.config["enable_presence_intent"]: + intents.presences = False + + super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` + self.session = None + self._api = None + self.formatter = SafeFormatter() + self.loaded_cogs = [ + "cogs.modmail", + "cogs.plugins", + "cogs.utility", + "cogs.threadmenu", + ] + self._connected = None + self.start_time = discord.utils.utcnow() + self._started = False + + self.threads = ThreadManager(self) + self._message_queues = {} # User ID -> asyncio.Queue for message ordering + + log_dir = os.path.join(temp_dir, "logs") + if not os.path.exists(log_dir): + os.mkdir(log_dir) + self.log_file_path = os.path.join(log_dir, "modmail.log") + configure_logging(self) + + self.plugin_db = PluginDatabaseClient(self) # Deprecated + self.startup() + + def get_guild_icon( + self, + guild: typing.Optional[discord.Guild], + *, + size: typing.Optional[int] = None, + ) -> str: + if guild is None: + guild = self.guild + if guild.icon is None: + return "https://cdn.discordapp.com/embed/avatars/0.png" + if size is None: + return guild.icon.url + return guild.icon.with_size(size).url + + def _resolve_snippet(self, name: str) -> typing.Optional[str]: + """ + Get actual snippet names from direct aliases to snippets. - self._configure_logging() + 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 self.self_hosted: - self._db = AsyncIOMotorClient(self.config.mongo_uri).modmail_bot - self._api = SelfHostedClient(self) - else: - self._api = ModmailApiClient(self) - self.plugin_db = PluginDatabaseClient(self) - - self.data_task = self.loop.create_task(self.data_loop()) - self.autoupdate_task = self.loop.create_task(self.autoupdate_loop()) - self._load_extensions() - self.owner = None - - def _configure_logging(self): - level_text = self.config.log_level.upper() - logging_levels = { - 'CRITICAL': logging.CRITICAL, - 'ERROR': logging.ERROR, - 'WARNING': logging.WARNING, - 'INFO': logging.INFO, - 'DEBUG': logging.DEBUG, - } + If no snippets were found, None is returned. + """ + if name in self.snippets: + return name - log_level = logging_levels.get(level_text) - logger.info(LINE) - if log_level is not None: - logger.setLevel(log_level) - ch.setLevel(log_level) - logger.info(info('Logging level: ' + level_text)) + 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: - logger.info(error('Invalid logging level set. ')) - logger.info(info('Using default logging level: INFO')) + if command in self.snippets: + return command @property - def version(self): - return __version__ + def uptime(self) -> str: + now = discord.utils.utcnow() + delta = now - self.start_time + hours, remainder = divmod(int(delta.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + days, hours = divmod(hours, 24) - @property - def db(self): - return self._db + fmt = "{h}h {m}m {s}s" + if days: + fmt = "{d}d " + fmt - @property - def self_hosted(self): - return bool(self.config.get('mongo_uri', '')) + return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) @property - def api(self): - return self._api + def hosting_method(self) -> HostingMethod: + # use enums + if ".heroku" in os.environ.get("PYTHONHOME", ""): + return HostingMethod.HEROKU + + if os.environ.get("pm_id"): + return HostingMethod.PM2 + + if os.environ.get("INVOCATION_ID"): + return HostingMethod.SYSTEMD + + if os.environ.get("USING_DOCKER"): + return HostingMethod.DOCKER + + if os.environ.get("TERM"): + return HostingMethod.SCREEN + + return HostingMethod.OTHER + + def startup(self): + logger.line() + logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") + logger.info("││││ │ │││││├─┤││") + logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") + logger.info("v%s", __version__) + logger.info("Authors: kyb3r, fourjr, Taaku18") + logger.line() + logger.info("discord.py: v%s", discord.__version__) + logger.line() + + async def load_extensions(self): + for cog in self.loaded_cogs: + if cog in self.extensions: + continue + logger.debug("Loading %s.", cog) + try: + await self.load_extension(cog) + logger.debug("Successfully loaded %s.", cog) + except Exception: + logger.exception("Failed to load %s.", cog) + logger.line("debug") @property - def config(self): - if self._config is None: - self._config = ConfigManager(self) - return self._config + def version(self): + return Version(__version__) @property - def session(self): - if self._session is None: - self._session = ClientSession(loop=self.loop) - return self._session + def api(self) -> ApiClient: + if self._api is None: + if self.config["database_type"].lower() == "mongodb": + self._api = MongoDBClient(self) + else: + logger.critical("Invalid database type.") + raise RuntimeError + return self._api @property - def threads(self): - if self._threads is None: - self._threads = ThreadManager(self) - return self._threads + def db(self): + # deprecated + return self.api.db async def get_prefix(self, message=None): - return [self.prefix, f'<@{self.user.id}> ', f'<@!{self.user.id}> '] - - def _load_extensions(self): - """Adds commands automatically""" - self.remove_command('help') - - logger.info(LINE) - logger.info(info('┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬')) - logger.info(info('││││ │ │││││├─┤││')) - logger.info(info('┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘')) - logger.info(info(f'v{__version__}')) - logger.info(info('Authors: kyb3r, fourjr, Taaku18')) - logger.info(LINE) - - for file in os.listdir('cogs'): - if not file.endswith('.py'): - continue - cog = f'cogs.{file[:-3]}' - logger.info(info(f'Loading {cog}')) - try: - self.load_extension(cog) - except Exception: - logger.exception(error(f'Failed to load {cog}')) + return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] + + def run(self): + async def runner(): + async with self: + self._connected = asyncio.Event() + self.session = ClientSession(loop=self.loop) + + if self.config["enable_presence_intent"]: + logger.info("Starting bot with presence intent.") + else: + logger.info("Starting bot without presence intent.") + + try: + await self.start(self.token) + except discord.PrivilegedIntentsRequired: + logger.critical( + "Privileged intents are not explicitly granted in the discord developers dashboard." + ) + except discord.LoginFailure: + logger.critical("Invalid token") + except Exception: + logger.critical("Fatal exception", exc_info=True) + finally: + if self.session: + await self.session.close() + if not self.is_closed(): + await self.close() + + async def _cancel_tasks(): + async with self: + task_retriever = asyncio.all_tasks + loop = self.loop + tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} + + if not tasks: + return + + logger.info("Cleaning up after %d tasks.", len(tasks)) + for task in tasks: + task.cancel() + + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("All tasks finished cancelling.") + + for task in tasks: + try: + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "Unhandled exception during Client.run shutdown.", + "exception": task.exception(), + "task": task, + } + ) + except (asyncio.InvalidStateError, asyncio.CancelledError): + pass - async def is_owner(self, user): - allowed = {int(x) for x in - str(self.config.get('owners', '0')).split(',')} - return user.id in allowed - - def run(self, *args, **kwargs): try: - self.loop.run_until_complete(self.start(self.token)) - except discord.LoginFailure: - logger.critical(error('Invalid token')) - except KeyboardInterrupt: - pass - except Exception: - logger.critical(error('Fatal exception'), exc_info=True) + asyncio.run(runner(), debug=bool(os.getenv("DEBUG_ASYNCIO"))) + except (KeyboardInterrupt, SystemExit): + logger.info("Received signal to terminate bot and event loop.") finally: - try: - self.data_task.cancel() - self.loop.run_until_complete(self.data_task) - except asyncio.CancelledError: - logger.debug('data_task has been cancelled') + logger.info("Cleaning up tasks.") try: - self.autoupdate_task.cancel() - self.loop.run_until_complete(self.autoupdate_task) - except asyncio.CancelledError: - logger.debug('autoupdate_task has been cancelled') - - self.loop.run_until_complete(self.logout()) - for task in asyncio.Task.all_tasks(): - task.cancel() - try: - self.loop.run_until_complete( - asyncio.gather(*asyncio.Task.all_tasks()) - ) - except asyncio.CancelledError: - logger.debug('All pending tasks has been cancelled') + cancel_tasks_coro = _cancel_tasks() + asyncio.run(cancel_tasks_coro) finally: - self.loop.run_until_complete(self.session.close()) - self.loop.close() - logger.info(error(' - Shutting down bot - ')) + logger.info("Closing the event loop.") + + @property + def bot_owner_ids(self): + owner_ids = self.config["owners"] + if owner_ids is not None: + owner_ids = set(map(int, str(owner_ids).split(","))) + if self.owner_id is not None: + owner_ids.add(self.owner_id) + permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) + for perm in permissions: + owner_ids.add(int(perm)) + return owner_ids + + async def is_owner(self, user: discord.User) -> bool: + if user.id in self.bot_owner_ids: + return True + return await super().is_owner(user) @property - def log_channel(self): - channel_id = self.config.get('log_channel_id') + def log_channel(self) -> typing.Optional[discord.TextChannel]: + channel_id = self.config["log_channel_id"] if channel_id is not None: - return self.get_channel(int(channel_id)) + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("LOG_CHANNEL_ID was invalid, removed.") + self.config.remove("log_channel_id") if self.main_category is not None: - return self.main_category.channels[0] + try: + channel = self.main_category.channels[0] + self.config["log_channel_id"] = channel.id + logger.warning( + "No log channel set, setting #%s to be the log channel.", + channel.name, + ) + return channel + except IndexError: + pass + logger.warning( + "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id `.", + self.prefix, + self.prefix, + ) return None @property - def snippets(self): - return {k: v for k, v in self.config.get('snippets', {}).items() if v} + def mention_channel(self): + channel_id = self.config["mention_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("MENTION_CHANNEL_ID was invalid, removed.") + self.config.remove("mention_channel_id") + + return self.log_channel + + @property + def update_channel(self): + channel_id = self.config["update_channel_id"] + if channel_id is not None: + try: + channel = self.get_channel(int(channel_id)) + if channel is not None: + return channel + except ValueError: + pass + logger.debug("UPDATE_CHANNEL_ID was invalid, removed.") + self.config.remove("update_channel_id") + + return self.log_channel + + async def wait_for_connected(self) -> None: + await self.wait_until_ready() + await self._connected.wait() + await self.config.wait_until_ready() + + @property + def snippets(self) -> typing.Dict[str, str]: + return self.config["snippets"] @property - def aliases(self): - return {k: v for k, v in self.config.get('aliases', {}).items() if v} + def aliases(self) -> typing.Dict[str, str]: + return self.config["aliases"] @property - def token(self): - return self.config.token + def auto_triggers(self) -> typing.Dict[str, str]: + return self.config["auto_triggers"] @property - def guild_id(self): - return int(self.config.guild_id) + def token(self) -> str: + token = self.config["token"] + if token is None: + logger.critical("TOKEN must be set, set this as bot token found on the Discord Developer Portal.") + sys.exit(0) + return token @property - def guild(self): + def guild_id(self) -> typing.Optional[int]: + guild_id = self.config["guild_id"] + if guild_id is not None: + try: + return int(str(guild_id)) + except ValueError: + self.config.remove("guild_id") + logger.critical("Invalid GUILD_ID set.") + else: + logger.debug("No GUILD_ID set.") + return None + + @property + def guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is serving (the server where users message it from) @@ -270,304 +415,985 @@ def guild(self): return discord.utils.get(self.guilds, id=self.guild_id) @property - def modmail_guild(self): + def modmail_guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is operating in (where the bot is creating threads) """ - modmail_guild_id = self.config.get('modmail_guild_id') - if not modmail_guild_id: + modmail_guild_id = self.config["modmail_guild_id"] + if modmail_guild_id is None: return self.guild - return discord.utils.get(self.guilds, id=int(modmail_guild_id)) + try: + guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) + if guild is not None: + return guild + except ValueError: + pass + self.config.remove("modmail_guild_id") + logger.critical("Invalid MODMAIL_GUILD_ID set.") + return self.guild @property - def using_multiple_server_setup(self): + def using_multiple_server_setup(self) -> bool: return self.modmail_guild != self.guild @property - def main_category(self): - category_id = self.config.get('main_category_id') - if category_id is not None: - return discord.utils.get(self.modmail_guild.categories, - id=int(category_id)) - - if self.modmail_guild: - return discord.utils.get(self.modmail_guild.categories, - name='Modmail') + def main_category(self) -> typing.Optional[discord.CategoryChannel]: + if self.modmail_guild is not None: + category_id = self.config["main_category_id"] + if category_id is not None: + try: + cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) + if cat is not None: + return cat + except ValueError: + pass + self.config.remove("main_category_id") + logger.debug("MAIN_CATEGORY_ID was invalid, removed.") + cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") + if cat is not None: + self.config["main_category_id"] = cat.id + logger.debug( + 'No main category set explicitly, setting category "Modmail" as the main category.' + ) + return cat return None @property - def blocked_users(self): - return self.config.get('blocked', {}) + def blocked_users(self) -> typing.Dict[str, str]: + return self.config["blocked"] @property - def prefix(self): - return self.config.get('prefix', '?') + def blocked_roles(self) -> typing.Dict[str, str]: + return self.config["blocked_roles"] @property - def mod_color(self): - color = self.config.get('mod_color') - if not color: - return discord.Color.green() - try: - color = int(color.lstrip('#'), base=16) - except ValueError: - logger.error(error('Invalid mod_color provided')) - return discord.Color.green() - else: - return color + def blocked_whitelisted_users(self) -> typing.List[str]: + return self.config["blocked_whitelist"] @property - def recipient_color(self): - color = self.config.get('recipient_color') - if not color: - return discord.Color.gold() - try: - color = int(color.lstrip('#'), base=16) - except ValueError: - logger.error(error('Invalid recipient_color provided')) - return discord.Color.gold() - else: - return color + def prefix(self) -> str: + return str(self.config["prefix"]) @property - def main_color(self): - color = self.config.get('main_color') - if not color: - return discord.Color.blurple() - try: - color = int(color.lstrip('#'), base=16) - except ValueError: - logger.error(error('Invalid main_color provided')) - return discord.Color.blurple() - else: - return color + def mod_color(self) -> int: + return self.config.get("mod_color") + + @property + def recipient_color(self) -> int: + return self.config.get("recipient_color") + + @property + def main_color(self) -> int: + return self.config.get("main_color") + + @property + def error_color(self) -> int: + return self.config.get("error_color") + + def command_perm(self, command_name: str) -> PermissionLevel: + level = self.config["override_command_level"].get(command_name) + if level is not None: + try: + return PermissionLevel[level.upper()] + except KeyError: + logger.warning("Invalid override_command_level for command %s.", command_name) + self.config["override_command_level"].pop(command_name) + + command = self.get_command(command_name) + if command is None: + logger.debug("Command %s not found.", command_name) + return PermissionLevel.INVALID + level = next( + (check.permission_level for check in command.checks if hasattr(check, "permission_level")), + None, + ) + if level is None: + logger.debug("Command %s does not have a permission level.", command_name) + return PermissionLevel.INVALID + return level async def on_connect(self): - logger.info(LINE) - if not self.self_hosted: - logger.info(info('MODE: Using the Modmail API')) - logger.info(LINE) - await self.validate_api_token() - logger.info(LINE) - else: - logger.info(info('Mode: Self-hosting logs.')) - await self.validate_database_connection() - logger.info(LINE) - logger.info(info('Connected to gateway.')) + try: + await self.api.validate_database_connection() + except Exception: + logger.debug("Logging out due to failed database connection.") + return await self.close() + logger.debug("Connected to gateway.") await self.config.refresh() - if self.db: - await self.setup_indexes() + await self.api.setup_indexes() + await self.load_extensions() self._connected.set() - async def setup_indexes(self): - """Setup text indexes so we can use the $search operator""" - coll = self.db.logs - index_name = 'messages.content_text_messages.author.name_text_key_text' + async def on_ready(self): + """Bot startup, sets uptime.""" - index_info = await coll.index_information() + # Wait until config cache is populated with stuff from db and on_connect ran + await self.wait_for_connected() - # Backwards compatibility - old_index = 'messages.content_text_messages.author.name_text' - if old_index in index_info: - logger.info(info(f'Dropping old index: {old_index}')) - await coll.drop_index(old_index) + if self.guild is None: + logger.error("Logging out due to invalid GUILD_ID.") + return await self.close() - if index_name not in index_info: - logger.info(info('Creating "text" index for logs collection.')) - logger.info(info('Name: ' + index_name)) - await coll.create_index([ - ('messages.content', 'text'), - ('messages.author.name', 'text'), - ('key', 'text') - ]) + if self._started: + # Bot has started before + logger.line() + logger.warning("Bot restarted due to internal discord reloading.") + logger.line() + return - async def on_ready(self): - """Bot startup, sets uptime.""" - await self._connected.wait() - logger.info(LINE) - logger.info(info('Client ready.')) - logger.info(LINE) - logger.info(info(f'Logged in as: {self.user}')) - logger.info(info(f'User ID: {self.user.id}')) - logger.info(info(f'Guild ID: {self.guild.id if self.guild else 0}')) - logger.info(LINE) - - if not self.guild: - logger.error(error('WARNING - The GUILD_ID ' - 'provided does not exist!')) - else: - await self.threads.populate_cache() + logger.line() + logger.debug("Client ready.") + logger.info("Logged in as: %s", self.user) + logger.info("Bot ID: %s", self.user.id) + owners = ", ".join( + getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids + ) + logger.info("Owners: %s", owners) + logger.info("Prefix: %s", self.prefix) + logger.info("Guild Name: %s", self.guild.name) + logger.info("Guild ID: %s", self.guild.id) + if self.using_multiple_server_setup: + logger.info("Receiving guild ID: %s", self.modmail_guild.id) + logger.line() + + if "dev" in __version__: + logger.warning( + "You are running a developmental version. This should not be used in production. (v%s)", + __version__, + ) + logger.line() - # Wait until config cache is populated with stuff from db - await self.config.wait_until_ready() + await self.threads.populate_cache() # closures - closures = self.config.closures.copy() - logger.info(info(f'There are {len(closures)} thread(s) ' - 'pending to be closed.')) - - for recipient_id, items in closures.items(): - after = (datetime.fromisoformat(items['time']) - - datetime.utcnow()).total_seconds() - if after < 0: + closures = self.config["closures"] + logger.info("There are %d thread(s) pending to be closed.", len(closures)) + logger.line() + + for recipient_id, items in tuple(closures.items()): + after = ( + datetime.fromisoformat(items["time"]).astimezone(timezone.utc) - discord.utils.utcnow() + ).total_seconds() + if after <= 0: + logger.debug("Closing thread for recipient %s.", recipient_id) after = 0 + else: + logger.debug( + "Thread for recipient %s will be closed after %s seconds.", + recipient_id, + after, + ) thread = await self.threads.find(recipient_id=int(recipient_id)) if not thread: # If the channel is deleted - self.config.closures.pop(str(recipient_id)) + logger.debug("Failed to close thread for recipient %s.", recipient_id) + self.config["closures"].pop(recipient_id) await self.config.update() continue await thread.close( - closer=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'], - message=items['message'] + silent=items["silent"], + delete_channel=items["delete_channel"], + message=items["message"], + auto_close=items.get("auto_close", False), + ) + + for log in await self.api.get_open_logs(): + if log.get("channel_id") is None or self.get_channel(int(log["channel_id"])) is None: + logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) + log_data = await self.api.post_log( + log["channel_id"], + { + "open": False, + "title": None, + "closed_at": str(discord.utils.utcnow()), + "close_message": "Channel has been deleted, no closer found.", + "closer": { + "id": str(self.user.id), + "name": self.user.name, + "discriminator": self.user.discriminator, + "avatar_url": self.user.display_avatar.url, + "mod": True, + }, + }, + ) + if log_data: + logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) + else: + logger.debug( + "Failed to close thread with channel %s, skipping.", + log["channel_id"], + ) + + other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] + if any(other_guilds): + logger.warning( + "The bot is in more servers other than the main and staff server. " + "This may cause data compromise (%s).", + ", ".join(str(guild.name) for guild in other_guilds), ) + logger.warning("If the external servers are valid, you may ignore this message.") - logger.info(LINE) + self.post_metadata.start() + self.autoupdate.start() + self.log_expiry.start() + self._started = True - async def convert_emoji(self, name): + async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() - if name not in UNICODE_EMOJI: + if not is_emoji(name): try: - name = await converter.convert(ctx, name.strip(':')) - except commands.BadArgument: - logger.warning(f'{name} is not a valid emoji.') + name = await converter.convert(ctx, name.strip(":")) + except commands.BadArgument as e: + logger.warning("%s is not a valid emoji: %s", name, e) + raise return name - async def retrieve_emoji(self): + async def get_or_fetch_user(self, id: int) -> discord.User: + """ + Retrieve a User based on their ID. - # TODO: use a function to convert emojis + 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) - ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) - converter = commands.EmojiConverter() + @staticmethod + async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Returns: + The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. + """ + return guild.get_member(member_id) or await guild.fetch_member(member_id) - sent_emoji = self.config.get('sent_emoji', '✅') - blocked_emoji = self.config.get('blocked_emoji', '🚫') + async def retrieve_emoji(self) -> typing.Tuple[str, str]: + sent_emoji = self.config["sent_emoji"] + blocked_emoji = self.config["blocked_emoji"] - if sent_emoji not in UNICODE_EMOJI: + if sent_emoji != "disable": try: - sent_emoji = await converter.convert( - ctx, sent_emoji.strip(':') - ) + sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: - if sent_emoji != 'disable': - logger.warning(info(f'Sent Emoji ({sent_emoji}) ' - f'is not a valid emoji.')) - del self.config.cache['sent_emoji'] - await self.config.update() + logger.warning("Removed sent emoji (%s).", sent_emoji) + sent_emoji = self.config.remove("sent_emoji") + await self.config.update() - if blocked_emoji not in UNICODE_EMOJI: + if blocked_emoji != "disable": try: - blocked_emoji = await converter.convert( - ctx, blocked_emoji.strip(':') - ) + blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: - if blocked_emoji != 'disable': - logger.warning(info(f'Blocked emoji ({blocked_emoji}) ' - 'is not a valid emoji.')) - del self.config.cache['blocked_emoji'] - await self.config.update() + logger.warning("Removed blocked emoji (%s).", blocked_emoji) + blocked_emoji = self.config.remove("blocked_emoji") + await self.config.update() return sent_emoji, blocked_emoji - async def process_modmail(self, message): + def check_account_age(self, author: discord.Member) -> bool: + account_age = self.config.get("account_age") + now = discord.utils.utcnow() + + try: + min_account_age = author.created_at + account_age + except ValueError: + logger.warning("Error with 'account_age'.", exc_info=True) + min_account_age = author.created_at + self.config.remove("account_age") + + if min_account_age > now: + # User account has not reached the required time + delta = human_timedelta(min_account_age) + logger.debug("Blocked due to account age, user %s.", author.name) + + if str(author.id) not in self.blocked_users: + new_reason = f"System Message: New Account. User can try again {delta}." + self.blocked_users[str(author.id)] = new_reason + + return False + return True + + def check_guild_age(self, author: discord.Member) -> bool: + guild_age = self.config.get("guild_age") + now = discord.utils.utcnow() + + if not hasattr(author, "joined_at"): + logger.warning("Not in guild, cannot verify guild_age, %s.", author.name) + return True + + try: + min_guild_age = author.joined_at + guild_age + except ValueError: + logger.warning("Error with 'guild_age'.", exc_info=True) + min_guild_age = author.joined_at + self.config.remove("guild_age") + + if min_guild_age > now: + # User has not stayed in the guild for long enough + delta = human_timedelta(min_guild_age) + logger.debug("Blocked due to guild age, user %s.", author.name) + + if str(author.id) not in self.blocked_users: + new_reason = f"System Message: Recently Joined. User can try again {delta}." + self.blocked_users[str(author.id)] = new_reason + + return False + return True + + def check_manual_blocked_roles(self, author: discord.Member) -> bool: + if isinstance(author, discord.Member): + for r in author.roles: + if str(r.id) in self.blocked_roles: + blocked_reason = self.blocked_roles.get(str(r.id)) or "" + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False + + if end_time is not None: + if after <= 0: + # No longer blocked + self.blocked_roles.pop(str(r.id)) + logger.debug("No longer blocked, role %s.", r.name) + return True + logger.debug("User blocked, role %s.", r.name) + return False + + return True + + def check_manual_blocked(self, author: discord.Member) -> bool: + if str(author.id) not in self.blocked_users: + return True + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + + if blocked_reason.startswith("System Message:"): + # Met the limits already, otherwise it would've been caught by the previous checks + logger.debug("No longer internally blocked, user %s.", author.name) + self.blocked_users.pop(str(author.id)) + return True + + try: + end_time, after = extract_block_timestamp(blocked_reason, author.id) + except ValueError: + return False + + if end_time is not None: + if after <= 0: + # No longer blocked + self.blocked_users.pop(str(author.id)) + logger.debug("No longer blocked, user %s.", author.name) + return True + logger.debug("User blocked, user %s.", author.name) + return False + + async def _process_blocked(self, message): + _, blocked_emoji = await self.retrieve_emoji() + if await self.is_blocked(message.author, channel=message.channel, send_message=True): + await self.add_reaction(message, blocked_emoji) + return True + return False + + async def is_blocked( + self, + author: discord.User, + *, + channel: discord.TextChannel = None, + send_message: bool = False, + ) -> bool: + member = self.guild.get_member(author.id) + if member is None: + # try to find in other guilds + for g in self.guilds: + member = g.get_member(author.id) + if member: + break + + if member is None: + logger.debug("User not in guild, %s.", author.id) + + if member is not None: + author = member + + if str(author.id) in self.blocked_whitelisted_users: + if str(author.id) in self.blocked_users: + self.blocked_users.pop(str(author.id)) + await self.config.update() + return False + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + + if not self.check_account_age(author) or not self.check_guild_age(author): + new_reason = self.blocked_users.get(str(author.id)) + if new_reason != blocked_reason: + if send_message: + await channel.send( + embed=discord.Embed( + title="Message not sent!", + description=new_reason, + color=self.error_color, + ) + ) + return True + + if not self.check_manual_blocked(author): + return True + + if not self.check_manual_blocked_roles(author): + return True + + await self.config.update() + return False + + async def get_thread_cooldown(self, author: discord.Member): + thread_cooldown = self.config.get("thread_cooldown") + now = discord.utils.utcnow() + + if thread_cooldown == isodate.Duration(): + return + + last_log = await self.api.get_latest_user_logs(author.id) + + if last_log is None: + logger.debug("Last thread wasn't found, %s.", author.name) + return + + last_log_closed_at = last_log.get("closed_at") + + if not last_log_closed_at: + logger.debug("Last thread was not closed, %s.", author.name) + return + + try: + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + thread_cooldown + except ValueError: + logger.warning("Error with 'thread_cooldown'.", exc_info=True) + cooldown = datetime.fromisoformat(last_log_closed_at).astimezone( + timezone.utc + ) + self.config.remove("thread_cooldown") + + if cooldown > now: + # User messaged before thread cooldown ended + delta = human_timedelta(cooldown) + logger.debug("Blocked due to thread cooldown, user %s.", author.name) + return delta + return + + @staticmethod + async def add_reaction( + msg, + reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str], + ) -> bool: + if reaction != "disable": + try: + await msg.add_reaction(reaction) + except (discord.HTTPException, TypeError) as e: + logger.warning("Failed to add reaction %s: %s.", reaction, e) + return False + return True + + async def _queue_dm_message(self, message: discord.Message) -> None: + """Queue DM messages to ensure they're processed in order per user.""" + user_id = message.author.id + + if user_id not in self._message_queues: + self._message_queues[user_id] = asyncio.Queue() + # Start processing task for this user + self.loop.create_task(self._process_user_messages(user_id)) + + await self._message_queues[user_id].put(message) + + async def _process_user_messages(self, user_id: int) -> None: + """Process messages for a specific user in order.""" + queue = self._message_queues[user_id] + + while True: + try: + # Wait for a message with timeout to clean up inactive queues + message = await asyncio.wait_for(queue.get(), timeout=300) # 5 minutes + await self.process_dm_modmail(message) + queue.task_done() + except asyncio.TimeoutError: + # Clean up inactive queue + if queue.empty(): + self._message_queues.pop(user_id, None) + break + except Exception as e: + logger.error(f"Error processing message for user {user_id}: {e}", exc_info=True) + queue.task_done() + + async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" + blocked = await self._process_blocked(message) + if blocked: + return sent_emoji, blocked_emoji = await self.retrieve_emoji() - account_age = self.config.get('account_age') - if account_age is None: - account_age = isodate.duration.Duration() + # Handle forwarded messages (Discord forwards) + # See: https://discord.com/developers/docs/resources/message#message-reference-content-attribution-forwards + # 1. Multi-forward (message_snapshots) + if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): + if hasattr(message, "message_snapshots") and message.message_snapshots: + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + # Extract forwarded content using utility function + combined_content = extract_forwarded_content(message) or "[Forwarded message with no content]" + + class ForwardedMessage: + def __init__(self, original_message, forwarded_content): + self.author = original_message.author + self.content = forwarded_content + self.attachments = [] + self.stickers = [] + self.created_at = original_message.created_at + self.embeds = [] + self.id = original_message.id + self.flags = original_message.flags + self.message_snapshots = original_message.message_snapshots + self.type = getattr(original_message, "type", None) + + forwarded_msg = ForwardedMessage(message, combined_content) + await thread.send(forwarded_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + # 2. Single-message forward (MessageType.forward) + elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + # Check for message.reference and its type + ref = getattr(message, "reference", None) + if ref and getattr(ref, "type", None) == getattr(discord, "MessageReferenceType", None).forward: + # Try to fetch the referenced message + ref_msg = None + try: + if ref.resolved: + ref_msg = ref.resolved + elif ref.message_id and ref.channel_id: + channel = self.get_channel(ref.channel_id) or ( + await self.fetch_channel(ref.channel_id) + ) + ref_msg = await channel.fetch_message(ref.message_id) + except Exception: + ref_msg = None + if ref_msg: + # Forward the referenced message as if it was sent + thread = await self.threads.find(recipient=message.author) + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + thread = await self.threads.create(message.author, message=message) + else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A message was blocked from %s due to disabled Modmail.", message.author + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + # Create a forwarded message wrapper to preserve forward info + class ForwardedMessage: + def __init__(self, original_message, ref_message): + self.author = original_message.author + # Use the utility function to extract content or fallback to ref message content + extracted_content = extract_forwarded_content(original_message) + self.content = ( + extracted_content + or ref_message.content + or "[Forwarded message with no text content]" + ) + self.attachments = getattr(ref_message, "attachments", []) + self.stickers = getattr(ref_message, "stickers", []) + self.created_at = original_message.created_at + self.embeds = getattr(ref_message, "embeds", []) + self.id = original_message.id + self.type = getattr(original_message, "type", None) + self.reference = original_message.reference + + forwarded_msg = ForwardedMessage(message, ref_msg) + await thread.send(forwarded_msg) + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + return + else: + message.content = "[Forwarded message with no content]" + + if message.type not in [discord.MessageType.default, discord.MessageType.reply]: + return + + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + self.threads.cache[thread.id] = thread + # No need to re-fetch the thread - it's already restored and cached properly + + # If the previous thread was closed with delete_channel=True the channel object + # stored on the thread will now be invalid (deleted). In some rare race cases + # the thread can still be returned from the cache (or reconstructed) while the + # channel lookup returns None, causing downstream relay attempts to raise + # discord.NotFound ("Channel not found when trying to send message."). Treat + # this situation as "no active thread" so the user's new DM starts a fresh + # thread instead of silently failing. + try: + if ( + thread + and thread.channel + and isinstance(thread.channel, discord.TextChannel) + and self.get_channel(getattr(thread.channel, "id", None)) is None + ): + logger.info( + "Stale thread detected for %s (channel deleted). Purging cache entry and creating new thread.", + message.author, + ) + # Best-effort removal; ignore if already gone. + self.threads.cache.pop(thread.id, None) + thread = None + except Exception: + # If any attribute access fails, fall back to treating it as closed. + self.threads.cache.pop(getattr(thread, "id", None), None) + thread = None + + if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title=self.config["cooldown_thread_title"], + description=self.config["cooldown_thread_response"].format(delta=delta), + color=self.error_color, + ) + ) + return + + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", + message.author, + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + thread = await self.threads.create(message.author, message=message) + # If thread menu is enabled, thread creation is deferred until user selects an option. + if getattr(thread, "_pending_menu", False): + return else: + if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title=self.config["disabled_current_thread_title"], + color=self.error_color, + description=self.config["disabled_current_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_current_thread_footer"], + icon_url=self.get_guild_icon(guild=message.guild, size=128), + ) + logger.info( + "A message was blocked from %s due to disabled Modmail.", + message.author, + ) + await self.add_reaction(message, blocked_emoji) + return await message.channel.send(embed=embed) + + if not thread.cancelled: try: - account_age = isodate.parse_duration(account_age) - except isodate.ISO8601Error: - logger.warning('The account age limit needs to be a ' - 'ISO-8601 duration formatted duration string ' - f'greater than 0 days, not "%s".', str(account_age)) - del self.config.cache['account_age'] - await self.config.update() - account_age = isodate.duration.Duration() + await thread.send(message) + except Exception: + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, blocked_emoji) + + try: + # Re-check channel existence + if thread and thread.channel and isinstance(thread.channel, discord.TextChannel): + if self.get_channel(thread.channel.id) is None: + logger.info( + "Relay failed due to deleted channel for %s; creating new thread.", + message.author, + ) + self.threads.cache.pop(thread.id, None) + new_thread = await self.threads.create(message.author, message=message) + if not getattr(new_thread, "_pending_menu", False) and not new_thread.cancelled: + try: + await new_thread.send(message) + except Exception: + logger.error( + "Failed to relay message after creating new thread:", + exc_info=True, + ) + else: + for user in new_thread.recipients: + if user != message.author: + try: + await new_thread.send(message, user) + except Exception: + logger.error( + "Failed to send message to additional recipient:", + exc_info=True, + ) + await self.add_reaction(message, sent_emoji) + self.dispatch( + "thread_reply", + new_thread, + False, + message, + False, + False, + ) + except Exception: + logger.warning( + "Unexpected failure in DM relay/new-thread follow-up block.", + exc_info=True, + ) + else: + for user in thread.recipients: + # send to all other recipients + if user != message.author: + try: + await thread.send(message, user) + except Exception: + # silently ignore + logger.error("Failed to send message:", exc_info=True) + + await self.add_reaction(message, sent_emoji) + self.dispatch("thread_reply", thread, False, message, False, False) + + def _get_snippet_command(self) -> commands.Command: + """Get the correct reply command based on the snippet config""" + modifiers = "f" + if self.config["plain_snippets"]: + modifiers += "p" + if self.config["anonymous_snippets"]: + modifiers += "a" + + return self.get_command(f"{modifiers}reply") + + async def get_contexts(self, message, *, cls=commands.Context): + """ + Returns all invocation contexts from the message. + Supports getting the prefix from database as well as command aliases. + """ + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + thread = await self.threads.find(channel=ctx.channel) + + if message.author.id == self.user.id: # type: ignore + return [ctx] + + prefixes = await self.get_prefix() + + invoked_prefix = discord.utils.find(view.skip_string, prefixes) + if invoked_prefix is None: + return [ctx] - reason = self.blocked_users.get(str(message.author.id)) - if reason is None: - reason = '' + 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: - min_account_age = message.author.created_at + account_age - except ValueError as e: - logger.warning(e.args[0]) - del self.config.cache['account_age'] - await self.config.update() - min_account_age = message.author.created_at + # Use removeprefix once PY3.9+ + snippet_text = self.snippets[message.content[len(invoked_prefix) :]] + except KeyError: + snippet_text = None - if min_account_age > datetime.utcnow(): - # user account has not reached the required time - reaction = blocked_emoji - changed = False - delta = human_timedelta(min_account_age) + # Check if there is any aliases being called. + alias = self.aliases.get(invoker) + if alias is not None and snippet_text is None: + ctxs = [] + aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) + if not aliases: + logger.warning("Alias %s is invalid, removing.", invoker) + self.aliases.pop(invoker) + + for alias in aliases: + command = None + try: + snippet_text = self.snippets[alias] + except KeyError: + command_invocation_text = alias + else: + command = self._get_snippet_command() + command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" + view = StringView(invoked_prefix + command_invocation_text) + ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx_.thread = thread + discord.utils.find(view.skip_string, prefixes) + ctx_.invoked_with = view.get_word().lower() + ctx_.command = command or self.all_commands.get(ctx_.invoked_with) + ctxs += [ctx_] + return ctxs + + ctx.thread = thread + + if snippet_text is not None: + # Process snippets + ctx.command = self._get_snippet_command() + reply_view = StringView(f"{invoked_prefix}{ctx.command} {snippet_text}") + discord.utils.find(reply_view.skip_string, prefixes) + ctx.invoked_with = reply_view.get_word().lower() + ctx.view = reply_view + else: + ctx.command = self.all_commands.get(invoker) + ctx.invoked_with = invoker - if str(message.author.id) not in self.blocked_users: - new_reason = f'System Message: New Account. Required to wait for {delta}.' - self.config.blocked[str(message.author.id)] = new_reason - await self.config.update() - changed = True - - if reason.startswith('System Message: New Account.') or changed: - await message.channel.send(embed=discord.Embed( - title='Message not sent!', - description=f'Your must wait for {delta} ' - f'before you can contact {self.user.mention}.', - color=discord.Color.red() - )) - - elif str(message.author.id) in self.blocked_users: - reaction = blocked_emoji - if reason.startswith('System Message: New Account.'): - # Met the age limit already - reaction = sent_emoji - del self.config.blocked[str(message.author.id)] - await self.config.update() - else: - end_time = re.search(r'%(.+?)%$', reason) - if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - - datetime.utcnow()).total_seconds() - if after <= 0: - # No longer blocked - reaction = sent_emoji - del self.config.blocked[str(message.author.id)] - await self.config.update() + return [ctx] + + async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context): + message.author = self.modmail_guild.me + message.channel = channel + message.guild = channel.guild + + view = StringView(message.content) + ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + thread = await self.threads.find(channel=ctx.channel) + + invoked_prefix = self.prefix + invoker = None + + if self.config.get("use_regex_autotrigger"): + trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) + if trigger: + invoker = re.search(trigger, message.content).group(0) else: - reaction = sent_emoji - - if reaction != 'disable': - try: - await message.add_reaction(reaction) - except (discord.HTTPException, discord.InvalidArgument): - pass + trigger = next( + filter( + lambda x: x.lower() in message.content.lower(), + self.auto_triggers.keys(), + ) + ) + if trigger: + invoker = trigger.lower() + + alias = self.auto_triggers[trigger] + + ctxs = [] + + if alias is not None: + ctxs = [] + aliases = normalize_alias(alias) + if not aliases: + logger.warning("Alias %s is invalid as called in autotrigger.", invoker) + + message.author = thread.recipient # Allow for get_contexts to work + + for alias in aliases: + message.content = invoked_prefix + alias + ctxs += await self.get_contexts(message) - if str(message.author.id) not in self.blocked_users: - thread = await self.threads.find_or_create(message.author) - await thread.send(message) + message.author = self.modmail_guild.me # Fix message so commands execute properly + + for ctx in ctxs: + if ctx.command: + old_checks = copy.copy(ctx.command.checks) + ctx.command.checks = [checks.has_permissions(PermissionLevel.INVALID)] + + await self.invoke(ctx) + + ctx.command.checks = old_checks + continue async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. - Supports getting the prefix from database as well as command aliases. + Supports getting the prefix from database. """ view = StringView(message.content) - ctx = cls(prefix=None, view=view, bot=self, message=message) + 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) @@ -580,348 +1406,861 @@ async def get_context(self, message, *, cls=commands.Context): invoker = view.get_word().lower() - # Check if there is any aliases being called. - alias = self.config.get('aliases', {}).get(invoker) - if alias is not None: - ctx._alias_invoked = True - len_ = len(f'{invoked_prefix}{invoker}') - view = StringView(f'{alias}{ctx.message.content[len_:]}') - ctx.view = view - invoker = view.get_word() - ctx.invoked_with = invoker - ctx.prefix = self.prefix # Sane prefix (No mentions) ctx.command = self.all_commands.get(invoker) - has_ai = hasattr(ctx, '_alias_invoked') - if ctx.command is self.get_command('eval') and has_ai: - # ctx.command.checks = None # Let anyone use the command. - pass - return ctx + async def update_perms( + self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True + ) -> None: + if value != -1: + value = str(value) + if isinstance(name, PermissionLevel): + level = True + permissions = self.config["level_permissions"] + name = name.name + else: + level = False + permissions = self.config["command_permissions"] + if name not in permissions: + if add: + permissions[name] = [value] + else: + if add: + if value not in permissions[name]: + permissions[name].append(value) + else: + if value in permissions[name]: + permissions[name].remove(value) + + if level: + self.config["level_permissions"] = permissions + else: + self.config["command_permissions"] = permissions + logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) + await self.config.update() + async def on_message(self, message): - if message.type == discord.MessageType.pins_add and \ - message.author == self.user: + await self.wait_for_connected() + if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() + if ( + (f"<@{self.user.id}" in message.content or f"<@!{self.user.id}" in message.content) + and self.config["alert_on_mention"] + and not message.author.bot + ): + em = discord.Embed( + title="Bot mention", + description=f"[Jump URL]({message.jump_url})\n{truncate(message.content, 50)}", + color=self.main_color, + ) + if self.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + if not self.config["silent_alert_on_mention"]: + content = self.config["mention"] + else: + content = "" + await self.mention_channel.send(content=content, embed=em) + + # --- MODERATOR-ONLY MESSAGE LOGGING --- + # If a moderator sends a message directly in a thread channel (not via modmail command), log it + if not message.author.bot and not isinstance(message.channel, discord.DMChannel): + thread = await self.threads.find(channel=message.channel) + if thread is not None: + ctxs = await self.get_contexts(message) + is_command = any(ctx.command for ctx in ctxs) + if not is_command: + # Only log if not a command + perms = message.channel.permissions_for(message.author) + if perms.manage_messages or perms.administrator: + await self.api.append_log(message, type_="internal") + + await self.process_commands(message) + + async def process_commands(self, message): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): - return await self.process_modmail(message) - - prefix = self.prefix + return await self._queue_dm_message(message) + + ctxs = await self.get_contexts(message) + for ctx in ctxs: + if ctx.command: + if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): + logger.debug( + "Command %s has no permissions check, adding invalid level.", + ctx.command.qualified_name, + ) + checks.has_permissions(PermissionLevel.INVALID)(ctx.command) + + # Check if thread is unsnoozing and queue command if so + thread = await self.threads.find(channel=ctx.channel) + if thread and thread._unsnoozing: + queued = await thread.queue_command(ctx, ctx.command) + if queued: + # Send a brief acknowledgment that command is queued + try: + await ctx.message.add_reaction("⏳") + except Exception as e: + logger.warning("Failed to add queued-reaction: %s", e) + continue + + await self.invoke(ctx) + continue - if message.content.startswith(prefix): - cmd = message.content[len(prefix):].strip() - if cmd in self.snippets: - message.content = f'{prefix}reply {self.snippets[cmd]}' - - ctx = await self.get_context(message) - if ctx.command: - return await self.invoke(ctx) - - thread = await self.threads.find(channel=ctx.channel) - if thread is not None: - if self.config.get('reply_without_command'): - await thread.reply(message) - else: - await self.api.append_log(message, type_='internal') - elif ctx.invoked_with: - exc = commands.CommandNotFound( - 'Command "{}" is not found'.format(ctx.invoked_with) - ) - self.dispatch('command_error', ctx, exc) + thread = await self.threads.find(channel=ctx.channel) + if thread is not None: + # If thread is snoozed (moved), auto-unsnooze when a mod sends a message directly in channel + behavior = (self.config.get("snooze_behavior") or "delete").lower() + if thread.snoozed and behavior == "move": + if not thread.snooze_data: + try: + log_entry = await self.api.logs.find_one( + {"recipient.id": str(thread.id), "snoozed": True} + ) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + except Exception: + logger.debug( + "Failed to add queued command reaction (⏳).", + exc_info=True, + ) + try: + await thread.restore_from_snooze() + # refresh local cache + self.threads.cache[thread.id] = thread + except Exception as e: + logger.warning("Auto-unsnooze on direct message failed: %s", e) + anonymous = False + plain = False + if self.config.get("anon_reply_without_command"): + anonymous = True + if self.config.get("plain_reply_without_command"): + plain = True + + if ( + self.config.get("reply_without_command") + or self.config.get("anon_reply_without_command") + or self.config.get("plain_reply_without_command") + ): + # When replying without a command in a thread channel, use the raw content + # from the sent message as reply text while still preserving attachments. + await thread.reply(message, message.content, anonymous=anonymous, plain=plain) + elif ctx.invoked_with: + exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) + self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): + await self.wait_for_connected() + if user.bot: - return + return + if isinstance(channel, discord.DMChannel): - if not self.config.get('user_typing'): + if not self.config.get("user_typing"): return + thread = await self.threads.find(recipient=user) + if thread: - await thread.channel.trigger_typing() + try: + await thread.channel.typing() + except Exception: + logger.debug( + "Failed to trigger typing indicator in recipient DM.", + exc_info=True, + ) else: - if not self.config.get('mod_typing'): + if not self.config.get("mod_typing"): return - thread = await self.threads.find(channel=channel) - if thread and thread.recipient: - await thread.recipient.trigger_typing() - - async def on_raw_reaction_add(self, payload): + thread = await self.threads.find(channel=channel) + if thread is not None and thread.recipient: + for user in thread.recipients: + if await self.is_blocked(user): + continue + try: + await user.typing() + except Exception: + logger.debug( + "Failed to trigger typing for recipient %s.", + getattr(user, "id", "?"), + exc_info=True, + ) + + async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) - - if user.bot: + if user is None or user.bot: return channel = self.get_channel(payload.channel_id) + thread = None + # dm channel not in internal cache + if not channel: + thread = await self.threads.find(recipient=user) + if not thread: + return + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return - if not channel: # dm channel not in internal cache - _thread = await self.threads.find(recipient=user) - if not _thread: + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: return - channel = await _thread.recipient.create_dm() - # TODO: change to fetch_message (breaking change in d.py) - message = await channel.get_message(payload.message_id) + # thread must exist before doing this API call + try: + message = await channel.fetch_message(payload.message_id) + except (discord.NotFound, discord.Forbidden): + return + reaction = payload.emoji + close_emoji = await self.convert_emoji(self.config["close_emoji"]) + if from_dm: + if ( + payload.event_type == "REACTION_ADD" + and message.embeds + and str(reaction) == str(close_emoji) + and self.config.get("recipient_thread_close") + ): + ts = message.embeds[0].timestamp + if ts == thread.channel.created_at: + # the reacted message is the corresponding thread creation embed + # closing thread + return await thread.close(closer=user) + if ( + message.author == self.user + and message.embeds + and self.config.get("confirm_thread_creation") + and message.embeds[0].title == self.config["confirm_thread_creation_title"] + and message.embeds[0].description == self.config["confirm_thread_response"] + ): + return + if not thread.recipient.dm_channel: + await thread.recipient.create_dm() + try: + linked_messages = await thread.find_linked_message_from_dm(message, either_direction=True) + except ValueError as e: + logger.warning("Failed to find linked message for reactions: %s", e) + return + else: + try: + _, *linked_messages = await thread.find_linked_messages( + message1=message, either_direction=True + ) + except ValueError as e: + logger.warning("Failed to find linked message for reactions: %s", e) + return + + if self.config["transfer_reactions"] and linked_messages != [None]: + if payload.event_type == "REACTION_ADD": + for msg in linked_messages: + await self.add_reaction(msg, reaction) + await self.add_reaction(message, reaction) + else: + try: + for msg in linked_messages: + await msg.remove_reaction(reaction, self.user) + await message.remove_reaction(reaction, self.user) + except (discord.HTTPException, TypeError) as e: + logger.warning("Failed to remove reaction: %s", e) + + async def handle_react_to_contact(self, payload): + react_message_id = tryint(self.config.get("react_to_contact_message")) + react_message_emoji = self.config.get("react_to_contact_emoji") + if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: + return + if payload.emoji.is_unicode_emoji(): + emoji_fmt = payload.emoji.name + else: + emoji_fmt = f"<:{payload.emoji.name}:{payload.emoji.id}>" - close_emoji = await self.convert_emoji(self.config.get('close_emoji', '🔒')) + if emoji_fmt != react_message_emoji: + return + channel = self.get_channel(payload.channel_id) + member = channel.guild.get_member(payload.user_id) + if member.bot: + return + message = await channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, member) + await message.add_reaction(emoji_fmt) # bot adds as well + + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): + embed = discord.Embed( + title=self.config["disabled_new_thread_title"], + color=self.error_color, + description=self.config["disabled_new_thread_response"], + ) + embed.set_footer( + text=self.config["disabled_new_thread_footer"], + icon_url=self.get_guild_icon(guild=channel.guild, size=128), + ) + logger.info( + "A new thread using react to contact was blocked from %s due to disabled Modmail.", + member, + ) + return await member.send(embed=embed) + + # Check if user has a snoozed thread + existing_thread = await self.threads.find(recipient=member) + if existing_thread and existing_thread.snoozed: + # Unsnooze the thread + await existing_thread.restore_from_snooze() + self.threads.cache[existing_thread.id] = existing_thread + # Send notification to the thread channel + if existing_thread.channel: + await existing_thread.channel.send( + f"ℹ️ {member.mention} reacted to contact and their snoozed thread has been unsnoozed." + ) + return - if isinstance(channel, discord.DMChannel) and str(reaction) == str(close_emoji): # closing thread - thread = await self.threads.find(recipient=user) - ts = message.embeds[0].timestamp if message.embeds else None - if thread and ts == thread.channel.created_at: - # the reacted message is the corresponding thread creation embed - if not self.config.get('disable_recipient_thread_close'): - await thread.close(closer=user) - elif not isinstance(channel, discord.DMChannel): - if not message.embeds: - return - message_id = str(message.embeds[0].author.url).split('/')[-1] - if message_id.isdigit(): - thread = await self.threads.find(channel=message.channel) - channel = thread.recipient.dm_channel - if not channel: - channel = await thread.recipient.create_dm() - async for msg in channel.history(): - if msg.id == int(message_id): - await msg.add_reaction(reaction) + ctx = await self.get_context(message) + await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) + + async def on_raw_reaction_add(self, payload): + await asyncio.gather( + self.handle_reaction_events(payload), + self.handle_react_to_contact(payload), + ) + + async def on_raw_reaction_remove(self, payload): + if self.config["transfer_reactions"]: + await self.handle_reaction_events(payload) async def on_guild_channel_delete(self, channel): if channel.guild != self.modmail_guild: return - audit_logs = self.modmail_guild.audit_logs() - entry = await audit_logs.find(lambda e: e.target.id == channel.id) - mod = entry.user - - if mod == self.user: + if isinstance(channel, discord.CategoryChannel): + if self.main_category == channel: + logger.debug("Main category was deleted.") + self.config.remove("main_category_id") + await self.config.update() return if not isinstance(channel, discord.TextChannel): - if int(self.config.get('main_category_id')) == channel.id: - await self.config.update({ - 'main_category_id': None - }) return - if int(self.config.get('log_channel_id')) == channel.id: - await self.config.update({ - 'log_channel_id': None - }) + if self.log_channel is None or self.log_channel == channel: + logger.info("Log channel deleted.") + self.config.remove("log_channel_id") + await self.config.update() return - thread = await self.threads.find(channel=channel) - if not thread: + # Attempt to attribute channel deletion to a moderator via audit logs. + # This requires the "View Audit Log" permission; if missing, skip silently. + if not self.modmail_guild.me.guild_permissions.view_audit_log: + logger.debug( + "Skipping audit log lookup for deleted channel %d: missing view_audit_log permission.", + channel.id, + ) return - await thread.close(closer=mod, silent=True, delete_channel=False) - + try: + audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) + found_entry = False + async for entry in audit_logs: + if int(entry.target.id) == channel.id: + found_entry = True + break + except discord.Forbidden: + logger.debug( + "Forbidden when fetching audit logs for deleted channel %d (missing permission).", channel.id + ) + return + except discord.HTTPException as e: + logger.debug("HTTPException when fetching audit logs for deleted channel %d: %s", channel.id, e) + return + + if not found_entry: + logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) + return + + mod = entry.user + if mod == self.user: + return + + thread = await self.threads.find(channel=channel) + if thread and thread.channel == channel: + logger.debug("Manually closed channel %s.", channel.name) + await thread.close(closer=mod, silent=True, delete_channel=False) + async def on_member_remove(self, member): thread = await self.threads.find(recipient=member) if thread: - em = discord.Embed( - description='The recipient has left the server.', - color=discord.Color.red() + if member.guild == self.guild and self.config["close_on_leave"]: + await thread.close( + closer=member.guild.me, + message=self.config["close_on_leave_reason"], + silent=True, ) - await thread.channel.send(embed=em) - + else: + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) + await thread.channel.send(embed=embed) + async def on_member_join(self, member): thread = await self.threads.find(recipient=member) if thread: - em = discord.Embed( - description='The recipient has joined the server.', - color=self.mod_color - ) - await thread.channel.send(embed=em) + if len(self.guilds) > 1: + guild_joined = member.guild + join_message = f"The recipient has joined {guild_joined}." + else: + join_message = "The recipient has joined the server." + embed = discord.Embed(description=join_message, color=self.mod_color) + await thread.channel.send(embed=embed) async def on_message_delete(self, message): """Support for deleting linked messages""" - if message.embeds and not isinstance(message.channel, - discord.DMChannel): - message_id = str(message.embeds[0].author.url).split('/')[-1] - if message_id.isdigit(): - thread = await self.threads.find(channel=message.channel) - channel = thread.recipient.dm_channel + if message.is_system(): + return + + if isinstance(message.channel, discord.DMChannel): + if message.author == self.user: + return + thread = await self.threads.find(recipient=message.author) + if not thread: + return + try: + message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) + except ValueError as e: + if str(e) != "Thread channel message not found.": + logger.debug("Failed to find linked message to delete: %s", e) + return + message = message[0] + embed = message.embeds[0] + + if embed.footer.icon: + icon_url = embed.footer.icon.url + else: + icon_url = None + + embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=icon_url) + await message.edit(embed=embed) + return + + if message.author != self.user: + return + + thread = await self.threads.find(channel=message.channel) + if not thread: + return + + try: + await thread.delete_message(message, note=False) + embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) + except ValueError as e: + # Treat common non-fatal cases as benign: relay counterpart not present, note embeds, etc. + if str(e) not in { + "DM message not found.", + "Malformed thread message.", + "Thread message not found.", + }: + logger.debug("Failed to find linked message to delete: %s", e) + embed = discord.Embed(description="Failed to delete message.", color=self.error_color) + else: + return + except discord.NotFound: + return + embed.set_footer(text=f"Message ID: {message.id} from {message.author}.") + return await message.channel.send(embed=embed) - async for msg in channel.history(): - if msg.embeds and msg.embeds[0].author: - url = str(msg.embeds[0].author.url) - if message_id == url.split('/')[-1]: - return await msg.delete() + async def on_bulk_message_delete(self, messages): + await discord.utils.async_all(self.on_message_delete(msg) for msg in messages) async def on_message_edit(self, before, after): - if before.author.bot: + if after.author.bot: + return + if before.content == after.content: return - if isinstance(before.channel, discord.DMChannel): + + if isinstance(after.channel, discord.DMChannel): thread = await self.threads.find(recipient=before.author) - async for msg in thread.channel.history(): - if msg.embeds: - embed = msg.embeds[0] - matches = str(embed.author.url).split('/') - if matches and matches[-1] == str(before.id): - embed.description = after.content - await msg.edit(embed=embed) - await self.api.edit_message(str(after.id), - after.content) - break + if not thread: + return + + try: + await thread.edit_dm_message(after, after.content) + except ValueError: + _, blocked_emoji = await self.retrieve_emoji() + await self.add_reaction(after, blocked_emoji) + else: + embed = discord.Embed(description="Successfully Edited Message", color=self.main_color) + embed.set_footer(text=f"Message ID: {after.id}") + await after.channel.send(embed=embed) async def on_error(self, event_method, *args, **kwargs): - logger.error(error('Ignoring exception in {}'.format(event_method))) - logger.error(error('Unexpected exception:'), exc_info=sys.exc_info()) - - async def on_command_error(self, context, exception): - if isinstance(exception, (commands.MissingRequiredArgument, - commands.UserInputError)): - await context.invoke(self.get_command('help'), - command=str(context.command)) + logger.error("Ignoring exception in %s.", event_method) + logger.error("Unexpected exception:", exc_info=sys.exc_info()) + + async def on_command_error( + self, + context: commands.Context, + exception: Exception, + *, + unhandled_by_cog: bool = False, + ) -> None: + if not unhandled_by_cog: + command = context.command + if command and command.has_error_handler(): + return + cog = context.cog + if cog and cog.has_error_handler(): + return + + if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): + try: + await context.typing() + except Exception: + logger.debug( + "Failed to start typing context for command error feedback.", + exc_info=True, + ) + await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): - logger.warning(error('CommandNotFound: ' + str(exception))) + logger.warning("CommandNotFound: %s", exception) + elif isinstance(exception, commands.MissingRequiredArgument): + await context.send_help(context.command) + elif isinstance(exception, commands.CommandOnCooldown): + await context.send( + embed=discord.Embed( + title="Command on cooldown", + description=f"Try again in {exception.retry_after:.2f} seconds", + color=self.error_color, + ) + ) elif isinstance(exception, commands.CheckFailure): - logger.warning(error('CheckFailure: ' + str(exception))) - else: - logger.error(error('Unexpected exception:'), exc_info=exception) - - @staticmethod - def overwrites(ctx): - """Permission overwrites for the guild.""" - overwrites = { - ctx.guild.default_role: discord.PermissionOverwrite( - read_messages=False - ), - ctx.guild.me: discord.PermissionOverwrite( - read_messages=True + for check in context.command.checks: + if not await check(context): + if hasattr(check, "fail_msg"): + await context.send( + embed=discord.Embed(color=self.error_color, description=check.fail_msg) + ) + if hasattr(check, "permission_level"): + corrected_permission_level = self.command_perm(context.command.qualified_name) + logger.warning( + "User %s does not have permission to use this command: `%s` (%s).", + context.author.name, + context.command.qualified_name, + corrected_permission_level.name, + ) + logger.warning("CheckFailure: %s", exception) + elif isinstance(exception, commands.DisabledCommand): + logger.info( + "DisabledCommand: %s is trying to run eval but it's disabled", + context.author.name, ) + else: + logger.error("Unexpected exception:", exc_info=exception) + + @tasks.loop(hours=1) + async def post_metadata(self): + info = await self.application_info() + + delta = discord.utils.utcnow() - self.start_time + data = { + "bot_id": self.user.id, + "bot_name": str(self.user), + "avatar_url": self.user.display_avatar.url, + "guild_id": self.guild_id, + "guild_name": self.guild.name, + "member_count": len(self.guild.members), + "uptime": delta.total_seconds(), + "latency": f"{self.ws.latency * 1000:.4f}", + "version": str(self.version), + "selfhosted": True, + "last_updated": str(discord.utils.utcnow()), } - for role in ctx.guild.roles: - if role.permissions.manage_guild: - overwrites[role] = discord.PermissionOverwrite( - read_messages=True - ) - return overwrites - - async def validate_api_token(self): - try: - self.config.modmail_api_token - except KeyError: - logger.critical(error(f'MODMAIL_API_TOKEN not found.')) - logger.critical(error('Set a config variable called ' - 'MODMAIL_API_TOKEN with a token from ' - 'https://dashboard.modmail.tk.')) - logger.critical(error('If you want to self-host logs, ' - 'input a MONGO_URI config variable.')) - logger.critical(error('A Modmail API token is not needed ' - 'if you are self-hosting logs.')) - - return await self.logout() - else: - valid = await self.api.validate_token() - if not valid: - logger.critical(error('Invalid MODMAIL_API_TOKEN - get one ' - 'from https://dashboard.modmail.tk')) - return await self.logout() - - user = await self.api.get_user_info() - username = user['user']['username'] - logger.info(info('Validated token.')) - logger.info(info('GitHub user: ' + username)) - - async def validate_database_connection(self): - try: - await self.db.command('buildinfo') - except Exception as exc: - logger.critical(error('Something went wrong ' - 'while connecting to the database.')) - logger.critical(error(f'{type(exc).__name__}: {str(exc)}')) - return await self.logout() + if info.team is not None: + data.update( + { + "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", + "owner_id": info.team.owner_id, + "team": True, + } + ) else: - logger.info(info('Successfully connected to the database.')) + data.update( + { + "owner_name": info.owner.name, + "owner_id": info.owner.id, + "team": False, + } + ) - async def data_loop(self): - await self.wait_until_ready() - self.owner = (await self.application_info()).owner - - while not self.is_closed(): - data = { - "owner_name": str(self.owner), - "owner_id": self.owner.id, - "bot_id": self.user.id, - "bot_name": str(self.user), - "avatar_url": self.user.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(), - "latency": f'{self.ws.latency * 1000:.4f}', - "version": self.version, - # TODO: change to `self_hosted` - "selfhosted": self.self_hosted, - "last_updated": str(datetime.utcnow()) - } - - await self.api.post_metadata(data) - await asyncio.sleep(3600) - - async def autoupdate_loop(self): - await self.wait_until_ready() + async with self.session.post("https://api.modmail.dev/metadata", json=data): + logger.debug("Uploading metadata to Modmail server.") - if self.config.get('disable_autoupdates'): - logger.warning(info('Autoupdates disabled.')) - logger.info(LINE) + @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 - if self.self_hosted and not self.config.get('github_access_token'): - logger.warning(info('GitHub access token not found.')) - logger.warning(info('Autoupdates disabled.')) - logger.info(LINE) - return + logger.debug("Starting metadata loop.") + logger.line("debug") - while not self.is_closed(): - metadata = await self.api.get_metadata() + @tasks.loop(hours=1) + async def autoupdate(self): + changelog = await Changelog.from_url(self) + latest = changelog.latest_version - if metadata['latest_version'] != self.version: + if self.version < Version(latest.version): + error = None + data = {} + try: + # update fork if gh_token exists data = await self.api.update_repository() + except InvalidConfigError: + pass + except ClientResponseError as exc: + error = exc + if self.hosting_method == HostingMethod.HEROKU: + if error is not None: + logger.error(f"Autoupdate failed! Status: {error.status}.") + logger.error(f"Error message: {error.message}") + self.autoupdate.cancel() + return + + commit_data = data.get("data") + if not commit_data: + return + + logger.info("Bot has been updated.") + + if not self.config["update_notifications"]: + return + + embed = discord.Embed(color=self.main_color) + message = commit_data["commit"]["message"] + html_url = commit_data["html_url"] + short_sha = commit_data["sha"][:6] + user = data["user"] + embed.add_field( + name="Merge Commit", + value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}", + ) + embed.set_author( + name=user["username"] + " - Updating Bot", + icon_url=user["avatar_url"], + url=user["url"], + ) - embed = discord.Embed(color=discord.Color.green()) - - commit_data = data['data'] - user = data['user'] - embed.set_author(name=user['username'] + ' - Updating Bot', - icon_url=user['avatar_url'], - url=user['url']) - embed.set_footer(text=f"Updating Modmail v{self.version} " - f"-> v{metadata['latest_version']}") + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") - changelog = await Changelog.from_url(self) - latest = changelog.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(info('Updating bot.')) - channel = self.log_channel - await channel.send(embed=embed) + channel = self.update_channel + await channel.send(embed=embed) + else: + command = "git pull" + proc = await asyncio.create_subprocess_shell( + command, + stderr=PIPE, + stdout=PIPE, + ) + err = await proc.stderr.read() + err = err.decode("utf-8").rstrip() + res = await proc.stdout.read() + res = res.decode("utf-8").rstrip() + + if err and not res: + logger.warning(f"Autoupdate failed: {err}") + self.autoupdate.cancel() + return + + elif res != "Already up to date.": + if os.getenv("PIPENV_ACTIVE"): + # Update pipenv if possible + await asyncio.create_subprocess_shell( + "pipenv sync", + stderr=PIPE, + stdout=PIPE, + ) + message = "" + else: + message = "\n\nDo manually update dependencies if your bot has crashed." + + logger.info("Bot has been updated.") + channel = self.update_channel + if self.hosting_method in ( + HostingMethod.PM2, + HostingMethod.SYSTEMD, + ): + embed = discord.Embed(title="Bot has been updated", color=self.main_color) + embed.set_footer( + text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" + ) + if self.config["update_notifications"]: + await channel.send(embed=embed) + else: + embed = discord.Embed( + title="Bot has been updated and is logging out.", + description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", + color=self.main_color, + ) + embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + if self.config["update_notifications"]: + await channel.send(embed=embed) + return await self.close() + + @autoupdate.before_loop + async def before_autoupdate(self): + await self.wait_for_connected() + logger.debug("Starting autoupdate loop") + + if self.config.get("disable_autoupdates"): + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + if self.hosting_method == HostingMethod.DOCKER: + logger.warning("Autoupdates disabled as using Docker.") + self.autoupdate.cancel() + return + + if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: + logger.warning("GitHub access token not found.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + + @tasks.loop(hours=1, reconnect=False) + async def log_expiry(self): + log_expire_after = self.config.get("log_expiration") + if log_expire_after == isodate.Duration(): + return self.log_expiry.stop() + + now = discord.utils.utcnow() + expiration_datetime = now - log_expire_after + # WARNING: comparison is done lexicographically, not by date. + # This is fine as long as the date is in zero-padded ISO format, which it should be. + expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) + + logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") + + def format_channel_name(self, author, exclude_channel=None, force_null=False): + """Sanitises a username for use with text channel names + + Placed in main bot class to be extendable to plugins""" + guild = self.modmail_guild + + if force_null: + name = new_name = "null" + else: + if self.config["use_random_channel_name"]: + to_hash = self.token.split(".")[-1] + str(author.id) + digest = hashlib.md5(to_hash.encode("utf8"), usedforsecurity=False) + name = new_name = digest.hexdigest()[-8:] + elif self.config["use_user_id_channel_name"]: + name = new_name = str(author.id) + elif self.config["use_timestamp_channel_name"]: + name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") + else: + if self.config["use_nickname_channel_name"]: + author_member = self.guild.get_member(author.id) + name = author_member.display_name.lower() + else: + name = author.name.lower() + + if force_null: + name = "null" - await asyncio.sleep(3600) + name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" + if author.discriminator != "0": + name += f"-{author.discriminator}" + new_name = name + counter = 1 + existed = set(c.name for c in guild.text_channels if c != exclude_channel) + while new_name in existed: + new_name = f"{name}_{counter}" # multiple channels with same name + counter += 1 -if __name__ == '__main__': - if os.name != 'nt': - import uvloop + return new_name + + +def main(): + try: + # noinspection PyUnresolvedReferences + import uvloop # type: ignore + + logger.debug("Setting up with uvloop.") uvloop.install() + except ImportError: + pass + + try: + import cairosvg # noqa: F401 + except OSError: + if os.name == "nt": + if struct.calcsize("P") * 8 != 64: + logger.error( + "Unable to import cairosvg, ensure your Python is a 64-bit version: https://www.python.org/downloads/" + ) + else: + logger.error( + "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" + ) + else: + if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): + logger.error( + "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + else: + logger.error( + "Unable to import cairosvg, report on our support server with your OS details: https://discord.gg/etJNHCQ" + ) + sys.exit(0) + + # check discord version + discord_version = "2.6.3" + if discord.__version__ != discord_version: + logger.error( + "Dependencies are not updated, run pipenv install. discord.py version expected %s, received %s", + discord_version, + discord.__version__, + ) + sys.exit(0) + bot = ModmailBot() bot.run() + + +if __name__ == "__main__": + main() diff --git a/cogs/modmail.py b/cogs/modmail.py index 443ade3c3d..b0e38ed9e0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,541 +1,1575 @@ import asyncio -from datetime import datetime -from typing import Optional, Union +import re +from datetime import datetime, timezone, timedelta +from itertools import zip_longest +from typing import Optional, Union, List, Tuple, Literal +import logging import discord from discord.ext import commands - -import re +from discord.ext import tasks +from discord.ext.commands.view import StringView +from discord.ext.commands.cooldowns import BucketType +from discord.role import Role +from discord.utils import escape_markdown from dateutil import parser -from natural.date import duration from core import checks -from core.decorators import trigger_typing -from core.models import Bot -from core.paginator import PaginatorSession +from core.models import DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger +from core.paginator import EmbedPaginatorSession +from core.thread import Thread from core.time import UserFriendlyTime, human_timedelta -from core.utils import format_preview, User +from core.utils import * + +logger = getLogger(__name__) -class Modmail: +class Modmail(commands.Cog): """Commands directly related to Modmail functionality.""" - def __init__(self, bot: Bot): + def __init__(self, bot): self.bot = bot + self._snoozed_cache = [] + self._auto_unsnooze_task = self.bot.loop.create_task(self.auto_unsnooze_task()) + + async def auto_unsnooze_task(self): + await self.bot.wait_until_ready() + last_db_query = 0 + while not self.bot.is_closed(): + now = datetime.now(timezone.utc) + try: + # Query DB every 2 minutes + if (now.timestamp() - last_db_query) > 120: + snoozed_threads = await self.bot.api.logs.find( + {"snooze_until": {"$gte": now.isoformat()}} + ).to_list(None) + self._snoozed_cache = snoozed_threads or [] + last_db_query = now.timestamp() + # Check cache every 10 seconds + to_unsnooze = [] + for thread_data in list(self._snoozed_cache): + snooze_until = thread_data.get("snooze_until") + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + if snooze_until: + try: + dt = parser.isoparse(snooze_until) + except Exception: + continue + if now >= dt: + to_unsnooze.append(thread_data) + for thread_data in to_unsnooze: + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + thread = self.bot.threads.cache.get(thread_id) or await self.bot.threads.find( + id=thread_id + ) + if thread and thread.snoozed: + await thread.restore_from_snooze() + logging.info(f"[AUTO-UNSNOOZE] Thread {thread_id} auto-unsnoozed.") + try: + channel = thread.channel + if channel: + await channel.send("⏰ This thread has been automatically unsnoozed.") + except Exception as e: + logger.info( + "Failed to notify channel after auto-unsnooze: %s", + e, + ) + self._snoozed_cache.remove(thread_data) + except Exception as e: + logging.error(f"Error in auto_unsnooze_task: {e}") + await asyncio.sleep(10) + + def _resolve_user(self, user_str): + """Helper to resolve a user from mention, ID, or username.""" + import re + + if not user_str: + return None + if user_str.isdigit(): + return int(user_str) + match = re.match(r"<@!?(\d+)>", user_str) + if match: + return int(match.group(1)) + return None + + def _resolve_user(self, user_str): + """Helper to resolve a user from mention, ID, or username.""" + import re + + if not user_str: + return None + if user_str.isdigit(): + return int(user_str) + match = re.match(r"<@!?(\d+)>", user_str) + if match: + return int(match.group(1)) + return None @commands.command() @trigger_typing - @checks.has_permissions(administrator=True) + @checks.has_permissions(PermissionLevel.OWNER) async def setup(self, ctx): - """Sets up a server for Modmail""" - if self.bot.main_category: - return await ctx.send( - f'{self.bot.modmail_guild} is already set up.' + """ + Sets up a server for Modmail. + + You only need to run this command + once after configuring Modmail. + """ + + if ctx.guild != self.bot.modmail_guild: + return await ctx.send(f"You can only setup in the Modmail guild: {self.bot.modmail_guild}.") + + if self.bot.main_category is not None: + logger.debug("Can't re-setup server, main_category is found.") + return await ctx.send(f"{self.bot.modmail_guild} is already set up.") + + if self.bot.modmail_guild is None: + embed = discord.Embed( + title="Error", + description="Modmail functioning guild not found.", + color=self.bot.error_color, ) + return await ctx.send(embed=embed) - category = await self.bot.modmail_guild.create_category( - name='Modmail', - overwrites=self.bot.overwrites(ctx) - ) + overwrites = { + self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False), + self.bot.modmail_guild.me: discord.PermissionOverwrite(read_messages=True), + } + + for level in PermissionLevel: + if level <= PermissionLevel.REGULAR: + continue + permissions = self.bot.config["level_permissions"].get(level.name, []) + for perm in permissions: + perm = int(perm) + if perm == -1: + key = self.bot.modmail_guild.default_role + else: + key = self.bot.modmail_guild.get_member(perm) + if key is None: + key = self.bot.modmail_guild.get_role(perm) + if key is not None: + logger.info("Granting %s access to Modmail category.", key.name) + overwrites[key] = discord.PermissionOverwrite(read_messages=True) + + category = await self.bot.modmail_guild.create_category(name="Modmail", overwrites=overwrites) await category.edit(position=0) - log_channel = await self.bot.modmail_guild.create_text_channel( - name='bot-logs', category=category - ) + log_channel = await self.bot.modmail_guild.create_text_channel(name="bot-logs", category=category) embed = discord.Embed( - title='Friendly Reminder:', - description='You may use the `config set log_channel_id ' - '` command to set up a custom log channel' - ', then you can delete the default ' - f'{log_channel.mention} channel.', - color=self.bot.main_color + title="Friendly Reminder", + description=f"You may use the `{self.bot.prefix}config set log_channel_id " + "` command to set up a custom log channel, then you can delete this default " + f"{log_channel.mention} log channel.", + color=self.bot.main_color, ) - embed.set_footer(text=f'Type "{self.bot.prefix}help" ' - 'for a complete list of commands.') + embed.add_field( + name="Thanks for using our bot!", + value="If you like what you see, consider giving the " + "[repo a star](https://github.com/modmail-dev/modmail) :star: and if you are " + "feeling extra generous, buy us coffee on [Buy Me A Coffee](https://buymeacoffee.com/modmaildev) :heart:!", + ) + + embed.set_footer(text=f'Type "{self.bot.prefix}help" for a complete list of commands.') await log_channel.send(embed=embed) - self.bot.config['main_category_id'] = category.id - self.bot.config['log_channel_id'] = log_channel.id + self.bot.config["main_category_id"] = category.id + self.bot.config["log_channel_id"] = log_channel.id await self.bot.config.update() - await ctx.send('Successfully set up server.') + await ctx.send( + "**Successfully set up server.**\n" + "Consider setting permission levels to give access to roles " + "or users the ability to use Modmail.\n\n" + f"Type:\n- `{self.bot.prefix}permissions` and `{self.bot.prefix}permissions add` " + "for more info on setting permissions.\n" + f"- `{self.bot.prefix}config help` for a list of available customizations." + ) - @commands.group() - @checks.has_permissions(manage_messages=True) - async def snippets(self, ctx): - """Returns a list of snippets that are currently set.""" - if ctx.invoked_subcommand is not None: - return + if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: + await self.bot.update_perms(PermissionLevel.REGULAR, -1) + for owner_id in self.bot.bot_owner_ids: + await self.bot.update_perms(PermissionLevel.OWNER, owner_id) - embeds = [] + @commands.group(aliases=["snippets"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet(self, ctx, *, name: str.lower = None): + """ + Create pre-defined messages for use in threads. - if self.bot.snippets: - embed = discord.Embed(color=self.bot.main_color, - description='Here is a list of snippets ' - 'that are currently configured.') - else: + When `{prefix}snippet` is used by itself, this will retrieve + a list of snippets that are currently set. `{prefix}snippet-name` will show what the + snippet point to. + + To create a snippet: + - `{prefix}snippet add snippet-name A pre-defined text.` + + You can use your snippet in a thread channel + with `{prefix}snippet-name`, the message "A pre-defined text." + will be sent to the recipient. + + Currently, there is not a built-in anonymous snippet command; however, a workaround + is available using `{prefix}alias`. Here is how: + - `{prefix}alias add snippet-name anonreply A pre-defined anonymous text.` + + See also `{prefix}alias`. + """ + + if name is not None: + if name == "compact": + embeds = [] + + for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): + description = format_description(i, names) + embed = discord.Embed(color=self.bot.main_color, description=description) + embed.set_author( + name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128) + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + return + + snippet_name = self.bot._resolve_snippet(name) + + if snippet_name is None: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + else: + val = self.bot.snippets[snippet_name] + embed = discord.Embed( + title=f'Snippet - "{snippet_name}":', + description=val, + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + + if not self.bot.snippets: embed = discord.Embed( - color=discord.Color.red(), - description='You dont have any snippets at the moment.' + color=self.bot.error_color, + description="You dont have any snippets at the moment.", ) - embed.set_footer( - text=f'Do {self.bot.prefix}help snippets for more commands.' + embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') + embed.set_author( + name="Snippets", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), ) + return await ctx.send(embed=embed) - embed.set_author(name='Snippets', icon_url=ctx.guild.icon_url) - embeds.append(embed) + embeds = [discord.Embed(color=self.bot.main_color) for _ in range((len(self.bot.snippets) // 10) + 1)] + for embed in embeds: + embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) - for name, value in self.bot.snippets.items(): - if len(embed.fields) == 5: - embed = discord.Embed(color=self.bot.main_color, - description=embed.description) - embed.set_author(name='Snippets', icon_url=ctx.guild.icon_url) - embeds.append(embed) - embed.add_field(name=name, value=value, inline=False) + for i, snippet in enumerate(sorted(self.bot.snippets.items())): + embeds[i // 10].add_field( + name=snippet[0], value=return_or_truncate(snippet[1], 350), inline=False + ) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() - @snippets.command(name='add') - @checks.has_permissions(manage_messages=True) - async def add_(self, ctx, name: str.lower, *, value): - """Add a snippet to the bot config.""" - if 'snippets' not in self.bot.config.cache: - self.bot.config['snippets'] = {} + @snippet.command(name="raw") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_raw(self, ctx, *, name: str.lower): + """ + View the raw content of a snippet. + """ + 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(self.bot.snippets[snippet_name]), 2048 - 7) + embed = discord.Embed( + 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", aliases=["create", "make"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_add(self, ctx, name: str.lower, *, value: commands.clean_content): + """ + Add a snippet. + + Simply to add a snippet, do: ``` + {prefix}snippet add hey hello there :) + ``` + then when you type `{prefix}hey`, "hello there :)" will get sent to the recipient. + + To add a multi-word snippet name, use quotes: ``` + {prefix}snippet add "two word" this is a two word snippet. + ``` + """ + if self.bot.get_command(name): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{name}`.", + ) + return await ctx.send(embed=embed) + elif name in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Snippet `{name}` already exists.", + ) + return await ctx.send(embed=embed) + + if name in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"An alias that shares the same name exists: `{name}`.", + ) + return await ctx.send(embed=embed) + + if len(name) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Snippet names cannot be longer than 120 characters.", + ) + return await ctx.send(embed=embed) - self.bot.config.snippets[name] = value + self.bot.snippets[name] = value await self.bot.config.update() embed = discord.Embed( - title='Added snippet', + title="Added snippet", color=self.bot.main_color, - description=f'`{name}` points to: {value}' + description="Successfully created snippet.", ) + return await ctx.send(embed=embed) - 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 - @snippets.command(name='del') - @checks.has_permissions(manage_messages=True) - async def del_(self, ctx, *, name: str.lower): - """Removes a snippet from bot config.""" + 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}" - if self.bot.config.snippets.get(name): embed = discord.Embed( - title='Removed snippet', + title="Removed snippet", color=self.bot.main_color, - description=f'`{name}` no longer exists.' + description=description, ) - del self.bot.config['snippets'][name] + self.bot.snippets.pop(name) await self.bot.config.update() - else: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + await ctx.send(embed=embed) + + @snippet.command(name="edit") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_edit(self, ctx, name: str.lower, *, value): + """ + Edit a snippet. + + To edit a multi-word snippet name, use quotes: ``` + {prefix}snippet edit "two word" this is a new two word snippet. + ``` + """ + if name in self.bot.snippets: + self.bot.snippets[name] = value + await self.bot.config.update() + embed = discord.Embed( - title='Error', - color=discord.Color.red(), - description=f'Snippet `{name}` does not exist.' + title="Edited snippet", + color=self.bot.main_color, + description=f'`{name}` will now send "{value}".', ) - + else: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) - @commands.command() - @checks.has_permissions(manage_messages=True) - async def move(self, ctx, *, category: discord.CategoryChannel): - """Moves a thread to a specified category.""" + @commands.command(usage=" [options]") + @checks.has_permissions(PermissionLevel.MODERATOR) + @checks.thread_only() + async def move(self, ctx, *, arguments): + """ + Move a thread to another category. + + `category` may be a category ID, mention, or name. + `options` is a string which takes in arguments on how to perform the move. Ex: "silently" + """ + split_args = arguments.strip('"').split(" ") + category = None + + # manually parse arguments, consumes as much of args as possible for category + for i in range(len(split_args)): + try: + if i == 0: + fmt = arguments + else: + fmt = " ".join(split_args[:-i]) + + category = await SimilarCategoryConverter().convert(ctx, fmt) + except commands.BadArgument: + if i == len(split_args) - 1: + # last one + raise + pass + else: + break + + if not category: + raise commands.ChannelNotFound(arguments) + + options = " ".join(arguments.split(" ")[-i:]) + thread = ctx.thread - if not thread: + silent = False + + if options: + silent_words = ["silent", "silently"] + silent = any(word in silent_words for word in options.split()) + + await thread.channel.move( + category=category, + end=True, + sync_permissions=True, + reason=f"{ctx.author} moved this thread.", + ) + + if self.bot.config["thread_move_notify"] and not silent: embed = discord.Embed( - title='Error', - description='This is not a Modmail thread.', - color=discord.Color.red() + title=self.bot.config["thread_move_title"], + description=self.bot.config["thread_move_response"], + color=self.bot.main_color, ) - return await ctx.send(embed=embed) + await thread.recipient.send(embed=embed) - await thread.channel.edit(category=category, sync_permissions=True) - await ctx.message.add_reaction('✅') + if self.bot.config["thread_move_notify_mods"]: + mention = self.bot.config["mention"] + if mention is not None: + msg = f"{mention}, thread has been moved." + else: + msg = "Thread has been moved." + await thread.channel.send(msg) - @staticmethod - async def send_scheduled_close_message(ctx, after, silent=False): - human_delta = human_timedelta(after.dt) + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) - silent = '*silently* ' if silent else '' + async def send_scheduled_close_message(self, ctx, after, silent=False): + """Send a scheduled close notice only to the staff thread channel. + Uses Discord relative timestamp formatting for better UX. + """ + ts = int((after.dt if after.dt.tzinfo else after.dt.replace(tzinfo=timezone.utc)).timestamp()) embed = discord.Embed( - title='Scheduled close', - description=f'This thread will close {silent}in {human_delta}.', - color=discord.Color.red() + title="Scheduled close", + description=f"This thread will{' silently' if silent else ''} close .", + color=self.bot.error_color, ) - if after.arg and not silent: - embed.add_field(name='Message', value=after.arg) - - embed.set_footer(text='Closing will be cancelled ' - 'if a thread message is sent.') + embed.add_field(name="Message", value=after.arg) + embed.set_footer(text="Closing will be cancelled if a thread message is sent.") embed.timestamp = after.dt - await ctx.send(embed=embed) + thread = getattr(ctx, "thread", None) + if thread and ctx.channel == thread.channel: + await thread.channel.send(embed=embed) - @commands.command(usage='[after] [close message]') + @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. Close after a period of time: - - `close in 5 hours` - - `close 2m30s` + - `{prefix}close in 5 hours` + - `{prefix}close 2m30s` Custom close messages: - - `close 2 hours The issue has been resolved.` - - `close We will contact you once we find out more.` + - `{prefix}close 2 hours The issue has been resolved.` + - `{prefix}close We will contact you once we find out more.` Silently close a thread (no message) - - `close silently` - - `close in 10m silently` + - `{prefix}close silently` + - `{prefix}close silently in 10m` Stop a thread from closing: - - `close cancel` + - `{prefix}close cancel` """ 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: - await thread.cancel_closure() - embed = discord.Embed(color=discord.Color.red(), - description='Scheduled close ' - 'has been cancelled.') + if thread.close_task is not None or thread.auto_close_task is not None: + await thread.cancel_closure(all=True) + embed = discord.Embed( + color=self.bot.error_color, + description="Scheduled close has been cancelled.", + ) else: embed = discord.Embed( - color=discord.Color.red(), - description='This thread has not already ' - 'been scheduled to close.' + color=self.bot.error_color, + description="This thread has not already been scheduled to close.", ) 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, - ) + await thread.close(closer=ctx.author, after=close_after, message=message, silent=silent) - @commands.command(aliases=['alert']) + @staticmethod + def parse_user_or_role(ctx, user_or_role): + mention = None + if user_or_role is None: + mention = ctx.author.mention + elif hasattr(user_or_role, "mention"): + mention = user_or_role.mention + elif user_or_role in {"here", "everyone", "@here", "@everyone"}: + mention = "@" + user_or_role.lstrip("@") + return mention + + @commands.command(aliases=["alert"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def notify(self, ctx, *, role=None): + async def notify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ - Notify a given role or yourself to the next thread message received. + Notify a user or role when the next thread message received. - Once a thread message is received you will be pinged once only. + Once a thread message is received, `user_or_role` will be pinged once. + + Leave `user_or_role` empty to notify yourself. + `@here` and `@everyone` can be substituted with `here` and `everyone`. + `user_or_role` may be a user ID, mention, name. role ID, mention, name, "everyone", or "here". """ - thread = ctx.thread + mention = self.parse_user_or_role(ctx, user_or_role) + if mention is None: + raise commands.BadArgument(f"{user_or_role} is not a valid user or role.") - if not role: - mention = ctx.author.mention - elif role.lower() in ('here', 'everyone'): - mention = '@' + role - else: - converter = commands.RoleConverter() - role = await converter.convert(ctx, role) - mention = role.mention + thread = ctx.thread - if str(thread.id) not in self.bot.config['notification_squad']: - self.bot.config['notification_squad'][str(thread.id)] = [] + if str(thread.id) not in self.bot.config["notification_squad"]: + self.bot.config["notification_squad"][str(thread.id)] = [] - mentions = self.bot.config['notification_squad'][str(thread.id)] + mentions = self.bot.config["notification_squad"][str(thread.id)] if mention in mentions: - embed = discord.Embed(color=discord.Color.red(), - description=f'{mention} is already ' - 'going to be mentioned.') + embed = discord.Embed( + color=self.bot.error_color, + description=f"{mention} is already going to be mentioned.", + ) else: mentions.append(mention) await self.bot.config.update() - embed = discord.Embed(color=self.bot.main_color, - description=f'{mention} will be mentioned ' - 'on the next message received.') + embed = discord.Embed( + color=self.bot.main_color, + description=f"{mention} will be mentioned on the next message received.", + ) return await ctx.send(embed=embed) - @commands.command(aliases=['sub']) + @commands.command(aliases=["unalert"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def subscribe(self, ctx, *, role=None): + async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): """ - Notify yourself or a given role for every thread message received. + Un-notify a user, role, or yourself from a thread. - You will be pinged for every thread message - received until you unsubscribe. + Leave `user_or_role` empty to un-notify yourself. + `@here` and `@everyone` can be substituted with `here` and `everyone`. + `user_or_role` may be a user ID, mention, name, role ID, mention, name, "everyone", or "here". """ + mention = self.parse_user_or_role(ctx, user_or_role) + if mention is None: + mention = f"`{user_or_role}`" + thread = ctx.thread - if not role: - mention = ctx.author.mention - elif role.lower() in ('here', 'everyone'): - mention = '@' + role + if str(thread.id) not in self.bot.config["notification_squad"]: + self.bot.config["notification_squad"][str(thread.id)] = [] + + mentions = self.bot.config["notification_squad"][str(thread.id)] + + if mention not in mentions: + embed = discord.Embed( + color=self.bot.error_color, + description=f"{mention} does not have a pending notification.", + ) else: - converter = commands.RoleConverter() - role = await converter.convert(ctx, role) - mention = role.mention + mentions.remove(mention) + await self.bot.config.update() + embed = discord.Embed( + color=self.bot.main_color, + description=f"{mention} will no longer be notified.", + ) + return await ctx.send(embed=embed) + + @commands.command(aliases=["sub"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def subscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + """ + Notify a user, role, or yourself for every thread message received. + + You will be pinged for every thread message received until you unsubscribe. + + Leave `user_or_role` empty to subscribe yourself. + `@here` and `@everyone` can be substituted with `here` and `everyone`. + `user_or_role` may be a user ID, mention, name, role ID, mention, name, "everyone", or "here". + """ + mention = self.parse_user_or_role(ctx, user_or_role) + if mention is None: + raise commands.BadArgument(f"{user_or_role} is not a valid user or role.") + + thread = ctx.thread - if str(thread.id) not in self.bot.config['subscriptions']: - self.bot.config['subscriptions'][str(thread.id)] = [] + if str(thread.id) not in self.bot.config["subscriptions"]: + self.bot.config["subscriptions"][str(thread.id)] = [] - mentions = self.bot.config['subscriptions'][str(thread.id)] + mentions = self.bot.config["subscriptions"][str(thread.id)] if mention in mentions: - embed = discord.Embed(color=discord.Color.red(), - description=f'{mention} is already ' - 'subscribed to this thread.') + embed = discord.Embed( + color=self.bot.error_color, + description=f"{mention} is already subscribed to this thread.", + ) else: mentions.append(mention) await self.bot.config.update() embed = discord.Embed( color=self.bot.main_color, - description=f'{mention} will now be ' - 'notified of all messages received.' + description=f"{mention} will now be notified of all messages received.", ) return await ctx.send(embed=embed) - @commands.command(aliases=['unsub']) + @commands.command(aliases=["unsub"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def unsubscribe(self, ctx, *, role=None): - """Unsubscribe yourself or a given role from a thread.""" - thread = ctx.thread + async def unsubscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + """ + Unsubscribe a user, role, or yourself from a thread. - if not role: - mention = ctx.author.mention - elif role.lower() in ('here', 'everyone'): - mention = '@' + role - else: - converter = commands.RoleConverter() - role = await converter.convert(ctx, role) - mention = role.mention + Leave `user_or_role` empty to unsubscribe yourself. + `@here` and `@everyone` can be substituted with `here` and `everyone`. + `user_or_role` may be a user ID, mention, name, role ID, mention, name, "everyone", or "here". + """ + mention = self.parse_user_or_role(ctx, user_or_role) + if mention is None: + mention = f"`{user_or_role}`" + + thread = ctx.thread - if str(thread.id) not in self.bot.config['subscriptions']: - self.bot.config['subscriptions'][str(thread.id)] = [] + if str(thread.id) not in self.bot.config["subscriptions"]: + self.bot.config["subscriptions"][str(thread.id)] = [] - mentions = self.bot.config['subscriptions'][str(thread.id)] + mentions = self.bot.config["subscriptions"][str(thread.id)] if mention not in mentions: - embed = discord.Embed(color=discord.Color.red(), - description=f'{mention} is not already ' - 'subscribed to this thread.') + embed = discord.Embed( + color=self.bot.error_color, + description=f"{mention} is not subscribed to this thread.", + ) else: mentions.remove(mention) await self.bot.config.update() - embed = discord.Embed(color=self.bot.main_color, - description=f'{mention} is now unsubscribed ' - 'to this thread.') + embed = discord.Embed( + color=self.bot.main_color, + description=f"{mention} is now unsubscribed from this thread.", + ) return await ctx.send(embed=embed) @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() async def nsfw(self, ctx): - """Flags a Modmail thread as nsfw.""" + """Flags a Modmail thread as NSFW (not safe for work).""" await ctx.channel.edit(nsfw=True) - await ctx.message.add_reaction('✅') + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def sfw(self, ctx): + """Flags a Modmail thread as SFW (safe for work).""" + await ctx.channel.edit(nsfw=False) + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def msglink(self, ctx, message_id: int): + """Retrieves the link to a message in the current thread.""" + found = False + for recipient in ctx.thread.recipients: + try: + message = await recipient.fetch_message(message_id) + found = True + break + except discord.NotFound: + continue + if not found: + embed = discord.Embed( + color=self.bot.error_color, + description="Message not found or no longer exists.", + ) + else: + embed = discord.Embed(color=self.bot.main_color, description=message.jump_url) + await ctx.send(embed=embed) @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() async def loglink(self, ctx): - """Return the link to the current thread's logs.""" + """Retrieves the link to the current thread's logs.""" log_link = await self.bot.api.get_log_link(ctx.channel.id) - await ctx.send( - embed=discord.Embed( - color=self.bot.main_color, - description=log_link - ) - ) + await ctx.send(embed=discord.Embed(color=self.bot.main_color, description=log_link)) def format_log_embeds(self, logs, avatar_url): embeds = [] logs = tuple(logs) - title = f'Total Results Found ({len(logs)})' + title = f"Total Results Found ({len(logs)})" for entry in logs: + created_at = parser.parse(entry["created_at"]).astimezone(timezone.utc) - key = entry['key'] - - created_at = parser.parse(entry['created_at']) - + prefix = self.bot.config["log_url_prefix"].strip("/") + if prefix == "NONE": + prefix = "" log_url = ( - f"https://logs.modmail.tk/{key}" - if not self.bot.self_hosted else - self.bot.config.log_url.strip('/') + f'/logs/{key}' + f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" ) - username = entry['recipient']['name'] + '#' - username += entry['recipient']['discriminator'] + username = entry["recipient"]["name"] + if entry["recipient"]["discriminator"] != "0": + username += "#" + entry["recipient"]["discriminator"] - embed = discord.Embed(color=self.bot.main_color, - timestamp=created_at) - embed.set_author(name=f'{title} - {username}', - icon_url=avatar_url, - url=log_url) + embed = 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='Closed By', - value=f"<@{entry['closer']['id']}>") - - if entry['recipient']['id'] != entry['creator']['id']: - embed.add_field(name='Created by', - value=f"<@{entry['creator']['id']}>") - - embed.add_field(name='Preview', - value=format_preview(entry['messages']), - inline=False) - embed.add_field(name='Link', value=log_url) - embed.set_footer( - text='Recipient ID: ' + str(entry['recipient']['id']) - ) + embed.add_field(name="Created", value=human_timedelta(created_at)) + closer = entry.get("closer") + if closer is None: + closer_msg = "Unknown" + else: + closer_msg = f"<@{closer['id']}>" + embed.add_field(name="Closed By", value=closer_msg) + + if entry["recipient"]["id"] != entry["creator"]["id"]: + embed.add_field(name="Created by", value=f"<@{entry['creator']['id']}>") + + if entry.get("title"): + embed.add_field(name="Title", value=entry["title"], inline=False) + + embed.add_field(name="Preview", value=format_preview(entry["messages"]), inline=False) + + if closer is not None: + # BUG: Currently, logviewer can't display logs without a closer. + embed.add_field(name="Link", value=log_url) + else: + logger.debug("Invalid log entry: no closer.") + embed.add_field(name="Log Key", value=f"`{entry['key']}`") + + embed.set_footer(text="Recipient ID: " + str(entry["recipient"]["id"])) embeds.append(embed) return embeds - @commands.group(invoke_without_command=True) - @checks.has_permissions(manage_messages=True) - async def logs(self, ctx, *, member: User = None): - """Shows a list of previous Modmail thread logs of a member.""" + @commands.command(cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def title(self, ctx, *, name: str): + """Sets title for a thread""" + await ctx.thread.set_title(name) + sent_emoji, _ = await self.bot.retrieve_emoji() + await ctx.message.pin() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Adds a user to a modmail thread - await ctx.trigger_typing() + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + # u is a discord.Member + curr_thread = await self.bot.threads.find(recipient=u) + if curr_thread == ctx.thread: + users.remove(u) + continue + + if curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is already in a thread: {curr_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="All users are already in the thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return - if not member: - thread = ctx.thread - if not thread: - raise commands.UserInputError - user = thread.recipient - else: - user = member + if len(users + ctx.thread.recipients) > 5: + em = discord.Embed( + title="Error", + description="Only 5 users are allowed in a group conversation", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return - default_avatar = 'https://cdn.discordapp.com/embed/avatars/0.png' - icon_url = getattr(user, 'avatar_url', default_avatar) + to_exec = [] + if not silent: + description = self.bot.formatter.format( + self.bot.config["private_added_to_group_response"], moderator=ctx.author + ) + em = discord.Embed( + title=self.bot.config["private_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=str(ctx.author), + icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + ) + for u in users: + to_exec.append(u.send(embed=em)) - logs = await self.bot.api.get_user_logs(user.id) + description = self.bot.formatter.format( + self.bot.config["public_added_to_group_response"], + moderator=ctx.author, + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) - if not any(not log['open'] for log in logs): - embed = discord.Embed(color=discord.Color.red(), - description='This user does not ' - 'have any previous logs.') - return await ctx.send(embed=embed) + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) - logs = reversed([e for e in logs if not e['open']]) + await ctx.thread.add_users(users) + if to_exec: + await asyncio.gather(*to_exec) - embeds = self.format_log_embeds(logs, avatar_url=icon_url) + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) - session = PaginatorSession(ctx, *embeds) - await session.run() + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Removes a user from a modmail thread - @logs.command(name='closed-by') - @checks.has_permissions(manage_messages=True) - async def closed_by(self, ctx, *, user: User = None): - """Returns all logs closed by a user.""" - if not self.bot.self_hosted: - embed = discord.Embed( - color=discord.Color.red(), - description='This command only works if ' - 'you are self-hosting your logs.' + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + # u is a discord.Member + curr_thread = await self.bot.threads.find(recipient=u) + if ctx.thread != curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is not in this thread.", + color=self.bot.error_color, ) - return await ctx.send(embed=embed) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + elif ctx.thread.recipient == u: + em = discord.Embed( + title="Error", + description=f"{u.mention} is the main recipient of the thread and cannot be removed.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="No valid users to remove.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return - user = user or ctx.author + to_exec = [] + if not silent: + description = self.bot.formatter.format( + self.bot.config["private_removed_from_group_response"], + moderator=ctx.author, + ) + em = discord.Embed( + title=self.bot.config["private_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=str(ctx.author), + icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + ) + for u in users: + to_exec.append(u.send(embed=em)) - query = { - 'guild_id': str(self.bot.guild_id), - 'open': False, - 'closer.id': str(user.id) - } + description = self.bot.formatter.format( + self.bot.config["public_removed_from_group_response"], + moderator=ctx.author, + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) - projection = { - 'messages': {'$slice': 5} - } + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) - entries = await self.bot.db.logs.find(query, projection).to_list(None) + await ctx.thread.remove_users(users) + if to_exec: + await asyncio.gather(*to_exec) - embeds = self.format_log_embeds(entries, - avatar_url=self.bot.guild.icon_url) + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) - if not embeds: - embed = discord.Embed( - color=discord.Color.red(), - description='No log entries have been found for that query' - ) + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Adds a user to a modmail thread anonymously + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + curr_thread = await self.bot.threads.find(recipient=u) + if curr_thread == ctx.thread: + users.remove(u) + continue + + if curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is already in a thread: {curr_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + if not users: + em = discord.Embed( + title="Error", + description="All users are already in the thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + em = discord.Embed( + title=self.bot.config["private_added_to_group_title"], + description=self.bot.config["private_added_to_group_description_anon"], + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + tag = self.bot.config["mod_tag"] + if tag is None: + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + name = self.bot.config["anon_username"] + if name is None: + name = "Anonymous" + avatar_url = self.bot.config["anon_avatar_url"] + if avatar_url is None: + avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) + em.set_footer(text=name, icon_url=avatar_url if avatar_url else None) + + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_added_to_group_description_anon"], + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_added_to_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.add_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command(usage=" [options]", cooldown_after_parsing=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + @commands.cooldown(1, 600, BucketType.channel) + async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + """Removes a user from a modmail thread anonymously + + `options` can be `silent` or `silently`. + """ + silent = False + users = [] + for u in users_arg: + if isinstance(u, str): + if "silent" in u or "silently" in u: + silent = True + elif isinstance(u, discord.Role): + users += u.members + elif isinstance(u, discord.Member): + users.append(u) + + for u in users: + curr_thread = await self.bot.threads.find(recipient=u) + if ctx.thread != curr_thread: + em = discord.Embed( + title="Error", + description=f"{u.mention} is not in this thread.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + elif ctx.thread.recipient == u: + em = discord.Embed( + title="Error", + description=f"{u.mention} is the main recipient of the thread and cannot be removed.", + color=self.bot.error_color, + ) + await ctx.send(embed=em) + ctx.command.reset_cooldown(ctx) + return + + to_exec = [] + if not silent: + em = discord.Embed( + title=self.bot.config["private_removed_from_group_title"], + description=self.bot.config["private_removed_from_group_description_anon"], + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + + tag = self.bot.config["mod_tag"] + if tag is None: + tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + name = self.bot.config["anon_username"] + if name is None: + name = "Anonymous" + avatar_url = self.bot.config["anon_avatar_url"] + if avatar_url is None: + avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) + em.set_footer(text=name, icon_url=avatar_url if avatar_url else None) + + for u in users: + to_exec.append(u.send(embed=em)) + + description = self.bot.formatter.format( + self.bot.config["public_removed_from_group_description_anon"], + users=", ".join(u.name for u in users), + ) + em = discord.Embed( + title=self.bot.config["public_removed_from_group_title"], + description=description, + color=self.bot.main_color, + ) + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + ) + + for i in ctx.thread.recipients: + if i not in users: + to_exec.append(i.send(embed=em)) + + await ctx.thread.remove_users(users) + if to_exec: + await asyncio.gather(*to_exec) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs(self, ctx, *, user: User = None): + """ + Get previous Modmail thread logs of a member. + + Leave `user` blank when this command is used within a + thread channel to show logs for the current recipient. + `user` may be a user ID, mention, or name. + """ + + async with safe_typing(ctx): + pass + + if not user: + thread = ctx.thread + if not thread: + raise commands.MissingRequiredArgument(DummyParam("user")) + user = thread.recipient or await self.bot.get_or_fetch_user(thread.id) + + default_avatar = "https://cdn.discordapp.com/embed/avatars/0.png" + icon_url = getattr(user, "avatar_url", default_avatar) + + logs = await self.bot.api.get_user_logs(user.id) + + if not any(not log["open"] for log in logs): + embed = discord.Embed( + color=self.bot.error_color, + description="This user does not have any previous logs.", + ) + return await ctx.send(embed=embed) + + logs = reversed([log for log in logs if not log["open"]]) + + embeds = self.format_log_embeds(logs, avatar_url=icon_url) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + + @logs.command(name="closed-by", aliases=["closeby"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_closed_by(self, ctx, *, user: User = None): + """ + Get all logs closed by the specified user. + + If no `user` is provided, the user will be the person who sent this command. + `user` may be a user ID, mention, or name. + """ + 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.get_guild_icon(guild=ctx.guild)) + + if not embeds: + embed = discord.Embed( + color=self.bot.error_color, + description="No log entries have been found for that query.", + ) return await ctx.send(embed=embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() - @logs.command(name='search') - @checks.has_permissions(manage_messages=True) - async def search(self, ctx, limit: Optional[int] = None, *, query): - """Searches all logs for a message that contains your query.""" + @logs.command(name="key", aliases=["id"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_key(self, ctx, key: str): + """ + Get the log link for the specified log key. + """ + icon_url = ctx.author.avatar.url - await ctx.trigger_typing() + logs = await self.bot.api.find_log_entry(key) - if not self.bot.self_hosted: + if not logs: embed = discord.Embed( - color=discord.Color.red(), - description='This command only works if you ' - 'are self-hosting your logs.' - ) + color=self.bot.error_color, + description=f"Log entry `{key}` not found.", + ) return await ctx.send(embed=embed) - query = { - 'guild_id': str(self.bot.guild_id), - 'open': False, - '$text': { - '$search': f'"{query}"' - } - } + embeds = self.format_log_embeds(logs, avatar_url=icon_url) - projection = { - 'messages': {'$slice': 5} - } + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() - entries = await self.bot.db.logs.find(query, projection).to_list(limit) + @logs.command(name="delete", aliases=["wipe"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def logs_delete(self, ctx, key_or_link: str): + """ + Wipe a log entry from the database. + """ + key = key_or_link.split("/")[-1] - embeds = self.format_log_embeds(entries, - avatar_url=self.bot.guild.icon_url) + success = await self.bot.api.delete_log_entry(key) + + if not success: + embed = discord.Embed( + title="Error", + description=f"Log entry `{key}` not found.", + color=self.bot.error_color, + ) + else: + embed = discord.Embed( + title="Success", + description=f"Log entry `{key}` successfully deleted.", + color=self.bot.main_color, + ) + + await ctx.send(embed=embed) + + @logs.command(name="responded") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_responded(self, ctx, *, user: User = None): + """ + Get all logs where the specified user has responded at least once. + + If no `user` is provided, the user will be the person who sent this command. + `user` may be a user ID, mention, or name. + """ + user = user if user is not None else ctx.author + + entries = await self.bot.api.get_responded_logs(user.id) + + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) if not embeds: embed = discord.Embed( - color=discord.Color.red(), - description='No log entries have been found for that query' - ) + color=self.bot.error_color, + description=f"{getattr(user, 'mention', user.id)} has not responded to any threads.", + ) + return await ctx.send(embed=embed) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + + @logs.command(name="search", aliases=["find"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_search(self, ctx, limit: Optional[int] = None, *, query): + """ + Retrieve all logs that contain messages with your query. + + Provide a `limit` to specify the maximum number of logs the bot should find. + """ + + async with safe_typing(ctx): + pass + + entries = await self.bot.api.search_by_text(query, limit) + + embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) + + if not embeds: + embed = discord.Embed( + color=self.bot.error_color, + description="No log entries have been found for that query.", + ) return await ctx.send(embed=embed) - session = PaginatorSession(ctx, *embeds) + session = EmbedPaginatorSession(ctx, *embeds) await session.run() @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def reply(self, ctx, *, msg=''): - """Reply to users using this command. + async def reply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread. Supports attachments and images as well as automatically embedding image URLs. """ + + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) - @commands.command() + @commands.command(aliases=["formatreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def freply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with variables. + + Works just like `{prefix}reply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) + + @commands.command(aliases=["formatanonreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def anonreply(self, ctx, *, msg=''): - """Reply to a thread anonymously. + async def fareply(self, ctx, *, msg: str = ""): + """ + Anonymously reply to a Modmail thread with variables. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True) + + @commands.command(aliases=["formatplainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpreply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, plain=True) + + @commands.command(aliases=["formatplainanonreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def fpareply(self, ctx, *, msg: str = ""): + """ + Anonymously reply to a Modmail thread with variables and a plain message. + + Works just like `{prefix}areply`, however with the addition of three variables: + - `{{channel}}` - the `discord.TextChannel` object + - `{{recipient}}` - the `discord.User` object of the recipient + - `{{author}}` - the `discord.User` object of the author + + Supports attachments and images as well as + automatically embedding image URLs. + """ + msg = self.bot.formatter.format( + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, + ) + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) + + @commands.command(aliases=["anonreply", "anonymousreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def areply(self, ctx, *, msg: str = ""): + """ + Reply to a thread anonymously. You can edit the anonymous user's name, avatar and tag using the config command. @@ -543,252 +1577,1209 @@ async def anonreply(self, ctx, *, msg=''): Edit the `anon_username`, `anon_avatar_url` and `anon_tag` config variables to do so. """ + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message, anonymous=True) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True) - @commands.command() + @commands.command(aliases=["plainreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def note(self, ctx, *, msg=''): - """Take a note about the current thread, useful for noting context.""" + async def preply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.note(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, plain=True) + + @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def pareply(self, ctx, *, msg: str = ""): + """ + Reply to a Modmail thread with a plain message and anonymously. + + Supports attachments and images as well as + automatically embedding image URLs. + """ + # Ensure logs record only the reply text, not the command. + ctx.message.content = msg + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def note(self, ctx, *, msg: str = ""): + """ + Take a note about the current thread. + + Useful for noting context. + """ + ctx.message.content = msg + async with safe_typing(ctx): + msg = await ctx.thread.note(ctx.message) + await msg.pin() + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound): + pass + + @note.command(name="persistent", aliases=["persist"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def note_persistent(self, ctx, *, msg: str = ""): + """ + Take a persistent note about the current user. + """ + ctx.message.content = msg + async with safe_typing(ctx): + msg = await ctx.thread.note(ctx.message, persistent=True) + await msg.pin() + await self.bot.api.create_note(recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id) + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound) as e: + logger.debug(f"Failed to delete note command message: {e}") @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def edit(self, ctx, message_id: Optional[int] = None, - *, new_message): - """Edit a message that was sent using the reply command. + async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): + """ + Edit a message that was sent using the reply or anonreply command. - If no `message_id` is provided, the - last message sent by a mod will be edited. + If no `message_id` is provided, + the last message sent by a staff will be edited. - `[message_id]` the id of the message that you want to edit. - `new_message` is the new message that will be edited in. + Note: attachments **cannot** be edited. """ thread = ctx.thread - linked_message_id = None + try: + await thread.edit_message(message_id, message) + except ValueError: + return await ctx.send( + embed=discord.Embed( + title="Failed", + description="Cannot find a message to edit. Plain messages are not supported.", + color=self.bot.error_color, + ) + ) - async for msg in ctx.channel.history(): - if message_id is None and msg.embeds: - embed = msg.embeds[0] - if isinstance(self.bot.mod_color, discord.Color): - mod_color = self.bot.mod_color.value - else: - mod_color = self.bot.mod_color - if embed.color.value != mod_color or not embed.author.url: + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command() + @checks.has_permissions(PermissionLevel.REGULAR) + async def selfcontact(self, ctx): + """Creates a thread with yourself""" + # Check if user already has a thread + existing_thread = await self.bot.threads.find(recipient=ctx.author) + if existing_thread: + if existing_thread.snoozed: + # Unsnooze the thread + msg = await ctx.send("ℹ️ You had a snoozed thread. Unsnoozing now...") + await existing_thread.restore_from_snooze() + self.bot.threads.cache[existing_thread.id] = existing_thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound): + pass + return + else: + # Thread already exists and is active + embed = discord.Embed( + title="Thread not created", + description=f"A thread for you already exists in {existing_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed, delete_after=10) + return + + await ctx.invoke(self.contact, users=[ctx.author]) + + @commands.command(usage=" [category] [options]") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def contact( + self, + ctx, + users: commands.Greedy[ + Union[ + Literal["silent", "silently"], + discord.Member, + discord.User, + discord.Role, + ] + ], + *, + category: SimilarCategoryConverter = None, + manual_trigger=True, + ): + """ + Create a thread with a specified member. + + If `category` is specified, the thread + will be created in that specified category. + + `category`, if specified, may be a category ID, mention, or name. + `users` may be a user ID, mention, or name. If multiple users are specified, a group thread will start. + A maximum of 5 users are allowed. + `options` can be `silent` or `silently`. + """ + silent = any(x in users for x in ("silent", "silently")) + if silent: + try: + users.remove("silent") + except ValueError: + pass + + try: + users.remove("silently") + except ValueError: + pass + + if isinstance(category, str): + category = category.split() + + category = " ".join(category) + if category: + try: + category = await SimilarCategoryConverter().convert( + ctx, category + ) # attempt to find a category again + except commands.BadArgument: + category = None + + if isinstance(category, str): + category = None + + errors = [] + for u in list(users): + if isinstance(u, discord.Role): + users += u.members + users.remove(u) + + snoozed_users = [] + for u in list(users): + exists = await self.bot.threads.find(recipient=u) + if exists: + # Check if thread is snoozed + if exists.snoozed: + snoozed_users.append(u) continue - # TODO: use regex to find the linked message id - linked_message_id = str(embed.author.url).split('/')[-1] - break - elif message_id and msg.id == message_id: - url = msg.embeds[0].author.url - linked_message_id = str(url).split('/')[-1] - break + errors.append(f"A thread for {u} already exists.") + if exists.channel: + errors[-1] += f" in {exists.channel.mention}" + errors[-1] += "." + users.remove(u) + elif u.bot: + errors.append(f"{u} is a bot, cannot add to thread.") + users.remove(u) + elif await self.bot.is_blocked(u): + ref = f"{u.mention} is" if ctx.author != u else "You are" + errors.append(f"{ref} currently blocked from contacting {self.bot.user.name}.") + users.remove(u) + + # Handle snoozed users - unsnooze them and return early + if snoozed_users: + for u in snoozed_users: + thread = await self.bot.threads.find(recipient=u) + if thread and thread.snoozed: + msg = await ctx.send(f"ℹ️ {u.mention} had a snoozed thread. Unsnoozing now...") + await thread.restore_from_snooze() + self.bot.threads.cache[thread.id] = thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound) as e: + logger.debug( + f"Failed to delete message (likely already deleted or lacking permissions): {e}" + ) + # Don't try to create a new thread - we just unsnoozed existing ones + return + + if len(users) > 5: + errors.append("Group conversations only support 5 users.") + users = [] + + if errors or not users: + if not users: + # no users left + title = "Thread not created" + else: + title = None + + if manual_trigger: # not react to contact + embed = discord.Embed( + title=title, + color=self.bot.error_color, + description="\n".join(errors), + ) + await ctx.send(embed=embed, delete_after=10) + + if not users: + return - if not linked_message_id: - raise commands.UserInputError + creator = ctx.author if manual_trigger else users[0] - await asyncio.gather( - thread.edit_message(linked_message_id, new_message), - self.bot.api.edit_message(linked_message_id, new_message) + thread = await self.bot.threads.create( + recipient=users[0], + creator=creator, + category=category, + manual_trigger=manual_trigger, + # The minimum character check is enforced in ThreadManager.create ) - await ctx.message.add_reaction('✅') + if thread.cancelled: + return - @commands.command() - @trigger_typing - @checks.has_permissions(manage_messages=True) - async def contact(self, ctx, - category: Optional[discord.CategoryChannel] = None, *, - user: Union[discord.Member, discord.User]): - """Create a thread with a specified member. + if self.bot.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): + logger.info("Contacting user %s when Modmail DM is disabled.", users[0]) - If the optional category argument is passed, the thread - will be created in the specified category. - """ + if not silent and not self.bot.config.get("thread_contact_silently"): + if creator.id == users[0].id: + description = self.bot.config["thread_creation_self_contact_response"] + else: + description = self.bot.formatter.format( + self.bot.config["thread_creation_contact_response"], creator=creator + ) - if user.bot: - embed = discord.Embed( - color=discord.Color.red(), - description='Cannot start a thread with a bot.' + em = discord.Embed( + title=self.bot.config["thread_creation_contact_title"], + description=description, + color=self.bot.main_color, ) - return await ctx.send(embed=embed) - - exists = await self.bot.threads.find(recipient=user) - if exists: - embed = discord.Embed( - color=discord.Color.red(), - description='A thread for this user already ' - f'exists in {exists.channel.mention}.' + if self.bot.config["show_timestamp"]: + em.timestamp = discord.utils.utcnow() + em.set_footer( + text=f"{creator}", icon_url=creator.display_avatar.url if creator.display_avatar else None ) - else: - thread = self.bot.threads.create(user, creator=ctx.author, - category=category) - await thread.wait_until_ready() - embed = discord.Embed( - title='Created thread', - description=f'Thread started in {thread.channel.mention} ' - f'for {user.mention}.', - color=self.bot.main_color - ) + for u in users: + await u.send(embed=em) - await ctx.send(embed=embed) + embed = discord.Embed( + title="Created Thread", + description=f"Thread started by {creator.mention} for {', '.join(u.mention for u in users)}.", + color=self.bot.main_color, + ) + await thread.wait_until_ready() - @commands.command() + if users[1:]: + await thread.add_users(users[1:]) + + await thread.channel.send(embed=embed) + + if manual_trigger: + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=5) + except (discord.Forbidden, discord.NotFound): + pass + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - @checks.has_permissions(kick_members=True) async def blocked(self, ctx): - """Returns a list of blocked users""" - embed = discord.Embed(title='Blocked Users', - color=self.bot.main_color, - description='Here is a list of blocked users.') + """Retrieve a list of blocked users.""" + roles = [] users = [] - not_reachable = [] + now = ctx.message.created_at + + blocked_users = list(self.bot.blocked_users.items()) + for id_, reason in blocked_users: + # parse "reason" and check if block is expired + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue + + if end_time is not None: + if after <= 0: + # No longer blocked + self.bot.blocked_users.pop(str(id_)) + logger.debug("No longer blocked, user %s.", id_) + continue + users.append((f"<@{id_}>", reason)) + + blocked_roles = list(self.bot.blocked_roles.items()) + for id_, reason in blocked_roles: + # parse "reason" and check if block is expired + # etc "blah blah blah... until 2019-10-14T21:12:45.559948." + try: + end_time, after = extract_block_timestamp(reason, id_) + except ValueError: + continue + + if end_time is not None: + if after <= 0: + # No longer blocked + self.bot.blocked_roles.pop(str(id_)) + logger.debug("No longer blocked, role %s.", id_) + continue - for id_, reason in self.bot.blocked_users.items(): - user = self.bot.get_user(int(id_)) - if user: - users.append((user, reason)) - else: - not_reachable.append((id_, reason)) + role = self.bot.guild.get_role(int(id_)) + if role: + roles.append((role.mention, reason)) + + user_embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] if users: - val = '\n'.join(u.mention + (f' - `{r}`' if r else '') - for u, r in users) - embed.add_field(name='Currently Known', value=val) - if not_reachable: - val = '\n'.join(f'`{i}`' + (f' - `{r}`' if r else '') - for i, r in not_reachable) - embed.add_field(name='Unknown', value=val, inline=False) + 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", + color=self.bot.main_color, + description=line, + ) + user_embeds.append(embed) + else: + embed.description += line + else: + user_embeds[0].description = "Currently there are no blocked users." + + if len(user_embeds) > 1: + for n, em in enumerate(user_embeds): + em.title = f"{em.title} [{n + 1}]" + + role_embeds = [discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="")] + + if roles: + embed = role_embeds[-1] + + for mention, reason in roles: + line = mention + f" - {reason or 'No Reason Provided'}\n" + if len(embed.description) + len(line) > 2048: + role_embeds[-1].set_author() + embed = discord.Embed( + title="Blocked Roles", + color=self.bot.main_color, + description=line, + ) + role_embeds.append(embed) + else: + embed.description += line + else: + role_embeds[-1].description = "Currently there are no blocked roles." - if not users and not not_reachable: - embed.description = 'Currently there are no blocked users.' + if len(role_embeds) > 1: + for n, em in enumerate(role_embeds): + em.title = f"{em.title} [{n + 1}]" - await ctx.send(embed=embed) + session = EmbedPaginatorSession(ctx, *user_embeds, *role_embeds) - @commands.command() + await session.run() + + @blocked.command(name="whitelist") + @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - @checks.has_permissions(kick_members=True) - async def block(self, ctx, user: Optional[User] = None, *, - after: UserFriendlyTime = None): + async def blocked_whitelist(self, ctx, *, user: User = None): """ - Block a user from using Modmail. + Whitelist or un-whitelist a user from getting blocked. - Note: reasons that start with "System Message: " are reserved for internal - use only. + Useful for preventing users from getting blocked by account_age/guild_age restrictions. """ - reason = '' - if user is None: thread = ctx.thread if thread: user = thread.recipient else: - raise commands.UserInputError + return await ctx.send_help(ctx.command) - if after is not None: - reason = after.arg - if reason.startswith('System Message: '): - raise commands.UserInputError - elif re.search(r'%(.+?)%$', reason) is not None: - raise commands.UserInputError - elif after.dt > after.now: - reason = f'{reason} %{after.dt.isoformat()}%' + mention = getattr(user, "mention", f"`{user.id}`") + msg = "" - if not reason: - reason = None + if str(user.id) in self.bot.blocked_whitelisted_users: + embed = discord.Embed( + title="Success", + description=f"{mention} is no longer whitelisted.", + color=self.bot.main_color, + ) + self.bot.blocked_whitelisted_users.remove(str(user.id)) + return await ctx.send(embed=embed) - mention = user.mention if hasattr(user, 'mention') else f'`{user.id}`' + self.bot.blocked_whitelisted_users.append(str(user.id)) - extend = f' for `{reason}`' if reason is not None else '' - msg = self.bot.blocked_users.get(str(user.id)) - if msg is None: - msg = '' + if str(user.id) in self.bot.blocked_users: + msg = self.bot.blocked_users.get(str(user.id)) or "" + self.bot.blocked_users.pop(str(user.id)) - if str(user.id) not in self.bot.blocked_users or extend or msg.startswith('System Message: '): - if str(user.id) in self.bot.blocked_users: + await self.bot.config.update() - old_reason = msg.strip().rstrip('.') or 'no reason' - embed = discord.Embed( - title='Success', - description=f'{mention} was previously blocked for ' - f'"{old_reason}". {mention} is now blocked{extend}.', - color=self.bot.main_color - ) + if msg.startswith("System Message: "): + # If the user is blocked internally (for example: below minimum account age) + # Show an extended message stating the original internal message + reason = msg[16:].strip().rstrip(".") + embed = discord.Embed( + title="Success", + description=f"{mention} was previously blocked internally for " + f'"{reason}". {mention} is now whitelisted.', + color=self.bot.main_color, + ) + else: + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"{mention} is now whitelisted.", + ) + + return await ctx.send(embed=embed) + + @commands.command(usage="[user] [duration] [reason]") + @checks.has_permissions(PermissionLevel.MODERATOR) + @trigger_typing + async def block( + self, + ctx, + user_or_role: Optional[Union[User, discord.Role]] = None, + *, + after: UserFriendlyTime = None, + ): + """ + Block a user or role from using Modmail. + + You may choose to set a time as to when the user will automatically be unblocked. + + Leave `user` blank when this command is used within a + thread channel to block the current recipient. + `user` may be a user ID, mention, or name. + `duration` may be a simple "human-readable" time text. See `{prefix}help close` for examples. + """ + + if user_or_role is None: + thread = ctx.thread + if thread: + user_or_role = thread.recipient + elif after is None: + raise commands.MissingRequiredArgument(DummyParam("user or role")) else: - embed = discord.Embed( - title='Success', - color=self.bot.main_color, - description=f'{mention} is now blocked{extend}.' - ) - self.bot.config.blocked[str(user.id)] = reason - await self.bot.config.update() + raise commands.BadArgument(f'User or role "{after.arg}" not found.') + + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") + + if ( + not isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_whitelisted_users + ): + embed = discord.Embed( + title="Error", + description=f"Cannot block {mention}, user is whitelisted.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + reason = f"by {escape_markdown(str(ctx.author))}" + + if after is not None: + if "%" in reason: + raise commands.BadArgument('The reason contains illegal character "%".') + + if after.arg: + fmt_dt = discord.utils.format_dt(after.dt, "R") + if after.dt > after.now: + fmt_dt = discord.utils.format_dt(after.dt, "f") + + reason += f" until {fmt_dt}" + + reason += "." + + if isinstance(user_or_role, discord.Role): + msg = self.bot.blocked_roles.get(str(user_or_role.id)) else: + msg = self.bot.blocked_users.get(str(user_or_role.id)) + + if msg is None: + msg = "" + + if msg: + old_reason = msg.strip().rstrip(".") embed = discord.Embed( - title='Error', - color=discord.Color.red(), - description=f'{mention} is already blocked.' + title="Success", + description=f"{mention} was previously blocked {old_reason}.\n" + f"{mention} is now blocked {reason}", + color=self.bot.main_color, ) + else: + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"{mention} is now blocked {reason}", + ) + + if isinstance(user_or_role, discord.Role): + self.bot.blocked_roles[str(user_or_role.id)] = reason + else: + self.bot.blocked_users[str(user_or_role.id)] = reason + await self.bot.config.update() return await ctx.send(embed=embed) @commands.command() + @checks.has_permissions(PermissionLevel.MODERATOR) @trigger_typing - @checks.has_permissions(kick_members=True) - async def unblock(self, ctx, *, user: User = None): + async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): """ - Unblocks a user from using Modmail. + Unblock a user from using Modmail. - Note: reasons start with "System Message: " are reserved for internal - use only. + Leave `user` blank when this command is used within a + thread channel to unblock the current recipient. + `user` may be a user ID, mention, or name. """ - if user is None: + if user_or_role is None: thread = ctx.thread if thread: - user = thread.recipient + user_or_role = thread.recipient else: - raise commands.UserInputError + raise commands.MissingRequiredArgument(DummyParam("user or role")) - mention = user.mention if hasattr(user, 'mention') else f'`{user.id}`' + mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") + name = getattr(user_or_role, "name", f"`{user_or_role.id}`") - if str(user.id) in self.bot.blocked_users: - msg = self.bot.blocked_users.get(str(user.id)) - if msg is None: - msg = '' - del self.bot.config.blocked[str(user.id)] + if not isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_users: + msg = self.bot.blocked_users.pop(str(user_or_role.id)) or "" await self.bot.config.update() - if msg.startswith('System Message: '): + if msg.startswith("System Message: "): # If the user is blocked internally (for example: below minimum account age) # Show an extended message stating the original internal message - reason = msg[16:].strip().rstrip('.') or 'no reason' + reason = msg[16:].strip().rstrip(".") or "no reason" embed = discord.Embed( - title='Success', - description=f'{mention} was previously blocked internally due to ' - f'"{reason}". {mention} is no longer blocked.', - color=self.bot.main_color + title="Success", + description=f"{mention} was previously blocked internally {reason}.\n" + f"{mention} is no longer blocked.", + color=self.bot.main_color, + ) + embed.set_footer( + text="However, if the original system block reason still applies, " + f"{name} will be automatically blocked again. " + f'Use "{self.bot.prefix}blocked whitelist {user_or_role.id}" to whitelist the user.' ) else: embed = discord.Embed( - title='Success', + title="Success", color=self.bot.main_color, - description=f'{mention} is no longer blocked.' + description=f"{mention} is no longer blocked.", ) + elif isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_roles: + msg = self.bot.blocked_roles.pop(str(user_or_role.id)) or "" + await self.bot.config.update() + + embed = discord.Embed( + title="Success", + color=self.bot.main_color, + description=f"{mention} is no longer blocked.", + ) else: embed = discord.Embed( - title='Error', - description=f'{mention} is not blocked.', - color=discord.Color.red() + title="Error", + description=f"{mention} is not blocked.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def delete(self, ctx, message_id: int = None): + """ + Delete a message that was sent using the reply command or a note. + + Deletes the previous message, unless a message ID is provided, + which in that case, deletes the message with that message ID. + + Notes can only be deleted when a note ID is provided. + """ + thread = ctx.thread + + try: + await thread.delete_message(message_id, note=True) + except ValueError as e: + logger.warning("Failed to delete message: %s.", e) + return await ctx.send( + embed=discord.Embed( + title="Failed", + description="Cannot find a message to delete. Plain messages are not supported.", + color=self.bot.error_color, + ) + ) + + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def repair(self, ctx): + """ + Repair a thread broken by Discord. + """ + sent_emoji, blocked_emoji = await self.bot.retrieve_emoji() + + if ctx.thread: + user_id = match_user_id(ctx.channel.topic) + if user_id == -1: + logger.info("Setting current channel's topic to User ID.") + await ctx.channel.edit(topic=f"User ID: {ctx.thread.id}") + return await self.bot.add_reaction(ctx.message, sent_emoji) + + logger.info("Attempting to fix a broken thread %s.", ctx.channel.name) -def setup(bot): - bot.add_cog(Modmail(bot)) + # Search cache for channel + user_id, thread = next( + ((k, v) for k, v in self.bot.threads.cache.items() if v.channel == ctx.channel), + (-1, None), + ) + if thread is not None: + logger.debug("Found thread with tempered ID.") + await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}") + return await self.bot.add_reaction(ctx.message, sent_emoji) + + # find genesis message to retrieve User ID + async for message in ctx.channel.history(limit=10, oldest_first=True): + if ( + message.author == self.bot.user + and message.embeds + and message.embeds[0].color + 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, any_string=True) + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = await self.bot.get_or_fetch_user(uid) + + if user_id != -1: + recipient = self.bot.get_user(user_id) + if recipient is None: + self.bot.threads.cache[user_id] = thread = Thread( + self.bot.threads, user_id, ctx.channel, other_recipients + ) + else: + self.bot.threads.cache[user_id] = thread = Thread( + self.bot.threads, recipient, ctx.channel, other_recipients + ) + thread.ready = True + logger.info("Setting current channel's topic to User ID and created new thread.") + await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}") + return await self.bot.add_reaction(ctx.message, sent_emoji) + + else: + logger.warning("No genesis message found.") + + # match username from channel name + # username-1234, username-1234_1, username-1234_2 + m = re.match(r"^(.+?)(?:-(\d{4}))?(?:_\d+)?$", ctx.channel.name) + if m is not None: + users = set( + filter( + lambda member: member.name == m.group(1) + and (member.discriminator == "0" or member.discriminator == m.group(2)), + ctx.guild.members, + ) + ) + if len(users) == 1: + user = users.pop() + name = self.bot.format_channel_name(user, exclude_channel=ctx.channel) + recipient = self.bot.get_user(user.id) + if user.id in self.bot.threads.cache: + thread = self.bot.threads.cache[user.id] + if thread.channel: + embed = discord.Embed( + title="Delete Channel", + description="This thread channel is no longer in use. " + f"All messages will be directed to {ctx.channel.mention} instead.", + color=self.bot.error_color, + ) + embed.set_footer( + text='Please manually delete this channel, do not use "{prefix}close".' + ) + try: + await thread.channel.send(embed=embed) + except discord.HTTPException: + pass + + other_recipients = match_other_recipients(ctx.channel.topic) + for n, uid in enumerate(other_recipients): + other_recipients[n] = await self.bot.get_or_fetch_user(uid) + + if recipient is None: + self.bot.threads.cache[user.id] = thread = Thread( + self.bot.threads, user_id, ctx.channel, other_recipients + ) + else: + self.bot.threads.cache[user.id] = thread = Thread( + self.bot.threads, recipient, ctx.channel, other_recipients + ) + thread.ready = True + logger.info("Setting current channel's topic to User ID and created new thread.") + await ctx.channel.edit( + reason="Fix broken Modmail thread", + name=name, + topic=f"User ID: {user.id}", + ) + return await self.bot.add_reaction(ctx.message, sent_emoji) + + elif len(users) >= 2: + logger.info("Multiple users with the same name and discriminator.") + return await self.bot.add_reaction(ctx.message, blocked_emoji) + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def enable(self, ctx): + """ + Re-enables DM functionalities of Modmail. + + Undo's the `{prefix}disable` command, all DM will be relayed after running this command. + """ + embed = discord.Embed( + title="Success", + description="Modmail will now accept all DM messages.", + color=self.bot.main_color, + ) + + if self.bot.config["dm_disabled"] != DMDisabled.NONE: + self.bot.config["dm_disabled"] = DMDisabled.NONE + await self.bot.config.update() + + return await ctx.send(embed=embed) + + @commands.group(invoke_without_command=True) + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def disable(self, ctx): + """ + Disable partial or full Modmail thread functions. + + To stop all new threads from being created, do `{prefix}disable new`. + To stop all existing threads from DMing Modmail, do `{prefix}disable all`. + To check if the DM function for Modmail is enabled, do `{prefix}isenable`. + """ + await ctx.send_help(ctx.command) + + @disable.command(name="new") + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def disable_new(self, ctx): + """ + Stop accepting new Modmail threads. + + No new threads can be created through DM. + """ + embed = discord.Embed( + title="Success", + description="Modmail will not create any new threads.", + color=self.bot.main_color, + ) + if self.bot.config["dm_disabled"] != DMDisabled.NEW_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.NEW_THREADS + await self.bot.config.update() + + return await ctx.send(embed=embed) + + @disable.command(name="all") + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def disable_all(self, ctx): + """ + Disables all DM functionalities of Modmail. + + No new threads can be created through DM nor no further DM messages will be relayed. + """ + embed = discord.Embed( + title="Success", + description="Modmail will not accept any DM messages.", + color=self.bot.main_color, + ) + + if self.bot.config["dm_disabled"] != DMDisabled.ALL_THREADS: + self.bot.config["dm_disabled"] = DMDisabled.ALL_THREADS + await self.bot.config.update() + + return await ctx.send(embed=embed) + + @commands.command() + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + async def isenable(self, ctx): + """ + Check if the DM functionalities of Modmail is enabled. + """ + + if self.bot.config["dm_disabled"] == DMDisabled.NEW_THREADS: + embed = discord.Embed( + title="New Threads Disabled", + description="Modmail is not creating new threads.", + color=self.bot.error_color, + ) + elif self.bot.config["dm_disabled"] == DMDisabled.ALL_THREADS: + embed = discord.Embed( + title="All DM Disabled", + description="Modmail is not accepting any DM messages for new and existing threads.", + color=self.bot.error_color, + ) + else: + embed = discord.Embed( + title="Enabled", + description="Modmail now is accepting all DM messages.", + color=self.bot.main_color, + ) + + return await ctx.send(embed=embed) + + @commands.command(usage="[duration]") + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def snooze(self, ctx, *, duration: UserFriendlyTime = None): + """ + Snooze this thread. Behavior depends on config: + - delete (default): deletes the channel and restores it later + - move: moves the channel to the configured snoozed category + Optionally specify a duration, e.g. 'snooze 2d' for 2 days. + Uses config: snooze_default_duration, snooze_title, snooze_text + """ + thread = ctx.thread + if thread.snoozed: + await ctx.send("This thread is already snoozed.") + logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.") + return + # Default snooze duration with safe fallback + try: + default_snooze = int(self.bot.config.get("snooze_default_duration", 604800)) + except (ValueError, TypeError): + default_snooze = 604800 + if duration: + snooze_for = int((duration.dt - duration.now).total_seconds()) + snooze_for = min(snooze_for, default_snooze) + else: + snooze_for = default_snooze + + # Capacity pre-check: if behavior is move, ensure snoozed category has room (<49 channels) + behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + if behavior == "move": + snoozed_cat_id = self.bot.config.get("snoozed_category_id") + target_category = None + if snoozed_cat_id: + try: + target_category = self.bot.modmail_guild.get_channel(int(snoozed_cat_id)) + except Exception: + target_category = None + # Auto-create snoozed category if missing + if not isinstance(target_category, discord.CategoryChannel): + try: + logging.info("Auto-creating snoozed category for move-based snoozing.") + # Hide category by default; only bot can view/manage + overwrites = { + self.bot.modmail_guild.default_role: discord.PermissionOverwrite(view_channel=False) + } + bot_member = self.bot.modmail_guild.me + if bot_member is not None: + overwrites[bot_member] = discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + manage_channels=True, + manage_messages=True, + attach_files=True, + embed_links=True, + add_reactions=True, + ) + target_category = await self.bot.modmail_guild.create_category( + name="Snoozed Threads", + overwrites=overwrites, + reason="Auto-created snoozed category for move-based snoozing", + ) + try: + await self.bot.config.set("snoozed_category_id", target_category.id) + await self.bot.config.update() + except Exception as e: + logging.warning("Failed to persist snoozed_category_id: %s", e) + try: + await ctx.send( + "⚠️ Created snoozed category but failed to save it to config. Please set `snoozed_category_id` manually." + ) + except Exception as e: + logging.info( + "Failed to notify about snoozed category persistence issue: %s", + e, + ) + await ctx.send( + embed=discord.Embed( + title="Snoozed category created", + description=( + f"Created category {target_category.mention if hasattr(target_category, 'mention') else target_category.name} " + "and set it as `snoozed_category_id`." + ), + color=self.bot.main_color, + ) + ) + except Exception as e: + await ctx.send( + embed=discord.Embed( + title="Could not create snoozed category", + description=( + "I couldn't create a category automatically. Please ensure I have Manage Channels " + "permission, or set `snoozed_category_id` manually." + ), + color=self.bot.error_color, + ) + ) + logging.warning("Failed to auto-create snoozed category: %s", e) + # Capacity check after ensuring category exists + if isinstance(target_category, discord.CategoryChannel): + try: + if len(target_category.channels) >= 49: + await ctx.send( + embed=discord.Embed( + title="Snooze unavailable", + description=( + "The configured snoozed category is full (49 channels). " + "Unsnooze or move some channels out before snoozing more." + ), + color=self.bot.error_color, + ) + ) + return + except Exception as e: + logging.debug("Failed to check snoozed category channel count: %s", e) + + # Store snooze_until timestamp for reliable auto-unsnooze + now = datetime.now(timezone.utc) + snooze_until = now + timedelta(seconds=snooze_for) + await self.bot.api.logs.update_one( + {"recipient.id": str(thread.id)}, + { + "$set": { + "snooze_start": now.isoformat(), + "snooze_for": snooze_for, + "snooze_until": snooze_until.isoformat(), + } + }, + ) + embed = discord.Embed( + title=self.bot.config.get("snooze_title") or "Thread Snoozed", + description=self.bot.config.get("snooze_text") or "This thread has been snoozed.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + ok = await thread.snooze(moderator=ctx.author, snooze_for=snooze_for) + if ok: + logging.info( + f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} snoozed for {snooze_for}s." + ) + self.bot.threads.cache[thread.id] = thread + else: + await ctx.send("Failed to snooze this thread.") + logging.error(f"[SNOOZE] Failed to snooze thread for {getattr(thread.recipient, 'id', None)}.") + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def unsnooze(self, ctx, *, user: str = None): + """ + Unsnooze a thread: restores the channel and replays messages. + You can specify a user by mention or ID, or run in a thread channel to unsnooze that thread. + Uses config: unsnooze_text + """ + import discord + + thread = None + user_obj = None + if user is not None: + user_id = self._resolve_user(user) + if user_id: + try: + user_obj = await self.bot.get_or_fetch_user(user_id) + except Exception: + logger.debug( + "Failed fetching user during unsnooze; falling back to partial object (%s).", + user_id, + exc_info=True, + ) + user_obj = discord.Object(user_id) + if user_obj: + thread = await self.bot.threads.find(recipient=user_obj) + if not thread: + await ctx.send(f"[DEBUG] No thread found for user {user} (obj: {user_obj}).") + logging.warning(f"[UNSNOOZE] No thread found for user {user} (obj: {user_obj})") + return + elif hasattr(ctx, "thread"): + thread = ctx.thread + else: + await ctx.send("This is not a Modmail thread.") + logging.warning("[UNSNOOZE] Not a Modmail thread context.") + return + if not thread.snoozed: + await ctx.send("This thread is not snoozed.") + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} is not snoozed.") + return + + # Manually fetch snooze_data if the thread object doesn't have it + if not thread.snooze_data: + log_entry = await self.bot.api.logs.find_one({"recipient.id": str(thread.id), "snoozed": True}) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + await ctx.send( + self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." + ) + logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} unsnoozed.") + else: + await ctx.send("Failed to unsnooze this thread.") + logging.error( + f"[UNSNOOZE] Failed to unsnooze thread for {getattr(thread.recipient, 'id', None)}." + ) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snoozed(self, ctx): + """ + List all currently snoozed threads/users. + """ + snoozed_threads = [thread for thread in self.bot.threads.cache.values() if thread.snoozed] + if not snoozed_threads: + await ctx.send("No threads are currently snoozed.") + return + + lines = [] + now = datetime.now(timezone.utc) + for thread in snoozed_threads: + user = thread.recipient.name if thread.recipient else "Unknown" + user_id = thread.id + + since_str = "?" + until_str = "?" + + if thread.snooze_data: + since = thread.snooze_data.get("snooze_start") + duration = thread.snooze_data.get("snooze_for") + + if since: + try: + since_dt = datetime.fromisoformat(since) + since_str = f"" # Discord relative timestamp + except (ValueError, TypeError) as e: + logging.warning(f"[SNOOZED] Invalid snooze_start for {user_id}: {since} ({e})") + else: + logging.warning(f"[SNOOZED] Missing snooze_start for {user_id}") + + if duration and since_str != "?": + try: + until_dt = datetime.fromisoformat(since) + timedelta(seconds=int(duration)) + until_str = f"" + except (ValueError, TypeError) as e: + logging.warning( + f"[SNOOZED] Invalid until time for {user_id}: {since} + {duration} ({e})" + ) + + lines.append(f"- {user} (`{user_id}`) since {since_str}, until {until_str}") + + await ctx.send("Snoozed threads:\n" + "\n".join(lines)) + + async def cog_load(self): + self.snooze_auto_unsnooze.start() + + @tasks.loop(seconds=10) + async def snooze_auto_unsnooze(self): + now = datetime.now(timezone.utc) + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + for entry in snoozed: + snooze_until = entry.get("snooze_until") + if snooze_until: + try: + until_dt = datetime.fromisoformat(snooze_until) + if now >= until_dt: + thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"])) + if thread and thread.snoozed: + await thread.restore_from_snooze() + except (ValueError, TypeError) as e: + logger.debug( + "Failed parsing snooze_until timestamp for auto-unsnooze loop: %s", + e, + ) + + @snooze_auto_unsnooze.before_loop + async def _snooze_auto_unsnooze_before(self): + await self.bot.wait_until_ready() + + async def process_dm_modmail(self, message: discord.Message) -> None: + # ... existing code ... + # Before processing, check if thread is snoozed and auto-unsnooze + thread = await self.threads.find(recipient=message.author) + if thread and thread.snoozed: + await thread.restore_from_snooze() + # Ensure the thread object in the cache is updated with the new channel + self.threads.cache[thread.id] = thread + # ... rest of the method unchanged ... + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + async def clearsnoozed(self, ctx): + """ + List all snoozed threads and ask for confirmation before clearing (unsnoozing) all of them. + Only proceed if the user confirms. + """ + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + if not snoozed: + await ctx.send("No threads are currently snoozed.") + return + lines = [] + for entry in snoozed: + user = entry.get("recipient", {}).get("name", "Unknown") + user_id = entry.get("recipient", {}).get("id", "?") + lines.append(f"- {user} (`{user_id}`)") + msg = await ctx.send( + "The following threads are currently snoozed and will be unsnoozed if you confirm:\n" + + "\n".join(lines) + + "\n\nType `yes` to confirm, or anything else to cancel." + ) + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel + + try: + reply = await self.bot.wait_for("message", check=check, timeout=30) + except asyncio.TimeoutError: + await ctx.send("Timed out. No threads were unsnoozed.") + return + if reply.content.strip().lower() != "yes": + await ctx.send("Cancelled. No threads were unsnoozed.") + return + count = 0 + for entry in snoozed: + user_id = entry.get("recipient", {}).get("id") + if not user_id: + continue + user_obj = None + try: + user_obj = await self.bot.get_or_fetch_user(int(user_id)) + except Exception: + user_obj = discord.Object(int(user_id)) + thread = await self.bot.threads.find(recipient=user_obj) + if thread and thread.snoozed: + ok = await thread.restore_from_snooze() + if ok: + self.bot.threads.cache[thread.id] = thread + count += 1 + await ctx.send(f"Unsnoozed {count} threads.") + + +async def setup(bot): + await bot.add_cog(Modmail(bot)) diff --git a/cogs/plugins.py b/cogs/plugins.py index 54ed742f53..aa4ad5a65c 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -1,240 +1,787 @@ -import importlib -import logging +import asyncio +import io +import json import os import shutil -import site -import stat -import subprocess import sys - +import typing +import zipfile +from difflib import get_close_matches +from importlib import invalidate_caches +from pathlib import Path, PurePath +from re import match +from site import USER_SITE +from subprocess import PIPE + +import discord from discord.ext import commands +from packaging.version import Version -from core.models import Bot -from core.utils import info, error +from core import checks +from core.models import PermissionLevel, getLogger +from core.paginator import EmbedPaginatorSession +from core.utils import trigger_typing, truncate, safe_typing -logger = logging.getLogger('Modmail') +logger = getLogger(__name__) -class DownloadError(Exception): +class InvalidPluginError(commands.BadArgument): pass -class Plugins: - """Plugins expand Modmail functionality by allowing third-party addons. +class Plugin: + def __init__(self, user, repo=None, name=None, branch=None): + if repo is None: + self.user = "@local" + self.repo = "@local" + self.name = user + self.local = True + self.branch = "@local" + self.url = f"@local/{user}" + self.link = f"@local/{user}" + else: + self.user = user + self.repo = repo + self.name = name + self.local = False + self.branch = branch if branch is not None else "master" + self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip" + self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}" + + @property + def path(self): + if self.local: + return PurePath("plugins") / "@local" / self.name + return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" + + @property + def abs_path(self): + return Path(__file__).absolute().parent.parent / self.path + + @property + def cache_path(self): + if self.local: + raise ValueError("No cache path for local plugins!") + return ( + Path(__file__).absolute().parent.parent + / "temp" + / "plugins-cache" + / f"{self.user}-{self.repo}-{self.branch}.zip" + ) + + @property + def ext_string(self): + if self.local: + return f"plugins.@local.{self.name}.{self.name}" + return f"plugins.{self.user}.{self.repo}.{self.name}-{self.branch}.{self.name}" + + def __str__(self): + if self.local: + return f"@local/{self.name}" + return f"{self.user}/{self.repo}/{self.name}@{self.branch}" + + def __lt__(self, other): + return self.name.lower() < other.name.lower() + + @classmethod + def from_string(cls, s, strict=False): + m = match(r"^@?local/(.+)$", s) + if m is None: + if not strict: + m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s) + else: + m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s) + + if m is not None: + return Plugin(*m.groups()) + raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple + + def __hash__(self): + return hash((self.user, self.repo, self.name, self.branch)) + + def __repr__(self): + return f"" + + def __eq__(self, other): + return isinstance(other, Plugin) and self.__str__() == other.__str__() + + +class Plugins(commands.Cog): + """ + Plugins expand Modmail functionality by allowing third-party addons. These addons could have a range of features from moderation to simply making your life as a moderator easier! Learn how to create a plugin yourself here: - https://github.com/kyb3r/modmail/wiki/Plugins + https://github.com/modmail-dev/modmail/wiki/Plugins """ - def __init__(self, bot: Bot): - self.bot = bot - self.bot.loop.create_task(self.download_initial_plugins()) - @staticmethod - def _asubprocess_run(cmd): - return subprocess.run(cmd, shell=True, check=True, - capture_output=True) + def __init__(self, bot): + self.bot = bot + self.registry = {} + self.loaded_plugins = set() + self._ready_event = asyncio.Event() + + async def cog_load(self): + await self.populate_registry() + if self.bot.config.get("enable_plugins"): + await self.initial_load_plugins() + else: + logger.info("Plugins not loaded since ENABLE_PLUGINS=false.") - @staticmethod - def parse_plugin(name): - # returns: (username, repo, plugin_name) + async def populate_registry(self): + url = "https://raw.githubusercontent.com/modmail-dev/modmail/master/plugins/registry.json" try: - result = name.split('/') - result[2] = '/'.join(result[2:]) - except IndexError: - return None - return tuple(result) - - async def download_initial_plugins(self): - await self.bot._connected.wait() - for i in self.bot.config.plugins: - parsed_plugin = self.parse_plugin(i) + async with self.bot.session.get(url) as resp: + self.registry = json.loads(await resp.text()) + except asyncio.TimeoutError: + logger.warning("Failed to fetch registry. Loading with empty registry") + async def initial_load_plugins(self): + for plugin_name in list(self.bot.config["plugins"]): try: - await self.download_plugin_repo(*parsed_plugin[:-1]) - except DownloadError as exc: - msg = f'{parsed_plugin[0]}/{parsed_plugin[1]} - {exc}' - logger.error(error(msg)) - else: + plugin = Plugin.from_string(plugin_name, strict=True) + except InvalidPluginError: + self.bot.config["plugins"].remove(plugin_name) try: - await self.load_plugin(*parsed_plugin) - except DownloadError as exc: - msg = f'{parsed_plugin[0]}/{parsed_plugin[1]} - {exc}' - logger.error(error(msg)) + # For backwards compat + plugin = Plugin.from_string(plugin_name) + except InvalidPluginError: + logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) + continue + + logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) + self.bot.config["plugins"].append(str(plugin)) - async def download_plugin_repo(self, username, repo): - try: - cmd = f'git clone https://github.com/{username}/{repo} ' - cmd += f'plugins/{username}-{repo} -q' - await self.bot.loop.run_in_executor( - None, - self._asubprocess_run, - cmd - ) - # -q (quiet) so there's no terminal output unless there's an error - except subprocess.CalledProcessError as exc: - err = exc.stderr.decode('utf-8').strip() - if not err.endswith('already exists and is ' - 'not an empty directory.'): - # don't raise error if the plugin folder exists - raise DownloadError(error) from exc - - async def load_plugin(self, username, repo, plugin_name): - ext = f'plugins.{username}-{repo}.{plugin_name}.{plugin_name}' - dirname = f'plugins/{username}-{repo}/{plugin_name}' - if 'requirements.txt' in os.listdir(dirname): - # Install PIP requirements try: - await self.bot.loop.run_in_executor( - None, self._asubprocess_run, - f'python3 -m pip install -U -r {dirname}/' - 'requirements.txt --user -q -q' + await self.download_plugin(plugin) + await self.load_plugin(plugin) + except Exception: + self.bot.config["plugins"].remove(plugin_name) + logger.error( + "Error when loading plugin %s. Plugin removed from config.", + plugin, + exc_info=True, ) - # -q -q (quiet) - # so there's no terminal output unless there's an error - except subprocess.CalledProcessError as exc: - err = exc.stderr.decode('utf8').strip() - if err: - raise DownloadError( - f'Unable to download requirements: ```\n{error}\n```' - ) from exc - else: - if not os.path.exists(site.USER_SITE): - os.makedirs(site.USER_SITE) + continue - sys.path.insert(0, site.USER_SITE) + logger.debug("Finished loading all plugins.") - try: - self.bot.load_extension(ext) - except ModuleNotFoundError as exc: - raise DownloadError('Invalid plugin structure') from exc + self.bot.dispatch("plugins_ready") + + self._ready_event.set() + await self.bot.config.update() + + async def download_plugin(self, plugin, force=False): + if plugin.abs_path.exists() and (not force or plugin.local): + return + + if plugin.local: + raise InvalidPluginError(f"Local plugin {plugin} not found!") + + plugin.abs_path.mkdir(parents=True, exist_ok=True) + + if plugin.cache_path.exists() and not force: + plugin_io = plugin.cache_path.open("rb") + logger.debug("Loading cached %s.", plugin.cache_path) else: - msg = f'Loaded plugins.{username}-{repo}.{plugin_name}' - logger.info(info(msg)) - - @commands.group(aliases=['plugins']) - @commands.is_owner() - async def plugin(self, ctx): - """Plugin handler. Controls the plugins in the bot.""" - if ctx.invoked_subcommand is None: - cmd = self.bot.get_command('help') - await ctx.invoke(cmd, command='plugin') - - @plugin.command() - async def add(self, ctx, *, plugin_name): - """Adds a plugin""" - if plugin_name in self.bot.config.plugins: - return await ctx.send('Plugin already installed') - if plugin_name in self.bot.cogs.keys(): - # another class with the same name - return await ctx.send('Another cog exists with the same name') + headers = {} + github_token = self.bot.config["github_token"] + if github_token is not None: + headers["Authorization"] = f"token {github_token}" - message = await ctx.send('Downloading plugin...') - async with ctx.typing(): - if len(plugin_name.split('/')) >= 3: - parsed_plugin = self.parse_plugin(plugin_name) + async with self.bot.session.get(plugin.url, headers=headers) as resp: + logger.debug("Downloading %s.", plugin.url) + raw = await resp.read() try: - await self.download_plugin_repo(*parsed_plugin[:-1]) - except DownloadError as exc: - return await ctx.send( - f'Unable to fetch plugin from Github: {exc}' - ) + raw = await resp.text() + except UnicodeDecodeError: + pass + else: + if raw == "Not Found": + raise InvalidPluginError("Plugin not found") + else: + raise InvalidPluginError("Invalid download received, non-bytes object") + + plugin_io = io.BytesIO(raw) + if not plugin.cache_path.parent.exists(): + plugin.cache_path.parent.mkdir(parents=True) + + with plugin.cache_path.open("wb") as f: + f.write(raw) + + with zipfile.ZipFile(plugin_io) as zipf: + for info in zipf.infolist(): + path = PurePath(info.filename) + if len(path.parts) >= 3 and path.parts[1] == plugin.name: + plugin_path = plugin.abs_path / Path(*path.parts[2:]) + if info.is_dir(): + plugin_path.mkdir(parents=True, exist_ok=True) + else: + plugin_path.parent.mkdir(parents=True, exist_ok=True) + with zipf.open(info) as src, plugin_path.open("wb") as dst: + shutil.copyfileobj(src, dst) + + plugin_io.close() + + async def load_plugin(self, plugin): + if not (plugin.abs_path / f"{plugin.name}.py").exists(): + raise InvalidPluginError(f"{plugin.name}.py not found.") + + req_txt = plugin.abs_path / "requirements.txt" + + if req_txt.exists(): + # Install PIP requirements - importlib.invalidate_caches() - try: - await self.load_plugin(*parsed_plugin) - except DownloadError as exc: - return await ctx.send(f'Unable to load plugin: `{exc}`') + venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env + user_install = " --user" if not venv else "" + proc = await asyncio.create_subprocess_shell( + f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', + stderr=PIPE, + stdout=PIPE, + ) - # if it makes it here, it has passed all checks and should - # be entered into the config + logger.debug("Downloading requirements for %s.", plugin.ext_string) - self.bot.config.plugins.append(plugin_name) - await self.bot.config.update() + stdout, stderr = await proc.communicate() - await message.edit(content='Plugin installed. Any plugin that ' - 'you install is of your OWN RISK.') - else: - await message.edit(content='Invalid plugin name format. ' - 'Use username/repo/plugin.') + if stdout: + logger.debug("[stdout]\n%s.", stdout.decode()) - @plugin.command() - async def remove(self, ctx, *, plugin_name): - """Removes a certain plugin""" - if plugin_name in self.bot.config.plugins: - username, repo, name = self.parse_plugin(plugin_name) - self.bot.unload_extension( - f'plugins.{username}-{repo}.{name}.{name}' + if stderr: + logger.debug("[stderr]\n%s.", stderr.decode()) + logger.error( + "Failed to download requirements for %s.", + plugin.ext_string, + exc_info=True, + ) + raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") + + if os.path.exists(USER_SITE): + sys.path.insert(0, USER_SITE) + + try: + await self.bot.load_extension(plugin.ext_string) + logger.info("Loaded plugin: %s", plugin.ext_string.split(".")[-1]) + self.loaded_plugins.add(plugin) + + except commands.ExtensionError as exc: + logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) + raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc + + async def unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + + async def parse_user_input(self, ctx, plugin_name, check_version=False): + if not self.bot.config["enable_plugins"]: + embed = discord.Embed( + description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + return + + if not self._ready_event.is_set(): + embed = discord.Embed( + description="Plugins are still loading, please try again later.", + color=self.bot.main_color, ) + await ctx.send(embed=embed) + return + + if plugin_name in self.registry: + details = self.registry[plugin_name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + if check_version: + required_version = details.get("bot_version", False) + + if required_version and self.bot.version < Version(required_version): + embed = discord.Embed( + description="Your bot's version is too low. " + f"This plugin requires version `{required_version}`.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return - self.bot.config.plugins.remove(plugin_name) + plugin = Plugin(user, repo, plugin_name, branch) + else: + if self.bot.config.get("registry_plugins_only"): + embed = discord.Embed( + description="This plugin is not in the registry. To install this plugin, " + "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return try: - if not any(i.startswith(f'{username}/{repo}') - for i in self.bot.config.plugins): - # if there are no more of such repos, delete the folder - def onerror(func, path, exc_info): - if not os.access(path, os.W_OK): - # Is the error an access error? - os.chmod(path, stat.S_IWUSR) - func(path) - - shutil.rmtree(f'plugins/{username}-{repo}', - onerror=onerror) - except Exception as exc: - logger.error(str(exc)) - self.bot.config.plugins.append(plugin_name) - raise exc - - await self.bot.config.update() - await ctx.send('Plugin uninstalled and ' - 'all related data is erased.') + plugin = Plugin.from_string(plugin_name) + except InvalidPluginError: + embed = discord.Embed( + description="Invalid plugin name, double check the plugin name " + "or use one of the following formats: " + "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return + return plugin + + @commands.group(aliases=["plugin"], invoke_without_command=True) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins(self, ctx): + """ + Manage plugins for Modmail. + """ + + await ctx.send_help(ctx.command) + + @plugins.command(name="add", aliases=["install", "load"]) + @checks.has_permissions(PermissionLevel.OWNER) + @trigger_typing + async def plugins_add(self, ctx, *, plugin_name: str): + """ + Install a new plugin for the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, + or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) + or `local/name` for local plugins. + """ + + plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) + if plugin is None: + return + + if str(plugin) in self.bot.config["plugins"]: + embed = discord.Embed( + description="This plugin is already installed.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + if plugin.name in self.bot.cogs: + # another class with the same name + embed = discord.Embed( + description="Cannot install this plugin (dupe cog name).", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + if plugin.local: + embed = discord.Embed( + description=f"Starting to load local plugin from {plugin.link}...", + color=self.bot.main_color, + ) else: - await ctx.send('Plugin not installed.') + embed = discord.Embed( + description=f"Starting to download plugin from {plugin.link}...", + color=self.bot.main_color, + ) + msg = await ctx.send(embed=embed) + + try: + await self.download_plugin(plugin, force=True) + except Exception as e: + logger.warning("Unable to download plugin %s.", plugin, exc_info=True) + + embed = discord.Embed( + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", + color=self.bot.error_color, + ) + + return await msg.edit(embed=embed) - @plugin.command() - async def update(self, ctx, *, plugin_name): - """Updates a certain plugin""" - if plugin_name not in self.bot.config.plugins: - return await ctx.send('Plugin not installed') + self.bot.config["plugins"].append(str(plugin)) + await self.bot.config.update() + + if self.bot.config.get("enable_plugins"): + invalidate_caches() - async with ctx.typing(): - username, repo, name = self.parse_plugin(plugin_name) try: - cmd = f'cd plugins/{username}-{repo} && git pull' - cmd = await self.bot.loop.run_in_executor( - None, - self._asubprocess_run, - cmd + await self.load_plugin(plugin) + except Exception as e: + logger.warning("Unable to load plugin %s.", plugin, exc_info=True) + + embed = discord.Embed( + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", + color=self.bot.error_color, ) - except subprocess.CalledProcessError as exc: - err = exc.stderr.decode('utf8').strip() - await ctx.send(f'Error while updating: {err}') + + else: + embed = discord.Embed( + description="Successfully installed plugin.\n" + "*Friendly reminder, plugins have absolute control over your bot. " + "Please only install plugins from developers you trust.*", + color=self.bot.main_color, + ) + else: + embed = discord.Embed( + description="Successfully installed plugin.\n" + "*Friendly reminder, plugins have absolute control over your bot. " + "Please only install plugins from developers you trust.*\n\n" + "This plugin is currently not enabled due to `ENABLE_PLUGINS=false`, " + "to re-enable plugins, remove or change `ENABLE_PLUGINS=true` and restart your bot.", + color=self.bot.main_color, + ) + return await msg.edit(embed=embed) + + @plugins.command(name="remove", aliases=["del", "delete"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_remove(self, ctx, *, plugin_name: str): + """ + Remove an installed plugin of the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + """ + plugin = await self.parse_user_input(ctx, plugin_name) + if plugin is None: + return + + if str(plugin) not in self.bot.config["plugins"]: + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + return await ctx.send(embed=embed) + + if self.bot.config.get("enable_plugins"): + try: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + except (commands.ExtensionNotLoaded, KeyError): + logger.warning("Plugin was never loaded.") + + self.bot.config["plugins"].remove(str(plugin)) + await self.bot.config.update() + if not plugin.local: + shutil.rmtree( + plugin.abs_path, + onerror=lambda *args: logger.warning( + "Failed to remove plugin files %s: %s", plugin, str(args[2]) + ), + ) + try: + plugin.abs_path.parent.rmdir() + plugin.abs_path.parent.parent.rmdir() + except OSError: + pass # dir not empty + + embed = discord.Embed( + description="The plugin is successfully uninstalled.", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + + async def update_plugin(self, ctx, plugin_name): + logger.debug("Updating %s.", plugin_name) + plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) + if plugin is None: + return + + if str(plugin) not in self.bot.config["plugins"]: + embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + return await ctx.send(embed=embed) + + async with safe_typing(ctx): + embed = discord.Embed( + description=f"Successfully updated {plugin.name}.", + color=self.bot.main_color, + ) + await self.download_plugin(plugin, force=True) + if self.bot.config.get("enable_plugins"): + try: + await self.unload_plugin(plugin) + except commands.ExtensionError: + logger.warning("Plugin unload fail.", exc_info=True) + + try: + await self.load_plugin(plugin) + except Exception: + embed = discord.Embed( + description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", + color=self.bot.error_color, + ) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) + else: + logger.debug("Updated %s.", plugin) + else: + logger.debug("Updated %s.", plugin) + return await ctx.send(embed=embed) + + @plugins.command(name="update") + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_update(self, ctx, *, plugin_name: str = None): + """ + Update a plugin for the bot. + + `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + + To update all plugins, do `{prefix}plugins update`. + """ + + if plugin_name is None: + # pylint: disable=redefined-argument-from-local + for plugin_name in list(self.bot.config["plugins"]): + await self.update_plugin(ctx, plugin_name) + else: + await self.update_plugin(ctx, plugin_name) + + @plugins.command(name="reset") + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_reset(self, ctx): + """ + Reset all plugins for the bot. + + Deletes all cache and plugins from config and unloads from the bot. + """ + logger.warning("Purging plugins.") + for ext in list(self.bot.extensions): + if not ext.startswith("plugins."): + continue + logger.error("Unloading plugin: %s.", ext) + try: + plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) + if plugin: + await self.unload_plugin(plugin) + self.loaded_plugins.remove(plugin) + else: + await self.bot.unload_extension(ext) + except Exception: + logger.error("Failed to unload plugin: %s.", ext) + + for module in list(sys.modules.keys()): + if module.startswith("plugins."): + del sys.modules[module] + + self.bot.config["plugins"].clear() + await self.bot.config.update() + + cache_path = Path(__file__).absolute().parent.parent / "temp" / "plugins-cache" + if cache_path.exists(): + logger.warning("Removing cache path.") + shutil.rmtree(cache_path) + + for entry in os.scandir(Path(__file__).absolute().parent.parent / "plugins"): + if entry.is_dir() and entry.name != "@local": + shutil.rmtree(entry.path) + logger.warning("Removing %s.", entry.name) + + embed = discord.Embed( + description="Successfully purged all plugins from the bot.", + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + + @plugins.command(name="loaded", aliases=["enabled", "installed"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_loaded(self, ctx): + """ + Show a list of currently loaded plugins. + """ + + if not self.bot.config.get("enable_plugins"): + embed = discord.Embed( + description="No plugins are loaded due to `ENABLE_PLUGINS=false`, " + "to re-enable plugins, remove or set `ENABLE_PLUGINS=true` and restart your bot.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + if not self._ready_event.is_set(): + embed = discord.Embed( + description="Plugins are still loading, please try again later.", + color=self.bot.main_color, + ) + return await ctx.send(embed=embed) + + if not self.loaded_plugins: + embed = discord.Embed( + description="There are no plugins currently loaded.", + color=self.bot.error_color, + ) + return await ctx.send(embed=embed) + + loaded_plugins = map(str, sorted(self.loaded_plugins)) + pages = ["```\n"] + for plugin in loaded_plugins: + msg = str(plugin) + "\n" + if len(msg) + len(pages[-1]) + 3 <= 2048: + pages[-1] += msg else: - output = cmd.stdout.decode('utf8').strip() - await ctx.send(f'```\n{output}\n```') - - if output != 'Already up to date.': - # repo was updated locally, now perform the cog reload - ext = f'plugins.{username}-{repo}.{name}.{name}' - importlib.reload(importlib.import_module(ext)) - - try: - await self.load_plugin(username, repo, name) - except DownloadError as exc: - await ctx.send(f'Unable to start plugin: `{exc}`') - - @plugin.command(name='list') - async def list_(self, ctx): - """Shows a list of currently enabled plugins""" - if self.bot.config.plugins: - msg = '```\n' + '\n'.join(self.bot.config.plugins) + '\n```' - await ctx.send(msg) + pages[-1] += "```" + pages.append(f"```\n{msg}") + + if pages[-1][-3:] != "```": + pages[-1] += "```" + + embeds = [] + for page in pages: + embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) + embeds.append(embed) + paginator = EmbedPaginatorSession(ctx, *embeds) + await paginator.run() + + @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): + """ + Shows a list of all approved plugins. + + Usage: + `{prefix}plugin registry` Details about all plugins. + `{prefix}plugin registry plugin-name` Details about the indicated plugin. + `{prefix}plugin registry page-number` Jump to a page in the registry. + """ + + await self.populate_registry() + + embeds = [] + + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) + + if not registry: + embed = discord.Embed( + color=self.bot.error_color, + description="Registry is empty. This could be because it failed to load.", + ) + await ctx.send(embed=embed) + return + + if isinstance(plugin_name, int): + index = plugin_name - 1 + if index < 0: + index = 0 + if index >= len(registry): + index = len(registry) - 1 else: - await ctx.send('No plugins installed') + index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) + + if not index and plugin_name is not None: + embed = discord.Embed( + color=self.bot.error_color, + description=f'Could not find a plugin with name "{plugin_name}" within the registry.', + ) + + matches = get_close_matches(plugin_name, self.registry.keys()) + + if matches: + embed.add_field( + name="Perhaps you meant:", + value="\n".join(f"`{m}`" for m in matches), + ) + + return await ctx.send(embed=embed) + + for name, details in registry: + details = self.registry[name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + plugin = Plugin(user, repo, name, branch) + + embed = discord.Embed( + color=self.bot.main_color, + description=details["description"], + url=plugin.link, + title=details["repository"], + ) + + embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") + + embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) + + if details.get("thumbnail_url"): + embed.set_thumbnail(url=details.get("thumbnail_url")) + + if details.get("image_url"): + embed.set_image(url=details.get("image_url")) + + if plugin in self.loaded_plugins: + embed.set_footer(text="This plugin is currently loaded.") + else: + required_version = details.get("bot_version", False) + if required_version and self.bot.version < Version(required_version): + embed.set_footer( + text="Your bot is unable to install this plugin, " + f"minimum required version is v{required_version}." + ) + else: + embed.set_footer(text="Your bot is able to install this plugin.") + + embeds.append(embed) + + paginator = EmbedPaginatorSession(ctx, *embeds) + paginator.current = index + await paginator.run() + + @plugins_registry.command(name="compact", aliases=["slim"]) + @checks.has_permissions(PermissionLevel.OWNER) + async def plugins_registry_compact(self, ctx): + """ + Shows a compact view of all plugins within the registry. + """ + + await self.populate_registry() + + registry = sorted(self.registry.items(), key=lambda elem: elem[0]) + + pages = [""] + + for plugin_name, details in registry: + details = self.registry[plugin_name] + user, repo = details["repository"].split("/", maxsplit=1) + branch = details.get("branch") + + plugin = Plugin(user, repo, plugin_name, branch) + + desc = discord.utils.escape_markdown(details["description"].replace("\n", "")) + + name = f"[`{plugin.name}`]({plugin.link})" + fmt = f"{name} - {desc}" + + if plugin_name in self.loaded_plugins: + limit = 75 - len(plugin_name) - 4 - 8 + len(name) + if limit < 0: + fmt = plugin.name + limit = 75 + fmt = truncate(fmt, limit) + "[loaded]\n" + else: + limit = 75 - len(plugin_name) - 4 + len(name) + if limit < 0: + fmt = plugin.name + limit = 75 + fmt = truncate(fmt, limit) + "\n" + + if len(fmt) + len(pages[-1]) <= 2048: + pages[-1] += fmt + else: + pages.append(fmt) + + embeds = [] + + for page in pages: + embed = discord.Embed(color=self.bot.main_color, description=page) + embed.set_author( + name="Plugin Registry", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + ) + embeds.append(embed) + + paginator = EmbedPaginatorSession(ctx, *embeds) + await paginator.run() -def setup(bot): - bot.add_cog(Plugins(bot)) +async def setup(bot): + await bot.add_cog(Plugins(bot)) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py new file mode 100644 index 0000000000..7f9e193844 --- /dev/null +++ b/cogs/threadmenu.py @@ -0,0 +1,830 @@ +import json +import asyncio +from copy import copy as _copy + +import discord +from discord.ext import commands + +from core import checks +from core.models import PermissionLevel + + +class ThreadCreationMenuCore(commands.Cog): + """Core-integrated thread menu configuration and management. + + This Cog exposes the same commands as the legacy plugin to manage menu options, + but stores settings in core config (no plugin DB). + """ + + def __init__(self, bot): + self.bot = bot + + # ----- helpers ----- + def _get_conf(self) -> dict: + return { + "enabled": bool(self.bot.config.get("thread_creation_menu_enabled")), + "options": self.bot.config.get("thread_creation_menu_options") or {}, + "submenus": self.bot.config.get("thread_creation_menu_submenus") or {}, + "timeout": int(self.bot.config.get("thread_creation_menu_timeout") or 20), + "close_on_timeout": bool(self.bot.config.get("thread_creation_menu_close_on_timeout")), + "anonymous_menu": bool(self.bot.config.get("thread_creation_menu_anonymous_menu")), + "embed_text": self.bot.config.get("thread_creation_menu_embed_text") + or "Please select an option.", + "dropdown_placeholder": self.bot.config.get("thread_creation_menu_dropdown_placeholder") + or "Select an option to contact the staff team.", + "embed_title": self.bot.config.get("thread_creation_menu_embed_title"), + "embed_footer": self.bot.config.get("thread_creation_menu_embed_footer"), + "embed_thumbnail_url": self.bot.config.get("thread_creation_menu_embed_thumbnail_url"), + "embed_footer_icon_url": self.bot.config.get("thread_creation_menu_embed_footer_icon_url"), + "embed_color": self.bot.config.get("thread_creation_menu_embed_color"), + } + + async def _save_conf(self, conf: dict): + await self.bot.config.set("thread_creation_menu_enabled", conf.get("enabled", False)) + await self.bot.config.set("thread_creation_menu_options", conf.get("options", {}), convert=False) + await self.bot.config.set("thread_creation_menu_submenus", conf.get("submenus", {}), convert=False) + await self.bot.config.set("thread_creation_menu_timeout", conf.get("timeout", 20)) + await self.bot.config.set( + "thread_creation_menu_close_on_timeout", conf.get("close_on_timeout", False) + ) + await self.bot.config.set("thread_creation_menu_anonymous_menu", conf.get("anonymous_menu", False)) + await self.bot.config.set( + "thread_creation_menu_embed_text", conf.get("embed_text", "Please select an option.") + ) + await self.bot.config.set( + "thread_creation_menu_dropdown_placeholder", + conf.get("dropdown_placeholder", "Select an option to contact the staff team."), + ) + await self.bot.config.set("thread_creation_menu_embed_title", conf.get("embed_title")) + await self.bot.config.set("thread_creation_menu_embed_footer", conf.get("embed_footer")) + await self.bot.config.set("thread_creation_menu_embed_thumbnail_url", conf.get("embed_thumbnail_url")) + await self.bot.config.set( + "thread_creation_menu_embed_footer_icon_url", conf.get("embed_footer_icon_url") + ) + if conf.get("embed_color"): + try: + await self.bot.config.set("thread_creation_menu_embed_color", conf.get("embed_color")) + except Exception: + pass + await self.bot.config.update() + + # ----- commands ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @commands.group(invoke_without_command=True) + async def threadmenu(self, ctx): + """Thread-creation menu settings (core).""" + await ctx.send_help(ctx.command) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="toggle") + async def threadmenu_toggle(self, ctx): + """Enable or disable the thread-creation menu. + + Toggles the global on/off state. When disabled, users won't see + or be able to use the interactive thread creation select menu. + """ + conf = self._get_conf() + conf["enabled"] = not conf["enabled"] + await self._save_conf(conf) + await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="show") + async def threadmenu_show(self, ctx): + """Show all current main-menu options. + + Lists every option (label + description) configured in the root + (non-submenu) select menu so you can review what users will see. + """ + conf = self._get_conf() + if not conf["options"]: + return await ctx.send("There are no options in the main menu.") + embed = discord.Embed(title="Main menu", color=discord.Color.blurple()) + for v in conf["options"].values(): + embed.add_field(name=v["label"], value=v["description"], inline=False) + await ctx.send(embed=embed) + + # ----- options ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.group(name="option", invoke_without_command=True) + async def threadmenu_option(self, ctx): + """Manage main-menu options (add/remove/edit/show). + + Use subcommands: + - add: interactive wizard to create an option + - remove