diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..84bdcc017d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.10-bullseye", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers-contrib/features/pdm:2": {} + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pdm install --dev && pdm install --plugins" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.dockerignore b/.dockerignore index bfb770d436..895a768213 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,157 +1,11 @@ -# 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/ -.ruff_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 -.env.example -.gitignore -.dockerignore -.github/ -app.json -Procfile -pyproject.toml -*.md -.*.json -Dockerfile -docker-compose.yml -LICENSE -PRIVACY.md - -# Docs -docs/ \ No newline at end of file +# Ignore everything +* +# Ignore the IDE, these are not duplicate and not having this makes the build context potentially *very* large +** +!/cogs +!/core +!bot.py +!LICENSE +!pdm.lock +!pyproject.toml +!README.md diff --git a/.env.example b/.env.example index 14bdf060bf..972eca4517 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ 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/FUNDING.yml b/.github/FUNDING.yml index 719cf0c3ad..e69de29bb2 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +0,0 @@ -ko_fi: raidensakura diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8580bd4a1d..47a904b087 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -8,9 +8,6 @@ on: - 'develop' tags: - 'v*' - pull_request: - branches: - - 'develop' env: REGISTRY: ghcr.io @@ -24,23 +21,25 @@ jobs: packages: write steps: - - name: Checkout repository - uses: actions/checkout@v3 +# - name: Checkout repository +# uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 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 + - name: Extract metadata (tags, labels) for Docker main + id: docker-meta-base + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=schedule,pattern=nightly type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} @@ -49,11 +48,18 @@ jobs: type=sha # set latest tag for stable releases type=raw,value=latest,enable=${{ github.event.release.prerelease == false }} + bake-target: docker-metadata-action - - name: Build and push Docker image - uses: docker/build-push-action@v3 + - name: Build and push + uses: docker/bake-action@v6 with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} +# source: . + push: ${{ ! startsWith(github.ref, 'refs/pull/') && ! github.event.act }} + pull: true + sbom: true + files: | + ./docker-bake.hcl + cwd://${{ steps.docker-meta-base.outputs.bake-file }} + targets: openmodmail + + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cd07c9a90e..6f9eb11a4d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8"] + python-version: ["3.11"] name: Python ${{ matrix.python-version }} on ubuntu-latest steps: - uses: actions/checkout@v3 @@ -21,12 +21,16 @@ jobs: with: python-version: ${{ matrix.python-version }} architecture: x64 + - name: Setup PDM + uses: pdm-project/setup-pdm@v4 + with: + cache: true - name: Install dependencies run: | - python -m pip install black ruff + pdm install --group dev - name: Ruff - run: ruff . --no-fix + run: pdm run ruff . --no-fix continue-on-error: true - name: Black run: | - black . --diff --check + pdm run black . --diff --check diff --git a/.gitignore b/.gitignore index 4f3df2b74e..f842ee1ac4 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,10 @@ config.json temp/ test.py stack.yml -.github/workflows/build_docker.yml \ No newline at end of file + +.pdm-python + +compose.override.yml + + +s3-data/* \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1e231026c..58659d8e60 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,20 @@ repos: - repo: https://github.com/psf/black - rev: "22.12.0" + rev: "24.4.2" hooks: - id: black - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.261' + rev: 'v0.4.9' hooks: - id: ruff + - repo: https://github.com/pdm-project/pdm + rev: 2.10.0 # a PDM release exposing the hook + hooks: + - id: pdm-export + # command arguments, e.g.: + args: [ '-o', 'requirements.txt', '--without-hashes' ] + files: ^pdm.lock$ + - repo: https://github.com/pdm-project/pdm + rev: 2.10.0 # a PDM release exposing the hook + hooks: + - id: pdm-lock-check \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d45fcabf5..031a59522b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,97 @@ however, insignificant breaking changes do not guarantee a major version bump, s # [UNRELEASED] +### Breaking + +- Completely rewritten blocklist system. Blocklisting now runs off its own mongoDB collection. This once again introduces backwards incompatible schema changes, so a manual migration is required. You may upgrade from both v4.0 and v4.1 using the `[p]migrate blocklist` command. This removes any need to perform the previous migration steps in v4.1.0, you may upgrade directly to this version. After running the command, blocklist functionality will return and legacy config based blocks will have been deleted. You should always back up your config before migration. +- Remove internal logviewer plugin +- Bump Python version to >= 3.12 + +### Deprecated + +- Legacy blocklist properties are deprecated and no longer function. They now log a warning when used and provide no functionality. They have been replaced with methods in blocklist.py + +### Added + +- Added `content_type` to attachments stored in the database. +- `?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)) +- Add Forced plugins. Allows auto installing un-removable plugins via `FORCED_PLUGINS` environment variable contain a comma separate list of plugins. (GH#5) +- S3 based attachment archival + + +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. + +* `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 + +- Changing a threads title or NSFW status immediately updates the status in the database. +- Replace Pipenv with PDM +- Bump discord.py version to 2.3.2 +- Rewrote docker build for PDM +- Use Alpine for docker image +- Upgrade dependencies for modern python + +### Removed + +- Remove Discord.py dependency version check +- Remove modmail telemetry +- Remove lottie sticker support +- Autoupdate system + - Autoupdating was prone to serious issues and cannot be used within container images, the only supported distribution method of OpenModmail. + +### Fixed + +- Persistent notes have been fixed after the previous discord.py update. +- `is_image` now is true only if the image is actually an image. +- Fix contact command reporting user was blocked when they weren't. +- Cleanup imports after removing/unloading a plugin. ([PR #3226](https://github.com/modmail-dev/Modmail/pull/3226)) +- 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. + + +### Internal + +- Add `update_title` and `update_nsfw` methods to `ApiClient` to update thread title and nsfw status in the database. +- `thread.set_title` now requires `channel_id` to be passed as keyword arguments. +- New `thread.set_nsfw_status` method to set nsfw status of a thread. + # v4.1.0 ### Breaking @@ -21,6 +112,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Support for trailing space in `?prefix` command, example: `?prefix "mm "` for `mm ping`. - Added logviewer as built-in local plugin `?plugin load @local/logviewer`. - `?plugin uninstall` is now an alias for `?plugin remove` ([GH #3260](https://github.com/modmail-dev/modmail/issues/3260)) +- `DISCORD_LOG_LEVEL` environment variable to set the log level of discord.py. ([PR #3216](https://github.com/modmail-dev/Modmail/pull/3216)) ### Changed - 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)) @@ -45,6 +137,14 @@ however, insignificant breaking changes do not guarantee a major version bump, s - Fixed uncached member issue in large guild for react_to_contact and ticket creation. - Fixed blocked roles improperly saving in `blocked_users` config. - Fixed `?block` command improperly parsing reason as timestamp. +- 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)) + +### Internal +- `ConfigManager.get` no longer accepts two positional arguments: the `convert` argument is now keyword-only. + +### 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)) # v4.0.2 diff --git a/Dockerfile b/Dockerfile index 41a4adacd4..a8db9c9efe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,49 @@ -FROM python:3.11-alpine as base +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-alpine AS base RUN apk update && apk add git \ - # cairosvg dependencies - cairo-dev cairo cairo-tools \ # pillow dependencies jpeg-dev zlib-dev -FROM base as python-deps +FROM base AS python-deps RUN apk add --virtual build-deps build-base gcc libffi-dev -COPY requirements.txt / -RUN pip install --prefix=/inst -U -r /requirements.txt -FROM base as runtime +#Install pdm +RUN pip install -U pip setuptools wheel +RUN pip install pdm -ENV USING_DOCKER yes -COPY --from=python-deps /inst /usr/local +FROM python-deps AS builder -COPY . /modmail +COPY pyproject.toml pdm.lock README.md /modmail/ + +WORKDIR /modmail + +RUN pdm install --check --prod --no-editable --fail-fast; + +ARG INCLUDE_SUPPORTUTILS=false +ARG INCLUDE_PIP=false + +RUN if [ "$INCLUDE_SUPPORTUTILS" = "true" ]; then \ + pdm install --prod -G supportutils --no-editable --fail-fast; \ + fi + +RUN if [ "$INCLUDE_PIP" = "true" ]; then \ + /modmail/.venv/bin/python -m ensurepip --upgrade; \ + fi + +FROM base AS runtime + +RUN adduser --disabled-password modmail +USER modmail + + +ENV USING_DOCKER=yes +COPY --chown=modmail:modmail --from=builder /modmail /modmail + +COPY --chown=modmail:modmail . /modmail WORKDIR /modmail +ENV PATH="/modmail/.venv/bin:${PATH}" CMD ["python", "bot.py"] -RUN adduser --disabled-password --gecos '' app && \ - chown -R app /modmail -USER app \ No newline at end of file diff --git a/PRIVACY.md b/PRIVACY.md index a93a104b8d..88d68748c5 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,11 +1,17 @@ # 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. +Hey, we are the lead developers of Modmail bot. This is a look into 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 +OpenModmail is a fork of Modmail, and this privacy policy is a fork of the original Modmail privacy policy. +All telemetry and data collection has been removed from OpenModmail. + +> [!NOTE] +> Plugins may collect additional data. Please check with the plugin developer for more information. + ### 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. +You collect some data to keep the bot functioning. External services also collect some data that is out of our control. ## Interpretation @@ -18,38 +24,6 @@ Yes, we collect some data to keep us happy. You collect some data to keep the bo - 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. @@ -74,7 +48,7 @@ When a message is sent in a thread, the bot saves the following data: - Message author avatar URL - Whether the message author is a moderator - Message content -- All attachment urls in the message +- Attachments This data is essential to have live logs for the web logviewer to function. The Modmail team does not track any data by users. @@ -99,11 +73,6 @@ If you wish to opt out of any of this data collection, please view their own pri - [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). diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 25286fa00b..0000000000 --- a/Pipfile +++ /dev/null @@ -1,35 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -black = "*" -ruff = "*" -typing-extensions = "==4.6.3" -pre-commit = "*" - -[packages] -# Anytime the requirements change, please sync with requirements.txt: -# pipenv run pip freeze > requirements.txt -aiohttp = "~=3.8.1" -colorama = "~=0.4.5" -emoji = "~=1.7.0" -isodate = "~=0.6.0" -motor = "~=3.1.2" -natural = "~=0.2.0" -parsedatetime = "~=2.6" -pymongo = {extras = ["srv"], version = "*"} # Required by motor -python-dateutil = "~=2.8.1" -python-dotenv = "~=1.0.0" -uvloop = {version = "~=0.17.0", markers = "sys_platform != 'win32'"} -lottie = {version = "~=0.6.11", extras = ["pdf"]} -requests = "~=2.31.0" -attrs = "~=23.1.0" -cairocffi = "~=1.3.0" -cffi = "~=1.15.0" -pillow = "~=9.5.0" -"discord.py" = "2.3.0" - -[scripts] -bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 6315de185c..0000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1118 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "e869217dea0eec76662b3dcb14234f8161ac7693fde43bdc7daab9132dc18d69" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiohttp": { - "hashes": [ - "sha256:03543dcf98a6619254b409be2d22b51f21ec66272be4ebda7b04e6412e4b2e14", - "sha256:03baa76b730e4e15a45f81dfe29a8d910314143414e528737f8589ec60cf7391", - "sha256:0a63f03189a6fa7c900226e3ef5ba4d3bd047e18f445e69adbd65af433add5a2", - "sha256:10c8cefcff98fd9168cdd86c4da8b84baaa90bf2da2269c6161984e6737bf23e", - "sha256:147ae376f14b55f4f3c2b118b95be50a369b89b38a971e80a17c3fd623f280c9", - "sha256:176a64b24c0935869d5bbc4c96e82f89f643bcdf08ec947701b9dbb3c956b7dd", - "sha256:17b79c2963db82086229012cff93ea55196ed31f6493bb1ccd2c62f1724324e4", - "sha256:1a45865451439eb320784918617ba54b7a377e3501fb70402ab84d38c2cd891b", - "sha256:1b3ea7edd2d24538959c1c1abf97c744d879d4e541d38305f9bd7d9b10c9ec41", - "sha256:22f6eab15b6db242499a16de87939a342f5a950ad0abaf1532038e2ce7d31567", - "sha256:3032dcb1c35bc330134a5b8a5d4f68c1a87252dfc6e1262c65a7e30e62298275", - "sha256:33587f26dcee66efb2fff3c177547bd0449ab7edf1b73a7f5dea1e38609a0c54", - "sha256:34ce9f93a4a68d1272d26030655dd1b58ff727b3ed2a33d80ec433561b03d67a", - "sha256:3a80464982d41b1fbfe3154e440ba4904b71c1a53e9cd584098cd41efdb188ef", - "sha256:3b90467ebc3d9fa5b0f9b6489dfb2c304a1db7b9946fa92aa76a831b9d587e99", - "sha256:3d89efa095ca7d442a6d0cbc755f9e08190ba40069b235c9886a8763b03785da", - "sha256:3d8ef1a630519a26d6760bc695842579cb09e373c5f227a21b67dc3eb16cfea4", - "sha256:3f43255086fe25e36fd5ed8f2ee47477408a73ef00e804cb2b5cba4bf2ac7f5e", - "sha256:40653609b3bf50611356e6b6554e3a331f6879fa7116f3959b20e3528783e699", - "sha256:41a86a69bb63bb2fc3dc9ad5ea9f10f1c9c8e282b471931be0268ddd09430b04", - "sha256:493f5bc2f8307286b7799c6d899d388bbaa7dfa6c4caf4f97ef7521b9cb13719", - "sha256:4a6cadebe132e90cefa77e45f2d2f1a4b2ce5c6b1bfc1656c1ddafcfe4ba8131", - "sha256:4c745b109057e7e5f1848c689ee4fb3a016c8d4d92da52b312f8a509f83aa05e", - "sha256:4d347a172f866cd1d93126d9b239fcbe682acb39b48ee0873c73c933dd23bd0f", - "sha256:4dac314662f4e2aa5009977b652d9b8db7121b46c38f2073bfeed9f4049732cd", - "sha256:4ddaae3f3d32fc2cb4c53fab020b69a05c8ab1f02e0e59665c6f7a0d3a5be54f", - "sha256:5393fb786a9e23e4799fec788e7e735de18052f83682ce2dfcabaf1c00c2c08e", - "sha256:59f029a5f6e2d679296db7bee982bb3d20c088e52a2977e3175faf31d6fb75d1", - "sha256:5a7bdf9e57126dc345b683c3632e8ba317c31d2a41acd5800c10640387d193ed", - "sha256:5b3f2e06a512e94722886c0827bee9807c86a9f698fac6b3aee841fab49bbfb4", - "sha256:5ce45967538fb747370308d3145aa68a074bdecb4f3a300869590f725ced69c1", - "sha256:5e14f25765a578a0a634d5f0cd1e2c3f53964553a00347998dfdf96b8137f777", - "sha256:618c901dd3aad4ace71dfa0f5e82e88b46ef57e3239fc7027773cb6d4ed53531", - "sha256:652b1bff4f15f6287550b4670546a2947f2a4575b6c6dff7760eafb22eacbf0b", - "sha256:6c08e8ed6fa3d477e501ec9db169bfac8140e830aa372d77e4a43084d8dd91ab", - "sha256:6ddb2a2026c3f6a68c3998a6c47ab6795e4127315d2e35a09997da21865757f8", - "sha256:6e601588f2b502c93c30cd5a45bfc665faaf37bbe835b7cfd461753068232074", - "sha256:6e74dd54f7239fcffe07913ff8b964e28b712f09846e20de78676ce2a3dc0bfc", - "sha256:7235604476a76ef249bd64cb8274ed24ccf6995c4a8b51a237005ee7a57e8643", - "sha256:7ab43061a0c81198d88f39aaf90dae9a7744620978f7ef3e3708339b8ed2ef01", - "sha256:7c7837fe8037e96b6dd5cfcf47263c1620a9d332a87ec06a6ca4564e56bd0f36", - "sha256:80575ba9377c5171407a06d0196b2310b679dc752d02a1fcaa2bc20b235dbf24", - "sha256:80a37fe8f7c1e6ce8f2d9c411676e4bc633a8462844e38f46156d07a7d401654", - "sha256:8189c56eb0ddbb95bfadb8f60ea1b22fcfa659396ea36f6adcc521213cd7b44d", - "sha256:854f422ac44af92bfe172d8e73229c270dc09b96535e8a548f99c84f82dde241", - "sha256:880e15bb6dad90549b43f796b391cfffd7af373f4646784795e20d92606b7a51", - "sha256:8b631e26df63e52f7cce0cce6507b7a7f1bc9b0c501fcde69742130b32e8782f", - "sha256:8c29c77cc57e40f84acef9bfb904373a4e89a4e8b74e71aa8075c021ec9078c2", - "sha256:91f6d540163f90bbaef9387e65f18f73ffd7c79f5225ac3d3f61df7b0d01ad15", - "sha256:92c0cea74a2a81c4c76b62ea1cac163ecb20fb3ba3a75c909b9fa71b4ad493cf", - "sha256:9bcb89336efa095ea21b30f9e686763f2be4478f1b0a616969551982c4ee4c3b", - "sha256:a1f4689c9a1462f3df0a1f7e797791cd6b124ddbee2b570d34e7f38ade0e2c71", - "sha256:a3fec6a4cb5551721cdd70473eb009d90935b4063acc5f40905d40ecfea23e05", - "sha256:a5d794d1ae64e7753e405ba58e08fcfa73e3fad93ef9b7e31112ef3c9a0efb52", - "sha256:a86d42d7cba1cec432d47ab13b6637bee393a10f664c425ea7b305d1301ca1a3", - "sha256:adfbc22e87365a6e564c804c58fc44ff7727deea782d175c33602737b7feadb6", - "sha256:aeb29c84bb53a84b1a81c6c09d24cf33bb8432cc5c39979021cc0f98c1292a1a", - "sha256:aede4df4eeb926c8fa70de46c340a1bc2c6079e1c40ccf7b0eae1313ffd33519", - "sha256:b744c33b6f14ca26b7544e8d8aadff6b765a80ad6164fb1a430bbadd593dfb1a", - "sha256:b7a00a9ed8d6e725b55ef98b1b35c88013245f35f68b1b12c5cd4100dddac333", - "sha256:bb96fa6b56bb536c42d6a4a87dfca570ff8e52de2d63cabebfd6fb67049c34b6", - "sha256:bbcf1a76cf6f6dacf2c7f4d2ebd411438c275faa1dc0c68e46eb84eebd05dd7d", - "sha256:bca5f24726e2919de94f047739d0a4fc01372801a3672708260546aa2601bf57", - "sha256:bf2e1a9162c1e441bf805a1fd166e249d574ca04e03b34f97e2928769e91ab5c", - "sha256:c4eb3b82ca349cf6fadcdc7abcc8b3a50ab74a62e9113ab7a8ebc268aad35bb9", - "sha256:c6cc15d58053c76eacac5fa9152d7d84b8d67b3fde92709195cb984cfb3475ea", - "sha256:c6cd05ea06daca6ad6a4ca3ba7fe7dc5b5de063ff4daec6170ec0f9979f6c332", - "sha256:c844fd628851c0bc309f3c801b3a3d58ce430b2ce5b359cd918a5a76d0b20cb5", - "sha256:c9cb1565a7ad52e096a6988e2ee0397f72fe056dadf75d17fa6b5aebaea05622", - "sha256:cab9401de3ea52b4b4c6971db5fb5c999bd4260898af972bf23de1c6b5dd9d71", - "sha256:cd468460eefef601ece4428d3cf4562459157c0f6523db89365202c31b6daebb", - "sha256:d1e6a862b76f34395a985b3cd39a0d949ca80a70b6ebdea37d3ab39ceea6698a", - "sha256:d1f9282c5f2b5e241034a009779e7b2a1aa045f667ff521e7948ea9b56e0c5ff", - "sha256:d265f09a75a79a788237d7f9054f929ced2e69eb0bb79de3798c468d8a90f945", - "sha256:db3fc6120bce9f446d13b1b834ea5b15341ca9ff3f335e4a951a6ead31105480", - "sha256:dbf3a08a06b3f433013c143ebd72c15cac33d2914b8ea4bea7ac2c23578815d6", - "sha256:de04b491d0e5007ee1b63a309956eaed959a49f5bb4e84b26c8f5d49de140fa9", - "sha256:e4b09863aae0dc965c3ef36500d891a3ff495a2ea9ae9171e4519963c12ceefd", - "sha256:e595432ac259af2d4630008bf638873d69346372d38255774c0e286951e8b79f", - "sha256:e75b89ac3bd27d2d043b234aa7b734c38ba1b0e43f07787130a0ecac1e12228a", - "sha256:ea9eb976ffdd79d0e893869cfe179a8f60f152d42cb64622fca418cd9b18dc2a", - "sha256:eafb3e874816ebe2a92f5e155f17260034c8c341dad1df25672fb710627c6949", - "sha256:ee3c36df21b5714d49fc4580247947aa64bcbe2939d1b77b4c8dcb8f6c9faecc", - "sha256:f352b62b45dff37b55ddd7b9c0c8672c4dd2eb9c0f9c11d395075a84e2c40f75", - "sha256:fabb87dd8850ef0f7fe2b366d44b77d7e6fa2ea87861ab3844da99291e81e60f", - "sha256:fe11310ae1e4cd560035598c3f29d86cef39a83d244c7466f95c27ae04850f10", - "sha256:fe7ba4a51f33ab275515f66b0a236bcde4fb5561498fe8f898d4e549b2e4509f" - ], - "index": "pypi", - "version": "==3.8.4" - }, - "aiosignal": { - "hashes": [ - "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", - "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.1" - }, - "async-timeout": { - "hashes": [ - "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", - "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" - ], - "markers": "python_version >= '3.6'", - "version": "==4.0.2" - }, - "attrs": { - "hashes": [ - "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", - "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" - ], - "index": "pypi", - "version": "==23.1.0" - }, - "cairocffi": { - "hashes": [ - "sha256:108a3a7cb09e203bdd8501d9baad91d786d204561bd71e9364e8b34897c47b91" - ], - "index": "pypi", - "version": "==1.3.0" - }, - "cairosvg": { - "hashes": [ - "sha256:17cb96423a896258848322a95c80160e714a58f1af3dd73b8e1750994519b9f9", - "sha256:ac4dc7c1d38b3a15717db2633a3a383012e0be664c727c911637e6af6a49293c" - ], - "version": "==2.7.0" - }, - "certifi": { - "hashes": [ - "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", - "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" - ], - "markers": "python_version >= '3.6'", - "version": "==2023.5.7" - }, - "cffi": { - "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "index": "pypi", - "version": "==1.15.1" - }, - "charset-normalizer": { - "hashes": [ - "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", - "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", - "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", - "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", - "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", - "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", - "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", - "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", - "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", - "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", - "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", - "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", - "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", - "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", - "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", - "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", - "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", - "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", - "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", - "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", - "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", - "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", - "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", - "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", - "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", - "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", - "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", - "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", - "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", - "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", - "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", - "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", - "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", - "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", - "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", - "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", - "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", - "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", - "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", - "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", - "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", - "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", - "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", - "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", - "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", - "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", - "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", - "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", - "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", - "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", - "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", - "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", - "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", - "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", - "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", - "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", - "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", - "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", - "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", - "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", - "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", - "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", - "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", - "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", - "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", - "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", - "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", - "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", - "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", - "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", - "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", - "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", - "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", - "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", - "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.1.0" - }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "index": "pypi", - "version": "==0.4.6" - }, - "cssselect2": { - "hashes": [ - "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a", - "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969" - ], - "markers": "python_version >= '3.7'", - "version": "==0.7.0" - }, - "defusedxml": { - "hashes": [ - "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", - "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.7.1" - }, - "discord.py": { - "hashes": [ - "sha256:3e9498967822ad4499f8f72deb9173f942d9827d92b6e4e4e7732d24f78f300c", - "sha256:c71066a30f037d069218e59092505c3e8945fd175e396a80748056d989756806" - ], - "index": "pypi", - "version": "==2.3.0" - }, - "dnspython": { - "hashes": [ - "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9", - "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46" - ], - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==2.3.0" - }, - "emoji": { - "hashes": [ - "sha256:65c54533ea3c78f30d0729288998715f418d7467de89ec258a31c0ce8660a1d1" - ], - "index": "pypi", - "version": "==1.7.0" - }, - "frozenlist": { - "hashes": [ - "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c", - "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f", - "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a", - "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784", - "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27", - "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d", - "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3", - "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678", - "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a", - "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483", - "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8", - "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf", - "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99", - "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c", - "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48", - "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5", - "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56", - "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e", - "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1", - "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401", - "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4", - "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e", - "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649", - "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a", - "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d", - "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0", - "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6", - "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d", - "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b", - "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6", - "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf", - "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef", - "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7", - "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842", - "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba", - "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420", - "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b", - "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d", - "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332", - "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936", - "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816", - "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91", - "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420", - "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448", - "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411", - "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4", - "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32", - "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b", - "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0", - "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530", - "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669", - "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7", - "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1", - "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5", - "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce", - "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4", - "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e", - "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2", - "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d", - "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9", - "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642", - "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0", - "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703", - "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb", - "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1", - "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13", - "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab", - "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38", - "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb", - "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb", - "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81", - "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8", - "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd", - "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.3" - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "isodate": { - "hashes": [ - "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", - "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9" - ], - "index": "pypi", - "version": "==0.6.1" - }, - "lottie": { - "extras": [ - "pdf" - ], - "hashes": [ - "sha256:d53e96265887aa9187c7c707fd612b3d52f38da64c81ea82297783efb47f7e3f" - ], - "index": "pypi", - "version": "==0.6.11" - }, - "motor": { - "hashes": [ - "sha256:4bfc65230853ad61af447088527c1197f91c20ee957cfaea3144226907335716", - "sha256:80c08477c09e70db4f85c99d484f2bafa095772f1d29b3ccb253270f9041da9a" - ], - "index": "pypi", - "version": "==3.1.2" - }, - "multidict": { - "hashes": [ - "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", - "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", - "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", - "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", - "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", - "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", - "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", - "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", - "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", - "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", - "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", - "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", - "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", - "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", - "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", - "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", - "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", - "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", - "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", - "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", - "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", - "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", - "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", - "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", - "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", - "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", - "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", - "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", - "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", - "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", - "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", - "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", - "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", - "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", - "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", - "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", - "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", - "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", - "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", - "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", - "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", - "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", - "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", - "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", - "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", - "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", - "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", - "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", - "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", - "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", - "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", - "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", - "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", - "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", - "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", - "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", - "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", - "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", - "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", - "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", - "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", - "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", - "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", - "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", - "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", - "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", - "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", - "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", - "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", - "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", - "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", - "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", - "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", - "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" - ], - "markers": "python_version >= '3.7'", - "version": "==6.0.4" - }, - "natural": { - "hashes": [ - "sha256:18c83662d2d33fd7e6eee4e3b0d7366e1ce86225664e3127a2aaf0a3233f7df2" - ], - "index": "pypi", - "version": "==0.2.0" - }, - "parsedatetime": { - "hashes": [ - "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", - "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" - ], - "index": "pypi", - "version": "==2.6" - }, - "pillow": { - "hashes": [ - "sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1", - "sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba", - "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a", - "sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799", - "sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51", - "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb", - "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5", - "sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270", - "sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6", - "sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47", - "sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf", - "sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e", - "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b", - "sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66", - "sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865", - "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec", - "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c", - "sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1", - "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38", - "sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906", - "sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705", - "sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef", - "sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc", - "sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f", - "sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf", - "sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392", - "sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d", - "sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe", - "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32", - "sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5", - "sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7", - "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44", - "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d", - "sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3", - "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625", - "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e", - "sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829", - "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089", - "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3", - "sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78", - "sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96", - "sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964", - "sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597", - "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99", - "sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a", - "sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140", - "sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7", - "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16", - "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903", - "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1", - "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296", - "sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572", - "sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115", - "sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a", - "sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd", - "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4", - "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1", - "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb", - "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa", - "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a", - "sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569", - "sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c", - "sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf", - "sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082", - "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062", - "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579" - ], - "index": "pypi", - "version": "==9.5.0" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pymongo": { - "extras": [ - "srv" - ], - "hashes": [ - "sha256:016c412118e1c23fef3a1eada4f83ae6e8844fd91986b2e066fc1b0013cdd9ae", - "sha256:01f7cbe88d22440b6594c955e37312d932fd632ffed1a86d0c361503ca82cc9d", - "sha256:08fc250b5552ee97ceeae0f52d8b04f360291285fc7437f13daa516ce38fdbc6", - "sha256:0c466710871d0026c190fc4141e810cf9d9affbf4935e1d273fbdc7d7cda6143", - "sha256:1074f1a6f23e28b983c96142f2d45be03ec55d93035b471c26889a7ad2365db3", - "sha256:12f3621a46cdc7a9ba8080422262398a91762a581d27e0647746588d3f995c88", - "sha256:2c2fdc855149efe7cdcc2a01ca02bfa24761c640203ea94df467f3baf19078be", - "sha256:316498b642c00401370b2156b5233b256f9b33799e0a8d9d0b8a7da217a20fca", - "sha256:341221e2f2866a5960e6f8610f4cbac0bb13097f3b1a289aa55aba984fc0d969", - "sha256:34b040e095e1671df0c095ec0b04fc4ebb19c4c160f87c2b55c079b16b1a6b00", - "sha256:34e95ffb0a68bffbc3b437f2d1f25fc916fef3df5cdeed0992da5f42fae9b807", - "sha256:39b03045c71f761aee96a12ebfbc2f4be89e724ff6f5e31c2574c1a0e2add8bd", - "sha256:3b93043b14ba7eb08c57afca19751658ece1cfa2f0b7b1fb5c7a41452fbb8482", - "sha256:47f7aa217b25833cd6f0e72b0d224be55393c2692b4f5e0561cb3beeb10296e9", - "sha256:49210feb0be8051a64d71691f0acbfbedc33e149f0a5d6e271fddf6a12493fed", - "sha256:4d00b91c77ceb064c9b0459f0d6ea5bfdbc53ea9e17cf75731e151ef25a830c7", - "sha256:4ed00f96e147f40b565fe7530d1da0b0f3ab803d5dd5b683834500fa5d195ec4", - "sha256:5134d33286c045393c7beb51be29754647cec5ebc051cf82799c5ce9820a2ca2", - "sha256:524d78673518dcd352a91541ecd2839c65af92dc883321c2109ef6e5cd22ef23", - "sha256:52896e22115c97f1c829db32aa2760b0d61839cfe08b168c2b1d82f31dbc5f55", - "sha256:54c377893f2cbbffe39abcff5ff2e917b082c364521fa079305f6f064e1a24a9", - "sha256:55b6163dac53ef1e5d834297810c178050bd0548a4136cd4e0f56402185916ca", - "sha256:599d3f6fbef31933b96e2d906b0f169b3371ff79ea6aaf6ecd76c947a3508a3d", - "sha256:5effd87c7d363890259eac16c56a4e8da307286012c076223997f8cc4a8c435b", - "sha256:66413c50d510e5bcb0afc79880d1693a2185bcea003600ed898ada31338c004e", - "sha256:695939036a320f4329ccf1627edefbbb67cc7892b8222d297b0dd2313742bfee", - "sha256:6c2216d8b6a6d019c6f4b1ad55f890e5e77eb089309ffc05b6911c09349e7474", - "sha256:6dd1cf2995fdbd64fc0802313e8323f5fa18994d51af059b5b8862b73b5e53f0", - "sha256:6fcfbf435eebf8a1765c6d1f46821740ebe9f54f815a05c8fc30d789ef43cb12", - "sha256:704d939656e21b073bfcddd7228b29e0e8a93dd27b54240eaafc0b9a631629a6", - "sha256:711bc52cb98e7892c03e9b669bebd89c0a890a90dbc6d5bb2c47f30239bac6e9", - "sha256:74731c9e423c93cbe791f60c27030b6af6a948cef67deca079da6cd1bb583a8e", - "sha256:7761cacb8745093062695b11574effea69db636c2fd0a9269a1f0183712927b4", - "sha256:7b16250238de8dafca225647608dddc7bbb5dce3dd53b4d8e63c1cc287394c2f", - "sha256:7c051fe37c96b9878f37fa58906cb53ecd13dcb7341d3a85f1e2e2f6b10782d9", - "sha256:7d43ac9c7eeda5100fb0a7152fab7099c9cf9e5abd3bb36928eb98c7d7a339c6", - "sha256:81d1a7303bd02ca1c5be4aacd4db73593f573ba8e0c543c04c6da6275fd7a47e", - "sha256:8a06a0c02f5606330e8f2e2f3b7949877ca7e4024fa2bff5a4506bec66c49ec7", - "sha256:8fd6e191b92a10310f5a6cfe10d6f839d79d192fb02480bda325286bd1c7b385", - "sha256:943f208840777f34312c103a2d1caab02d780c4e9be26b3714acf6c4715ba7e1", - "sha256:9b87b23570565a6ddaa9244d87811c2ee9cffb02a753c8a2da9c077283d85845", - "sha256:a6cd6f1db75eb07332bd3710f58f5fce4967eadbf751bad653842750a61bda62", - "sha256:a966d5304b7d90c45c404914e06bbf02c5bf7e99685c6c12f0047ef2aa837142", - "sha256:a9c2885b4a8e6e39db5662d8b02ca6dcec796a45e48c2de12552841f061692ba", - "sha256:b0cfe925610f2fd59555bb7fc37bd739e4b197d33f2a8b2fae7b9c0c6640318c", - "sha256:b38a96b3eed8edc515b38257f03216f382c4389d022a8834667e2bc63c0c0c31", - "sha256:b8a03af1ce79b902a43f5f694c4ca8d92c2a4195db0966f08f266549e2fc49bc", - "sha256:bb869707d8e30645ed6766e44098600ca6cdf7989c22a3ea2b7966bb1d98d4b2", - "sha256:be1d2ce7e269215c3ee9a215e296b7a744aff4f39233486d2c4d77f5f0c561a6", - "sha256:c0640b4e9d008e13956b004d1971a23377b3d45491f87082161c92efb1e6c0d6", - "sha256:c09956606c08c4a7c6178a04ba2dd9388fcc5db32002ade9c9bc865ab156ab6d", - "sha256:c184ec5be465c0319440734491e1aa4709b5f3ba75fdfc9dbbc2ae715a7f6829", - "sha256:c1a70c51da9fa95bd75c167edb2eb3f3c4d27bc4ddd29e588f21649d014ec0b7", - "sha256:c29e758f0e734e1e90357ae01ec9c6daf19ff60a051192fe110d8fb25c62600e", - "sha256:c6258a3663780ae47ba73d43eb63c79c40ffddfb764e09b56df33be2f9479837", - "sha256:cafa52873ae12baa512a8721afc20de67a36886baae6a5f394ddef0ce9391f91", - "sha256:cd6a4afb20fb3c26a7bfd4611a0bbb24d93cbd746f5eb881f114b5e38fd55501", - "sha256:cdb87309de97c63cb9a69132e1cb16be470e58cffdfbad68fdd1dc292b22a840", - "sha256:d07d06dba5b5f7d80f9cc45501456e440f759fe79f9895922ed486237ac378a8", - "sha256:d3a51901066696c4af38c6c63a1f0aeffd5e282367ff475de8c191ec9609b56d", - "sha256:d5571b6978750601f783cea07fb6b666837010ca57e5cefa389c1d456f6222e2", - "sha256:d86c35d94b5499689354ccbc48438a79f449481ee6300f3e905748edceed78e7", - "sha256:dc0cff74cd36d7e1edba91baa09622c35a8a57025f2f2b7a41e3f83b1db73186", - "sha256:dc24d245026a72d9b4953729d31813edd4bd4e5c13622d96e27c284942d33f24", - "sha256:dca34367a4e77fcab0693e603a959878eaf2351585e7d752cac544bc6b2dee46", - "sha256:e2961b05f9c04a53da8bfc72f1910b6aec7205fcf3ac9c036d24619979bbee4b", - "sha256:e7fac06a539daef4fcf5d8288d0d21b412f9b750454cd5a3cf90484665db442a", - "sha256:eac0a143ef4f28f49670bf89cb15847eb80b375d55eba401ca2f777cd425f338", - "sha256:ef888f48eb9203ee1e04b9fb27429017b290fb916f1e7826c2f7808c88798394", - "sha256:f3055510fdfdb1775bc8baa359783022f70bb553f2d46e153c094dfcb08578ff", - "sha256:fa7e202feb683dad74f00dea066690448d0cfa310f8a277db06ec8eb466601b5", - "sha256:fc28e8d85d392a06434e9a934908d97e2cf453d69488d2bcd0bfb881497fd975", - "sha256:fd7bb378d82b88387dc10227cfd964f6273eb083e05299e9b97cbe075da12d11", - "sha256:ffcc8394123ea8d43fff8e5d000095fe7741ce3f8988366c5c919c4f5eb179d3" - ], - "index": "pypi", - "version": "==4.3.3" - }, - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "index": "pypi", - "version": "==2.8.2" - }, - "python-dotenv": { - "hashes": [ - "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", - "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "requests": { - "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" - ], - "index": "pypi", - "version": "==2.31.0" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "tinycss2": { - "hashes": [ - "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847", - "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627" - ], - "markers": "python_version >= '3.7'", - "version": "==1.2.1" - }, - "urllib3": { - "hashes": [ - "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1", - "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.3" - }, - "uvloop": { - "hashes": [ - "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d", - "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1", - "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595", - "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b", - "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05", - "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8", - "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20", - "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded", - "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c", - "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8", - "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474", - "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f", - "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62", - "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376", - "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c", - "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e", - "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b", - "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4", - "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578", - "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811", - "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d", - "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738", - "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa", - "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9", - "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539", - "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c", - "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718", - "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667", - "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c", - "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024" - ], - "markers": "sys_platform != 'win32'", - "version": "==0.17.0" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "yarl": { - "hashes": [ - "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571", - "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3", - "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3", - "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c", - "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7", - "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04", - "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191", - "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea", - "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4", - "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4", - "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095", - "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e", - "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74", - "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef", - "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33", - "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde", - "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45", - "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf", - "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b", - "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac", - "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0", - "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528", - "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716", - "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb", - "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18", - "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72", - "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6", - "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582", - "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5", - "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368", - "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc", - "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9", - "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be", - "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a", - "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80", - "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8", - "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6", - "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417", - "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574", - "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59", - "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608", - "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82", - "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1", - "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3", - "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d", - "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8", - "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc", - "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac", - "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8", - "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955", - "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0", - "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367", - "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb", - "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a", - "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623", - "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2", - "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6", - "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7", - "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4", - "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051", - "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938", - "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8", - "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9", - "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3", - "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5", - "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9", - "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333", - "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185", - "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3", - "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560", - "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b", - "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7", - "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78", - "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7" - ], - "markers": "python_version >= '3.7'", - "version": "==1.9.2" - } - }, - "develop": { - "black": { - "hashes": [ - "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5", - "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915", - "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326", - "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940", - "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b", - "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30", - "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c", - "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c", - "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab", - "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27", - "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2", - "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961", - "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9", - "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb", - "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70", - "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331", - "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2", - "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266", - "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d", - "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6", - "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b", - "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925", - "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8", - "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4", - "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3" - ], - "index": "pypi", - "version": "==23.3.0" - }, - "cfgv": { - "hashes": [ - "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", - "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736" - ], - "markers": "python_full_version >= '3.6.1'", - "version": "==3.3.1" - }, - "click": { - "hashes": [ - "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", - "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" - ], - "markers": "python_version >= '3.7'", - "version": "==8.1.3" - }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "index": "pypi", - "version": "==0.4.6" - }, - "distlib": { - "hashes": [ - "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", - "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e" - ], - "version": "==0.3.6" - }, - "filelock": { - "hashes": [ - "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81", - "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec" - ], - "markers": "python_version >= '3.7'", - "version": "==3.12.2" - }, - "identify": { - "hashes": [ - "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4", - "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d" - ], - "markers": "python_version >= '3.7'", - "version": "==2.5.24" - }, - "mypy-extensions": { - "hashes": [ - "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", - "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.0" - }, - "nodeenv": { - "hashes": [ - "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2", - "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec" - ], - "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": "==1.8.0" - }, - "packaging": { - "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" - ], - "markers": "python_version >= '3.7'", - "version": "==23.1" - }, - "pathspec": { - "hashes": [ - "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", - "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" - ], - "markers": "python_version >= '3.7'", - "version": "==0.11.1" - }, - "platformdirs": { - "hashes": [ - "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed", - "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e" - ], - "markers": "python_version >= '3.7'", - "version": "==3.5.3" - }, - "pre-commit": { - "hashes": [ - "sha256:66e37bec2d882de1f17f88075047ef8962581f83c234ac08da21a0c58953d1f0", - "sha256:8056bc52181efadf4aac792b1f4f255dfd2fb5a350ded7335d251a68561e8cb6" - ], - "index": "pypi", - "version": "==3.3.2" - }, - "pyyaml": { - "hashes": [ - "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", - "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", - "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", - "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", - "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", - "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", - "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", - "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", - "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", - "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", - "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", - "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", - "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", - "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", - "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", - "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", - "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", - "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", - "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", - "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", - "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", - "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", - "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", - "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", - "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", - "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", - "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", - "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", - "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", - "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", - "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", - "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", - "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", - "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", - "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", - "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", - "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", - "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", - "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", - "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" - ], - "markers": "python_version >= '3.6'", - "version": "==6.0" - }, - "ruff": { - "hashes": [ - "sha256:06b8ee4eb8711ab119db51028dd9f5384b44728c23586424fd6e241a5b9c4a3b", - "sha256:1609b864a8d7ee75a8c07578bdea0a7db75a144404e75ef3162e0042bfdc100d", - "sha256:19643d448f76b1eb8a764719072e9c885968971bfba872e14e7257e08bc2f2b7", - "sha256:273a01dc8c3c4fd4c2af7ea7a67c8d39bb09bce466e640dd170034da75d14cab", - "sha256:27b2ea68d2aa69fff1b20b67636b1e3e22a6a39e476c880da1282c3e4bf6ee5a", - "sha256:48eccf225615e106341a641f826b15224b8a4240b84269ead62f0afd6d7e2d95", - "sha256:677284430ac539bb23421a2b431b4ebc588097ef3ef918d0e0a8d8ed31fea216", - "sha256:691d72a00a99707a4e0b2846690961157aef7b17b6b884f6b4420a9f25cd39b5", - "sha256:86bc788245361a8148ff98667da938a01e1606b28a45e50ac977b09d3ad2c538", - "sha256:905ff8f3d6206ad56fcd70674453527b9011c8b0dc73ead27618426feff6908e", - "sha256:9c4bfb75456a8e1efe14c52fcefb89cfb8f2a0d31ed8d804b82c6cf2dc29c42c", - "sha256:a37ec80e238ead2969b746d7d1b6b0d31aa799498e9ba4281ab505b93e1f4b28", - "sha256:ae9b57546e118660175d45d264b87e9b4c19405c75b587b6e4d21e6a17bf4fdf", - "sha256:bd2bbe337a3f84958f796c77820d55ac2db1e6753f39d1d1baed44e07f13f96d", - "sha256:d5a208f8ef0e51d4746930589f54f9f92f84bb69a7d15b1de34ce80a7681bc00", - "sha256:dc406e5d756d932da95f3af082814d2467943631a587339ee65e5a4f4fbe83eb", - "sha256:ee76b4f05fcfff37bd6ac209d1370520d509ea70b5a637bdf0a04d0c99e13dff" - ], - "index": "pypi", - "version": "==0.0.272" - }, - "setuptools": { - "hashes": [ - "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f", - "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102" - ], - "markers": "python_version >= '3.7'", - "version": "==67.8.0" - }, - "typing-extensions": { - "hashes": [ - "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26", - "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5" - ], - "index": "pypi", - "version": "==4.6.3" - }, - "virtualenv": { - "hashes": [ - "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e", - "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924" - ], - "markers": "python_version >= '3.7'", - "version": "==20.23.0" - } - } -} diff --git a/README.md b/README.md index 8fb61513a7..0b671b8c4b 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,24 @@
-
- Fork of a feature-rich Modmail bot for Discord written in Python. + Fork² of a feature-rich Modmail bot for Discord written in Python.

- - - - - - Support - - - Ko-fi - - - Made with Python 3.8 - - - - - - MIT License - -
+> [!NOTE] +> This is a *hard* fork of modmail by khakers based on [raidensakura](https://github.com/raidensakura)'s fork +> of modmail. +> This fork is not affiliated with the original modmail project. +> +> It was done to add features that were not being added to the original modmail project due slow PR merges and to +> better align with what I wanted for modmail-viewer (and now modmail-view-ts). +> You probably shouldn't use this, but if you for some reason do, it does have some extra features, fixes, and bugs. +> I try to fix issues that I've caused as quickly as is reasonable, but my time is very limited. + +I am the developer of [modmail-viewer](https://github.com/khakers/modmail-viewer) and [modmail-viewer-ts](https://github.com/khakers/modmail-viewer-ts), which are the recommended way to view +logs for this fork. + ## Features * **Highly Customisable:** @@ -38,94 +30,89 @@ * Minimum length for members to be in the guild before allowed to contact Modmail (`guild_age`). * **Advanced Logging Functionality:** - * When you close a thread, Modmail will generate a [log link](https://logs.modmail.dev/example) and post it to your log channel. + * When you close a thread, Modmail will generate a log link and post it to your log channel. * Native Discord dark-mode feel. * Markdown/formatting support. * Login via Discord to protect your logs (optional feature). * See past logs of a user with `?logs`. * Searchable by text queries using `?logs search`. + * **S3 Attachment Archival:** Archive message attachments to durable S3 storage to preserve access beyond Discord's URL expiration (~7 days). Supports AWS S3 and S3-compatible services. * **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). + * Support for a 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 -This is a general installation guide. Refer to the [documentation](https://modmail-docs.netlify.app) for detailed user guide. - -This guide assumes you have git, and a supported Python version installed and added to system PATH. - -1. Clone the repository - ```console - $ git clone https://github.com/raidensakura/modmail - $ cd modmail - ``` -2. Create a Discord bot account, grant the necessary intents, and invite the bot. -3. Create a free MongoDB database. -4. Rename the file `.env.example` to `.env` and fill it with appropriate values -5. Update pip, install pipenv, and install dependencies using pipenv - ```console - $ pip install -U pip - $ pip install pipenv - $ pipenv install - ``` -6. Start the bot - ```console - $ pipenv run bot - ``` -7. Load the logviewer plugin included with Modmail with `?plugin load @local/logviewer` - ### Running the Docker Image This guide assume you already have Docker or Docker Compose installed. - Running with docker: ```console - $ docker run --env-file=.env --name=modmail ghcr.io/raidensakura/modmail:stable + $ docker run --env-file=.env --name=modmail ghcr.io/khakers/openmodmail:latest ``` - Running with Docker Compose: ```console $ docker compose up -d ``` +> [!NOTE] +> You should always view the docker compose file to understand the services that it runs and modify it to your wishes. +> +> I always recommend using a specific tagged release or commit as your image ( i.e. ghcr.io/khakers/openmodmail:sha-32asfd7 or an immutable sha256 hash) + + +### Docker image tags + +To pin to a specific version, use either the sha256 hash of the image or the commit sha. ex: `ghcr.io/khakers/openmodmail:sha-24286a`. +For most users, the latest tag is recommended and will be updated to point to stable releases when they become available. +> [!WARNING] +> The latest tag currently points to development releases, but will change in the future. + +### Image tag variants + +Images with '-supportutils' include a fixed version of the supportutils package, used by a number of plugins. +Images with '-pip' include pip installed into the virtual environment, which is required for some plugins. It is not currently included by default ## Plugins Modmail supports the use of third-party plugins to extend or add functionalities to the bot. -Plugins allow niche features as well as anything else outside of the scope of the core functionality of Modmail. +Plugins allow niche features as well as anything else outside the scope of core Modmail functionality. You can find a list of third-party plugins using the `?plugins registry` command on the bot or by reading through the official [REGISTRY.json](https://github.com/modmail-dev/modmail/blob/master/plugins/registry.json). +> [!NOTE] +> Modmail has deleted their github wiki + To develop your own, check out the [plugins documentation](https://github.com/modmail-dev/modmail/wiki/Plugins). Plugins requests and support are available in the [Modmail Support Server](https://discord.gg/cnUpwrnpYb). ## Support & Issues -Issues with the bot can be opened through [GitHub Issues](https://github.com/raidensakura/modmail/issues/new/choose). +Issues with the bot can be opened through [GitHub Issues](https://github.com/khakers/openmodmail/issues/new/choose). -Support for this forked version of Modmail can be requested through [Raiden's Discord server](https://dsc.gg/transience). -As I don't have a dedicated team to answer questions and provide help, it will be be answered when I'm available. ## Contributing -Check out the [contributing guidelines](https://github.com/raidensakura/modmail/blob/stable/.github/CONTRIBUTING.md) before you get started. +Check out the [contributing guidelines](https://github.com/khakers/modmail/blob/stable/.github/CONTRIBUTING.md) before you get started. -The [develop](https://github.com/raidensakura/modmail/tree/develop) branch is where most of the features are tested before stable release. +The [develop](https://github.com/khakers/openmodmail/tree/develop) branch is where most of the features are tested before stable release. This project has included pre-commit script that automatically runs black and ruff linter on every commit. -1. Install development dependencies inside pipenv +1. Install development dependencies inside pdm ```console - $ pipenv install --dev + $ pdm sync -d ``` 2. Install the pre-commit hook ```console - $ pre-commit install + $ pdm install --plugins ``` + The pre-commit hook should be automatically installed Alternatively, you can also lint the codebase manually diff --git a/app.json b/app.json index 5d03887632..2b68d01cec 100644 --- a/app.json +++ b/app.json @@ -11,10 +11,6 @@ "description": "The id for the server you are hosting this bot for.", "required": true }, - "MODMAIL_GUILD_ID": { - "description": "The ID of the discord server where the threads channels should be created (receiving server). Default to GUILD_ID.", - "required": false - }, "OWNERS": { "description": "Comma separated user IDs of people that are allowed to use owner only commands. (eval).", "required": true @@ -68,4 +64,4 @@ "required": false } } -} \ No newline at end of file +} diff --git a/bot.py b/bot.py index 780e94c7ab..fbfec5900d 100644 --- a/bot.py +++ b/bot.py @@ -1,28 +1,27 @@ -__version__ = "4.1.0" - +__version__ = "5.0.0-alpha.2" import asyncio import copy import hashlib -import logging import os import re import string import sys import typing from datetime import datetime, timedelta, timezone -from subprocess import PIPE from types import SimpleNamespace +from typing import Optional import discord import isodate -from aiohttp import ClientResponseError, ClientSession +from aiohttp import ClientSession from dateutil import parser from discord.ext import commands, tasks -from discord.ext.commands import MemberConverter from discord.ext.commands.view import StringView -from emoji import UNICODE_EMOJI -from pkg_resources import parse_version +from emoji import is_emoji +from packaging.version import Version + +from core.blocklist import Blocklist, BlockReason try: # noinspection PyUnresolvedReferences @@ -33,25 +32,22 @@ pass from core import checks -from core.changelog import Changelog -from core.clients import ApiClient, MongoDBClient, PluginDatabaseClient +from core.clients import MongoDBClient, PluginDatabaseClient from core.config import ConfigManager from core.models import ( DMDisabled, HostingMethod, - InvalidConfigError, PermissionLevel, SafeFormatter, configure_logging, getLogger, ) -from core.thread import ThreadManager +from core.thread import Thread, ThreadManager from core.time import human_timedelta -from core.utils import normalize_alias, parse_alias, truncate, tryint +from core.utils import extract_forwarded_content, human_join, normalize_alias, parse_alias, truncate, tryint logger = getLogger(__name__) - temp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "temp") if not os.path.exists(temp_dir): os.mkdir(temp_dir) @@ -63,6 +59,10 @@ logger.error("Failed to use WindowsProactorEventLoopPolicy.", exc_info=True) +class ModmailCommandContext(commands.Context["ModmailBot"]): + thread: Optional[Thread] + + class ModmailBot(commands.Bot): def __init__(self): self.config = ConfigManager(self) @@ -76,21 +76,36 @@ def __init__(self): self.session = None self._api = None self.formatter = SafeFormatter() - self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] + self.loaded_cogs = [ + "cogs.modmail", + "cogs.utility", + "cogs.plugins", + "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 - self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") - self._configure_logging() + log_dir = os.path.join(temp_dir, "logs") + if not os.path.exists(log_dir): + os.mkdir(log_dir) + self.log_file_path = os.path.join(log_dir, "modmail.log") + configure_logging(self) self.plugin_db = PluginDatabaseClient(self) # Deprecated + + self.blocklist = Blocklist(bot=self) + self.startup() def get_guild_icon( - self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None + self, + guild: typing.Optional[discord.Guild], + *, + size: typing.Optional[int] = None, ) -> str: if guild is None: guild = self.guild @@ -159,15 +174,18 @@ def hosting_method(self) -> HostingMethod: return HostingMethod.OTHER def startup(self): - logger.line() - logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") - logger.info("││││ │ │││││├─┤││") - logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") + """""" + logger.info( + r""" + ____ __ ___ __ _ __ + / __ \___ ___ ___ / |/ /__ ___/ /_ _ ___ _(_) / +/ /_/ / _ \/ -_) _ \/ /|_/ / _ \/ _ / ' \/ _ `/ / / +\____/ .__/\__/_//_/_/ /_/\___/\_,_/_/_/_/\_,_/_/_/ + /_/ + """ + ) 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: @@ -179,37 +197,14 @@ async def load_extensions(self): logger.debug("Successfully loaded %s.", cog) except Exception: logger.exception("Failed to load %s.", cog) - logger.line("debug") - - def _configure_logging(self): - level_text = self.config["log_level"].upper() - logging_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - } - logger.line() - - log_level = logging_levels.get(level_text) - if log_level is None: - log_level = self.config.remove("log_level") - logger.warning("Invalid logging level set: %s.", level_text) - logger.warning("Using default logging level: INFO.") - else: - logger.info("Logging level: %s", level_text) - - logger.info("Log file: %s", self.log_file_name) - configure_logging(self.log_file_name, log_level) - logger.debug("Successfully configured logging.") @property def version(self): - return parse_version(__version__) + return Version(__version__) @property - def api(self) -> ApiClient: + def api(self) -> MongoDBClient: + # TODO there's no real point in having this, we are heavily dependent on mongodb queries. if self._api is None: if self.config["database_type"].lower() == "mongodb": self._api = MongoDBClient(self) @@ -328,7 +323,10 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id - logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) + logger.warning( + "No log channel set, setting #%s to be the log channel.", + channel.name, + ) return channel except IndexError: pass @@ -441,11 +439,14 @@ def using_multiple_server_setup(self) -> bool: @property def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: + logger.debug(f"Resolving main category for guild {self.modmail_guild.name}") category_id = self.config["main_category_id"] + logger.debug(f"Found main category ID in config: {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: + logger.debug(f"Resolved main category: {cat.id}") return cat except ValueError: pass @@ -462,10 +463,14 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: @property def blocked_users(self) -> typing.Dict[str, str]: + """DEPRECATED, used blocklist instead""" + logger.warning("blocked_users is deprecated and does not function, its usage is a bug") return self.config["blocked"] @property def blocked_roles(self) -> typing.Dict[str, str]: + """DEPRECATED, used blocklist instead""" + logger.warning("blocked_roles is deprecated and does not function, its usage is a bug") return self.config["blocked_roles"] @property @@ -524,6 +529,7 @@ async def on_connect(self): logger.debug("Connected to gateway.") await self.config.refresh() await self.api.setup_indexes() + await self.blocklist.setup() await self.load_extensions() self._connected.set() @@ -534,17 +540,16 @@ async def on_ready(self): await self.wait_for_connected() if self.guild is None: - logger.error("Logging out due to invalid GUILD_ID.") + logger.critical( + f"Unable to find main guild, no guilds found matching ID '{self.guild_id}', is your GUILD_ID set correctly?" + ) return await self.close() if self._started: # Bot has started before - logger.line() logger.warning("Bot restarted due to internal discord reloading.") - logger.line() return - logger.line() logger.debug("Client ready.") logger.info("Logged in as: %s", self.user) logger.info("Bot ID: %s", self.user.id) @@ -557,21 +562,24 @@ async def on_ready(self): 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() + + if len(self.config["blocked"]) > 0 or len(self.config["blocked_roles"]) > 0: + logger.warning( + "Un-migrated blocklists found. Please run the '[p]migrate blocklist' command after backing " + "up your config/database. Blocklist functionality will be disabled until this is done." + ) await self.threads.populate_cache() # closures 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 = ( @@ -581,7 +589,11 @@ async def on_ready(self): logger.debug("Closing thread for recipient %s.", recipient_id) after = 0 else: - logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after) + logger.debug( + "Thread for recipient %s will be closed after %s seconds.", + recipient_id, + after, + ) thread = await self.threads.find(recipient_id=int(recipient_id)) @@ -602,8 +614,9 @@ async def on_ready(self): ) for log in await self.api.get_open_logs(): - if self.get_channel(int(log["channel_id"])) is None: + if log.get("channel_id") is None or self.get_channel(int(log["channel_id"])) is None: logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) + assert self.user log_data = await self.api.post_log( log["channel_id"], { @@ -623,7 +636,10 @@ async def on_ready(self): if log_data: logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) else: - logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) + 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): @@ -634,19 +650,17 @@ async def on_ready(self): ) logger.warning("If the external servers are valid, you may ignore this message.") - self.post_metadata.start() - self.autoupdate.start() self._started = True async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() - if name not in UNICODE_EMOJI["en"]: + if not is_emoji(name): try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument as e: - logger.warning("%s is not a valid emoji. %s.", name, e) + logger.warning(f"{name} is not a valid emoji. {e}.") raise return name @@ -659,6 +673,16 @@ async def get_or_fetch_user(self, id: int) -> discord.User: """ return self.get_user(id) or await self.fetch_user(id) + @staticmethod + async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Returns: + The :obj:`discord.Member` or :obj:`None` to indicate the member could not be found. + """ + return guild.get_member(member_id) or await guild.fetch_member(member_id) + async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] @@ -718,6 +742,8 @@ def check_guild_age(self, author: discord.Member) -> bool: return True def check_manual_blocked_roles(self, author: discord.Member) -> bool: + """DEPRECATED""" + logger.error("check_manual_blocked_roles is deprecated, usage is a bug.") for role in author.roles: if str(role.id) not in self.blocked_roles: continue @@ -734,6 +760,8 @@ def check_manual_blocked_roles(self, author: discord.Member) -> bool: return True def check_manual_blocked(self, author: discord.User) -> bool: + """DEPRECATED""" + logger.error("check_manual_blocked is deprecated, usage is a bug.") if str(author.id) not in self.blocked_users: return True @@ -758,6 +786,7 @@ async def _process_blocked(self, message): # This is to store blocked message cooldown in memory _block_msg_cooldown = dict() + # This has a bunch of side effects async def is_blocked( self, author: discord.User, @@ -765,13 +794,45 @@ async def is_blocked( channel: discord.TextChannel = None, send_message: bool = False, ) -> bool: - member = self.guild.get_member(author.id) or await MemberConverter.convert(author) + """ + Check if a user is blocked for any reason and send a message if they are (if send_message is true). + + If you are using this method with send_message set to false or not set, + You should be using blocklist.is_user_blocked() or blocklist.is_id_blocked() + if you only care whether a user is manually blocked then use blocklist.is_id_blocked(). + + Parameters + ---------- + author + channel + send_message + + Returns + ------- + bool + Whether the user is blocked or not. + """ + member = None + if self.guild is not None: + member = self.guild.get_member(author.id) + if member is None: + try: + member = await self.guild.fetch_member(author.id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + member = None + if member is None: # try to find in other guilds for g in self.guilds: member = g.get_member(author.id) if member: break + try: + member = await g.fetch_member(author.id) + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + continue + else: + break if member is None: logger.debug(f"{str(author)} not in any of my guilds, unable to check guild age and roles.") @@ -794,35 +855,38 @@ async def send_embed(title=None, desc=None): self._block_msg_cooldown[str(author)] = now + timedelta(minutes=5) return await channel.send(embed=emb) - 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 + if member is not None: + blocked, block_type = await self.blocklist.is_user_blocked(member) + else: + blocked, block_type = await self.blocklist.is_id_blocked(author.id) + if not self.blocklist.is_valid_account_age(author): + blocked = True + block_type = BlockReason.ACCOUNT_AGE - if not self.check_account_age(author): - blocked_reason = "Sorry, your account is too new to initiate a ticket." - await send_embed(title="Message not sent!", desc=blocked_reason) - return True + if blocked: + if block_type == BlockReason.ACCOUNT_AGE: + blocked_reason = "Sorry, your account is too new to initiate a ticket." + await send_embed(title="Message not sent!", desc=blocked_reason) + return True - if not self.check_manual_blocked(author): - blocked_reason = "You have been blocked from contacting Modmail." - await send_embed(title="Message not sent!", desc=blocked_reason) - return True + if block_type == BlockReason.BLOCKED_USER: + blocked_reason = "You have been blocked from contacting Modmail." + await send_embed(title="Message not sent!", desc=blocked_reason) + return True - if not self.check_guild_age(member): - blocked_reason = "Sorry, you joined the server too recently to initiate a ticket." - await send_embed(title="Message not sent!", desc=blocked_reason) - return True + if block_type == BlockReason.GUILD_AGE: + blocked_reason = "Sorry, you joined the server too recently to initiate a ticket." + await send_embed(title="Message not sent!", desc=blocked_reason) + return True - if not self.check_manual_blocked_roles(member): - blocked_reason = "Sorry, your role(s) has been blacklisted from contacting Modmail." - await send_embed(title="Message not sent!", desc=blocked_reason) - return True + if block_type == BlockReason.BLOCKED_ROLE: + blocked_reason = "Sorry, your role(s) has been blacklisted from contacting Modmail." + await send_embed(title="Message not sent!", desc=blocked_reason) + return True - return False + return blocked - async def get_thread_cooldown(self, author: discord.Member): + async def get_thread_cooldown(self, author: discord.Member | discord.User) -> typing.Optional[str]: thread_cooldown = self.config.get("thread_cooldown") now = discord.utils.utcnow() @@ -858,7 +922,8 @@ async def get_thread_cooldown(self, author: discord.Member): @staticmethod async def add_reaction( - msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str] + msg, + reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str], ) -> bool: if reaction != "disable": try: @@ -868,6 +933,36 @@ async def add_reaction( 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) @@ -875,10 +970,210 @@ async def process_dm_modmail(self, message: discord.Message) -> None: return sent_emoji, blocked_emoji = await self.retrieve_emoji() + # TODO pretty sure these conditionals are completely incorrect and will never evaluate to true + # The enum values do not exist and messages aren't even structured like this. + # 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): + logger.debug("Received a forwarded message with snapshots from ") + 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): + logger.debug( + "Received a forwarded message from", + ) + # Check for message.reference and its type + ref = message.reference + if ref and ref.type == discord.MessageReferenceType.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) + await message.channel.send(embed=embed) + return + 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) + await message.channel.send(embed=embed) + return + + # Create a forwarded message wrapper to preserve forward info + class ForwardedMessage: + def __init__(self, original_message: discord.Message, ref_message: discord.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(thread.channel.id) 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: @@ -891,7 +1186,10 @@ async def process_dm_modmail(self, message: discord.Message) -> None: ) return - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + 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, @@ -901,11 +1199,18 @@ async def process_dm_modmail(self, message: discord.Message) -> None: 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) + 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) + await message.channel.send(embed=embed) + return 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( @@ -917,9 +1222,13 @@ async def process_dm_modmail(self, message: discord.Message) -> None: 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) + logger.info( + "A message was blocked from %s because Modmail is currently disabled.", + message.author, + ) await self.add_reaction(message, blocked_emoji) - return await message.channel.send(embed=embed) + await message.channel.send(embed=embed) + return if not thread.cancelled: try: @@ -927,6 +1236,49 @@ async def process_dm_modmail(self, message: discord.Message) -> None: 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 @@ -1039,7 +1391,12 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) if trigger: invoker = re.search(trigger, message.content).group(0) else: - trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys())) + trigger = next( + filter( + lambda x: x.lower() in message.content.lower(), + self.auto_triggers.keys(), + ) + ) if trigger: invoker = trigger.lower() @@ -1071,19 +1428,20 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) ctx.command.checks = old_checks continue - async def get_context(self, message, *, cls=commands.Context): + async def get_context(self, message, *, cls=ModmailCommandContext): """ Returns the invocation context from the message. Supports getting the prefix from database. """ view = StringView(message.content) - ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) + ctx: ModmailCommandContext = cls(prefix=self.prefix, view=view, bot=self, message=message) if message.author.id == self.user.id: return ctx - ctx.thread = await self.threads.find(channel=ctx.channel) + if isinstance(ctx.channel, discord.TextChannel): + ctx.thread = await self.threads.find(channel=ctx.channel) prefixes = await self.get_prefix() @@ -1152,14 +1510,27 @@ async def on_message(self, message): 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, thread_key=thread.key, type_="internal") + await self.process_commands(message) - async def process_commands(self, message): + async def process_commands(self, message: discord.Message): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): - return await self.process_dm_modmail(message) + return await self._queue_dm_message(message) ctxs = await self.get_contexts(message) for ctx in ctxs: @@ -1171,11 +1542,45 @@ async def process_commands(self, message): ) checks.has_permissions(PermissionLevel.INVALID)(ctx.command) + # Check if thread is unsnoozing and queue command if so + thread = await self.threads.find(channel=ctx.channel) + if thread and thread._unsnoozing: + queued = await thread.queue_command(ctx, ctx.command) + if queued: + # Send a brief acknowledgment that command is queued + try: + await ctx.message.add_reaction("⏳") + except Exception as e: + logger.warning("Failed to add queued-reaction: %s", e) + continue + await self.invoke(ctx) continue thread = await self.threads.find(channel=ctx.channel) if thread is not None: + # If thread is snoozed (moved), auto-unsnooze when a mod sends a message directly in channel + behavior = (self.config.get("snooze_behavior") or "delete").lower() + if thread.snoozed and behavior == "move": + if not thread.snooze_data: + try: + # TODO/BUG: recipient.id + snoozed alone is under-scoped and may select wrong log. + log_entry = await self.api.logs.find_one( + {"recipient.id": str(thread.id), "snoozed": True, "open": True} + ) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + except Exception: + logger.error( + "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"): @@ -1188,11 +1593,18 @@ async def process_commands(self, message): or self.config.get("anon_reply_without_command") or self.config.get("plain_reply_without_command") ): - await thread.reply(message, anonymous=anonymous, plain=plain) + # Check to see if the message starts with the ignore prefix + if not message.content.startswith(self.config.get("ignore_prefix")): + # 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) + else: + logger.debug(f"Message {message.id} ignored because it started with the ignore_prefix.") + await self.api.append_log(message, type_="internal") else: await self.api.append_log(message, type_="internal") elif ctx.invoked_with: - exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) + exc = commands.CommandNotFound(f'Command "{ctx.invoked_with}" is not found') self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): @@ -1208,7 +1620,13 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(recipient=user) if thread: - await thread.channel.typing() + try: + await thread.channel.typing() + except Exception: + logger.debug( + "Failed to trigger typing indicator in recipient DM.", + exc_info=True, + ) else: if not self.config.get("mod_typing"): return @@ -1218,7 +1636,14 @@ async def on_typing(self, channel, user, _): for user in thread.recipients: if await self.is_blocked(user): continue - await user.typing() + 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) @@ -1226,25 +1651,36 @@ async def handle_reaction_events(self, payload): return channel = self.get_channel(payload.channel_id) - if not channel: # dm channel not in internal cache - _thread = await self.threads.find(recipient=user) - if not _thread: + thread = None + # dm channel not in internal cache + if not channel: + thread = await self.threads.find(recipient=user) + if not thread: + return + channel = await thread.recipient.create_dm() + if channel.id != payload.channel_id: + return + + from_dm = isinstance(channel, discord.DMChannel) + from_txt = isinstance(channel, discord.TextChannel) + if not from_dm and not from_txt: + return + + if not thread: + params = {"recipient": user} if from_dm else {"channel": channel} + thread = await self.threads.find(**params) + if not thread: return - channel = await _thread.recipient.create_dm() + # thread must exist before doing this API call try: message = await channel.fetch_message(payload.message_id) except (discord.NotFound, discord.Forbidden): return reaction = payload.emoji - close_emoji = await self.convert_emoji(self.config["close_emoji"]) - - if isinstance(channel, discord.DMChannel): - thread = await self.threads.find(recipient=user) - if not thread: - return + if from_dm: if ( payload.event_type == "REACTION_ADD" and message.embeds @@ -1252,7 +1688,7 @@ async def handle_reaction_events(self, payload): and self.config.get("recipient_thread_close") ): ts = message.embeds[0].timestamp - if thread and ts == thread.channel.created_at: + if ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed # closing thread return await thread.close(closer=user) @@ -1272,16 +1708,15 @@ async def handle_reaction_events(self, payload): logger.warning("Failed to find linked message for reactions: %s", e) return else: - thread = await self.threads.find(channel=channel) - if not thread: - return try: - _, *linked_messages = await thread.find_linked_messages(message.id, either_direction=True) + _, *linked_messages = await thread.find_linked_messages( + message1=message, either_direction=True + ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return - if self.config["transfer_reactions"] and linked_messages is not [None]: + 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) @@ -1316,7 +1751,10 @@ async def handle_react_to_contact(self, payload): 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): + 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, @@ -1332,6 +1770,19 @@ async def handle_react_to_contact(self, payload): ) return await member.send(embed=embed) + # Check if user has a snoozed thread + existing_thread = await self.threads.find(recipient=member) + if existing_thread and existing_thread.snoozed: + # Unsnooze the thread + await existing_thread.restore_from_snooze() + self.threads.cache[existing_thread.id] = existing_thread + # Send notification to the thread channel + if existing_thread.channel: + await existing_thread.channel.send( + f"ℹ️ {member.mention} reacted to contact and their snoozed thread has been unsnoozed." + ) + return + ctx = await self.get_context(message) await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) @@ -1365,12 +1816,30 @@ async def on_guild_channel_delete(self, channel): await self.config.update() return - audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) - found_entry = False - async for entry in audit_logs: - if int(entry.target.id) == channel.id: - found_entry = True - break + # Attempt to attribute channel deletion to a moderator via audit logs. + # This requires the "View Audit Log" permission; if missing, skip silently. + if not self.modmail_guild.me.guild_permissions.view_audit_log: + logger.debug( + "Skipping audit log lookup for deleted channel %d: missing view_audit_log permission.", + channel.id, + ) + return + + try: + audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) + found_entry = False + async for entry in audit_logs: + if int(entry.target.id) == channel.id: + found_entry = True + break + except discord.Forbidden: + logger.debug( + "Forbidden when fetching audit logs for deleted channel %d (missing permission).", channel.id + ) + return + except discord.HTTPException as e: + logger.debug("HTTPException when fetching audit logs for deleted channel %d: %s", channel.id, e) + return if not found_entry: logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) @@ -1386,28 +1855,44 @@ async def on_guild_channel_delete(self, channel): await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - if self.config["close_on_leave"]: + if member.guild == self.guild and self.config["close_on_leave"]: await thread.close( closer=member.guild.me, message=self.config["close_on_leave_reason"], silent=True, ) else: - embed = discord.Embed( - description=self.config["close_on_leave_reason"], color=self.error_color - ) + if len(self.guilds) > 1: + guild_left = member.guild + remaining_guilds = member.mutual_guilds + + if remaining_guilds: + remaining_guild_names = [guild.name for guild in remaining_guilds] + leave_message = ( + f"The recipient has left {guild_left}. " + f"They are still in {human_join(remaining_guild_names, final='and')}." + ) + else: + leave_message = ( + f"The recipient has left {guild_left}. We no longer share any mutual servers." + ) + else: + leave_message = "The recipient has left the server." + + embed = discord.Embed(description=leave_message, color=self.error_color) await thread.channel.send(embed=embed) async def on_member_join(self, member): - if member.guild != self.guild: - return thread = await self.threads.find(recipient=member) if thread: - embed = discord.Embed(description="The recipient has joined the server.", color=self.mod_color) + if len(self.guilds) > 1: + guild_joined = member.guild + join_message = f"The recipient has joined {guild_joined}." + else: + join_message = "The recipient has joined the server." + embed = discord.Embed(description=join_message, color=self.mod_color) await thread.channel.send(embed=embed) async def on_message_delete(self, message): @@ -1451,7 +1936,14 @@ async def on_message_delete(self, message): await thread.delete_message(message, note=False) embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) except ValueError as e: - if str(e) not in {"DM message not found.", "Malformed thread message."}: + # 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.", + "Linked DM message not found.", + "Thread message is an internal message, not a note.", + }: logger.debug("Failed to find linked message to delete: %s", e) embed = discord.Embed(description="Failed to delete message.", color=self.error_color) else: @@ -1490,7 +1982,11 @@ async def on_error(self, event_method, *args, **kwargs): 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 + self, + context: commands.Context, + exception: Exception, + *, + unhandled_by_cog: bool = False, ) -> None: if not unhandled_by_cog: command = context.command @@ -1501,7 +1997,13 @@ async def on_command_error( return if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): - await context.typing() + try: + await context.typing() + except Exception: + logger.debug( + "Failed to start typing context for command error feedback.", + exc_info=True, + ) await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) @@ -1532,176 +2034,26 @@ async def on_command_error( ) 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()), - } - - 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, - } + logger.info( + "DisabledCommand: %s is trying to run eval but it's disabled", + context.author.name, ) else: - data.update({"owner_name": info.owner.name, "owner_id": info.owner.id, "team": False}) - - async with self.session.post("https://api.modmail.dev/metadata", json=data): - logger.debug("Uploading metadata to Modmail server.") - - @post_metadata.before_loop - async def before_post_metadata(self): - await self.wait_for_connected() - if not self.config.get("data_collection") or not self.guild: - self.post_metadata.cancel() - return - - logger.debug("Starting metadata loop.") - logger.line("debug") - - @tasks.loop(hours=1) - async def autoupdate(self): - changelog = await Changelog.from_url(self) - latest = changelog.latest_version - - if self.version < parse_version(latest.version): - 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}) " f"{message} - {user['username']}", - ) - embed.set_author( - name=user["username"] + " - Updating Bot", - icon_url=user["avatar_url"], - url=user["url"], - ) - - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") - - embed.description = latest.description - for name, value in latest.fields.items(): - embed.add_field(name=name, value=value) - - channel = self.update_channel - await channel.send(embed=embed) - else: - command = "git pull" - proc = await asyncio.create_subprocess_shell( - command, - stderr=PIPE, - stdout=PIPE, - ) - err = await proc.stderr.read() - err = err.decode("utf-8").rstrip() - res = await proc.stdout.read() - res = res.decode("utf-8").rstrip() - - if err and not res: - logger.warning(f"Autoupdate failed: {err}") - self.autoupdate.cancel() - return - - elif res != "Already up to date.": - if os.getenv("PIPENV_ACTIVE"): - # Update pipenv if possible - await asyncio.create_subprocess_shell( - "pipenv sync", - stderr=PIPE, - stdout=PIPE, - ) - message = "" - else: - message = "\n\nDo manually update dependencies if your bot has crashed." - - logger.info("Bot has been updated.") - channel = self.update_channel - if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): - embed = discord.Embed(title="Bot has been updated", color=self.main_color) - embed.set_footer( - text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" - ) - if self.config["update_notifications"]: - await channel.send(embed=embed) - else: - embed = discord.Embed( - title="Bot has been updated and is logging out.", - description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", - color=self.main_color, - ) - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") - if self.config["update_notifications"]: - await channel.send(embed=embed) - return await self.close() - - @autoupdate.before_loop - async def before_autoupdate(self): - await self.wait_for_connected() - logger.debug("Starting autoupdate loop") + logger.error("Unexpected exception:", exc_info=exception) - if self.config.get("disable_autoupdates"): - 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() - if self.hosting_method == HostingMethod.DOCKER: - logger.warning("Autoupdates disabled as using Docker.") - self.autoupdate.cancel() - return + 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)}}) - 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 + 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 @@ -1756,26 +2108,6 @@ def main(): except ImportError: pass - # check discord version - discord_version = "2.3.0" - 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) - - # Set up discord.py internal logging - if os.environ.get("LOG_DISCORD"): - logger.debug(f"Discord logging enabled: {os.environ['LOG_DISCORD'].upper()}") - d_logger = logging.getLogger("discord") - - d_logger.setLevel(os.environ["LOG_DISCORD"].upper()) - handler = logging.FileHandler(filename="discord.log", encoding="utf-8", mode="w") - handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s")) - d_logger.addHandler(handler) - bot = ModmailBot() bot.run() diff --git a/cogs/modmail.py b/cogs/modmail.py index e41ecbe321..2bde4204d0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1,18 +1,20 @@ import asyncio -import datetime import re -from datetime import timezone -from itertools import zip_longest -from types import SimpleNamespace +import time +from datetime import datetime, timedelta, timezone +from itertools import batched from typing import List, Literal, Optional, Tuple, Union import discord from dateutil import parser -from discord.ext import commands +from discord import Embed +from discord.ext import commands, tasks from discord.ext.commands.cooldowns import BucketType from discord.ext.commands.view import StringView -from core import checks +from bot import ModmailBot, ModmailCommandContext +from core import blocklist, checks +from core.blocklist import BlockType from core.models import DMDisabled, PermissionLevel, SimilarCategoryConverter, getLogger from core.paginator import EmbedPaginatorSession from core.thread import Thread @@ -25,8 +27,90 @@ class Modmail(commands.Cog): """Commands directly related to Modmail functionality.""" - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: ModmailBot): + self.bot: ModmailBot = bot + self._snoozed_cache = [] + self._auto_unsnooze_task = self.bot.loop.create_task(self.auto_unsnooze_task()) + + @staticmethod + def _to_utc_datetime(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if isinstance(value, str): + try: + parsed = parser.isoparse(value) + except (TypeError, ValueError): + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + return None + + async def auto_unsnooze_task(self): + await self.bot.wait_until_ready() + logger.debug("auto_unsnooze_task") + last_db_query: int | float = 0 + while not self.bot.is_closed(): + now = time.monotonic() + try: + # Query DB every 2 minutes + if (now - last_db_query) > 120: + snoozed_threads = await self.bot.api.logs.find({"snoozed": True, "open": True}).to_list( + None + ) + self._snoozed_cache = snoozed_threads or [] + last_db_query = time.monotonic() + # 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: + dt = self._to_utc_datetime(snooze_until) + if dt is None: + continue + if datetime.now(timezone.utc) >= 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() + logger.info(f"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.error( + "Failed to notify channel after auto-unsnooze: %s", + e, + ) + self._snoozed_cache.remove(thread_data) + except Exception as e: + logger.error(f"Error in auto_unsnooze_task: {e}") + await asyncio.sleep(10) + + def _resolve_user(self, user_str: str) -> Optional[int]: + """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 @@ -43,7 +127,8 @@ async def setup(self, ctx): 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.") + logger.warning("Can't re-setup server, main_category is found.") + logger.debug(f"Current main category: {self.bot.main_category.name}") return await ctx.send(f"{self.bot.modmail_guild} is already set up.") if self.bot.modmail_guild is None: @@ -142,6 +227,21 @@ async def snippet(self, ctx, *, name: str.lower = None): """ if name is not None: + if name == "compact": + embeds = [] + + for i, names in enumerate(batched(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: @@ -149,25 +249,34 @@ async def snippet(self, ctx, *, name: str.lower = None): else: val = self.bot.snippets[snippet_name] embed = discord.Embed( - title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color + title=f'Snippet - "{snippet_name}":', + description=val, + color=self.bot.main_color, ) return await ctx.send(embed=embed) if not self.bot.snippets: embed = discord.Embed( - color=self.bot.error_color, description="You dont have any snippets at the moment." + color=self.bot.error_color, + description="You dont have any snippets at the moment.", ) embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') - embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) + embed.set_author( + name="Snippets", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), + ) return await ctx.send(embed=embed) - 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) + embeds: list[Embed] = [ + discord.Embed(color=self.bot.main_color) for _ in range((len(self.bot.snippets) + 9) // 10) + ] + for embed in embeds: embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) - embeds.append(embed) + + for i, snippet in enumerate(sorted(self.bot.snippets.items())): + embeds[i // 10].add_field( + name=snippet[0], value=return_or_truncate(snippet[1], 350), inline=False + ) session = EmbedPaginatorSession(ctx, *embeds) await session.run() @@ -191,61 +300,48 @@ async def snippet_raw(self, ctx, *, name: str.lower): return await ctx.send(embed=embed) + def _validate_snippet_name(self, name): + if self.bot.get_command(name): + return f"A command with the same name already exists: `{name}`." + elif name in self.bot.snippets: + return f"Snippet `{name}` already exists." + elif name in self.bot.aliases: + return f"An alias that shares the same name exists: `{name}`." + elif len(name) > 120: + return "Snippet names cannot be longer than 120 characters." + return None + @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 :) - ``` + 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. - ``` + 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): + if self._validate_snippet_name(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.", + description="Snippet names cannot be longer than 120 characters.", ) - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + else: - 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) + self.bot.snippets[name] = value + await self.bot.config.update() - if len(name) > 120: embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description="Snippet names cannot be longer than 120 characters.", + title="Added snippet", + color=self.bot.main_color, + description="Successfully created snippet.", ) - return await ctx.send(embed=embed) - - self.bot.snippets[name] = value - await self.bot.config.update() - - embed = discord.Embed( - title="Added snippet", - color=self.bot.main_color, - 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]]: """ @@ -374,6 +470,32 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) + @snippet.command(name="rename") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_rename(self, ctx, name: str.lower, *, value): + """ + Rename a snippet. + + To rename a multi-word snippet name, use quotes: ``` + {prefix}snippet rename "two word" this is a new two word snippet. + ``` + """ + if name not in self.bot.snippets: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + else: + error_msg = self._validate_snippet_name(value) + if error_msg: + embed = discord.Embed(title="Error", color=self.bot.error_color, description=error_msg) + else: + self.bot.snippets[value] = self.bot.snippets.pop(name) + await self.bot.config.update() + embed = discord.Embed( + title="Renamed snippet", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) + await ctx.send(embed=embed) + @commands.command(usage=" [options]") @checks.has_permissions(PermissionLevel.MODERATOR) @checks.thread_only() @@ -417,7 +539,10 @@ async def move(self, ctx, *, arguments): 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." + category=category, + end=True, + sync_permissions=True, + reason=f"{ctx.author} moved this thread.", ) if self.bot.config["thread_move_notify"] and not silent: @@ -439,12 +564,15 @@ async def move(self, ctx, *, arguments): sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - async def send_scheduled_close_message(self, ctx, after, silent=False): - human_delta = human_timedelta(after.dt) + async def send_scheduled_close_message(self, ctx, after: UserFriendlyTime, 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{' silently' if silent else ''} close in {human_delta}.", + description=f"This thread will{' silently' if silent else ''} close .", color=self.bot.error_color, ) @@ -454,7 +582,9 @@ async def send_scheduled_close_message(self, ctx, after, silent=False): embed.set_footer(text="Closing will be cancelled if a thread message is sent.") embed.timestamp = after.dt - await ctx.send(embed=embed) + thread = getattr(ctx, "thread", None) + if thread and ctx.channel == thread.channel: + await thread.channel.send(embed=embed) @commands.command(usage="[after] [close message]") @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -495,7 +625,8 @@ async def close( if thread.close_task is not None or thread.auto_close_task is not None: await thread.cancel_closure(all=True) embed = discord.Embed( - color=self.bot.error_color, description="Scheduled close has been cancelled." + color=self.bot.error_color, + description="Scheduled close has been cancelled.", ) else: embed = discord.Embed( @@ -594,7 +725,8 @@ async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.low mentions.remove(mention) await self.bot.config.update() embed = discord.Embed( - color=self.bot.main_color, description=f"{mention} will no longer be notified." + color=self.bot.main_color, + description=f"{mention} will no longer be notified.", ) return await ctx.send(embed=embed) @@ -677,7 +809,8 @@ async def unsubscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str. @checks.thread_only() async def nsfw(self, ctx): """Flags a Modmail thread as NSFW (not safe for work).""" - await ctx.channel.edit(nsfw=True) + await ctx.thread.set_nsfw_status(True) + sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) @@ -686,7 +819,8 @@ async def nsfw(self, ctx): @checks.thread_only() async def sfw(self, ctx): """Flags a Modmail thread as SFW (safe for work).""" - await ctx.channel.edit(nsfw=False) + await ctx.thread.set_nsfw_status(False) + sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) @@ -695,11 +829,18 @@ async def sfw(self, ctx): @checks.thread_only() async def msglink(self, ctx, message_id: int): """Retrieves the link to a message in the current thread.""" - try: - message = await ctx.thread.recipient.fetch_message(message_id) - except discord.NotFound: + 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." + 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) @@ -753,7 +894,6 @@ def format_log_embeds(self, logs, avatar_url): 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.") @@ -769,7 +909,7 @@ def format_log_embeds(self, logs, avatar_url): @commands.cooldown(1, 600, BucketType.channel) async def title(self, ctx, *, name: str): """Sets title for a thread""" - await ctx.thread.set_title(name) + await ctx.thread.set_title(name, ctx.channel.id) sent_emoji, _ = await self.bot.retrieve_emoji() await ctx.message.pin() await self.bot.add_reaction(ctx.message, sent_emoji) @@ -843,7 +983,10 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str ) 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) + 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)) @@ -859,7 +1002,9 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str ) 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) + 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: @@ -927,7 +1072,8 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, to_exec = [] if not silent: description = self.bot.formatter.format( - self.bot.config["private_removed_from_group_response"], moderator=ctx.author + self.bot.config["private_removed_from_group_response"], + moderator=ctx.author, ) em = discord.Embed( title=self.bot.config["private_removed_from_group_title"], @@ -936,7 +1082,10 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) 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) + 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)) @@ -952,7 +1101,9 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) 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) + 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: @@ -1026,11 +1177,11 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: - name = tag + 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) + 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)) @@ -1046,7 +1197,9 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, ) 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) + 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: @@ -1115,11 +1268,11 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) name = self.bot.config["anon_username"] if name is None: - name = tag + 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) + 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)) @@ -1135,7 +1288,9 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro ) 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) + 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: @@ -1159,12 +1314,13 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.typing() + async with safe_typing(ctx): + pass if not user: thread = ctx.thread if not thread: - raise commands.MissingRequiredArgument(SimpleNamespace(name="member")) + 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" @@ -1210,6 +1366,28 @@ async def logs_closed_by(self, ctx, *, user: User = None): session = EmbedPaginatorSession(ctx, *embeds) await session.run() + @logs.command(name="key", aliases=["id"]) + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def logs_key(self, ctx, key: str): + """ + Get the log link for the specified log key. + """ + icon_url = ctx.author.avatar.url + + logs = await self.bot.api.find_log_entry(key) + + if not logs: + embed = discord.Embed( + color=self.bot.error_color, + description=f"Log entry `{key}` not found.", + ) + return await ctx.send(embed=embed) + + embeds = self.format_log_embeds(logs, avatar_url=icon_url) + + session = EmbedPaginatorSession(ctx, *embeds) + await session.run() + @logs.command(name="delete", aliases=["wipe"]) @checks.has_permissions(PermissionLevel.OWNER) async def logs_delete(self, ctx, key_or_link: str): @@ -1269,7 +1447,8 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): Provide a `limit` to specify the maximum number of logs the bot should find. """ - await ctx.typing() + async with safe_typing(ctx): + pass entries = await self.bot.api.search_by_text(query, limit) @@ -1296,10 +1475,10 @@ async def reply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - - async with ctx.typing(): - await ctx.thread.reply(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) @commands.command(aliases=["formatreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1317,11 +1496,15 @@ async def freply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg) @commands.command(aliases=["formatanonreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1339,11 +1522,15 @@ async def fareply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with 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(aliases=["formatplainreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1361,16 +1548,20 @@ async def fpreply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message, plain=True) + 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 = ""): + async def fpareply(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Anonymously reply to a Modmail thread with variables and a plain message. @@ -1382,17 +1573,23 @@ async def fpareply(self, ctx, *, msg: str = ""): Supports attachments and images as well as automatically embedding image URLs. """ + assert ctx.thread is not None + msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + 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 = ""): + async def areply(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Reply to a thread anonymously. @@ -1402,64 +1599,86 @@ async def areply(self, ctx, *, msg: str = ""): Edit the `anon_username`, `anon_avatar_url` and `anon_tag` config variables to do so. """ + assert ctx.thread is not None + # 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(aliases=["plainreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def preply(self, ctx, *, msg: str = ""): + async def preply(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Reply to a Modmail thread with a plain message. Supports attachments and images as well as automatically embedding image URLs. """ + assert ctx.thread is not None + # Ensure logs record only the reply text, not the command. ctx.message.content = msg - async with ctx.typing(): - await ctx.thread.reply(ctx.message, plain=True) + 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 = ""): + async def pareply(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Reply to a Modmail thread with a plain message and anonymously. Supports attachments and images as well as automatically embedding image URLs. """ + assert ctx.thread is not None + + # 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, plain=True) + async with safe_typing(ctx): + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) + + async def _create_note( + self, message: discord.Message, msg: str, thread: Thread, persistent: bool + ) -> Optional[discord.Message]: + message.content = msg + async with safe_typing(message.channel): + note_message = await thread.note(message, persistent) + await note_message.pin() + if persistent: + await self.bot.api.create_note( + recipient=thread.recipient, message=message, message_id=note_message.id + ) + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(message, sent_emoji) + try: + await message.delete(delay=3) + except (discord.Forbidden, discord.NotFound) as e: + logger.error(f"Failed to delete note command message: {e}", exc_info=True) @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def note(self, ctx, *, msg: str = ""): + async def note(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Take a note about the current thread. Useful for noting context. """ - ctx.message.content = msg - async with ctx.typing(): - msg = await ctx.thread.note(ctx.message) - await msg.pin() + assert ctx.thread is not None + + await self._create_note(ctx.message, msg, ctx.thread, persistent=False) @note.command(name="persistent", aliases=["persist"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def note_persistent(self, ctx, *, msg: str = ""): + async def note_persistent(self, ctx: ModmailCommandContext, *, msg: str = ""): """ Take a persistent note about the current user. """ - ctx.message.content = msg - async with ctx.typing(): - msg = await ctx.thread.note(ctx.message, persistent=True) - await msg.pin() - await self.bot.api.create_note(recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id) + assert ctx.thread is not None + await self._create_note(ctx.message, msg, ctx.thread, persistent=True) @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1477,11 +1696,11 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): try: await thread.edit_message(message_id, message) - except ValueError: + except ValueError as e: return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to edit. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) @@ -1491,8 +1710,31 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): @commands.command() @checks.has_permissions(PermissionLevel.REGULAR) - async def selfcontact(self, ctx): + async def selfcontact(self, ctx: ModmailCommandContext): """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.id}>.", + 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]") @@ -1501,7 +1743,12 @@ async def contact( self, ctx, users: commands.Greedy[ - Union[Literal["silent", "silently"], discord.Member, discord.User, discord.Role] + Union[ + Literal["silent", "silently"], + discord.Member, + discord.User, + discord.Role, + ] ], *, category: SimilarCategoryConverter = None, @@ -1551,9 +1798,14 @@ async def contact( users += u.members users.remove(u) + snoozed_users = [] for u in list(users): exists = await self.bot.threads.find(recipient=u) if exists: + # Check if thread is snoozed + if exists.snoozed: + snoozed_users.append(u) + continue errors.append(f"A thread for {u} already exists.") if exists.channel: errors[-1] += f" in {exists.channel.mention}" @@ -1562,11 +1814,28 @@ async def contact( elif u.bot: errors.append(f"{u} is a bot, cannot add to thread.") users.remove(u) - elif await self.bot.is_blocked(u): + elif (await self.bot.blocklist.is_user_blocked(u))[0]: 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 = [] @@ -1579,11 +1848,14 @@ async def contact( title = None if manual_trigger: # not react to contact - embed = discord.Embed(title=title, color=self.bot.error_color, description="\n".join(errors)) + 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: - # end return creator = ctx.author if manual_trigger else users[0] @@ -1593,12 +1865,16 @@ async def contact( creator=creator, category=category, manual_trigger=manual_trigger, + # The minimum character check is enforced in ThreadManager.create ) if thread.cancelled: return - if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.bot.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): logger.info("Contacting user %s when Modmail DM is disabled.", users[0]) if not silent and not self.bot.config.get("thread_contact_silently"): @@ -1616,7 +1892,9 @@ async def contact( ) if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() - em.set_footer(text=f"{creator}", icon_url=creator.display_avatar.url) + em.set_footer( + text=f"{creator}", icon_url=creator.display_avatar.url if creator.display_avatar else None + ) for u in users: await u.send(embed=em) @@ -1636,8 +1914,10 @@ async def contact( if manual_trigger: sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - await asyncio.sleep(5) - await ctx.message.delete() + try: + await ctx.message.delete(delay=5) + except (discord.Forbidden, discord.NotFound): + pass @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) @@ -1645,57 +1925,34 @@ async def contact( async def blocked(self, ctx): """Retrieve a list of blocked users.""" - roles, users, now = [], [], discord.utils.utcnow() - - blocked_users = list(self.bot.blocked_users.items()) - for id_, data in blocked_users: - blocked_by_id = data["blocked_by"] - blocked_at = parser.parse(data["blocked_at"]) - human_blocked_at = discord.utils.format_dt(blocked_at, style="R") - if "until" in data: - blocked_until = parser.parse(data["until"]) - human_blocked_until = discord.utils.format_dt(blocked_until, style="R") - else: - blocked_until = human_blocked_until = "Permanent" + roles, users = [], [] - if isinstance(blocked_until, datetime.datetime) and blocked_until < now: - self.bot.blocked_users.pop(str(id_)) - logger.debug("No longer blocked, user %s.", id_) - continue + blocked: list[blocklist.BlocklistEntry] = await self.bot.blocklist.get_all_blocks() - string = f"<@{id_}> ({human_blocked_until})" - string += f"\n- Issued {human_blocked_at} by <@{blocked_by_id}>" - - reason = data.get("reason") - if reason: - string += f"\n- Blocked for {reason}" - - users.append(string + "\n") + for item in blocked: + human_blocked_at = discord.utils.format_dt(item.timestamp, style="R") + if item.expires_at is not None: + human_blocked_until = discord.utils.format_dt(item.expires_at, style="R") + else: + human_blocked_until = "Permanent" - blocked_roles = list(self.bot.blocked_roles.items()) - for id_, data in blocked_roles: - blocked_by_id = data["blocked_by"] - blocked_at = parser.parse(data["blocked_at"]) - human_blocked_at = discord.utils.format_dt(blocked_at, style="R") - if "until" in data: - blocked_until = parser.parse(data["until"]) - human_blocked_until = discord.utils.format_dt(blocked_until, style="R") + if item.type == blocklist.BlockType.USER: + string = f"<@{item.id}>" else: - blocked_until = human_blocked_until = "Permanent" + string = f"<@&{item.id}>" - if isinstance(blocked_until, datetime.datetime) and blocked_until < now: - self.bot.blocked_users.pop(str(id_)) - logger.debug("No longer blocked, user %s.", id_) - continue + string += f" ({human_blocked_until})" - string = f"<@&{id_}> ({human_blocked_until})" - string += f"\n- Issued {human_blocked_at} by <@{blocked_by_id}>" + string += f"\n- Issued {human_blocked_at} by <@{item.blocking_user_id}>" - reason = data.get("reason") - if reason: - string += f"\n- Blocked for {reason}" + if item.reason is not None: + string += f"\n- Blocked for {item.reason}" + string += "\n" - roles.append(string + "\n") + if item.type == blocklist.BlockType.USER: + users.append(string) + elif item.type == blocklist.BlockType.ROLE: + roles.append(string) user_embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] @@ -1713,7 +1970,7 @@ async def blocked(self, ctx): else: embed.description += line else: - user_embeds[0].description = "Currently there are no blocked users." + user_embeds[0].description = "No users are currently blocked." if len(user_embeds) > 1: for n, em in enumerate(user_embeds): @@ -1736,7 +1993,7 @@ async def blocked(self, ctx): else: embed.description += line else: - role_embeds[-1].description = "Currently there are no blocked roles." + role_embeds[-1].description = "No roles are currently blocked." if len(role_embeds) > 1: for n, em in enumerate(role_embeds): @@ -1763,7 +2020,6 @@ async def blocked_whitelist(self, ctx, *, user: User = None): return await ctx.send_help(ctx.command) mention = getattr(user, "mention", f"`{user.id}`") - msg = "" if str(user.id) in self.bot.blocked_whitelisted_users: embed = discord.Embed( @@ -1775,21 +2031,20 @@ async def blocked_whitelist(self, ctx, *, user: User = None): return await ctx.send(embed=embed) self.bot.blocked_whitelisted_users.append(str(user.id)) - - 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)) - await self.bot.config.update() - 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(".") + blocked: bool + blocklist_entry: blocklist.BlocklistEntry + + blocked, blocklist_entry = await self.bot.blocklist.is_id_blocked(user.id) + if blocked: + await self.bot.blocklist.unblock_id(user.id) embed = discord.Embed( title="Success", - description=f"{mention} was previously blocked internally for " - f'"{reason}". {mention} is now whitelisted.', + description=f""" + {mention} has been whitelisted. + They were previously blocked by <@{blocklist_entry.blocking_user_id}> {" for "+blocklist_entry.reason if blocklist_entry.reason is not None else ""}. + """, color=self.bot.main_color, ) else: @@ -1825,7 +2080,7 @@ async def block( user_or_role = ctx.thread.recipient if (ctx.thread and not user_or_role) else user_or_role if not user_or_role: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user")) mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") @@ -1843,8 +2098,6 @@ async def send_embed(title: str, message: str): ): return await send_embed("Error", f"Cannot block {mention}, user is whitelisted.") - now, blocked = discord.utils.utcnow(), dict() - desc = f"{mention} is now blocked." if duration: desc += f"\n- Expires: {discord.utils.format_dt(duration.dt, style='R')}" @@ -1852,24 +2105,24 @@ async def send_embed(title: str, message: str): if reason: desc += f"\n- Reason: {reason}" - blocked["blocked_at"] = str(now) - blocked["blocked_by"] = ctx.author.id - if duration: - blocked["until"] = str(duration.dt) - if reason: - blocked["reason"] = reason + blocktype: BlockType if isinstance(user_or_role, discord.Role): - self.bot.blocked_roles[str(user_or_role.id)] = blocked + blocktype = BlockType.ROLE elif isinstance(user_or_role, discord.User): - blocked_users = self.bot.blocked_users - blocked_users[str(user_or_role.id)] = blocked + blocktype = BlockType.USER else: return logger.warning( f"{__name__}: cannot block user, user is neither an instance of Discord Role or User" ) - await self.bot.config.update() + await self.bot.blocklist.block_id( + user_id=user_or_role.id, + reason=reason, + expires_at=duration.dt if duration is not None else None, + blocked_by=ctx.author.id, + block_type=blocktype, + ) return await send_embed("Success", desc) @@ -1887,7 +2140,7 @@ async def unblock(self, ctx, *, user_or_role: Union[discord.User, discord.Role, user_or_role = ctx.thread.recipient if (ctx.thread and not user_or_role) else user_or_role if not user_or_role: - raise commands.MissingRequiredArgument(SimpleNamespace(name="user")) + raise commands.MissingRequiredArgument(DummyParam("user or role")) mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") @@ -1899,22 +2152,13 @@ async def send_embed(title: str, message: str): ) return await ctx.send(embed=embed) - title, desc = "Error", f"{mention} is not blocked." - - if isinstance(user_or_role, discord.Role): - if str(user_or_role.id) not in self.bot.blocked_roles: - return await send_embed(title, desc) - self.bot.blocked_roles.pop(str(user_or_role.id)) - elif isinstance(user_or_role, discord.User): - if str(user_or_role.id) not in self.bot.blocked_users: - return await send_embed(title, desc) - self.bot.blocked_users.pop(str(user_or_role.id)) - else: + if not isinstance(user_or_role, (discord.Role, discord.User)): return logger.warning( f"{__name__}: cannot unblock, user is neither an instance of Discord Role or User" ) - await self.bot.config.update() + if not await self.bot.blocklist.unblock_id(user_or_role.id): + return await send_embed("Error", f"{mention} is not blocked.") return await send_embed("Success", f"{mention} has been unblocked.") @@ -1939,7 +2183,7 @@ async def delete(self, ctx, message_id: int = None): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to delete. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) @@ -2052,7 +2296,9 @@ async def repair(self, ctx): thread.ready = True logger.info("Setting current channel's topic to User ID and created new thread.") await ctx.channel.edit( - reason="Fix broken Modmail thread", name=name, topic=f"User ID: {user.id}" + reason="Fix broken Modmail thread", + name=name, + topic=f"User ID: {user.id}", ) return await self.bot.add_reaction(ctx.message, sent_emoji) @@ -2159,6 +2405,355 @@ async def isenable(self, ctx): return await ctx.send(embed=embed) + @commands.command(usage="[duration]") + @checks.has_permissions(PermissionLevel.SUPPORTER) + @checks.thread_only() + async def snooze(self, ctx: ModmailCommandContext, *, 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 + """ + assert ctx.thread is not None + thread: Thread = ctx.thread + if thread.snoozed: + await ctx.send("This thread is already snoozed.") + logger.debug( + f"Tried to snooze thread {getattr(thread.recipient, 'id', None)}, but it was already snoozed" + ) + return + # Default snooze duration with safe fallback + try: + default_snooze = int(self.bot.config.get("snooze_default_duration") or 604800) + except (ValueError, TypeError): + default_snooze = 604800 + if duration: + snooze_for = int((duration.dt - duration.now).total_seconds()) + else: + snooze_for = default_snooze + + assert self.bot.modmail_guild is not None + + # 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: + logger.debug("Auto-creating snoozed category for move-based snoozing.") + # Hide category by default; only bot can view/manage + overwrites: dict[ + Union[discord.Role, discord.Member, discord.Object], discord.PermissionOverwrite + ] = {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: + logger.info( + f"Creating snoozed category {target_category.name} (ID: {target_category.id}) and saving to config." + ) + await self.bot.config.set("snoozed_category_id", target_category.id) + await self.bot.config.update() + except Exception as e: + logger.exception("Failed to persist snoozed_category_id: %s", e) + try: + await ctx.send( + embed=discord.Embed( + title="Could not create snoozed category", + description=( + "Created snoozed category but failed to save it to config. Please set `snoozed_category_id` manually." + ), + color=self.bot.error_color, + ) + ) + except Exception as e: + logger.exception( + "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, + ) + ) + logger.exception("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: + logger.exception("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), "open": True, "_id": str(thread.key)}, + { + "$set": { + "snooze_start": now, + "snooze_for": snooze_for, + "snooze_until": snooze_until, + } + }, + ) + cmd_message = await ctx.send( + 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, + ) + ) + ok = await thread.snooze( + moderator=ctx.author, snooze_for=snooze_for, ignored_message_ids={cmd_message.id} + ) + if ok: + logger.info(f"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.") + logger.error(f"Failed to snooze thread for {getattr(thread.recipient, 'id', None)}.") + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def unsnooze(self, ctx: ModmailCommandContext, *, user: str | None = 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 + """ + + assert self.bot.threads is not None + + thread: Thread | None = 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"No thread found for user {user} (obj: {user_obj}).") + logger.warning(f"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.") + logger.debug("Snooze command ran without thread context") + return + assert thread is not None + if not thread.snoozed: + await ctx.send("This thread is not snoozed.") + logger.info(f"Tried to unsnooze non-snoozed Thread for {getattr(thread.recipient, 'id', None)}") + 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, "_id": str(thread.key)} + ) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + else: + logger.warning("unable to find thread 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." + ) + logger.info(f"{getattr(thread.recipient, 'id', None)} unsnoozed thread") + else: + await ctx.send("Failed to unsnooze this thread.") + logger.error( + f"unsnooze command failed to unsnooze thread for {getattr(thread.recipient, 'id', None)}" + ) + + @commands.command() + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snoozed(self, ctx: commands.Context): + """ + 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 + + # TODO this should really be a + 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 = "?" + since_dt = None + + if thread.snooze_data: + since = thread.snooze_data.get("snooze_start") + duration = thread.snooze_data.get("snooze_for") + + if since: + since_dt = self._to_utc_datetime(since) + if since_dt is not None: + since_str = f"" # Discord relative timestamp + else: + logger.warning(f"Invalid snooze_start for {user_id}: {since}") + else: + logger.warning(f" Missing snooze_start for {user_id}") + + if duration is not None and since_dt is not None: + try: + until_dt = since_dt + timedelta(seconds=int(duration)) + until_str = f"" + except (ValueError, TypeError) as e: + logger.warning(f"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, "open": True, "snooze_until": {"$lte": now}} + ).to_list(None) + for entry in snoozed: + snooze_until = entry.get("snooze_until") + if snooze_until: + try: + until_dt = self._to_utc_datetime(snooze_until) + if until_dt is None: + continue + 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.error( + "Failed parsing snooze_until timestamp for auto-unsnooze loop: %s", + e, + ) + + @commands.command() + @checks.has_permissions(PermissionLevel.OWNER) + async def clearsnoozed(self, ctx: commands.Context): + """ + 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}`)") + 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 6b675ee16f..54ce092b84 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -15,12 +15,12 @@ import discord from discord.ext import commands -from pkg_resources import parse_version +from packaging.version import Version from core import checks from core.models import PermissionLevel, getLogger from core.paginator import EmbedPaginatorSession -from core.utils import trigger_typing, truncate +from core.utils import trigger_typing, truncate, safe_typing logger = getLogger(__name__) @@ -113,10 +113,11 @@ class Plugins(commands.Cog): These addons could have a range of features from moderation to simply making your life as a moderator easier! Learn how to create a plugin yourself here: - https://github.com/modmail-dev/modmail/wiki/Plugins + https://docs.modmail.dev/usage-guide/plugins """ def __init__(self, bot): + self.forced_plugins: list[str] = [] self.bot = bot self.registry = {} self.loaded_plugins = set() @@ -134,33 +135,56 @@ async def populate_registry(self): async with self.bot.session.get(url) as resp: self.registry = json.loads(await resp.text()) - async def initial_load_plugins(self): - for plugin_name in list(self.bot.config["plugins"]): + def _get_forced_plugins(self) -> list[str]: + env_list = os.getenv("FORCED_PLUGINS") + if env_list is None: + return [] + plugins = env_list.split(",") + return plugins + + async def _init_load_plugin(self, plugin_name: str): + try: + # Strict seems to only affect whether specifying the branch is required + plugin = Plugin.from_string(plugin_name, strict=False) + except InvalidPluginError: + self.bot.config["plugins"].remove(plugin_name) try: - plugin = Plugin.from_string(plugin_name, strict=True) + # For backwards compat + plugin = Plugin.from_string(plugin_name) except InvalidPluginError: - self.bot.config["plugins"].remove(plugin_name) - try: - # 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.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) + return - logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) - self.bot.config["plugins"].append(str(plugin)) + logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) + self.bot.config["plugins"].append(str(plugin)) - try: - 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, - ) + try: + 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, + ) + + async def initial_load_plugins(self): + self.forced_plugins = self._get_forced_plugins() + logger.debug(f"loading {len(self.forced_plugins)} forced plugins") + for plugin_name in self.forced_plugins: + logger.debug(f"loading forced plugin {plugin_name}") + await self._init_load_plugin(plugin_name) + + user_plugins = list(self.bot.config["plugins"]) + logger.debug(f"loading {len(user_plugins)} config plugins") + + for plugin_name in user_plugins: + # Skip loading this plugin if it is in the force load list and thus already loaded + if plugin_name in self.forced_plugins: + logger.debug(f"Skipped loading user plugin {plugin_name} because it is forced installed.") continue + await self._init_load_plugin(plugin_name) logger.debug("Finished loading all plugins.") @@ -234,7 +258,7 @@ async def load_plugin(self, plugin): venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env user_install = " --user" if not venv else "" proc = await asyncio.create_subprocess_shell( - f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', + f'"{sys.executable}" -m pip install --upgrade{user_install} -r "{req_txt}" -q -q', stderr=PIPE, stdout=PIPE, ) @@ -248,7 +272,11 @@ async def load_plugin(self, plugin): if stderr: logger.debug("[stderr]\n%s.", stderr.decode()) - logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) + 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): @@ -263,6 +291,17 @@ async def load_plugin(self, plugin): logger.error("Plugin load failure: %s", plugin.ext_string, exc_info=True) raise InvalidPluginError("Cannot load extension, plugin invalid.") from exc + async def unload_plugin(self, plugin: Plugin) -> None: + try: + await self.bot.unload_extension(plugin.ext_string) + except commands.ExtensionError as exc: + raise exc + + ext_parent = ".".join(plugin.ext_string.split(".")[:-1]) + for module in list(sys.modules.keys()): + if module == ext_parent or module.startswith(ext_parent + "."): + del sys.modules[module] + async def parse_user_input(self, ctx, plugin_name, check_version=False): if not self.bot.config["enable_plugins"]: embed = discord.Embed( @@ -288,7 +327,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): if check_version: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed = discord.Embed( description="Your bot's version is too low. " f"This plugin requires version `{required_version}`.", @@ -300,10 +339,10 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): plugin = Plugin(user, repo, plugin_name, branch) else: - if not self.bot.config.get("registry_plugins_only", False): + if self.bot.config.get("registry_plugins_only"): embed = discord.Embed( - description="This plugin is not in the registry. " - "To install it, you must set `REGISTRY_PLUGINS_ONLY=false` in your .env file or config settings.", + 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) @@ -314,7 +353,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): 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.", + "username/repo/plugin-name, username/repo/plugin-name@branch, @local/plugin-name.", color=self.bot.error_color, ) await ctx.send(embed=embed) @@ -339,7 +378,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): `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. + or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) @@ -347,7 +386,10 @@ async def plugins_add(self, ctx, *, plugin_name: str): return if str(plugin) in self.bot.config["plugins"]: - embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) + embed = discord.Embed( + description="This plugin is already installed.", + color=self.bot.error_color, + ) return await ctx.send(embed=embed) if plugin.name in self.bot.cogs: @@ -376,7 +418,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to download plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to download plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -394,7 +436,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): logger.warning("Unable to load plugin %s.", plugin, exc_info=True) embed = discord.Embed( - description=f"Failed to download plugin, check logs for error.\n{type(e)}: {e}", + description=f"Failed to load plugin, check logs for error.\n{type(e).__name__}: {e}", color=self.bot.error_color, ) @@ -423,19 +465,28 @@ 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. + 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) in self.forced_plugins: + await ctx.send( + embed=discord.Embed( + description="This plugin cannot be removed. Contact your admin for more information.", + color=self.bot.error_color, + ) + ) + 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.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) self.loaded_plugins.remove(plugin) except (commands.ExtensionNotLoaded, KeyError): logger.warning("Plugin was never loaded.") @@ -456,7 +507,8 @@ async def plugins_remove(self, ctx, *, plugin_name: str): pass # dir not empty embed = discord.Embed( - description="The plugin is successfully uninstalled.", color=self.bot.main_color + description="The plugin is successfully uninstalled.", + color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -470,16 +522,18 @@ async def update_plugin(self, ctx, plugin_name): embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) return await ctx.send(embed=embed) - async with ctx.typing(): + async with safe_typing(ctx): embed = discord.Embed( - description=f"Successfully updated {plugin.name}.", color=self.bot.main_color + 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.bot.unload_extension(plugin.ext_string) + await self.unload_plugin(plugin) except commands.ExtensionError: logger.warning("Plugin unload fail.", exc_info=True) + try: await self.load_plugin(plugin) except Exception: @@ -487,12 +541,12 @@ async def update_plugin(self, ctx, plugin_name): description=f"Failed to update {plugin.name}. This plugin will now be removed from your bot.", color=self.bot.error_color, ) - self.bot.config["plugins"].remove(plugin_name) - logger.debug("Failed to update %s. Removed plugin from config.", plugin_name) + self.bot.config["plugins"].remove(str(plugin)) + logger.debug("Failed to update %s. Removed plugin from config.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) else: - logger.debug("Updated %s.", plugin_name) + logger.debug("Updated %s.", plugin) return await ctx.send(embed=embed) @plugins.command(name="update") @@ -502,7 +556,7 @@ 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 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`. """ @@ -526,17 +580,20 @@ async def plugins_reset(self, ctx): for ext in list(self.bot.extensions): if not ext.startswith("plugins."): continue + logger.error("Unloading plugin: %s.", ext) try: - logger.error("Unloading plugin: %s.", ext) - await self.bot.unload_extension(ext) - except Exception: - logger.error("Failed to unload plugin: %s.", ext) - else: - if not self.loaded_plugins: - continue 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() @@ -552,7 +609,8 @@ async def plugins_reset(self, ctx): logger.warning("Removing %s.", entry.name) embed = discord.Embed( - description="Successfully purged all plugins from the bot.", color=self.bot.main_color + description="Successfully purged all plugins from the bot.", + color=self.bot.main_color, ) return await ctx.send(embed=embed) @@ -580,7 +638,8 @@ async def plugins_loaded(self, ctx): if not self.loaded_plugins: embed = discord.Embed( - description="There are no plugins currently loaded.", color=self.bot.error_color + description="There are no plugins currently loaded.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -640,7 +699,10 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N matches = get_close_matches(plugin_name, self.registry.keys()) if matches: - embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) + embed.add_field( + name="Perhaps you meant:", + value="\n".join(f"`{m}`" for m in matches), + ) return await ctx.send(embed=embed) @@ -672,7 +734,7 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N embed.set_footer(text="This plugin is currently loaded.") else: required_version = details.get("bot_version", False) - if required_version and self.bot.version < parse_version(required_version): + if required_version and self.bot.version < Version(required_version): embed.set_footer( text="Your bot is unable to install this plugin, " f"minimum required version is v{required_version}." @@ -733,7 +795,10 @@ async def plugins_registry_compact(self, ctx): for page in pages: embed = discord.Embed(color=self.bot.main_color, description=page) - embed.set_author(name="Plugin Registry", icon_url=self.bot.user.display_avatar.url) + embed.set_author( + name="Plugin Registry", + icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + ) embeds.append(embed) paginator = EmbedPaginatorSession(ctx, *embeds) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py new file mode 100644 index 0000000000..4f4f1985ae --- /dev/null +++ b/cogs/threadmenu.py @@ -0,0 +1,846 @@ +import json +import asyncio +from copy import copy as _copy + +import discord +from discord.ext import commands + +from core import checks +from core.models import PermissionLevel + + +class ThreadCreationMenuCore(commands.Cog): + """Core-integrated thread menu configuration and management. + + This Cog exposes the same commands as the legacy plugin to manage menu options, + but stores settings in core config (no plugin DB). + """ + + def __init__(self, bot): + self.bot = bot + + # ----- helpers ----- + def _get_conf(self) -> dict: + return { + "enabled": bool(self.bot.config.get("thread_creation_menu_enabled")), + "options": self.bot.config.get("thread_creation_menu_options") or {}, + "submenus": self.bot.config.get("thread_creation_menu_submenus") or {}, + "timeout": int(self.bot.config.get("thread_creation_menu_timeout") or 20), + "close_on_timeout": bool(self.bot.config.get("thread_creation_menu_close_on_timeout")), + "anonymous_menu": bool(self.bot.config.get("thread_creation_menu_anonymous_menu")), + "embed_text": self.bot.config.get("thread_creation_menu_embed_text") + or "Please select an option.", + "dropdown_placeholder": self.bot.config.get("thread_creation_menu_dropdown_placeholder") + or "Select an option to contact the staff team.", + "embed_title": self.bot.config.get("thread_creation_menu_embed_title"), + "embed_footer": self.bot.config.get("thread_creation_menu_embed_footer"), + "embed_thumbnail_url": self.bot.config.get("thread_creation_menu_embed_thumbnail_url"), + "embed_footer_icon_url": self.bot.config.get("thread_creation_menu_embed_footer_icon_url"), + "embed_color": self.bot.config.get("thread_creation_menu_embed_color"), + } + + async def _save_conf(self, conf: dict): + await self.bot.config.set("thread_creation_menu_enabled", conf.get("enabled", False)) + await self.bot.config.set("thread_creation_menu_options", conf.get("options", {}), convert=False) + await self.bot.config.set("thread_creation_menu_submenus", conf.get("submenus", {}), convert=False) + await self.bot.config.set("thread_creation_menu_timeout", conf.get("timeout", 20)) + await self.bot.config.set( + "thread_creation_menu_close_on_timeout", conf.get("close_on_timeout", False) + ) + await self.bot.config.set("thread_creation_menu_anonymous_menu", conf.get("anonymous_menu", False)) + await self.bot.config.set( + "thread_creation_menu_embed_text", conf.get("embed_text", "Please select an option.") + ) + await self.bot.config.set( + "thread_creation_menu_dropdown_placeholder", + conf.get("dropdown_placeholder", "Select an option to contact the staff team."), + ) + await self.bot.config.set("thread_creation_menu_embed_title", conf.get("embed_title")) + await self.bot.config.set("thread_creation_menu_embed_footer", conf.get("embed_footer")) + await self.bot.config.set("thread_creation_menu_embed_thumbnail_url", conf.get("embed_thumbnail_url")) + await self.bot.config.set( + "thread_creation_menu_embed_footer_icon_url", conf.get("embed_footer_icon_url") + ) + if conf.get("embed_color"): + try: + await self.bot.config.set("thread_creation_menu_embed_color", conf.get("embed_color")) + except Exception: + pass + await self.bot.config.update() + + # ----- commands ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @commands.group(invoke_without_command=True) + async def threadmenu(self, ctx): + """Thread-creation menu settings (core).""" + await ctx.send_help(ctx.command) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="toggle") + async def threadmenu_toggle(self, ctx): + """Enable or disable the thread-creation menu. + + Toggles the global on/off state. When disabled, users won't see + or be able to use the interactive thread creation select menu. + """ + conf = self._get_conf() + conf["enabled"] = not conf["enabled"] + await self._save_conf(conf) + await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.") + advancedmenu_plugin = self.bot.get_cog("AdvancedMenu") + if ( + advancedmenu_plugin + and hasattr(advancedmenu_plugin, "config") + and advancedmenu_plugin.config.get("enabled") + and advancedmenu_plugin.config["enabled"] is True + and conf["enabled"] + ): + await ctx.send( + "**Warning:** You are using both the core threadmenu feature and the advancedmenu plugin.\n" + "It is recommended to disable/uninstall the advancedmenu plugin to avoid interruption.\n" + "Migration guide can be found at: " + ) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="show") + async def threadmenu_show(self, ctx): + """Show all current main-menu options. + + Lists every option (label + description) configured in the root + (non-submenu) select menu so you can review what users will see. + """ + conf = self._get_conf() + if not conf["options"]: + return await ctx.send("There are no options in the main menu.") + embed = discord.Embed(title="Main menu", color=discord.Color.blurple()) + for v in conf["options"].values(): + embed.add_field(name=v["label"], value=v["description"], inline=False) + await ctx.send(embed=embed) + + # ----- options ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.group(name="option", invoke_without_command=True) + async def threadmenu_option(self, ctx): + """Manage main-menu options (add/remove/edit/show). + + Use subcommands: + - add: interactive wizard to create an option + - remove