diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d572bd7059..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -branch = true -source = - bot - tests diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..f866e3ea80 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Migrate code style to ruff +8dca42846d2956122d45795763095559a6a51b64 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6dfe7e8599..8cd3c13a4e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,43 +1,23 @@ # Extensions **/bot/exts/backend/sync/** @MarkKoz -**/bot/exts/filters/*token_remover.py @MarkKoz **/bot/exts/moderation/*silence.py @MarkKoz bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz -bot/exts/utils/snekbox.py @MarkKoz @Akarys42 @jb3 -bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 @jb3 -bot/exts/info/** @Akarys42 @Den4200 @jb3 -bot/exts/info/information.py @mbaruh @jb3 -bot/exts/filters/** @mbaruh @jb3 -bot/exts/fun/** @ks129 -bot/exts/utils/** @ks129 @jb3 +bot/exts/utils/snekbox.py @MarkKoz +bot/exts/moderation/** @mbaruh +bot/exts/info/information.py @mbaruh +bot/exts/filtering/** @mbaruh bot/exts/recruitment/** @wookie184 -# Rules -bot/rules/** @mbaruh - # Utils -bot/utils/extensions.py @MarkKoz bot/utils/function.py @MarkKoz bot/utils/lock.py @MarkKoz -bot/utils/regex.py @Akarys42 -bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz -tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3 -Dockerfile @MarkKoz @Akarys42 @Den4200 @jb3 -docker-compose.yml @MarkKoz @Akarys42 @Den4200 @jb3 - -# Tools -poetry.lock @Akarys42 -pyproject.toml @Akarys42 - -# Statistics -bot/async_stats.py @jb3 -bot/exts/info/stats.py @jb3 +.github/workflows/** @MarkKoz +Dockerfile @MarkKoz +docker-compose.yml @MarkKoz diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..a7b8aebe7a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: + - version-update:semver-patch + - version-update:semver-minor + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + reviewers: + - "python-discord/devops" + groups: + ci-dependencies: + patterns: + - "*" diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000000..36de727b6e --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,83 @@ +name: Build & Deploy + +on: + workflow_call: + inputs: + sha-tag: + description: "A short-form SHA tag for the commit that triggered this workflow" + required: true + type: string + + +jobs: + build: + name: Build & Push + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + # The current version (v2) of Docker's build-push action uses + # buildx, which comes with BuildKit features that help us speed + # up our builds using additional cache features. Buildx also + # has a lot of other features that are not as relevant to us. + # + # See https://github.com/docker/build-push-action + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to Github Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and push the container to the GitHub Container + # Repository. The container will be tagged as "latest" + # and with the short SHA of the commit. + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: ${{ github.ref == 'refs/heads/main' }} + cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest + cache-to: type=inline + tags: | + ghcr.io/python-discord/bot:latest + ghcr.io/python-discord/bot:${{ inputs.sha-tag }} + build-args: | + git_sha=${{ github.sha }} + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/main' }} + environment: production + steps: + - name: Checkout Kubernetes repository + uses: actions/checkout@v6 + with: + repository: python-discord/infra + path: infra + + - uses: azure/setup-kubectl@v5 + + - name: Authenticate with Kubernetes + uses: azure/k8s-set-context@v5 + with: + method: kubeconfig + kubeconfig: ${{ secrets.KUBECONFIG }} + + - name: Deploy to Kubernetes + uses: azure/k8s-deploy@v6 + with: + namespace: bots + manifests: | + infra/kubernetes/namespaces/bots/bot/deployment.yaml + images: 'ghcr.io/python-discord/bot:${{ inputs.sha-tag }}' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 84a671917a..0000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Build - -on: - workflow_run: - workflows: ["Lint & Test"] - branches: - - main - types: - - completed - -jobs: - build: - if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' - name: Build & Push - runs-on: ubuntu-latest - - steps: - # Create a commit SHA-based tag for the container repositories - - name: Create SHA Container Tag - id: sha_tag - run: | - tag=$(cut -c 1-7 <<< $GITHUB_SHA) - echo "::set-output name=tag::$tag" - - - name: Checkout code - uses: actions/checkout@v2 - - # The current version (v2) of Docker's build-push action uses - # buildx, which comes with BuildKit features that help us speed - # up our builds using additional cache features. Buildx also - # has a lot of other features that are not as relevant to us. - # - # See https://github.com/docker/build-push-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Github Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Build and push the container to the GitHub Container - # Repository. The container will be tagged as "latest" - # and with the short SHA of the commit. - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: true - cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest - cache-to: type=inline - tags: | - ghcr.io/python-discord/bot:latest - ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }} - build-args: | - git_sha=${{ github.sha }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 8b809b777f..0000000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Deploy - -on: - workflow_run: - workflows: ["Build"] - branches: - - main - types: - - completed - -jobs: - build: - environment: production - if: github.event.workflow_run.conclusion == 'success' - name: Build & Push - runs-on: ubuntu-latest - - steps: - - name: Create SHA Container Tag - id: sha_tag - run: | - tag=$(cut -c 1-7 <<< $GITHUB_SHA) - echo "::set-output name=tag::$tag" - - - name: Checkout code - uses: actions/checkout@v2 - with: - repository: python-discord/kubernetes - token: ${{ secrets.REPO_TOKEN }} - - - name: Authenticate with Kubernetes - uses: azure/k8s-set-context@v1 - with: - method: kubeconfig - kubeconfig: ${{ secrets.KUBECONFIG }} - - - name: Deploy to Kubernetes - uses: Azure/k8s-deploy@v1 - with: - manifests: | - bot/deployment.yaml - images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' - kubectl-version: 'latest' diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index d96f324ec9..47e190f677 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -1,11 +1,7 @@ name: Lint & Test on: - push: - branches: - - main - pull_request: - + workflow_call jobs: lint-test: @@ -19,97 +15,33 @@ jobs: REDDIT_SECRET: ham REDIS_PASSWORD: '' - # Configure pip to cache dependencies and do a user install - PIP_NO_CACHE_DIR: false - PIP_USER: 1 - - # Make sure package manager does not use virtualenv - POETRY_VIRTUALENVS_CREATE: false - - # Specify explicit paths for python dependencies and the pre-commit - # environment so we know which directories to cache - POETRY_CACHE_DIR: ${{ github.workspace }}/.cache/py-user-base - PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base - PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache - steps: - - name: Add custom PYTHONUSERBASE to PATH - run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH - - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v6 - - name: Setup python - id: python - uses: actions/setup-python@v2 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: '3.9' + enable-cache: true + resolution-strategy: "lowest" + cache-dependency-glob: "uv.lock" + activate-environment: true - # This step caches our Python dependencies. To make sure we - # only restore a cache when the dependencies, the python version, - # the runner operating system, and the dependency location haven't - # changed, we create a cache key that is a composite of those states. - # - # Only when the context is exactly the same, we will restore the cache. - - name: Python Dependency Caching - uses: actions/cache@v2 - id: python_cache - with: - path: ${{ env.PYTHONUSERBASE }} - key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./pyproject.toml', './poetry.lock') }}" - - # Install our dependencies if we did not restore a dependency cache - - name: Install dependencies using poetry - if: steps.python_cache.outputs.cache-hit != 'true' - run: | - pip install poetry - poetry install + - name: Install dependencies + run: uv sync --locked - # This step caches our pre-commit environment. To make sure we - # do create a new environment when our pre-commit setup changes, - # we create a cache key based on relevant factors. - - name: Pre-commit Environment Caching - uses: actions/cache@v2 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./.pre-commit-config.yaml') }}" - - # We will not run `flake8` here, as we will use a separate flake8 - # action. As pre-commit does not support user installs, we set - # PIP_USER=0 to not do a user install. - name: Run pre-commit hooks - run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files + run: SKIP=ruff pre-commit run --all-files - # Run flake8 and have it format the linting errors in the format of - # the GitHub Workflow command to register error annotations. This - # means that our flake8 output is automatically added as an error - # annotation to both the run result and in the "Files" tab of a - # pull request. - # - # Format used: - # ::error file={filename},line={line},col={col}::{message} - - name: Run flake8 - run: "flake8 \ - --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ - [flake8] %(code)s: %(text)s'" + # Run `ruff` using github formatting to enable automatic inline annotations. + - name: Run ruff + run: "ruff check --output-format=github ." - # We run `coverage` using the `python` command so we can suppress - # irrelevant warnings in our CI output. - name: Run tests and generate coverage report - run: | - python -Wignore -m coverage run -m unittest - coverage report -m - - # This step will publish the coverage reports coveralls.io and - # print a "job" link in the output of the GitHub Action - - name: Publish coverage report to coveralls.io + run: pytest -n auto --cov -q env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls + # Use "sys.monitoring" based coverage backend for better speed (see #3075) + COVERAGE_CORE: sysmon # Prepare the Pull Request Payload artifact. If this fails, we # we fail silently using the `continue-on-error` option. It's @@ -128,7 +60,7 @@ jobs: - name: Upload a Build Artifact if: always() && steps.prepare-artifact.outcome == 'success' continue-on-error: true - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v7 with: name: pull-request-payload path: pull_request_payload.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000000..6f471c9318 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +jobs: + + lint-test: + uses: ./.github/workflows/lint-test.yml + + generate-sha-tag: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + sha-tag: ${{ steps.sha-tag.outputs.sha-tag }} + steps: + - name: Create SHA Container tag + id: sha-tag + run: | + tag=$(cut -c 1-7 <<< $GITHUB_SHA) + echo "sha-tag=$tag" >> $GITHUB_OUTPUT + + + build-deploy: + uses: ./.github/workflows/build-deploy.yml + needs: + - lint-test + - generate-sha-tag + with: + sha-tag: ${{ needs.generate-sha-tag.outputs.sha-tag }} + secrets: inherit + + sentry-release: + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/sentry_release.yml + needs: build-deploy + secrets: inherit diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index f6a1e1f0e4..a5e0244d94 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -1,24 +1,22 @@ name: Create Sentry release on: - push: - branches: - - main + workflow_call + jobs: create_sentry_release: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@main + uses: actions/checkout@v6 - name: Create a Sentry.io release - uses: tclindner/sentry-releases-action@v1.2.0 + uses: getsentry/action-release@v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: python-discord SENTRY_PROJECT: bot with: - tagName: ${{ github.sha }} environment: production - releaseNamePrefix: bot@ + version_prefix: bot@ diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index b6a71b887b..f90b211b3c 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -3,30 +3,16 @@ name: Status Embed on: workflow_run: workflows: - - Lint & Test - - Build - - Deploy + - CI types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: status_embed: - # We need to send a status embed whenever the workflow - # sequence we're running terminates. There are a number - # of situations in which that happens: - # - # 1. We reach the end of the Deploy workflow, without - # it being skipped. - # - # 2. A `pull_request` triggered a Lint & Test workflow, - # as the sequence always terminates with one run. - # - # 3. If any workflow ends in failure or was cancelled. - if: >- - (github.event.workflow_run.name == 'Deploy' && github.event.workflow_run.conclusion != 'skipped') || - github.event.workflow_run.event == 'pull_request' || - github.event.workflow_run.conclusion == 'failure' || - github.event.workflow_run.conclusion == 'cancelled' name: Send Status Embed to Discord runs-on: ubuntu-latest @@ -41,13 +27,13 @@ jobs: curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') [ -z "$DOWNLOAD_URL" ] && exit 1 - wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 + curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 unzip -p pull_request_payload.zip > pull_request_payload.json [ -s pull_request_payload.json ] || exit 3 - echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)" - echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)" - echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)" - echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)" + echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT + echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT + echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT + echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -56,20 +42,18 @@ jobs: # more information and we can fine tune when we actually want # to send an embed. - name: GitHub Actions Status Embed for Discord - uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 + uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 with: # Our GitHub Actions webhook webhook_id: '784184528997842985' webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} - # Workflow information + # We need to provide the information of the workflow that + # triggered this workflow instead of this workflow. workflow_name: ${{ github.event.workflow_run.name }} run_id: ${{ github.event.workflow_run.id }} run_number: ${{ github.event.workflow_run.run_number }} status: ${{ github.event.workflow_run.conclusion }} - actor: ${{ github.actor }} - repository: ${{ github.repository }} - ref: ${{ github.ref }} sha: ${{ github.event.workflow_run.head_sha }} pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} diff --git a/.gitignore b/.gitignore index f74a142f30..630f838700 100644 --- a/.gitignore +++ b/.gitignore @@ -114,11 +114,14 @@ log.* !log.py # Custom user configuration -config.yml +*config.yml docker-compose.override.yml +metricity-config.toml +pyrightconfig.json # xmlrunner unittest XML reports TEST-**.xml # Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder .DS_Store +*.env* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9412f07d7..0a674724a5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,27 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v6.0.0 hooks: - id: check-merge-conflict - id: check-toml - id: check-yaml - args: [--unsafe] # Required due to custom constructors (e.g. !ENV) - id: end-of-file-fixer - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.5.1 - hooks: - - id: python-check-blanket-noqa - repo: local hooks: - - id: flake8 - name: Flake8 - description: This hook runs flake8 within our project's environment. - entry: poetry run flake8 - language: system - types: [python] - require_serial: true + - id: uv-lock + name: uv-lock + description: "Automatically run 'uv lock' on your project dependencies" + entry: uv lock + language: system + files: ^(uv\.lock|pyproject\.toml|uv\.toml)$ + pass_filenames: false + - id: ruff + name: ruff + description: Run ruff linting + entry: uv run --locked ruff check --force-exclude + language: system + 'types_or': [python, pyi] + require_serial: true + args: [--fix, --exit-non-zero-on-fix] diff --git a/Dockerfile b/Dockerfile index c285898dc9..d9885fa974 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,36 @@ -FROM python:3.9.5-slim +ARG python_version=3.14-slim -# Set pip to have no saved cache -ENV PIP_NO_CACHE_DIR=false \ - POETRY_VIRTUALENVS_CREATE=false +FROM python:$python_version AS builder +COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /bin/ +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy -# Install poetry -RUN pip install -U poetry +# Install project dependencies with build tools available +WORKDIR /build -# Create the working directory -WORKDIR /bot +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-dev -# Install project dependencies -COPY pyproject.toml poetry.lock ./ -RUN poetry install --no-dev +# ------------------------------------------------------------------------------- -# Define Git SHA build argument -ARG git_sha="development" +FROM python:$python_version -# Set Git SHA environment variable for Sentry +# Define Git SHA build argument for sentry +ARG git_sha="development" ENV GIT_SHA=$git_sha +# Install dependencies from build cache +# .venv not put in /app so that it doesn't conflict with the dev +# volume we use to avoid rebuilding image every code change locally +COPY --from=builder /build /build +ENV PATH="/build/.venv/bin:$PATH" + # Copy the source code in last to optimize rebuilding the image +WORKDIR /bot COPY . . -ENTRYPOINT ["python3"] -CMD ["-m", "bot"] +ENTRYPOINT ["python", "-m"] +CMD ["bot"] diff --git a/README.md b/README.md index 9df905dc89..4418a73729 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,7 @@ # Python Utility Bot -[![Discord][7]][8] -[![Lint & Test][1]][2] -[![Build][3]][4] -[![Deploy][5]][6] -[![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot) +[![Discord][1]][2] +[![CI][3]][4] [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities @@ -12,11 +9,7 @@ and other tools to help keep the server running like a well-oiled machine. Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) on our website if you're interested in helping out. -[1]: https://github.com/python-discord/bot/workflows/Lint%20&%20Test/badge.svg?branch=main -[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amain -[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=main -[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amain -[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=main -[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amain -[7]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg -[8]: https://discord.gg/python +[1]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg +[2]: https://discord.gg/python +[3]: https://github.com/python-discord/bot/actions/workflows/main.yml/badge.svg?branch=main +[4]: https://github.com/python-discord/bot/actions/workflows/main.yml?query=branch%3Amain diff --git a/bot/__init__.py b/bot/__init__.py index 8f880b8e65..ebd4fc212b 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -1,12 +1,10 @@ import asyncio import os -from functools import partial, partialmethod from typing import TYPE_CHECKING -from discord.ext import commands +from pydis_core.utils import apply_monkey_patches from bot import log -from bot.command import Command if TYPE_CHECKING: from bot.bot import Bot @@ -17,9 +15,6 @@ if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. -# Must be patched before any cogs are added. -commands.command = partial(commands.command, cls=Command) -commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) +apply_monkey_patches() -instance: "Bot" = None # Global Bot instance. +instance: Bot = None # Global Bot instance. diff --git a/bot/__main__.py b/bot/__main__.py index 9317563c84..540c97d1bc 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,27 +1,90 @@ -import logging +import asyncio import aiohttp +import discord +from async_rediscache import RedisSession +from discord.ext import commands +from pydis_core import StartupError +from pydis_core.site_api import APIClient +from redis import RedisError import bot from bot import constants -from bot.bot import Bot, StartupError -from bot.log import setup_sentry +from bot.bot import Bot +from bot.log import get_logger, setup_sentry + +LOCALHOST = "127.0.0.1" + + +async def _create_redis_session() -> RedisSession: + """Create and connect to a redis session.""" + redis_session = RedisSession( + host=constants.Redis.host, + port=constants.Redis.port, + password=constants.Redis.password, + use_fakeredis=constants.Redis.use_fakeredis, + global_namespace="bot", + decode_responses=True, + ) + try: + return await redis_session.connect() + except RedisError as e: + raise StartupError(e) + + +async def main() -> None: + """Entry async method for starting the bot.""" + setup_sentry() + + statsd_url = constants.Stats.statsd_host + if constants.DEBUG_MODE: + # Since statsd is UDP, there are no errors for sending to a down port. + # For this reason, setting the statsd host to 127.0.0.1 for development + # will effectively disable stats. + statsd_url = LOCALHOST + + allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}) + intents = discord.Intents.all() + intents.presences = False + intents.dm_typing = False + intents.dm_reactions = False + intents.invites = False + intents.webhooks = False + intents.integrations = False + + async with aiohttp.ClientSession() as session: + bot.instance = Bot( + guild_id=constants.Guild.id, + http_session=session, + redis_session=await _create_redis_session(), + statsd_url=statsd_url, + command_prefix=commands.when_mentioned_or(constants.Bot.prefix), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), + case_insensitive=True, + max_messages=10_000, + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), + intents=intents, + allowed_roles=list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}), + api_client=APIClient( + site_api_url=constants.URLs.site_api, + site_api_token=constants.Keys.site_api, + ), + ) + async with bot.instance as _bot: + await _bot.start(constants.Bot.token) -setup_sentry() try: - bot.instance = Bot.create() - bot.instance.load_extensions() - bot.instance.run(constants.Bot.token) + asyncio.run(main()) except StartupError as e: message = "Unknown Startup Error Occurred." - if isinstance(e.exception, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError)): + if isinstance(e.exception, aiohttp.ClientConnectorError | aiohttp.ServerDisconnectedError): message = "Could not connect to site API. Is it running?" elif isinstance(e.exception, OSError): message = "Could not connect to Redis. Is it running?" # The exception is logged with an empty message so the actual message is visible at the bottom - log = logging.getLogger("bot") + log = get_logger("bot") log.fatal("", exc_info=e.exception) log.fatal(message) diff --git a/bot/api.py b/bot/api.py deleted file mode 100644 index 6ce9481f45..0000000000 --- a/bot/api.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import logging -from typing import Optional -from urllib.parse import quote as quote_url - -import aiohttp - -from .constants import Keys, URLs - -log = logging.getLogger(__name__) - - -class ResponseCodeError(ValueError): - """Raised when a non-OK HTTP response is received.""" - - def __init__( - self, - response: aiohttp.ClientResponse, - response_json: Optional[dict] = None, - response_text: str = "" - ): - self.status = response.status - self.response_json = response_json or {} - self.response_text = response_text - self.response = response - - def __str__(self): - response = self.response_json if self.response_json else self.response_text - return f"Status: {self.status} Response: {response}" - - -class APIClient: - """Django Site API wrapper.""" - - # These are class attributes so they can be seen when being mocked for tests. - # See commit 22a55534ef13990815a6f69d361e2a12693075d5 for details. - session: Optional[aiohttp.ClientSession] = None - loop: asyncio.AbstractEventLoop = None - - def __init__(self, **session_kwargs): - auth_headers = { - 'Authorization': f"Token {Keys.site_api}" - } - - if 'headers' in session_kwargs: - session_kwargs['headers'].update(auth_headers) - else: - session_kwargs['headers'] = auth_headers - - # aiohttp will complain if APIClient gets instantiated outside a coroutine. Thankfully, we - # don't and shouldn't need to do that, so we can avoid scheduling a task to create it. - self.session = aiohttp.ClientSession(**session_kwargs) - - @staticmethod - def _url_for(endpoint: str) -> str: - return f"{URLs.site_api_schema}{URLs.site_api}/{quote_url(endpoint)}" - - async def close(self) -> None: - """Close the aiohttp session.""" - await self.session.close() - - async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None: - """Raise ResponseCodeError for non-OK response if an exception should be raised.""" - if should_raise and response.status >= 400: - try: - response_json = await response.json() - raise ResponseCodeError(response=response, response_json=response_json) - except aiohttp.ContentTypeError: - response_text = await response.text() - raise ResponseCodeError(response=response, response_text=response_text) - - async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Send an HTTP request to the site API and return the JSON response.""" - async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp: - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() - - async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API GET.""" - return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs) - - async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API PATCH.""" - return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs) - - async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API POST.""" - return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs) - - async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict: - """Site API PUT.""" - return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs) - - async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> Optional[dict]: - """Site API DELETE.""" - async with self.session.delete(self._url_for(endpoint), **kwargs) as resp: - if resp.status == 204: - return None - - await self.maybe_raise_for_status(resp, raise_for_status) - return await resp.json() diff --git a/bot/async_stats.py b/bot/async_stats.py deleted file mode 100644 index 58a80f5282..0000000000 --- a/bot/async_stats.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio -import socket - -from statsd.client.base import StatsClientBase - - -class AsyncStatsClient(StatsClientBase): - """An async transport method for statsd communication.""" - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - host: str = 'localhost', - port: int = 8125, - prefix: str = None - ): - """Create a new client.""" - family, _, _, _, addr = socket.getaddrinfo( - host, port, socket.AF_INET, socket.SOCK_DGRAM)[0] - self._addr = addr - self._prefix = prefix - self._loop = loop - self._transport = None - - async def create_socket(self) -> None: - """Use the loop.create_datagram_endpoint method to create a socket.""" - self._transport, _ = await self._loop.create_datagram_endpoint( - asyncio.DatagramProtocol, - family=socket.AF_INET, - remote_addr=self._addr - ) - - def _send(self, data: str) -> None: - """Start an async task to send data to statsd.""" - self._loop.create_task(self._async_send(data)) - - async def _async_send(self, data: str) -> None: - """Send data to the statsd server using the async transport.""" - self._transport.sendto(data.encode('ascii'), self._addr) diff --git a/bot/bot.py b/bot/bot.py index 914da9c983..35dbd1ba4e 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,22 +1,17 @@ import asyncio -import logging -import socket -import warnings -from collections import defaultdict -from contextlib import suppress -from typing import Dict, List, Optional +import contextlib +from sys import exception import aiohttp -import discord -from async_rediscache import RedisSession -from discord.ext import commands -from sentry_sdk import push_scope +from discord.errors import Forbidden +from pydis_core import BotBase +from pydis_core.utils.error_handling import handle_forbidden_from_block +from sentry_sdk import new_scope, start_transaction -from bot import api, constants -from bot.async_stats import AsyncStatsClient +from bot import constants, exts +from bot.log import get_logger -log = logging.getLogger('bot') -LOCALHOST = "127.0.0.1" +log = get_logger("bot") class StartupError(Exception): @@ -27,67 +22,17 @@ def __init__(self, base: Exception): self.exception = base -class Bot(commands.Bot): - """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client.""" +class Bot(BotBase): + """A subclass of `pydis_core.BotBase` that implements bot-specific functions.""" - def __init__(self, *args, redis_session: RedisSession, **kwargs): - if "connector" in kwargs: - warnings.warn( - "If login() is called (or the bot is started), the connector will be overwritten " - "with an internal one" - ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.http_session: Optional[aiohttp.ClientSession] = None - self.redis_session = redis_session - self.api_client: Optional[api.APIClient] = None - self.filter_list_cache = defaultdict(dict) - - self._connector = None - self._resolver = None - self._statsd_timerhandle: asyncio.TimerHandle = None - self._guild_available = asyncio.Event() - - statsd_url = constants.Stats.statsd_host - - if constants.DEBUG_MODE: - # Since statsd is UDP, there are no errors for sending to a down port. - # For this reason, setting the statsd host to 127.0.0.1 for development - # will effectively disable stats. - statsd_url = LOCALHOST - - self.stats = AsyncStatsClient(self.loop, LOCALHOST) - self._connect_statsd(statsd_url) - - def _connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: - """Callback used to retry a connection to statsd if it should fail.""" - if attempt >= 8: - log.error("Reached 8 attempts trying to reconnect AsyncStatsClient. Aborting") - return - - try: - self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - except socket.gaierror: - log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})") - # Use a fallback strategy for retrying, up to 8 times. - self._statsd_timerhandle = self.loop.call_later( - retry_after, - self._connect_statsd, - statsd_url, - retry_after * 2, - attempt + 1 - ) - - # All tasks that need to block closing until finished - self.closing_tasks: List[asyncio.Task] = [] - - async def cache_filter_list_data(self) -> None: - """Cache all the data in the FilterList on the site.""" - full_cache = await self.api_client.get('bot/filter-lists') - - for item in full_cache: - self.insert_item_into_filter_list_cache(item) + async def load_extension(self, name: str, *args, **kwargs) -> None: + """Extend D.py's load_extension function to also record sentry performance stats.""" + with start_transaction(op="cog-load", name=name): + await super().load_extension(name, *args, **kwargs) async def ping_services(self) -> None: """A helper to make sure all the services the bot relies on are available on startup.""" @@ -105,250 +50,30 @@ async def ping_services(self) -> None: raise await asyncio.sleep(constants.URLs.connect_cooldown) - @classmethod - def create(cls) -> "Bot": - """Create and return an instance of a Bot.""" - loop = asyncio.get_event_loop() - allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] - - intents = discord.Intents.all() - intents.presences = False - intents.dm_typing = False - intents.dm_reactions = False - intents.invites = False - intents.webhooks = False - intents.integrations = False - - return cls( - redis_session=_create_redis_session(loop), - loop=loop, - command_prefix=commands.when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), - case_insensitive=True, - max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), - intents=intents, - ) - - def load_extensions(self) -> None: - """Load all enabled extensions.""" - # Must be done here to avoid a circular import. - from bot.utils.extensions import EXTENSIONS - - extensions = set(EXTENSIONS) # Create a mutable copy. - if not constants.HelpChannels.enable: - extensions.remove("bot.exts.help_channels") - - for extension in extensions: - self.load_extension(extension) - - def add_cog(self, cog: commands.Cog) -> None: - """Adds a "cog" to the bot and logs the operation.""" - super().add_cog(cog) - log.info(f"Cog loaded: {cog.qualified_name}") - - def add_command(self, command: commands.Command) -> None: - """Add `command` as normal and then add its root aliases to the bot.""" - super().add_command(command) - self._add_root_aliases(command) - - def remove_command(self, name: str) -> Optional[commands.Command]: - """ - Remove a command/alias as normal and then remove its root aliases from the bot. - - Individual root aliases cannot be removed by this function. - To remove them, either remove the entire command or manually edit `bot.all_commands`. - """ - command = super().remove_command(name) - if command is None: - # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. - return - - self._remove_root_aliases(command) - return command - - def clear(self) -> None: - """Not implemented! Re-instantiate the bot instead of attempting to re-use a closed one.""" - raise NotImplementedError("Re-using a Bot object after closing it is not supported.") - - async def close(self) -> None: - """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver.""" - # Done before super().close() to allow tasks finish before the HTTP session closes. - for ext in list(self.extensions): - with suppress(Exception): - self.unload_extension(ext) - - for cog in list(self.cogs): - with suppress(Exception): - self.remove_cog(cog) - - # Wait until all tasks that have to be completed before bot is closing is done - log.trace("Waiting for tasks before closing.") - await asyncio.gather(*self.closing_tasks) - - # Now actually do full close of bot - await super().close() - - if self.api_client: - await self.api_client.close() - - if self.http_session: - await self.http_session.close() - - if self._connector: - await self._connector.close() - - if self._resolver: - await self._resolver.close() - - if self.stats._transport: - self.stats._transport.close() - - if self.redis_session: - await self.redis_session.close() - - if self._statsd_timerhandle: - self._statsd_timerhandle.cancel() + async def setup_hook(self) -> None: + """Default async initialisation method for discord.py.""" + await super().setup_hook() + await self.load_extensions(exts) - def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: - """Add an item to the bots filter_list_cache.""" - type_ = item["type"] - allowed = item["allowed"] - content = item["content"] - - self.filter_list_cache[f"{type_}.{allowed}"][content] = { - "id": item["id"], - "comment": item["comment"], - "created_at": item["created_at"], - "updated_at": item["updated_at"], - } - - async def login(self, *args, **kwargs) -> None: - """Re-create the connector and set up sessions before logging into Discord.""" - # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - self._resolver = aiohttp.AsyncResolver() - - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - self._connector = aiohttp.TCPConnector( - resolver=self._resolver, - family=socket.AF_INET, - ) - - # Client.login() will call HTTPClient.static_login() which will create a session using - # this connector attribute. - self.http.connector = self._connector - - self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client = api.APIClient(connector=self._connector) - - if self.redis_session.closed: - # If the RedisSession was somehow closed, we try to reconnect it - # here. Normally, this shouldn't happen. - await self.redis_session.connect() - - try: - await self.ping_services() - except Exception as e: - raise StartupError(e) - - # Build the FilterList cache - await self.cache_filter_list_data() - - await self.stats.create_socket() - await super().login(*args, **kwargs) - - async def on_guild_available(self, guild: discord.Guild) -> None: - """ - Set the internal guild available event when constants.Guild.id becomes available. - - If the cache appears to still be empty (no members, no channels, or no roles), the event - will not be set. - """ - if guild.id != constants.Guild.id: - return - - if not guild.roles or not guild.members or not guild.channels: - msg = "Guild available event was dispatched but the cache appears to still be empty!" - log.warning(msg) - - try: - webhook = await self.fetch_webhook(constants.Webhooks.dev_log) - except discord.HTTPException as e: - log.error(f"Failed to fetch webhook to send empty cache warning: status {e.status}") - else: - await webhook.send(f"<@&{constants.Roles.admin}> {msg}") - - return - - self._guild_available.set() - - async def on_guild_unavailable(self, guild: discord.Guild) -> None: - """Clear the internal guild available event when constants.Guild.id becomes unavailable.""" - if guild.id != constants.Guild.id: - return - - self._guild_available.clear() + async def on_error(self, event: str, *args, **kwargs) -> None: + """Log errors raised in event listeners rather than printing them to stderr.""" + e_val = exception() - async def wait_until_guild_available(self) -> None: - """ - Wait until the constants.Guild.id guild is available (and the cache is ready). + if isinstance(e_val, Forbidden): + message = args[0] if event == "on_message" else args[1] if event == "on_message_edit" else None - The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE - gateway event before giving up and thus not populating the cache for unavailable guilds. - """ - await self._guild_available.wait() + with contextlib.suppress(Forbidden): + # Attempt to handle the error. This reraises the error if's not due to a block, + # in which case the error is suppressed and handled normally. Otherwise, it was + # handled so return. + await handle_forbidden_from_block(e_val, message) + return - async def on_error(self, event: str, *args, **kwargs) -> None: - """Log errors raised in event listeners rather than printing them to stderr.""" self.stats.incr(f"errors.event.{event}") - with push_scope() as scope: + with new_scope() as scope: scope.set_tag("event", event) scope.set_extra("args", args) scope.set_extra("kwargs", kwargs) log.exception(f"Unhandled exception in {event}.") - - def _add_root_aliases(self, command: commands.Command) -> None: - """Recursively add root aliases for `command` and any of its subcommands.""" - if isinstance(command, commands.Group): - for subcommand in command.commands: - self._add_root_aliases(subcommand) - - for alias in getattr(command, "root_aliases", ()): - if alias in self.all_commands: - raise commands.CommandRegistrationError(alias, alias_conflict=True) - - self.all_commands[alias] = command - - def _remove_root_aliases(self, command: commands.Command) -> None: - """Recursively remove root aliases for `command` and any of its subcommands.""" - if isinstance(command, commands.Group): - for subcommand in command.commands: - self._remove_root_aliases(subcommand) - - for alias in getattr(command, "root_aliases", ()): - self.all_commands.pop(alias, None) - - -def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession: - """ - Create and connect to a redis session. - - Ensure the connection is established before returning to prevent race conditions. - `loop` is the event loop on which to connect. The Bot should use this same event loop. - """ - redis_session = RedisSession( - address=(constants.Redis.host, constants.Redis.port), - password=constants.Redis.password, - minsize=1, - maxsize=20, - use_fakeredis=constants.Redis.use_fakeredis, - global_namespace="bot", - ) - try: - loop.run_until_complete(redis_session.connect()) - except OSError as e: - raise StartupError(e) - return redis_session diff --git a/bot/command.py b/bot/command.py deleted file mode 100644 index 0fb900f7bc..0000000000 --- a/bot/command.py +++ /dev/null @@ -1,18 +0,0 @@ -from discord.ext import commands - - -class Command(commands.Command): - """ - A `discord.ext.commands.Command` subclass which supports root aliases. - - A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as - top-level commands rather than being aliases of the command's group. It's stored as an attribute - also named `root_aliases`. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.root_aliases = kwargs.get("root_aliases", []) - - if not isinstance(self.root_aliases, (list, tuple)): - raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/constants.py b/bot/constants.py index ab55da482f..63fc156fef 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,693 +1,610 @@ """ -Loads bot configuration from YAML files. -By default, this simply loads the default -configuration located at `config-default.yml`. -If a file called `config.yml` is found in the -project directory, the default configuration -is recursively updated with any settings from -the custom configuration. Any settings left -out in the custom user configuration will stay -their default values from `config-default.yml`. -""" +Loads bot configuration from environment variables and `.env` files. + +By default, the values defined in the classes are used, these can be overridden by an env var with the same name. -import logging +`.env` and `.env.server` files are used to populate env vars, if present. +""" import os -from collections.abc import Mapping from enum import Enum -from pathlib import Path -from typing import Dict, List, Optional -import yaml +from pydantic import BaseModel, computed_field +from pydantic_settings import BaseSettings -try: - import dotenv - dotenv.load_dotenv() -except ModuleNotFoundError: - pass -log = logging.getLogger(__name__) +class EnvConfig( + BaseSettings, + env_file=(".env.server", ".env"), + env_file_encoding = "utf-8", + env_nested_delimiter = "__", + extra="ignore", +): + """Our default configuration for models that should load from .env files.""" -def _env_var_constructor(loader, node): - """ - Implements a custom YAML tag for loading optional environment - variables. If the environment variable is set, returns the - value of it. Otherwise, returns `None`. +class _Miscellaneous(EnvConfig): + debug: bool = True + file_logs: bool = False - Example usage in the YAML configuration: - # Optional app configuration. Set `MY_APP_KEY` in the environment to use it. - application: - key: !ENV 'MY_APP_KEY' - """ +Miscellaneous = _Miscellaneous() - default = None - # Check if the node is a plain string value - if node.id == 'scalar': - value = loader.construct_scalar(node) - key = str(value) - else: - # The node value is a list - value = loader.construct_sequence(node) +FILE_LOGS = Miscellaneous.file_logs +DEBUG_MODE = Miscellaneous.debug - if len(value) >= 2: - # If we have at least two values, then we have both a key and a default value - default = value[1] - key = value[0] - else: - # Otherwise, we just have a key - key = value[0] - return os.getenv(key, default) +class _Bot(EnvConfig, env_prefix="bot_"): + prefix: str = "!" + sentry_dsn: str = "" + token: str + trace_loggers: str = "*" + + +Bot = _Bot() + + +class _Channels(EnvConfig, env_prefix="channels_"): + + announcements: int = 354619224620138496 + changelog: int = 748238795236704388 + mailing_lists: int = 704372456592506880 + python_events: int = 729674110270963822 + python_news: int = 704372456592506880 + reddit: int = 458224812528238616 + + dev_contrib: int = 635950537262759947 + dev_core: int = 411200599653351425 + dev_log: int = 622895325144940554 + + meta: int = 429409067623251969 + python_general: int = 267624335836053506 + + python_help: int = 1035199133436354600 + + attachment_log: int = 649243850006855680 + filter_log: int = 1014943924185473094 + message_log: int = 467752170159079424 + mod_log: int = 282638479504965634 + nomination_voting_archive: int = 833371042046148738 + user_log: int = 528976905546760203 + voice_log: int = 640292421988646961 + + off_topic_0: int = 291284109232308226 + off_topic_1: int = 463035241142026251 + off_topic_2: int = 463035268514185226 + + bot_commands: int = 267659945086812160 + discord_bots: int = 343944376055103488 + esoteric: int = 470884583684964352 + voice_gate: int = 764802555427029012 + code_jam_planning: int = 490217981872177157 + + # Staff + admins: int = 365960823622991872 + admin_spam: int = 563594791770914816 + defcon: int = 464469101889454091 + helpers: int = 385474242440986624 + incidents: int = 714214212200562749 + incidents_archive: int = 720668923636351037 + mod_alerts: int = 473092532147060736 + # Actually a thread, but for bootstrap purposes we treat it as a channel + rule_alerts: int = 1386490058739290182 + mod_meta: int = 775412552795947058 + mods: int = 305126844661760000 + nominations: int = 822920136150745168 + nomination_discussion: int = 798959130634747914 + nomination_voting: int = 822853512709931008 + organisation: int = 551789653284356126 -def _join_var_constructor(loader, node): - """ - Implements a custom YAML tag for concatenating other tags in - the document to strings. This allows for a much more DRY configuration - file. - """ + # Staff announcement channels + admin_announcements: int = 749736155569848370 + mod_announcements: int = 372115205867700225 + staff_announcements: int = 464033278631084042 + staff_info: int = 396684402404622347 + staff_lounge: int = 464905259261755392 + + # Voice Channels + admins_voice: int = 500734494840717332 + code_help_voice_0: int = 751592231726481530 + code_help_voice_1: int = 764232549840846858 + general_voice_0: int = 751591688538947646 + general_voice_1: int = 799641437645701151 + staff_voice: int = 412375055910043655 + + black_formatter: int = 846434317021741086 + + # Voice Chat + code_help_chat_0: int = 755154969761677312 + code_help_chat_1: int = 766330079135268884 + staff_voice_chat: int = 541638762007101470 + voice_chat_0: int = 412357430186344448 + voice_chat_1: int = 799647045886541885 + + big_brother: int = 468507907357409333 + duck_pond: int = 637820308341915648 + roles: int = 851270062434156586 + + rules: int = 693837295685730335 + + +Channels = _Channels() + + +class _Roles(EnvConfig, env_prefix="roles_"): + + # Self-assignable roles, see the Subscribe cog + advent_of_code: int = 518565788744024082 + announcements: int = 463658397560995840 + lovefest: int = 542431903886606399 + pyweek_announcements: int = 897568414044938310 + revival_of_code: int = 988801794668908655 + archived_channels_access: int = 1074780483776417964 + aspiring_contributors: int = 1496224728686268657 + + contributors: int = 295488872404484098 + python_community: int = 458226413825294336 + voice_verified: int = 764802720779337729 + + # Streaming + video: int = 764245844798079016 + + # Staff + admins: int = 267628507062992896 + core_developers: int = 587606783669829632 + code_jam_event_team: int = 787816728474288181 + devops: int = 409416496733880320 + domain_leads: int = 807415650778742785 + events_lead: int = 778361735739998228 + helpers: int = 267630620367257601 + moderators: int = 831776746206265384 + mod_team: int = 267629731250176001 + owners: int = 267627879762755584 + project_leads: int = 815701647526330398 + founders: int = 1069394343867199590 + + # Code Jam + jammers: int = 737249140966162473 + + # Patreon + patreon_tier_1: int = 505040943800516611 + patreon_tier_2: int = 743399725914390631 + patreon_tier_3: int = 743400204367036520 + + +Roles = _Roles() + + +class _Categories(EnvConfig, env_prefix="categories_"): + + logs: int = 468520609152892958 + moderators: int = 749736277464842262 + modmail: int = 714494672835444826 + appeals: int = 890331800025563216 + appeals_2: int = 895417395261341766 + voice: int = 356013253765234688 + + # 2021 Summer Code Jam + summer_code_jam: int = 861692638540857384 + python_help_system: int = 691405807388196926 + + +Categories = _Categories() - fields = loader.construct_sequence(node) - return "".join(str(x) for x in fields) +class _Guild(EnvConfig, env_prefix="guild_"): -yaml.SafeLoader.add_constructor("!ENV", _env_var_constructor) -yaml.SafeLoader.add_constructor("!JOIN", _join_var_constructor) + id: int = 267624335836053506 + invite: str = "https://discord.gg/python" -# Pointing old tag to !ENV constructor to avoid breaking existing configs -yaml.SafeLoader.add_constructor("!REQUIRED_ENV", _env_var_constructor) + moderation_categories: tuple[int, ...] = ( + Categories.moderators, + Categories.modmail, + Categories.logs, + Categories.appeals, + Categories.appeals_2 + ) + moderation_channels: tuple[int, ...] = (Channels.admins, Channels.admin_spam, Channels.mods) + modlog_blacklist: tuple[int, ...] = ( + Channels.attachment_log, + Channels.message_log, + Channels.mod_log, + Channels.staff_voice, + Channels.filter_log + ) + reminder_whitelist: tuple[int, ...] = (Channels.bot_commands, Channels.dev_contrib, Channels.black_formatter) + moderation_roles: tuple[int, ...] = (Roles.admins, Roles.mod_team, Roles.moderators, Roles.owners) + staff_roles: tuple[int, ...] = (Roles.admins, Roles.helpers, Roles.mod_team, Roles.owners) -with open("config-default.yml", encoding="UTF-8") as f: - _CONFIG_YAML = yaml.safe_load(f) +Guild = _Guild() -def _recursive_update(original, new): +class Event(Enum): """ - Helper method which implements a recursive `dict.update` - method, used for updating the original configuration with - configuration specified by the user. + Discord.py event names. + + This does not include every event (for example, raw events aren't here), only events used in ModLog for now. """ - for key, value in original.items(): - if key not in new: - continue + guild_channel_create = "guild_channel_create" + guild_channel_delete = "guild_channel_delete" + guild_channel_update = "guild_channel_update" + guild_role_create = "guild_role_create" + guild_role_delete = "guild_role_delete" + guild_role_update = "guild_role_update" + guild_update = "guild_update" - if isinstance(value, Mapping): - if not any(isinstance(subvalue, Mapping) for subvalue in value.values()): - original[key].update(new[key]) - _recursive_update(original[key], new[key]) - else: - original[key] = new[key] + member_join = "member_join" + member_remove = "member_remove" + member_ban = "member_ban" + member_unban = "member_unban" + member_update = "member_update" + message_delete = "message_delete" + message_edit = "message_edit" -if Path("config.yml").exists(): - log.info("Found `config.yml` file, loading constants from it.") - with open("config.yml", encoding="UTF-8") as f: - user_config = yaml.safe_load(f) - _recursive_update(_CONFIG_YAML, user_config) + voice_state_update = "voice_state_update" -def check_required_keys(keys): - """ - Verifies that keys that are set to be required are present in the - loaded configuration. - """ - for key_path in keys: - lookup = _CONFIG_YAML - try: - for key in key_path.split('.'): - lookup = lookup[key] - if lookup is None: - raise KeyError(key) - except KeyError: - log.critical( - f"A configuration for `{key_path}` is required, but was not found. " - "Please set it in `config.yml` or setup an environment variable and try again." - ) - raise - - -try: - required_keys = _CONFIG_YAML['config']['required_keys'] -except KeyError: - pass -else: - check_required_keys(required_keys) - - -class YAMLGetter(type): - """ - Implements a custom metaclass used for accessing - configuration data by simply accessing class attributes. - Supports getting configuration from up to two levels - of nested configuration through `section` and `subsection`. - - `section` specifies the YAML configuration section (or "key") - in which the configuration lives, and must be set. - - `subsection` is an optional attribute specifying the section - within the section from which configuration should be loaded. - - Example Usage: - - # config.yml - bot: - prefixes: - direct_message: '' - guild: '!' - - # config.py - class Prefixes(metaclass=YAMLGetter): - section = "bot" - subsection = "prefixes" - - # Usage in Python code - from config import Prefixes - def get_prefix(bot, message): - if isinstance(message.channel, PrivateChannel): - return Prefixes.direct_message - return Prefixes.guild - """ +class ThreadArchiveTimes(Enum): + """The time periods threads can have the archive time set to.""" - subsection = None + HOUR = 60 + DAY = 1440 + THREE_DAY = 4320 + WEEK = 10080 - def __getattr__(cls, name): - name = name.lower() - try: - if cls.subsection is not None: - return _CONFIG_YAML[cls.section][cls.subsection][name] - return _CONFIG_YAML[cls.section][name] - except KeyError as e: - dotted_path = '.'.join( - (cls.section, cls.subsection, name) - if cls.subsection is not None else (cls.section, name) - ) - # Only an INFO log since this can be caught through `hasattr` or `getattr`. - log.info(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") - raise AttributeError(repr(name)) from e +class Webhook(BaseModel): + """A base class for all webhooks.""" - def __getitem__(cls, name): - return cls.__getattr__(name) + id: int + channel: int - def __iter__(cls): - """Return generator of key: value pairs of current constants class' config values.""" - for name in cls.__annotations__: - yield name, getattr(cls, name) +class _Webhooks(EnvConfig, env_prefix="webhooks_", nested_model_default_partial_update=True): -# Dataclasses -class Bot(metaclass=YAMLGetter): - section = "bot" + big_brother: Webhook = Webhook(id=569133704568373283, channel=Channels.big_brother) + dev_log: Webhook = Webhook(id=680501655111729222, channel=Channels.dev_log) + duck_pond: Webhook = Webhook(id=637821475327311927, channel=Channels.duck_pond) + incidents: Webhook = Webhook(id=816650601844572212, channel=Channels.incidents) + incidents_archive: Webhook = Webhook(id=720671599790915702, channel=Channels.incidents_archive) + python_news: Webhook = Webhook(id=704381182279942324, channel=Channels.python_news) - prefix: str - sentry_dsn: Optional[str] - token: str - trace_loggers: Optional[str] +Webhooks = _Webhooks() -class Redis(metaclass=YAMLGetter): - section = "bot" - subsection = "redis" - host: str - password: Optional[str] - port: int - use_fakeredis: bool # If this is True, Bot will use fakeredis.aioredis +class _BigBrother(EnvConfig, env_prefix="big_brother_"): + header_message_limit: int = 15 + log_delay: int = 15 -class Filter(metaclass=YAMLGetter): - section = "filter" - filter_domains: bool - filter_everyone_ping: bool - filter_invites: bool - filter_zalgo: bool - watch_regex: bool - watch_rich_embeds: bool +BigBrother = _BigBrother() - # Notifications are not expected for "watchlist" type filters - notify_user_domains: bool - notify_user_everyone_ping: bool - notify_user_invites: bool - notify_user_zalgo: bool +class _CodeBlock(EnvConfig, env_prefix="code_block_"): - offensive_msg_delete_days: int - ping_everyone: bool + # The channels in which code blocks will be detected. They are not subject to a cooldown. + channel_whitelist: tuple[int, ...] = (Channels.bot_commands,) + # The channels which will be affected by a cooldown. These channels are also whitelisted. + cooldown_channels: tuple[int, ...] = (Channels.python_general,) - channel_whitelist: List[int] - role_whitelist: List[int] + cooldown_seconds: int = 300 + minimum_lines: int = 4 -class Cooldowns(metaclass=YAMLGetter): - section = "bot" - subsection = "cooldowns" +CodeBlock = _CodeBlock() - tags: int +class _HelpChannels(EnvConfig, env_prefix="help_channels_"): -class Colours(metaclass=YAMLGetter): - section = "style" - subsection = "colours" + enable: bool = True + idle_minutes: int = 60 + deleted_idle_minutes: int = 5 + # Roles which are allowed to use the command which makes channels dormant + cmd_whitelist: tuple[int, ...] = Guild.moderation_roles - blue: int - bright_green: int - orange: int - pink: int - purple: int - soft_green: int - soft_orange: int - soft_red: int - white: int - yellow: int +HelpChannels = _HelpChannels() -class DuckPond(metaclass=YAMLGetter): - section = "duck_pond" - threshold: int - channel_blacklist: List[int] +class _RedirectOutput(EnvConfig, env_prefix="redirect_output_"): + delete_delay: int = 15 + delete_invocation: bool = True -class Emojis(metaclass=YAMLGetter): - section = "style" - subsection = "emojis" - badge_bug_hunter: str - badge_bug_hunter_level_2: str - badge_early_supporter: str - badge_hypesquad: str - badge_hypesquad_balance: str - badge_hypesquad_bravery: str - badge_hypesquad_brilliance: str - badge_partner: str - badge_staff: str - badge_verified_bot_developer: str - verified_bot: str - bot: str +RedirectOutput = _RedirectOutput() - defcon_shutdown: str # noqa: E704 - defcon_unshutdown: str # noqa: E704 - defcon_update: str # noqa: E704 - failmail: str +class _DuckPond(EnvConfig, env_prefix="duck_pond_"): - incident_actioned: str - incident_investigating: str - incident_unactioned: str + threshold: int = 7 - status_dnd: str - status_idle: str - status_offline: str - status_online: str + default_channel_blacklist: tuple[int, ...] = ( + Channels.announcements, + Channels.python_news, + Channels.python_events, + Channels.mailing_lists, + Channels.reddit, + Channels.duck_pond, + Channels.changelog, + Channels.staff_announcements, + Channels.mod_announcements, + Channels.admin_announcements, + Channels.staff_info, + ) - ducky_dave: str + extra_channel_blacklist: tuple[int, ...] = tuple() - trashcan: str + @computed_field + @property + def channel_blacklist(self) -> tuple[int, ...]: + return self.default_channel_blacklist + self.extra_channel_blacklist - bullet: str - check_mark: str - cross_mark: str - new: str - pencil: str +DuckPond = _DuckPond() - ok_hand: str +class _PythonNews(EnvConfig, env_prefix="python_news_"): -class Icons(metaclass=YAMLGetter): - section = "style" - subsection = "icons" + channel: int = Webhooks.python_news.channel + webhook: int = Webhooks.python_news.id + mail_lists: tuple[str, ...] = ("python-ideas", "python-announce-list", "pypi-announce", "python-dev") - crown_blurple: str - crown_green: str - crown_red: str - defcon_denied: str # noqa: E704 - defcon_shutdown: str # noqa: E704 - defcon_unshutdown: str # noqa: E704 - defcon_update: str # noqa: E704 +PythonNews = _PythonNews() - filtering: str - green_checkmark: str - green_questionmark: str - guild_update: str +class _VoiceGate(EnvConfig, env_prefix="voice_gate_"): - hash_blurple: str - hash_green: str - hash_red: str + delete_after_delay: int = 60 + minimum_activity_blocks: int = 9 # 1h30m + minimum_days_member: int = 3 + minimum_messages: int = 25 - message_bulk_delete: str - message_delete: str - message_edit: str - pencil: str +VoiceGate = _VoiceGate() - questionmark: str - remind_blurple: str - remind_green: str - remind_red: str +class _Branding(EnvConfig, env_prefix="branding_"): - sign_in: str - sign_out: str + cycle_frequency: int = 3 - superstarify: str - unsuperstarify: str - token_removed: str +Branding = _Branding() - user_ban: str - user_mute: str - user_unban: str - user_unmute: str - user_update: str - user_verified: str - user_warn: str - voice_state_blue: str - voice_state_green: str - voice_state_red: str +class _VideoPermission(EnvConfig, env_prefix="video_permission_"): + default_permission_duration: int = 5 -class CleanMessages(metaclass=YAMLGetter): - section = "bot" - subsection = "clean" - message_limit: int +VideoPermission = _VideoPermission() -class Stats(metaclass=YAMLGetter): - section = "bot" - subsection = "stats" +class _Redis(EnvConfig, env_prefix="redis_"): - presence_update_timeout: int - statsd_host: str + host: str = "redis.databases.svc.cluster.local" + password: str = "" + port: int = 6379 + use_fakeredis: bool = False # If this is True, Bot will use fakeredis.aioredis -class Categories(metaclass=YAMLGetter): - section = "guild" - subsection = "categories" +Redis = _Redis() - help_available: int - help_dormant: int - help_in_use: int - moderators: int - modmail: int - voice: int +class _CleanMessages(EnvConfig, env_prefix="clean_"): -class Channels(metaclass=YAMLGetter): - section = "guild" - subsection = "channels" + message_limit: int = 10_000 - announcements: int - change_log: int - mailing_lists: int - python_events: int - python_news: int - reddit: int - dev_contrib: int - dev_core: int - dev_log: int +CleanMessages = _CleanMessages() - meta: int - python_general: int - cooldown: int - how_to_get_help: int +class _Stats(EnvConfig, env_prefix="stats_"): - attachment_log: int - message_log: int - mod_log: int - nomination_archive: int - user_log: int - voice_log: int + presence_update_timeout: int = 30 + statsd_host: str = "statsd-exporter.monitoring.svc.cluster.local" - off_topic_0: int - off_topic_1: int - off_topic_2: int - bot_commands: int - discord_py: int - esoteric: int - voice_gate: int +Stats = _Stats() - admins: int - admin_spam: int - defcon: int - helpers: int - incidents: int - incidents_archive: int - mod_alerts: int - nominations: int - nomination_voting: int - organisation: int - admin_announcements: int - mod_announcements: int - staff_announcements: int - - admins_voice: int - code_help_voice_1: int - code_help_voice_2: int - general_voice: int - staff_voice: int - - code_help_chat_1: int - code_help_chat_2: int - staff_voice_chat: int - voice_chat: int - - big_brother_logs: int - talent_pool: int +class _Cooldowns(EnvConfig, env_prefix="cooldowns_"): + tags: int = 60 -class Webhooks(metaclass=YAMLGetter): - section = "guild" - subsection = "webhooks" - big_brother: int - dev_log: int - duck_pond: int - incidents_archive: int - talent_pool: int - - -class Roles(metaclass=YAMLGetter): - section = "guild" - subsection = "roles" - - announcements: int - contributors: int - help_cooldown: int - muted: int - partners: int - python_community: int - sprinters: int - voice_verified: int - video: int - - admins: int - core_developers: int - devops: int - domain_leads: int - helpers: int - moderators: int - mod_team: int - owners: int - project_leads: int - - jammers: int - team_leaders: int - - -class Guild(metaclass=YAMLGetter): - section = "guild" +Cooldowns = _Cooldowns() - id: int - invite: str # Discord invite, gets embedded in chat - moderation_categories: List[int] - moderation_channels: List[int] - modlog_blacklist: List[int] - reminder_whitelist: List[int] - moderation_roles: List[int] - staff_roles: List[int] +class _Metabase(EnvConfig, env_prefix="metabase_"): + username: str = "" + password: str = "" + base_url: str = "http://metabase.tooling.svc.cluster.local" + public_url: str = "https://metabase.pydis.wtf" + max_session_age: int = 20_160 -class Keys(metaclass=YAMLGetter): - section = "keys" - github: Optional[str] - site_api: Optional[str] +Metabase = _Metabase() -class URLs(metaclass=YAMLGetter): - section = "urls" +class _BaseURLs(EnvConfig, env_prefix="urls_"): # Snekbox endpoints - snekbox_eval_api: str + snekbox_eval_api: str = "http://snekbox.snekbox.svc.cluster.local/eval" - # Discord API endpoints - discord_api: str - discord_invite_api: str + # Discord API + discord_api: str = "https://discordapp.com/api/v7/" # Misc endpoints - bot_avatar: str - github_bot_repo: str + bot_avatar: str = "https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png" + github_bot_repo: str = "https://github.com/python-discord/bot" - # Base site vars - connect_max_retries: int - connect_cooldown: int - site: str - site_api: str - site_schema: str - site_api_schema: str + # Site + site_api: str = "http://site.web.svc.cluster.local/api" + paste_url: str = "https://paste.pythondiscord.com" - # Site endpoints - site_logs_view: str - paste_service: str +BaseURLs = _BaseURLs() -class Metabase(metaclass=YAMLGetter): - section = "metabase" - username: Optional[str] - password: Optional[str] - url: str - max_session_age: int +class _URLs(_BaseURLs): + # Discord API endpoints + discord_invite_api: str = "".join([BaseURLs.discord_api, "invites"]) -class AntiSpam(metaclass=YAMLGetter): - section = 'anti_spam' + # Base site vars + connect_max_retries: int = 3 + connect_cooldown: int = 5 - clean_offending: bool - ping_everyone: bool + site_logs_view: str = "https://pythondiscord.com/staff/bot/logs" - punishment: Dict[str, Dict[str, int]] - rules: Dict[str, Dict[str, int]] +URLs = _URLs() -class BigBrother(metaclass=YAMLGetter): - section = 'big_brother' - header_message_limit: int - log_delay: int +class _Emojis(EnvConfig, env_prefix="emojis_"): + badge_bug_hunter: str = "<:bug_hunter_lvl1:743882896372269137>" + badge_bug_hunter_level_2: str = "<:bug_hunter_lvl2:743882896611344505>" + badge_early_supporter: str = "<:early_supporter:743882896909140058>" + badge_hypesquad: str = "<:hypesquad_events:743882896892362873>" + badge_hypesquad_balance: str = "<:hypesquad_balance:743882896460480625>" + badge_hypesquad_bravery: str = "<:hypesquad_bravery:743882896745693335>" + badge_hypesquad_brilliance: str = "<:hypesquad_brilliance:743882896938631248>" + badge_partner: str = "<:partner:748666453242413136>" + badge_staff: str = "<:discord_staff:743882896498098226>" + badge_verified_bot_developer: str = "<:verified_bot_dev:743882897299210310>" + badge_discord_certified_moderator: str = "<:discord_certified_moderator:1114130029547364434>" + badge_bot_http_interactions: str = "<:bot_http_interactions:1114130379754975283>" + badge_active_developer: str = "<:active_developer:1114130031036338176>" + verified_bot: str = "<:verified_bot:811645219220750347>" + bot: str = "<:bot:812712599464443914>" -class CodeBlock(metaclass=YAMLGetter): - section = 'code_block' + defcon_shutdown: str = "<:defcondisabled:470326273952972810>" + defcon_unshutdown: str = "<:defconenabled:470326274213150730>" + defcon_update: str = "<:defconsettingsupdated:470326274082996224>" - channel_whitelist: List[int] - cooldown_channels: List[int] - cooldown_seconds: int - minimum_lines: int + failmail: str = "<:failmail:633660039931887616>" + failed_file: str = "<:failed_file:1073298441968562226>" + incident_actioned: str = "<:incident_actioned:714221559279255583>" + incident_investigating: str = "<:incident_investigating:714224190928191551>" + incident_unactioned: str = "<:incident_unactioned:714223099645526026>" -class Free(metaclass=YAMLGetter): - section = 'free' + status_dnd: str = "<:status_dnd:470326272082313216>" + status_idle: str = "<:status_idle:470326266625785866>" + status_offline: str = "<:status_offline:470326266537705472>" + status_online: str = "<:status_online:470326272351010816>" - activity_timeout: int - cooldown_per: float - cooldown_rate: int + ducky_dave: str = "<:ducky_dave:742058418692423772>" + trashcan: str = "<:trashcan:637136429717389331>" -class HelpChannels(metaclass=YAMLGetter): - section = 'help_channels' + bullet: str = "\u2022" + check_mark: str = "\u2705" + cross_mark: str = "\u274C" + new: str = "\U0001F195" + pencil: str = "\u270F" - enable: bool - cmd_whitelist: List[int] - idle_minutes_claimant: int - idle_minutes_others: int - deleted_idle_minutes: int - max_available: int - max_total_channels: int - name_prefix: str - notify: bool - notify_channel: int - notify_minutes: int - notify_roles: List[int] + ok_hand: str = ":ok_hand:" -class RedirectOutput(metaclass=YAMLGetter): - section = 'redirect_output' +Emojis = _Emojis() - delete_delay: int - delete_invocation: bool +class Icons: + """URLs to commonly used icons.""" -class PythonNews(metaclass=YAMLGetter): - section = 'python_news' + crown_blurple = "https://cdn.discordapp.com/emojis/469964153289965568.png" + crown_green = "https://cdn.discordapp.com/emojis/469964154719961088.png" + crown_red = "https://cdn.discordapp.com/emojis/469964154879344640.png" - channel: int - webhook: int - mail_lists: List[str] + defcon_denied = "https://cdn.discordapp.com/emojis/472475292078964738.png" + defcon_shutdown = "https://cdn.discordapp.com/emojis/470326273952972810.png" + defcon_unshutdown = "https://cdn.discordapp.com/emojis/470326274213150730.png" + defcon_update = "https://cdn.discordapp.com/emojis/472472638342561793.png" + filtering = "https://cdn.discordapp.com/emojis/472472638594482195.png" -class VoiceGate(metaclass=YAMLGetter): - section = "voice_gate" + green_checkmark = "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png" + green_questionmark = "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png" + guild_update = "https://cdn.discordapp.com/emojis/469954765141442561.png" - bot_message_delete_delay: int - minimum_activity_blocks: int - minimum_days_member: int - minimum_messages: int - voice_ping_delete_delay: int + hash_blurple = "https://cdn.discordapp.com/emojis/469950142942806017.png" + hash_green = "https://cdn.discordapp.com/emojis/469950144918585344.png" + hash_red = "https://cdn.discordapp.com/emojis/469950145413251072.png" + message_bulk_delete = "https://cdn.discordapp.com/emojis/469952898994929668.png" + message_delete = "https://cdn.discordapp.com/emojis/472472641320648704.png" + message_edit = "https://cdn.discordapp.com/emojis/472472638976163870.png" -class Branding(metaclass=YAMLGetter): - section = "branding" + pencil = "https://cdn.discordapp.com/emojis/470326272401211415.png" - cycle_frequency: int + questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png" + remind_blurple = "https://cdn.discordapp.com/emojis/477907609215827968.png" + remind_green = "https://cdn.discordapp.com/emojis/477907607785570310.png" + remind_red = "https://cdn.discordapp.com/emojis/477907608057937930.png" -class Event(Enum): - """ - Event names. This does not include every event (for example, raw - events aren't here), but only events used in ModLog for now. - """ + sign_in = "https://cdn.discordapp.com/emojis/469952898181234698.png" + sign_out = "https://cdn.discordapp.com/emojis/469952898089091082.png" - guild_channel_create = "guild_channel_create" - guild_channel_delete = "guild_channel_delete" - guild_channel_update = "guild_channel_update" - guild_role_create = "guild_role_create" - guild_role_delete = "guild_role_delete" - guild_role_update = "guild_role_update" - guild_update = "guild_update" + superstarify = "https://cdn.discordapp.com/emojis/636288153044516874.png" + unsuperstarify = "https://cdn.discordapp.com/emojis/636288201258172446.png" - member_join = "member_join" - member_remove = "member_remove" - member_ban = "member_ban" - member_unban = "member_unban" - member_update = "member_update" + token_removed = "https://cdn.discordapp.com/emojis/470326273298792469.png" # noqa: S105 - message_delete = "message_delete" - message_edit = "message_edit" + user_ban = "https://cdn.discordapp.com/emojis/469952898026045441.png" + user_timeout = "https://cdn.discordapp.com/emojis/472472640100106250.png" + user_unban = "https://cdn.discordapp.com/emojis/469952898692808704.png" + user_untimeout = "https://cdn.discordapp.com/emojis/472472639206719508.png" + user_update = "https://cdn.discordapp.com/emojis/469952898684551168.png" + user_verified = "https://cdn.discordapp.com/emojis/470326274519334936.png" + user_warn = "https://cdn.discordapp.com/emojis/470326274238447633.png" - voice_state_update = "voice_state_update" + voice_state_blue = "https://cdn.discordapp.com/emojis/656899769662439456.png" + voice_state_green = "https://cdn.discordapp.com/emojis/656899770094452754.png" + voice_state_red = "https://cdn.discordapp.com/emojis/656899769905709076.png" + + +class Colours: + """Colour codes, mostly used to set discord.Embed colours.""" + blue: int = 0x3775a8 + bright_green: int = 0x01d277 + orange: int = 0xe67e22 + pink: int = 0xcf84e0 + purple: int = 0xb734eb + soft_green: int = 0x68c290 + soft_orange: int = 0xf9cb54 + soft_red: int = 0xcd6d6d + white: int = 0xfffffe + yellow: int = 0xffd241 -class VideoPermission(metaclass=YAMLGetter): - section = "video_permission" - default_permission_duration: int +class _Keys(EnvConfig, env_prefix="api_keys_"): + github: str = "" + site_api: str = "" + + +Keys = _Keys() -# Debug mode -DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") -# Paths BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations MODERATION_ROLES = Guild.moderation_roles STAFF_ROLES = Guild.staff_roles +STAFF_AND_COMMUNITY_ROLES = STAFF_ROLES + (Roles.python_community,) # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels @@ -698,8 +615,9 @@ class VideoPermission(metaclass=YAMLGetter): # Git SHA for Sentry GIT_SHA = os.environ.get("GIT_SHA", "development") + # Bot replies -NEGATIVE_REPLIES = [ +NEGATIVE_REPLIES = ( "Noooooo!!", "Nope.", "I'm sorry Dave, I'm afraid I can't do that.", @@ -712,14 +630,14 @@ class VideoPermission(metaclass=YAMLGetter): "Not likely.", "No way, José.", "Not in a million years.", - "Fat chance.", + "I would love to, but unfortunately... no.", "Certainly not.", "NEGATORY.", "Nuh-uh.", "Not in my house!", -] +) -POSITIVE_REPLIES = [ +POSITIVE_REPLIES = ( "Yep.", "Absolutely!", "Can do!", @@ -737,17 +655,18 @@ class VideoPermission(metaclass=YAMLGetter): "Of course!", "Aye aye, cap'n!", "I'll allow it.", -] +) -ERROR_REPLIES = [ +ERROR_REPLIES = ( "Please don't do that.", "You have to stop.", "Do you mind?", "In the future, don't do that.", "That was a mistake.", "You blew it.", - "You're bad at computers.", - "Are you trying to kill me?", + "Application bot.exe will be closed.", + "Kernel Panic! *Kernel runs around in panic*", + "Error 418. I am a teapot.", "Noooooo!!", "I can't believe you've done this", -] +) diff --git a/bot/converters.py b/bot/converters.py index 2a3943831c..7371e203b1 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,131 +1,69 @@ -import logging import re import typing as t -from datetime import datetime -from functools import partial +from datetime import UTC, datetime from ssl import CertificateError import dateutil.parser -import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter -from discord.utils import DISCORD_EPOCH, snowflake_time +from discord.ext.commands import BadArgument, Context, Converter, IDConverter, MemberConverter, UserConverter +from discord.utils import snowflake_time +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import unqualify -from bot.api import ResponseCodeError -from bot.constants import URLs +from bot import exts, instance as bot_instance +from bot.errors import InvalidInfractionError from bot.exts.info.doc import _inventory_parser -from bot.utils.regex import INVITE_RE -from bot.utils.time import parse_duration_string +from bot.log import get_logger +from bot.utils import time -log = logging.getLogger(__name__) +if t.TYPE_CHECKING: + from bot.exts.info.source import SourceType -DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +log = get_logger(__name__) + +DISCORD_EPOCH_DT = snowflake_time(0) RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") -def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]: +class Extension(Converter): """ - Return a converter which only allows arguments equal to one of the given values. + Fully qualify the name of an extension and ensure it exists. - Unless preserve_case is True, the argument is converted to lowercase. All values are then - expected to have already been given in lowercase too. + The * and ** values bypass this when used with the reload command. """ - def converter(arg: str) -> str: - if not preserve_case: - arg = arg.lower() - - if arg not in values: - raise BadArgument(f"Only the following values are allowed:\n```{', '.join(values)}```") - else: - return arg - return converter + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + argument = argument.lower() -class ValidDiscordServerInvite(Converter): - """ - A converter that validates whether a given string is a valid Discord server invite. + if argument in bot_instance.all_extensions: + return argument - Raises 'BadArgument' if: - - The string is not a valid Discord server invite. - - The string is valid, but is an invite for a group DM. - - The string is valid, but is expired. + if (qualified_arg := f"{exts.__name__}.{argument}") in bot_instance.all_extensions: + return qualified_arg - Returns a (partial) guild object if: - - The string is a valid vanity - - The string is a full invite URI - - The string contains the invite code (the stuff after discord.gg/) + matches = [] + for ext in bot_instance.all_extensions: + if argument == unqualify(ext): + matches.append(ext) - See the Discord API docs for documentation on the guild object: - https://discord.com/developers/docs/resources/guild#guild-object - """ - - async def convert(self, ctx: Context, server_invite: str) -> dict: - """Check whether the string is a valid Discord server invite.""" - invite_code = INVITE_RE.search(server_invite) - if invite_code: - response = await ctx.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite_code[1]}" + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}```" ) - if response.status != 404: - invite_data = await response.json() - return invite_data.get("guild") - - id_converter = IDConverter() - if id_converter._get_id_match(server_invite): - raise BadArgument("Guild IDs are not supported, only invites.") - - raise BadArgument("This does not appear to be a valid Discord server invite.") - -class ValidFilterListType(Converter): - """ - A converter that checks whether the given string is a valid FilterList type. - - Raises `BadArgument` if the argument is not a valid FilterList type, and simply - passes through the given argument otherwise. - """ - - @staticmethod - async def get_valid_types(bot: Bot) -> list: - """ - Try to get a list of valid filter list types. - - Raise a BadArgument if the API can't respond. - """ - try: - valid_types = await bot.api_client.get('bot/filter-lists/get-types') - except ResponseCodeError: - raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") - - return [enum for enum, classname in valid_types] - - async def convert(self, ctx: Context, list_type: str) -> str: - """Checks whether the given string is a valid FilterList type.""" - valid_types = await self.get_valid_types(ctx.bot) - list_type = list_type.upper() - - if list_type not in valid_types: - - # Maybe the user is using the plural form of this type, - # e.g. "guild_invites" instead of "guild_invite". - # - # This code will support the simple plural form (a single 's' at the end), - # which works for all current list types, but if a list type is added in the future - # which has an irregular plural form (like 'ies'), this code will need to be - # refactored to support this. - if list_type.endswith("S") and list_type[:-1] in valid_types: - list_type = list_type[:-1] - - else: - valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) - raise BadArgument( - f"You have provided an invalid list type!\n\n" - f"Please provide one of the following: \n{valid_types_list}" - ) - return list_type + if matches: + return matches[0] + raise BadArgument(f":x: Could not find the extension `{argument}`.") class PackageName(Converter): @@ -165,7 +103,7 @@ async def convert(ctx: Context, url: str) -> str: f"HTTP GET on `{url}` returned status `{resp.status}`, expected 200" ) except CertificateError: - if url.startswith('https'): + if url.startswith("https"): raise BadArgument( f"Got a `CertificateError` for URL `{url}`. Does it support HTTPS?" ) @@ -188,14 +126,19 @@ class Inventory(Converter): """ @staticmethod - async def convert(ctx: Context, url: str) -> t.Tuple[str, _inventory_parser.InventoryDict]: + async def convert(ctx: Context, url: str) -> tuple[str, _inventory_parser.InventoryDict]: """Convert url to Intersphinx inventory URL.""" - await ctx.trigger_typing() - if (inventory := await _inventory_parser.fetch_inventory(url)) is None: - raise BadArgument( - f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." - ) - return url, inventory + await ctx.typing() + try: + inventory = await _inventory_parser.fetch_inventory(url) + except _inventory_parser.InvalidHeaderError: + raise BadArgument("Unable to parse inventory because of invalid header, check if URL is correct.") + else: + if inventory is None: + raise BadArgument( + f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." + ) + return url, inventory class Snowflake(IDConverter): @@ -230,66 +173,12 @@ async def convert(self, ctx: Context, arg: str) -> int: if time < DISCORD_EPOCH_DT: raise BadArgument(f"{error}: timestamp is before the Discord epoch.") - elif (datetime.utcnow() - time).days < -1: + if (datetime.now(UTC) - time).days < -1: raise BadArgument(f"{error}: timestamp is too far into the future.") return snowflake -class TagNameConverter(Converter): - """ - Ensure that a proposed tag name is valid. - - Valid tag names meet the following conditions: - * All ASCII characters - * Has at least one non-whitespace character - * Not solely numeric - * Shorter than 127 characters - """ - - @staticmethod - async def convert(ctx: Context, tag_name: str) -> str: - """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" - tag_name = tag_name.lower().strip() - - # The tag name has at least one invalid character. - if ascii(tag_name)[1:-1] != tag_name: - raise BadArgument("Don't be ridiculous, you can't use that character!") - - # The tag name is either empty, or consists of nothing but whitespace. - elif not tag_name: - raise BadArgument("Tag names should not be empty, or filled with whitespace.") - - # The tag name is longer than 127 characters. - elif len(tag_name) > 127: - raise BadArgument("Are you insane? That's way too long!") - - # The tag name is ascii but does not contain any letters. - elif not any(character.isalpha() for character in tag_name): - raise BadArgument("Tag names must contain at least one letter.") - - return tag_name - - -class TagContentConverter(Converter): - """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" - - @staticmethod - async def convert(ctx: Context, tag_content: str) -> str: - """ - Ensure tag_content is non-empty and contains at least one non-whitespace character. - - If tag_content is valid, return the stripped version. - """ - tag_content = tag_content.strip() - - # The tag contents should not be empty, or filled with whitespace. - if not tag_content: - raise BadArgument("Tag contents should not be empty, or filled with whitespace.") - - return tag_content - - class DurationDelta(Converter): """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" @@ -308,7 +197,7 @@ async def convert(self, ctx: Context, duration: str) -> relativedelta: The units need to be provided in descending order of magnitude. """ - if not (delta := parse_duration_string(duration)): + if not (delta := time.parse_duration_string(duration)): raise BadArgument(f"`{duration}` is not a valid duration string.") return delta @@ -324,7 +213,7 @@ async def convert(self, ctx: Context, duration: str) -> datetime: The converter supports the same symbols for each unit of time as its parent class. """ delta = await super().convert(ctx, duration) - now = datetime.utcnow() + now = datetime.now(UTC) try: return now + delta @@ -332,10 +221,29 @@ async def convert(self, ctx: Context, duration: str) -> datetime: raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") +class Age(DurationDelta): + """Convert duration strings into UTC datetime.datetime objects.""" + + async def convert(self, ctx: Context, duration: str) -> datetime: + """ + Converts a `duration` string to a datetime object that's `duration` in the past. + + The converter supports the same symbols for each unit of time as its parent class. + """ + delta = await super().convert(ctx, duration) + now = datetime.now(UTC) + + try: + return now - delta + except (ValueError, OverflowError): + raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") + + class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" - ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + ALLOWED_CHARACTERS = r"ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>\/" + TRANSLATED_CHARACTERS = "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-<>⧹⧸" # noqa: RUF001 @classmethod def translate_name(cls, name: str, *, from_unicode: bool = True) -> str: @@ -345,9 +253,9 @@ def translate_name(cls, name: str, *, from_unicode: bool = True) -> str: If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text. """ if from_unicode: - table = str.maketrans(cls.ALLOWED_CHARACTERS, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-') + table = str.maketrans(cls.ALLOWED_CHARACTERS, cls.TRANSLATED_CHARACTERS) else: - table = str.maketrans('𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-', cls.ALLOWED_CHARACTERS) + table = str.maketrans(cls.TRANSLATED_CHARACTERS, cls.ALLOWED_CHARACTERS) return name.translate(table) @@ -359,7 +267,7 @@ async def convert(self, ctx: Context, argument: str) -> str: if not (2 <= len(argument) <= 96): raise BadArgument("Channel name must be between 2 and 96 chars long") - elif not all(c.isalnum() or c in self.ALLOWED_CHARACTERS for c in argument): + if not all(c.isalnum() or c in self.ALLOWED_CHARACTERS for c in argument): raise BadArgument( "Channel name must only consist of " "alphanumeric characters, minus signs or apostrophes." @@ -379,8 +287,8 @@ async def convert(self, ctx: Context, datetime_string: str) -> datetime: The converter is flexible in the formats it accepts, as it uses the `isoparse` method of `dateutil.parser`. In general, it accepts datetime strings that start with a date, optionally followed by a time. Specifying a timezone offset in the datetime string is - supported, but the `datetime` object will be converted to UTC and will be returned without - `tzinfo` as a timezone-unaware `datetime` object. + supported, but the `datetime` object will be converted to UTC. If no timezone is specified, the datetime will + be assumed to be in UTC already. In all cases, the returned object will have the UTC timezone. See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse @@ -405,8 +313,9 @@ async def convert(self, ctx: Context, datetime_string: str) -> datetime: raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string") if dt.tzinfo: - dt = dt.astimezone(dateutil.tz.UTC) - dt = dt.replace(tzinfo=None) + dt = dt.astimezone(UTC) + else: # Without a timezone, assume it represents UTC. + dt = dt.replace(tzinfo=UTC) return dt @@ -416,11 +325,11 @@ class HushDurationConverter(Converter): MINUTES_RE = re.compile(r"(\d+)(?:M|m|$)") - async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: + async def convert(self, ctx: Context, argument: str) -> int: """ Convert `argument` to a duration that's max 15 minutes or None. - If `"forever"` is passed, None is returned; otherwise an int of the extracted time. + If `"forever"` is passed, -1 is returned; otherwise an int of the extracted time. Accepted formats are: * , * m, @@ -428,7 +337,7 @@ async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: * forever. """ if argument == "forever": - return None + return -1 match = self.MINUTES_RE.match(argument) if not match: raise BadArgument(f"{argument} is not a valid minutes duration.") @@ -439,103 +348,45 @@ async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: return duration -def proxy_user(user_id: str) -> discord.Object: - """ - Create a proxy user object from the given id. - - Used when a Member or User object cannot be resolved. - """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") +def _is_an_unambiguous_user_argument(argument: str) -> bool: + """Check if the provided argument is a user mention or user id.""" + user_id = IDConverter._get_id_match(argument) + user_mention = RE_USER_MENTION.match(argument) - try: - user_id = int(user_id) - except ValueError: - log.debug(f"Failed to create proxy user {user_id}: could not convert to int.") - raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.") + return bool(user_id or user_mention) - user = discord.Object(user_id) - user.mention = user.id - user.display_name = f"<@{user.id}>" - user.avatar_url_as = lambda static_format: None - user.bot = False - return user +AMBIGUOUS_ARGUMENT_MSG = "`{argument}` is not a User mention or a User ID." -class UserMentionOrID(UserConverter): +class UnambiguousUser(UserConverter): """ Converts to a `discord.User`, but only if a mention or userID is provided. - Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim. - This is useful in cases where that lookup strategy would lead to ambiguity. + Unlike the default `UserConverter`, it doesn't allow conversion from a name. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.User: - """Convert the `arg` to a `discord.User`.""" - match = self._get_id_match(argument) or RE_USER_MENTION.match(argument) - - if match is not None: + """Convert the `argument` to a `discord.User`.""" + if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) - else: - raise BadArgument(f"`{argument}` is not a User mention or a User ID.") - - -class FetchedUser(UserConverter): - """ - Converts to a `discord.User` or, if it fails, a `discord.Object`. - - Unlike the default `UserConverter`, which only does lookups via the global user cache, this - converter attempts to fetch the user via an API call to Discord when the using the cache is - unsuccessful. - - If the fetch also fails and the error doesn't imply the user doesn't exist, then a - `discord.Object` is returned via the `user_proxy` converter. - - The lookup strategy is as follows (in order): - - 1. Lookup by ID. - 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name - 5. Lookup via API - 6. Create a proxy user with discord.Object - """ - - async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]: - """Convert the `arg` to a `discord.User` or `discord.Object`.""" - try: - return await super().convert(ctx, arg) - except BadArgument: - pass - - try: - user_id = int(arg) - log.trace(f"Fetching user {user_id}...") - return await ctx.bot.fetch_user(user_id) - except ValueError: - log.debug(f"Failed to fetch user {arg}: could not convert to int.") - raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`") - except discord.HTTPException as e: - # If the Discord error isn't `Unknown user`, return a proxy instead - if e.code != 10013: - log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}") - return proxy_user(arg) - - log.debug(f"Failed to fetch user {arg}: user does not exist.") - raise BadArgument(f"User `{arg}` does not exist") + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) -def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: +class UnambiguousMember(MemberConverter): """ - Extract the snowflake from `arg` using a regex `pattern` and return it as an int. + Converts to a `discord.Member`, but only if a mention or userID is provided. - The snowflake is expected to be within the first capture group in `pattern`. + Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ - match = pattern.match(arg) - if not match: - raise BadArgument(f"Mention {str!r} is invalid.") - return int(match.group(1)) + async def convert(self, ctx: Context, argument: str) -> discord.Member: + """Convert the `argument` to a `discord.Member`.""" + if _is_an_unambiguous_user_argument(argument): + return await super().convert(ctx, argument) + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) class Infraction(Converter): @@ -546,7 +397,7 @@ class Infraction(Converter): obtain the most recent infraction by the actor. """ - async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]: + async def convert(self, ctx: Context, arg: str) -> dict | None: """Attempts to convert `arg` into an infraction `dict`.""" if arg in ("l", "last", "recent"): params = { @@ -554,19 +405,45 @@ async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]: "ordering": "-inserted_at" } - infractions = await ctx.bot.api_client.get("bot/infractions", params=params) + infractions = await ctx.bot.api_client.get("bot/infractions/expanded", params=params) if not infractions: raise BadArgument( "Couldn't find most recent infraction; you have never given an infraction." ) - else: - return infractions[0] + return infractions[0] - else: - return await ctx.bot.api_client.get(f"bot/infractions/{arg}") - - -Expiry = t.Union[Duration, ISODateTime] -FetchedMember = t.Union[discord.Member, FetchedUser] -UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) + try: + return await ctx.bot.api_client.get(f"bot/infractions/{arg}/expanded") + except ResponseCodeError as e: + if e.status == 404: + raise InvalidInfractionError( + converter=Infraction, + original=e, + infraction_arg=arg + ) + raise e + + +if t.TYPE_CHECKING: + ValidFilterListType = str + Extension = str + PackageName = str + ValidURL = str + Inventory = tuple[str, _inventory_parser.InventoryDict] + Snowflake = int + SourceConverter = SourceType + DurationDelta = relativedelta + Duration = datetime + Age = datetime + OffTopicName = str + ISODateTime = datetime + HushDurationConverter = int + UnambiguousUser = discord.User + UnambiguousMember = discord.Member + Infraction = dict | None + +Expiry = Duration | ISODateTime +DurationOrExpiry = DurationDelta | ISODateTime +MemberOrUser = discord.Member | discord.User +UnambiguousMemberOrUser = UnambiguousMember | UnambiguousUser diff --git a/bot/decorators.py b/bot/decorators.py index f65ec4103c..d39c1cc33c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,20 +1,24 @@ import asyncio import functools -import logging import types import typing as t from contextlib import suppress +import arrow +import discord from discord import Member, NotFound from discord.ext import commands from discord.ext.commands import Cog, Context +from pydis_core.utils import scheduling +from pydis_core.utils.paste_service import PasteFile, PasteUploadError, send_to_paste_service from bot.constants import Channels, DEBUG_MODE, RedirectOutput +from bot.log import get_logger from bot.utils import function from bot.utils.checks import ContextCheckFailure, in_whitelist_check from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__) def in_whitelist( @@ -22,7 +26,7 @@ def in_whitelist( channels: t.Container[int] = (), categories: t.Container[int] = (), roles: t.Container[int] = (), - redirect: t.Optional[int] = Channels.bot_commands, + redirect: int | None = Channels.bot_commands, fail_silently: bool = False, ) -> t.Callable: """ @@ -55,7 +59,7 @@ def not_in_blacklist( categories: t.Container[int] = (), roles: t.Container[int] = (), override_roles: t.Container[int] = (), - redirect: t.Optional[int] = Channels.bot_commands, + redirect: int | None = Channels.bot_commands, fail_silently: bool = False, ) -> t.Callable: """ @@ -88,7 +92,7 @@ def predicate(ctx: Context) -> bool: return commands.check(predicate) -def has_no_roles(*roles: t.Union[str, int]) -> t.Callable: +def has_no_roles(*roles: str | int) -> t.Callable: """ Returns True if the user does not have any of the roles specified. @@ -109,9 +113,9 @@ async def predicate(ctx: Context) -> bool: def redirect_output( destination_channel: int, - bypass_roles: t.Optional[t.Container[int]] = None, - channels: t.Optional[t.Container[int]] = None, - categories: t.Optional[t.Container[int]] = None, + bypass_roles: t.Container[int] | None = None, + channels: t.Container[int] | None = None, + categories: t.Container[int] | None = None, ping_user: bool = True ) -> t.Callable: """ @@ -136,12 +140,12 @@ async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: await func(self, ctx, *args, **kwargs) return - elif channels and ctx.channel.id not in channels: + if channels and ctx.channel.id not in channels: log.trace(f"{ctx.author} used {ctx.command} in a channel that can bypass output redirection") await func(self, ctx, *args, **kwargs) return - elif categories and ctx.channel.category.id not in categories: + if categories and ctx.channel.category.id not in categories: log.trace(f"{ctx.author} used {ctx.command} in a category that can bypass output redirection") await func(self, ctx, *args, **kwargs) return @@ -152,9 +156,39 @@ async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}") ctx.channel = redirect_channel - if ping_user: - await ctx.send(f"Here's the output of your command, {ctx.author.mention}") - asyncio.create_task(func(self, ctx, *args, **kwargs)) + paste_response = None + if RedirectOutput.delete_invocation: + try: + paste_response = await send_to_paste_service( + files=[PasteFile(content=ctx.message.content, lexer="markdown")], + http_session=ctx.bot.http_session, + ) + except PasteUploadError: + log.exception( + "Failed to upload message %d in channel %d to paste service when redirecting output", + ctx.message.id, ctx.message.channel.id + ) + + msg = "Here's the output of " + msg += f"[your command]({paste_response.link})" if paste_response else "your command" + msg += f", {ctx.author.mention}:" if ping_user else ":" + + await ctx.send(msg) + if paste_response: + try: + # Send a DM to the user about the redirect and paste removal + await ctx.author.send( + f"Your command output was redirected to <#{Channels.bot_commands}>." + f" [Click here](<{paste_response.removal}>) to delete the pasted" + " copy of your original command." + ) + except discord.Forbidden: + log.info( + "Failed to DM %s with redirected command paste removal link, user has bot DMs disabled", + ctx.author.name + ) + + scheduling.create_task(func(self, ctx, *args, **kwargs)) message = await old_channel.send( f"Hey, {ctx.author.mention}, you can find the output of your command here: " @@ -188,7 +222,7 @@ def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: """ def decorator(func: types.FunctionType) -> types.FunctionType: @command_wraps(func) - async def wrapper(*args, **kwargs) -> None: + async def wrapper(*args, **kwargs) -> t.Any: log.trace(f"{func.__name__}: respect role hierarchy decorator called") bound_args = function.get_bound_args(func, args, kwargs) @@ -196,8 +230,7 @@ async def wrapper(*args, **kwargs) -> None: if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - await func(*args, **kwargs) - return + return await func(*args, **kwargs) ctx = function.get_arg_value(1, bound_args) cmd = ctx.command.name @@ -212,9 +245,10 @@ async def wrapper(*args, **kwargs) -> None: f":x: {actor.mention}, you may not {cmd} " "someone with an equal or higher top role." ) - else: - log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") - await func(*args, **kwargs) + return None + + log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") + return await func(*args, **kwargs) return wrapper return decorator @@ -237,3 +271,35 @@ async def wrapped(*args, **kwargs) -> t.Any: return await func(*args, **kwargs) return wrapped return decorator + + +def ensure_future_timestamp(timestamp_arg: function.Argument) -> t.Callable: + """ + Ensure the timestamp argument is in the future. + + If the condition fails, send a warning to the invoking context. + + `timestamp_arg` is the keyword name or position index of the parameter of the decorated command + whose value is the target timestamp. + + This decorator must go before (below) the `command` decorator. + """ + def decorator(func: types.FunctionType) -> types.FunctionType: + @command_wraps(func) + async def wrapper(*args, **kwargs) -> t.Any: + bound_args = function.get_bound_args(func, args, kwargs) + target = function.get_arg_value(timestamp_arg, bound_args) + + ctx = function.get_arg_value(1, bound_args) + + try: + is_future = target > arrow.utcnow() + except TypeError: + is_future = True + if not is_future: + await ctx.send(":x: Provided timestamp is in the past.") + return None + + return await func(*args, **kwargs) + return wrapper + return decorator diff --git a/bot/errors.py b/bot/errors.py index 3544c6320b..df66b54fbc 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,10 @@ -from typing import Hashable, Union +from collections.abc import Hashable +from typing import TYPE_CHECKING -from discord import Member, User +from discord.ext.commands import ConversionError, Converter + +if TYPE_CHECKING: + from bot.converters import MemberOrUser class LockedResourceError(RuntimeError): @@ -22,7 +26,7 @@ def __init__(self, resource_type: str, resource_id: Hashable): ) -class InvalidInfractedUser(Exception): +class InvalidInfractedUserError(Exception): """ Exception raised upon attempt of infracting an invalid user. @@ -30,14 +34,42 @@ class InvalidInfractedUser(Exception): `user` -- User or Member which is invalid """ - def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): + def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."): + self.user = user self.reason = reason super().__init__(reason) -class BrandingMisconfiguration(RuntimeError): - """Raised by the Branding cog when a misconfigured event is encountered.""" +class InvalidInfractionError(ConversionError): + """ + Raised by the Infraction converter when trying to fetch an invalid infraction id. + + Attributes: + `infraction_arg` -- the value that we attempted to convert into an Infraction + """ + + def __init__(self, converter: Converter, original: Exception, infraction_arg: int | str): + + self.infraction_arg = infraction_arg + super().__init__(converter, original) + + +class BrandingMisconfigurationError(RuntimeError): + """Raised by the Branding cog when branding misconfiguration is detected.""" + + + +class NonExistentRoleError(ValueError): + """ + Raised by the Information Cog when encountering a Role that does not exist. + + Attributes: + `role_id` -- the ID of the role that does not exist + """ + + def __init__(self, role_id: int): + super().__init__(f"Could not fetch data for role {role_id}") - pass + self.role_id = role_id diff --git a/bot/exts/backend/branding/__init__.py b/bot/exts/backend/branding/__init__.py index 20a747b7ff..8460465cbf 100644 --- a/bot/exts/backend/branding/__init__.py +++ b/bot/exts/backend/branding/__init__.py @@ -2,6 +2,6 @@ from bot.exts.backend.branding._cog import Branding -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load Branding cog.""" - bot.add_cog(Branding(bot)) + await bot.add_cog(Branding(bot)) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 47c379a34a..154a94f195 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -1,13 +1,12 @@ import asyncio import contextlib -import logging import random +import types import typing as t from datetime import timedelta from enum import Enum from operator import attrgetter -import async_timeout import discord from arrow import Arrow from async_rediscache import RedisCache @@ -17,8 +16,9 @@ from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES from bot.decorators import mock_in_debug from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class AssetType(Enum): @@ -50,7 +50,7 @@ def make_embed(title: str, description: str, *, success: bool) -> discord.Embed: For both `title` and `description`, empty string are valid values ~ fields will be empty. """ colour = Colours.soft_green if success else Colours.soft_red - return discord.Embed(title=title[:256], description=description[:2048], colour=colour) + return discord.Embed(title=title[:256], description=description[:4096], colour=colour) def extract_event_duration(event: Event) -> str: @@ -104,19 +104,24 @@ class Branding(commands.Cog): """ # RedisCache[ - # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands. - # "event_path": str | Current event's path in the branding repo. - # "event_description": str | Current event's Markdown description. - # "event_duration": str | Current event's human-readable date range. - # "banner_hash": str | SHA of the currently applied banner. - # "icons_hash": str | Compound SHA of all icons in current rotation. - # "last_rotation_timestamp": float | POSIX UTC timestamp. + # "daemon_active": bool | If True, daemon starts on start-up. Controlled via commands. + # "event_path": str | Current event's path in the branding repo. + # "event_description": str | Current event's Markdown description. + # "event_duration": str | Current event's human-readable date range. + # "banners_hash": str | Compound SHA of all banners in the current rotation. + # "icons_hash": str | Compound SHA of all icons in current rotation. + # "last_icon_rotation_timestamp": float | POSIX UTC timestamp. + # "last_banner_rotation_timestamp": float | POSIX UTC timestamp. # ] cache_information = RedisCache() - # Icons in current rotation. Keys (str) are download URLs, values (int) track the amount of times each - # icon has been used in the current rotation. - cache_icons = RedisCache() + # Icons and banners in current rotation. + # Keys (str) are download URLs, values (int) track the amount of times each + # asset has been used in the current rotation. + asset_caches = types.MappingProxyType({ + AssetType.ICON: RedisCache(namespace="Branding.icon_cache"), + AssetType.BANNER: RedisCache(namespace="Branding.banner_cache") + }) # All available event names & durations. Cached by the daemon nightly; read by the calendar command. cache_events = RedisCache() @@ -126,7 +131,9 @@ def __init__(self, bot: Bot) -> None: self.bot = bot self.repository = BrandingRepository(bot) - self.bot.loop.create_task(self.maybe_start_daemon()) # Start depending on cache. + async def cog_load(self) -> None: + """Carry out cog asynchronous initialisation.""" + await self.maybe_start_daemon() # Start depending on cache. # region: Internal logic & state management @@ -150,119 +157,104 @@ async def apply_asset(self, asset_type: AssetType, download_url: str) -> bool: timeout = 10 # Seconds. try: - with async_timeout.timeout(timeout): # Raise after `timeout` seconds. + async with asyncio.timeout(timeout): # Raise after `timeout` seconds. await pydis.edit(**{asset_type.value: file}) except discord.HTTPException: log.exception("Asset upload to Discord failed.") return False - except asyncio.TimeoutError: + except TimeoutError: log.error(f"Asset upload to Discord timed out after {timeout} seconds.") return False else: log.trace("Asset uploaded successfully.") return True - async def apply_banner(self, banner: RemoteObject) -> bool: + async def rotate_assets(self, asset_type: AssetType) -> bool: """ - Apply `banner` to the guild and cache its hash if successful. + Choose and apply the next-up asset in rotation. - Banners should always be applied via this method to ensure that the last hash is cached. - - Return a boolean indicating whether the application was successful. - """ - success = await self.apply_asset(AssetType.BANNER, banner.download_url) - - if success: - await self.cache_information.set("banner_hash", banner.sha) - - return success - - async def rotate_icons(self) -> bool: - """ - Choose and apply the next-up icon in rotation. - - We keep track of the amount of times each icon has been used. The values in `cache_icons` can be understood - to be iteration IDs. When an icon is chosen & applied, we bump its count, pushing it into the next iteration. + We keep track of the amount of times each asset has been used. The values in the cache can be understood + to be iteration IDs. When an asset is chosen & applied, we bump its count, pushing it into the next iteration. Once the current iteration (lowest count in the cache) depletes, we move onto the next iteration. - In the case that there is only 1 icon in the rotation and has already been applied, do nothing. + In the case that there is only 1 asset in the rotation and has already been applied, do nothing. - Return a boolean indicating whether a new icon was applied successfully. + Return a boolean indicating whether a new asset was applied successfully. """ - log.debug("Rotating icons.") + log.debug(f"Rotating {asset_type.value}s.") - state = await self.cache_icons.to_dict() - log.trace(f"Total icons in rotation: {len(state)}.") + state = await self.asset_caches[asset_type].to_dict() + log.trace(f"Total {asset_type.value}s in rotation: {len(state)}.") if not state: # This would only happen if rotation not initiated, but we can handle gracefully. - log.warning("Attempted icon rotation with an empty icon cache. This indicates wrong logic.") + log.warning(f"Attempted {asset_type.value} rotation with an empty cache. This indicates wrong logic.") return False if len(state) == 1 and 1 in state.values(): - log.debug("Aborting icon rotation: only 1 icon is available and has already been applied.") + log.debug(f"Aborting {asset_type.value} rotation: only 1 asset is available and has already been applied.") return False current_iteration = min(state.values()) # Choose iteration to draw from. options = [download_url for download_url, times_used in state.items() if times_used == current_iteration] - log.trace(f"Choosing from {len(options)} icons in iteration {current_iteration}.") - next_icon = random.choice(options) + log.trace(f"Choosing from {len(options)} {asset_type.value}s in iteration {current_iteration}.") + next_asset = random.choice(options) - success = await self.apply_asset(AssetType.ICON, next_icon) + success = await self.apply_asset(asset_type, next_asset) if success: - await self.cache_icons.increment(next_icon) # Push the icon into the next iteration. + await self.asset_caches[asset_type].increment(next_asset) # Push the asset into the next iteration. timestamp = Arrow.utcnow().timestamp() - await self.cache_information.set("last_rotation_timestamp", timestamp) + await self.cache_information.set(f"last_{asset_type.value}_rotation_timestamp", timestamp) return success - async def maybe_rotate_icons(self) -> None: + async def maybe_rotate_assets(self, asset_type: AssetType) -> None: """ - Call `rotate_icons` if the configured amount of time has passed since last rotation. + Call `rotate_assets` if the configured amount of time has passed since last rotation. We offset the calculated time difference into the future to avoid off-by-a-little-bit errors. Because there is work to be done before the timestamp is read and written, the next read will likely commence slightly under 24 hours after the last write. """ - log.debug("Checking whether it's time for icons to rotate.") + log.debug(f"Checking whether it's time for {asset_type.value}s to rotate.") - last_rotation_timestamp = await self.cache_information.get("last_rotation_timestamp") + last_rotation_timestamp = await self.cache_information.get(f"last_{asset_type.value}_rotation_timestamp") if last_rotation_timestamp is None: # Maiden case ~ never rotated. - await self.rotate_icons() + await self.rotate_assets(asset_type) return last_rotation = Arrow.utcfromtimestamp(last_rotation_timestamp) difference = (Arrow.utcnow() - last_rotation) + timedelta(minutes=5) - log.trace(f"Icons last rotated at {last_rotation} (difference: {difference}).") + log.trace(f"{asset_type.value.title()}s last rotated at {last_rotation} (difference: {difference}).") if difference.days >= BrandingConfig.cycle_frequency: - await self.rotate_icons() + await self.rotate_assets(asset_type) - async def initiate_icon_rotation(self, available_icons: t.List[RemoteObject]) -> None: + async def initiate_rotation(self, asset_type: AssetType, available_assets: list[RemoteObject]) -> None: """ - Set up a new icon rotation. + Set up a new asset rotation. - This function should be called whenever available icons change. This is generally the case when we enter + This function should be called whenever available asset groups change. This is generally the case when we enter a new event, but potentially also when the assets of an on-going event change. In such cases, a reset - of `cache_icons` is necessary, because it contains download URLs which may have gotten stale. + of the cache is necessary, because it contains download URLs which may have gotten stale. - This function does not upload a new icon! + This function does not upload a new asset! """ - log.debug("Initiating new icon rotation.") + log.debug(f"Initiating new {asset_type.value} rotation.") - await self.cache_icons.clear() + await self.asset_caches[asset_type].clear() - new_state = {icon.download_url: 0 for icon in available_icons} - await self.cache_icons.update(new_state) + new_state = {asset.download_url: 0 for asset in available_assets} + await self.asset_caches[asset_type].update(new_state) - log.trace(f"Icon rotation initiated for {len(new_state)} icons.") + log.trace(f"{asset_type.value.title()} rotation initiated for {len(new_state)} assets.") - await self.cache_information.set("icons_hash", compound_hash(available_icons)) + await self.cache_information.set(f"{asset_type.value}s_hash", compound_hash(available_assets)) async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> None: """ @@ -276,7 +268,7 @@ async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> No log.debug(f"Sending event information event to channel: {channel_id} ({is_notification=}).") await self.bot.wait_until_guild_available() - channel: t.Optional[discord.TextChannel] = self.bot.get_channel(channel_id) + channel: discord.TextChannel | None = self.bot.get_channel(channel_id) if channel is None: log.warning(f"Cannot send event information: channel {channel_id} not found!") @@ -293,12 +285,12 @@ async def send_info_embed(self, channel_id: int, *, is_notification: bool) -> No else: content = "Python Discord is entering a new event!" if is_notification else None - embed = discord.Embed(description=description[:2048], colour=discord.Colour.blurple()) - embed.set_footer(text=duration[:2048]) + embed = discord.Embed(description=description[:4096], colour=discord.Colour.og_blurple()) + embed.set_footer(text=duration[:4096]) await channel.send(content=content, embed=embed) - async def enter_event(self, event: Event) -> t.Tuple[bool, bool]: + async def enter_event(self, event: Event) -> tuple[bool, bool]: """ Apply `event` assets and update information cache. @@ -314,10 +306,12 @@ async def enter_event(self, event: Event) -> t.Tuple[bool, bool]: """ log.info(f"Entering event: '{event.path}'.") - banner_success = await self.apply_banner(event.banner) # Only one asset ~ apply directly. + # Prepare and apply new icon and banner rotations + await self.initiate_rotation(AssetType.ICON, event.icons) + await self.initiate_rotation(AssetType.BANNER, event.banners) - await self.initiate_icon_rotation(event.icons) # Prepare a new rotation. - icon_success = await self.rotate_icons() # Apply an icon from the new rotation. + icon_success = await self.rotate_assets(AssetType.ICON) + banner_success = await self.rotate_assets(AssetType.BANNER) # This will only be False in the case of a manual same-event re-synchronisation. event_changed = event.path != await self.cache_information.get("event_path") @@ -330,13 +324,13 @@ async def enter_event(self, event: Event) -> t.Tuple[bool, bool]: # Notify guild of new event ~ this reads the information that we cached above. if event_changed and not event.meta.is_fallback: - await self.send_info_embed(Channels.change_log, is_notification=True) + await self.send_info_embed(Channels.changelog, is_notification=True) else: log.trace("Omitting #changelog notification. Event has not changed, or new event is fallback.") return banner_success, icon_success - async def synchronise(self) -> t.Tuple[bool, bool]: + async def synchronise(self) -> tuple[bool, bool]: """ Fetch the current event and delegate to `enter_event`. @@ -348,17 +342,17 @@ async def synchronise(self) -> t.Tuple[bool, bool]: """ log.debug("Synchronise: fetching current event.") - current_event, available_events = await self.repository.get_current_event() + try: + current_event, available_events = await self.repository.get_current_event() + except Exception: + log.exception("Synchronisation aborted: failed to fetch events.") + return False, False await self.populate_cache_events(available_events) - if current_event is None: - log.error("Failed to fetch event. Cannot synchronise!") - return False, False - return await self.enter_event(current_event) - async def populate_cache_events(self, events: t.List[Event]) -> None: + async def populate_cache_events(self, events: list[Event]) -> None: """ Clear `cache_events` and re-populate with names and durations of `events`. @@ -407,12 +401,12 @@ async def maybe_start_daemon(self) -> None: """ log.debug("Checking whether daemon should start.") - should_begin: t.Optional[bool] = await self.cache_information.get("daemon_active") # None if never set! + should_begin: bool | None = await self.cache_information.get("daemon_active") # None if never set! if should_begin: self.daemon_loop.start() - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """ Cancel the daemon in case of cog unload. @@ -439,10 +433,6 @@ async def daemon_main(self) -> None: await self.populate_cache_events(available_events) - if new_event is None: - log.warning("Daemon main: failed to get current event from branding repository, will do nothing.") - return - if new_event.path != await self.cache_information.get("event_path"): log.debug("Daemon main: new event detected!") await self.enter_event(new_event) @@ -452,16 +442,19 @@ async def daemon_main(self) -> None: log.trace("Daemon main: event has not changed, checking for change in assets.") - if new_event.banner.sha != await self.cache_information.get("banner_hash"): + if compound_hash(new_event.banners) != await self.cache_information.get("banners_hash"): log.debug("Daemon main: detected banner change.") - await self.apply_banner(new_event.banner) + await self.initiate_rotation(AssetType.BANNER, new_event.banners) + await self.rotate_assets(AssetType.BANNER) + else: + await self.maybe_rotate_assets(AssetType.BANNER) if compound_hash(new_event.icons) != await self.cache_information.get("icons_hash"): log.debug("Daemon main: detected icon change.") - await self.initiate_icon_rotation(new_event.icons) - await self.rotate_icons() + await self.initiate_rotation(AssetType.ICON, new_event.icons) + await self.rotate_assets(AssetType.ICON) else: - await self.maybe_rotate_icons() + await self.maybe_rotate_assets(AssetType.ICON) @tasks.loop(hours=24) async def daemon_loop(self) -> None: @@ -572,7 +565,7 @@ async def branding_calendar_group(self, ctx: commands.Context) -> None: await ctx.send(embed=resp) return - embed = discord.Embed(title="Current event calendar", colour=discord.Colour.blurple()) + embed = discord.Embed(title="Current event calendar", colour=discord.Colour.og_blurple()) # Because Discord embeds can only contain up to 25 fields, we only show the first 25. first_25 = list(available_events.items())[:25] @@ -599,10 +592,24 @@ async def branding_calendar_refresh_cmd(self, ctx: commands.Context) -> None: log.info("Performing command-requested event cache refresh.") async with ctx.typing(): - available_events = await self.repository.get_events() - await self.populate_cache_events(available_events) + try: + available_events = await self.repository.get_events() + except Exception: + log.exception("Refresh aborted: failed to fetch events.") + resp = make_embed( + "Refresh aborted", + "Failed to fetch events. See log for details.", + success=False, + ) + else: + await self.populate_cache_events(available_events) + resp = make_embed( + "Refresh successful", + "The event calendar has been refreshed.", + success=True, + ) - await ctx.invoke(self.branding_calendar_group) + await ctx.send(embed=resp) # endregion # region: Command interface (branding daemon) diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py index 7b09d46416..18720e581a 100644 --- a/bot/exts/backend/branding/_repository.py +++ b/bot/exts/backend/branding/_repository.py @@ -1,12 +1,15 @@ -import logging +import inspect import typing as t -from datetime import date, datetime +from datetime import UTC, date, datetime import frontmatter +from aiohttp import ClientResponse, ClientResponseError +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from bot.bot import Bot from bot.constants import Keys -from bot.errors import BrandingMisconfiguration +from bot.errors import BrandingMisconfigurationError +from bot.log import get_logger # Base URL for requests into the branding repository. BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" @@ -25,7 +28,7 @@ # Format used to parse date strings after we inject `ARBITRARY_YEAR` at the end. DATE_FMT = "%B %d %Y" # Ex: July 10 2020 -log = logging.getLogger(__name__) +log = get_logger(__name__) class RemoteObject: @@ -39,14 +42,15 @@ class RemoteObject: name: str # Filename. path: str # Path from repo root. type: str # Either 'file' or 'dir'. - download_url: t.Optional[str] # If type is 'dir', this is None! + download_url: str | None # If type is 'dir', this is None! - def __init__(self, dictionary: t.Dict[str, t.Any]) -> None: + def __init__(self, dictionary: dict[str, t.Any]) -> None: """Initialize by grabbing annotated attributes from `dictionary`.""" - missing_keys = self.__annotations__.keys() - dictionary.keys() + annotation_keys = inspect.get_annotations(self.__class__) + missing_keys = annotation_keys - dictionary.keys() if missing_keys: raise KeyError(f"Fetched object lacks expected keys: {missing_keys}") - for annotation in self.__annotations__: + for annotation in annotation_keys: setattr(self, annotation, dictionary[annotation]) @@ -54,8 +58,8 @@ class MetaFile(t.NamedTuple): """Attributes defined in a 'meta.md' file.""" is_fallback: bool - start_date: t.Optional[date] - end_date: t.Optional[date] + start_date: date | None + end_date: date | None description: str # Markdown event description. @@ -64,13 +68,42 @@ class Event(t.NamedTuple): path: str # Path from repo root where event lives. This is the event's identity. meta: MetaFile - banner: RemoteObject - icons: t.List[RemoteObject] + banners: list[RemoteObject] + icons: list[RemoteObject] def __str__(self) -> str: return f"" +class GitHubServerError(Exception): + """ + GitHub responded with 5xx status code. + + Such error shall be retried. + """ + + +def _raise_for_status(resp: ClientResponse) -> None: + """Raise custom error if resp status is 5xx.""" + # Use the response's raise_for_status so that we can + # attach the full traceback to our custom error. + log.trace(f"GitHub response status: {resp.status}") + try: + resp.raise_for_status() + except ClientResponseError as err: + if resp.status >= 500: + raise GitHubServerError from err + raise + + +_retry_server_error = retry( + retry=retry_if_exception_type(GitHubServerError), # Only retry this error. + stop=stop_after_attempt(5), # Up to 5 attempts. + wait=wait_exponential(), # Exponential backoff: 1, 2, 4, 8 seconds. + reraise=True, # After final failure, re-raise original exception. +) + + class BrandingRepository: """ Branding repository abstraction. @@ -93,7 +126,8 @@ class BrandingRepository: def __init__(self, bot: Bot) -> None: self.bot = bot - async def fetch_directory(self, path: str, types: t.Container[str] = ("file", "dir")) -> t.Dict[str, RemoteObject]: + @_retry_server_error + async def fetch_directory(self, path: str, types: t.Container[str] = ("file", "dir")) -> dict[str, RemoteObject]: """ Fetch directory found at `path` in the branding repository. @@ -105,14 +139,12 @@ async def fetch_directory(self, path: str, types: t.Container[str] = ("file", "d log.debug(f"Fetching directory from branding repository: '{full_url}'.") async with self.bot.http_session.get(full_url, params=PARAMS, headers=HEADERS) as response: - if response.status != 200: - raise RuntimeError(f"Failed to fetch directory due to status: {response.status}") - - log.debug("Fetch successful, reading JSON response.") + _raise_for_status(response) json_directory = await response.json() return {file["name"]: RemoteObject(file) for file in json_directory if file["type"] in types} + @_retry_server_error async def fetch_file(self, download_url: str) -> bytes: """ Fetch file as bytes from `download_url`. @@ -122,10 +154,7 @@ async def fetch_file(self, download_url: str) -> bytes: log.debug(f"Fetching file from branding repository: '{download_url}'.") async with self.bot.http_session.get(download_url, params=PARAMS, headers=HEADERS) as response: - if response.status != 200: - raise RuntimeError(f"Failed to fetch file due to status: {response.status}") - - log.debug("Fetch successful, reading payload.") + _raise_for_status(response) return await response.read() def parse_meta_file(self, raw_file: bytes) -> MetaFile: @@ -137,7 +166,7 @@ def parse_meta_file(self, raw_file: bytes) -> MetaFile: attrs, description = frontmatter.parse(raw_file, encoding="UTF-8") if not description: - raise BrandingMisconfiguration("No description found in 'meta.md'!") + raise BrandingMisconfigurationError("No description found in 'meta.md'!") if attrs.get("fallback", False): return MetaFile(is_fallback=True, start_date=None, end_date=None, description=description) @@ -146,12 +175,12 @@ def parse_meta_file(self, raw_file: bytes) -> MetaFile: end_date_raw = attrs.get("end_date") if None in (start_date_raw, end_date_raw): - raise BrandingMisconfiguration("Non-fallback event doesn't have start and end dates defined!") + raise BrandingMisconfigurationError("Non-fallback event doesn't have start and end dates defined!") # We extend the configured month & day with an arbitrary leap year, allowing a datetime object to exist. # This may raise errors if misconfigured. We let the caller handle such cases. - start_date = datetime.strptime(f"{start_date_raw} {ARBITRARY_YEAR}", DATE_FMT).date() - end_date = datetime.strptime(f"{end_date_raw} {ARBITRARY_YEAR}", DATE_FMT).date() + start_date = datetime.strptime(f"{start_date_raw} {ARBITRARY_YEAR}", DATE_FMT).replace(tzinfo=UTC).date() + end_date = datetime.strptime(f"{end_date_raw} {ARBITRARY_YEAR}", DATE_FMT).replace(tzinfo=UTC).date() return MetaFile(is_fallback=False, start_date=start_date, end_date=end_date, description=description) @@ -163,59 +192,59 @@ async def construct_event(self, directory: RemoteObject) -> Event: """ contents = await self.fetch_directory(directory.path) - missing_assets = {"meta.md", "banner.png", "server_icons"} - contents.keys() + missing_assets = {"meta.md", "server_icons", "banners"} - contents.keys() if missing_assets: - raise BrandingMisconfiguration(f"Directory is missing following assets: {missing_assets}") + raise BrandingMisconfigurationError(f"Directory is missing following assets: {missing_assets}") server_icons = await self.fetch_directory(contents["server_icons"].path, types=("file",)) + banners = await self.fetch_directory(contents["banners"].path, types=("file",)) if len(server_icons) == 0: - raise BrandingMisconfiguration("Found no server icons!") + raise BrandingMisconfigurationError("Found no server icons!") + if len(banners) == 0: + raise BrandingMisconfigurationError("Found no server banners!") meta_bytes = await self.fetch_file(contents["meta.md"].download_url) meta_file = self.parse_meta_file(meta_bytes) - return Event(directory.path, meta_file, contents["banner.png"], list(server_icons.values())) + return Event(directory.path, meta_file, list(banners.values()), list(server_icons.values())) - async def get_events(self) -> t.List[Event]: + async def get_events(self) -> list[Event]: """ Discover available events in the branding repository. - Misconfigured events are skipped. May return an empty list in the catastrophic case. + Propagate errors if an event fails to fetch or deserialize. """ log.debug("Discovering events in branding repository.") - try: - event_directories = await self.fetch_directory("events", types=("dir",)) # Skip files. - except Exception: - log.exception("Failed to fetch 'events' directory.") - return [] + event_directories = await self.fetch_directory("events", types=("dir",)) # Skip files. - instances: t.List[Event] = [] + instances: list[Event] = [] for event_directory in event_directories.values(): - log.trace(f"Attempting to construct event from directory: '{event_directory.path}'.") - try: - instance = await self.construct_event(event_directory) - except Exception as exc: - log.warning(f"Could not construct event '{event_directory.path}'.", exc_info=exc) - else: - instances.append(instance) + log.trace(f"Reading event directory: '{event_directory.path}'.") + instance = await self.construct_event(event_directory) + instances.append(instance) return instances - async def get_current_event(self) -> t.Tuple[t.Optional[Event], t.List[Event]]: + async def get_current_event(self) -> tuple[Event, list[Event]]: """ Get the currently active event, or the fallback event. The second return value is a list of all available events. The caller may discard it, if not needed. Returning all events alongside the current one prevents having to query the API twice in some cases. - The current event may be None in the case that no event is active, and no fallback event is found. + Raise an error in the following cases: + * GitHub request fails + * The branding repo contains an invalid event + * No event is active and the fallback event is missing + + Events are validated in the branding repo. The bot assumes that events are valid. """ - utc_now = datetime.utcnow() + utc_now = datetime.now(tz=UTC) log.debug(f"Finding active event for: {utc_now}.") # Construct an object in the arbitrary year for the purpose of comparison. @@ -227,7 +256,17 @@ async def get_current_event(self) -> t.Tuple[t.Optional[Event], t.List[Event]]: for event in available_events: meta = event.meta - if not meta.is_fallback and (meta.start_date <= lookup_now <= meta.end_date): + if meta.is_fallback: + continue + + start_date, end_date = meta.start_date, meta.end_date + + # Case where the event starts and ends in the same year. + if start_date <= lookup_now <= end_date: + return event, available_events + + # Case where the event spans across two years. + if start_date > end_date and (lookup_now >= start_date or lookup_now <= end_date): return event, available_events log.trace("No active event found. Looking for fallback event.") @@ -236,5 +275,4 @@ async def get_current_event(self) -> t.Tuple[t.Optional[Event], t.List[Event]]: if event.meta.is_fallback: return event, available_events - log.warning("No event is currently active and no fallback event was found!") - return None, available_events + raise BrandingMisconfigurationError("No event is active and the fallback event is missing!") diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py index d72c6c22ea..84ae5ca92f 100644 --- a/bot/exts/backend/config_verifier.py +++ b/bot/exts/backend/config_verifier.py @@ -1,12 +1,10 @@ -import logging - from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.log import get_logger - -log = logging.getLogger(__name__) +log = get_logger(__name__) class ConfigVerifier(Cog): @@ -14,9 +12,8 @@ class ConfigVerifier(Cog): def __init__(self, bot: Bot): self.bot = bot - self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) - async def verify_channels(self) -> None: + async def cog_load(self) -> None: """ Verify channels. @@ -27,14 +24,14 @@ async def verify_channels(self) -> None: server_channel_ids = {channel.id for channel in server.channels} invalid_channels = [ - channel_name for channel_name, channel_id in constants.Channels + (channel_name, channel_id) for channel_name, channel_id in constants.Channels if channel_id not in server_channel_ids ] if invalid_channels: - log.warning(f"Configured channels do not exist in server: {', '.join(invalid_channels)}.") + log.warning(f"Configured channels do not exist in server: {invalid_channels}.") -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the ConfigVerifier cog.""" - bot.add_cog(ConfigVerifier(bot)) + await bot.add_cog(ConfigVerifier(bot)) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index d8de177f55..352be313dc 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,19 +1,50 @@ +import copy import difflib -import logging -import typing as t -from discord import Embed -from discord.ext.commands import Cog, Context, errors -from sentry_sdk import push_scope +import discord +from discord import ButtonStyle, Embed, Forbidden, Interaction, Member, User +from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.error_handling import handle_forbidden_from_block +from pydis_core.utils.interactions import DeleteMessageButton, ViewWithUserAndRoleCheck +from sentry_sdk import new_scope -from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES -from bot.converters import TagNameConverter -from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.errors import InvalidInfractedUserError, LockedResourceError +from bot.log import get_logger from bot.utils.checks import ContextCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__) + + +class HelpEmbedView(ViewWithUserAndRoleCheck): + """View to allow showing the help command for command error responses.""" + + def __init__(self, help_embed: Embed, owner: User | Member): + super().__init__(allowed_roles=MODERATION_ROLES, allowed_users=[owner.id]) + self.help_embed = help_embed + + self.delete_button = DeleteMessageButton() + self.add_item(self.delete_button) + + async def interaction_check(self, interaction: Interaction) -> bool: + """Overriden check to allow anyone to use the help button.""" + if (interaction.data or {}).get("custom_id") == self.help_button.custom_id: + log.trace( + "Allowed interaction by %s (%d) on %d as interaction was with the help button.", + interaction.user, + interaction.user.id, + interaction.message.id, + ) + return True + + return await super().interaction_check(interaction) + + @discord.ui.button(label="Help", style=ButtonStyle.primary) + async def help_button(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Send an ephemeral message with the contents of the help command.""" + await interaction.response.send_message(embed=self.help_embed, ephemeral=True) class ErrorHandler(Cog): @@ -59,51 +90,63 @@ async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: log.trace(f"Command {command} had its error already handled locally; ignoring.") return + debug_message = ( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False): - if await self.try_silence(ctx): - return - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. + # We might not invoke a command from the error handler, but it's easier and safer to ensure + # this is always set rather than trying to get it exact, and shouldn't cause any issues. + ctx.invoked_from_error_handler = True + + # All errors from attempting to execute these commands should be handled by the error handler. + # We wrap non CommandErrors in CommandInvokeError to mirror the behaviour of normal commands. + try: + if await self.try_silence(ctx): + return + if await self.try_run_fixed_codeblock(ctx): + return + await self.try_get_tag(ctx) + except Exception as err: + log.info("Re-handling error raised by command in error handler") + if isinstance(err, errors.CommandError): + await self.on_command_error(ctx, err) + else: + await self.on_command_error(ctx, errors.CommandInvokeError(err)) elif isinstance(e, errors.UserInputError): + log.debug(debug_message) await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): + log.debug(debug_message) await self.handle_check_failure(ctx, e) - elif isinstance(e, errors.CommandOnCooldown): + elif isinstance(e, errors.CommandOnCooldown | errors.MaxConcurrencyReached): + log.debug(debug_message) await ctx.send(e) elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) elif isinstance(e.original, LockedResourceError): await ctx.send(f"{e.original} Please wait for it to finish and try again later.") - elif isinstance(e.original, InvalidInfractedUser): + elif isinstance(e.original, InvalidInfractedUserError): await ctx.send(f"Cannot infract that user. {e.original.reason}") + elif isinstance(e.original, Forbidden): + try: + await handle_forbidden_from_block(e.original, ctx.message) + except Forbidden: + await self.handle_unexpected_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. elif isinstance(e, errors.ConversionError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. - elif not isinstance(e, errors.DisabledCommand): - # MaxConcurrencyReached, ExtensionError + elif isinstance(e, errors.DisabledCommand): + log.debug(debug_message) + else: + # ExtensionError await self.handle_unexpected_error(ctx, e) - return # Exit early to avoid logging. - - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) - - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: - """Return a prepared `help` command invocation coroutine.""" - if ctx.command: - return ctx.send_help(ctx.command) - - return ctx.send_help() async def try_silence(self, ctx: Context) -> bool: """ @@ -114,9 +157,14 @@ async def try_silence(self, ctx: Context) -> bool: * invoked with `unshh+` unsilence channel Return bool depending on success of command. """ - command = ctx.invoked_with.lower() silence_command = self.bot.get_command("silence") - ctx.invoked_from_error_handler = True + if not silence_command: + log.debug("Not attempting to parse message as `shh`/`unshh` as could not find `silence` command.") + return False + + command = ctx.invoked_with.lower() + args = ctx.message.content.lower().split(" ") + try: if not await silence_command.can_run(ctx): log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") @@ -124,51 +172,89 @@ async def try_silence(self, ctx: Context) -> bool: except errors.CommandError: log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") return False + + # Parse optional args + channel = None + duration = min(command.count("h") * 2, 15) + kick = False + + if len(args) > 1: + # Parse channel + for converter in (TextChannelConverter(), VoiceChannelConverter()): + try: + channel = await converter.convert(ctx, args[1]) + break + except ChannelNotFound: + continue + + if len(args) > 2 and channel is not None: + # Parse kick + kick = args[2].lower() == "true" + if command.startswith("shh"): - await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + await ctx.invoke(silence_command, duration_or_channel=channel, duration=duration, kick=kick) return True - elif command.startswith("unshh"): - await ctx.invoke(self.bot.get_command("unsilence")) + if command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence"), channel=channel) return True return False async def try_get_tag(self, ctx: Context) -> None: - """ - Attempt to display a tag by interpreting the command name as a tag name. + """Attempt to display a tag by interpreting the command name as a tag name.""" + tags_cog = self.bot.get_cog("Tags") + if not tags_cog: + log.debug("Not attempting to parse message as a tag as could not find `Tags` cog.") + return + tags_get_command = tags_cog.get_command_ctx - The invocation of tags get respects its checks. Any CommandErrors raised will be handled - by `on_command_error`, but the `invoked_from_error_handler` attribute will be added to - the context to prevent infinite recursion in the case of a CommandNotFound exception. - """ - tags_get_command = self.bot.get_command("tags get") - ctx.invoked_from_error_handler = True + maybe_tag_name = ctx.invoked_with + if not maybe_tag_name or not isinstance(ctx.author, Member): + return - log_msg = "Cancelling attempt to fall back to a tag due to failed checks." try: - if not await tags_get_command.can_run(ctx): - log.debug(log_msg) + if not await self.bot.can_run(ctx): + log.debug("Cancelling attempt to fall back to a tag due to failed checks.") return - except errors.CommandError as tag_error: - log.debug(log_msg) - await self.on_command_error(ctx, tag_error) + except errors.CommandError: + log.debug("Cancelling attempt to fall back to a tag due to failed checks.") return - try: - tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) - except errors.BadArgument: - log.debug( - f"{ctx.author} tried to use an invalid command " - f"and the fallback tag failed validation in TagNameConverter." - ) - else: - if await ctx.invoke(tags_get_command, tag_name=tag_name): - return + if await tags_get_command(ctx, maybe_tag_name): + return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): - await self.send_command_suggestion(ctx, ctx.invoked_with) + await self.send_command_suggestion(ctx, maybe_tag_name) + + async def try_run_fixed_codeblock(self, ctx: Context) -> bool: + """ + Attempt to run eval or timeit command with triple backticks directly after command. + + For example: !eval```print("hi")``` + + Return True if command was invoked, else False + """ + msg = copy.copy(ctx.message) + + command, sep, end = msg.content.partition("```") + msg.content = command + " " + sep + end + new_ctx = await self.bot.get_context(msg) + + if new_ctx.command is None: + return False + + allowed_commands = [ + self.bot.get_command("eval"), + self.bot.get_command("timeit"), + ] + + if new_ctx.command not in allowed_commands: + return False - # Return to not raise the exception - return + log.debug("Running %r command with fixed codeblock.", new_ctx.command.qualified_name) + new_ctx.invoked_from_error_handler = True + await self.bot.invoke(new_ctx) + + return True async def send_command_suggestion(self, ctx: Context, command_name: str) -> None: """Sends user similar commands if any can be found.""" @@ -214,38 +300,44 @@ async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) """ if isinstance(e, errors.MissingRequiredArgument): embed = self._get_error_embed("Missing required argument", e.param.name) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): embed = self._get_error_embed("Too many arguments", str(e)) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): embed = self._get_error_embed("Bad argument", str(e)) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): embed = self._get_error_embed("Argument parsing error", str(e)) await ctx.send(embed=embed) - self.get_help_command(ctx).close() self.bot.stats.incr("errors.argument_parsing_error") + return else: embed = self._get_error_embed( "Input error", "Something about your input seems off. Check the arguments and try again." ) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.other_user_input_error") + await self.send_error_with_help(ctx, embed) + + async def send_error_with_help(self, ctx: Context, error_embed: Embed) -> None: + """Send error message, with button to show command help.""" + # Fall back to just sending the error embed if the custom help cog isn't loaded yet. + # ctx.command shouldn't be None here, but check just to be safe. + help_embed_creator = getattr(self.bot.help_command, "command_formatting", None) + if not help_embed_creator or not ctx.command: + await ctx.send(embed=error_embed) + return + + self.bot.help_command.context = ctx + help_embed, _ = await help_embed_creator(ctx.command) + view = HelpEmbedView(help_embed, ctx.author) + view.message = await ctx.send(embed=error_embed, view=view) + @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """ @@ -270,7 +362,7 @@ async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: await ctx.send( "Sorry, it looks like I don't have the permissions or roles I need to do that." ) - elif isinstance(e, (ContextCheckFailure, errors.NoPrivateMessage)): + elif isinstance(e, ContextCheckFailure | errors.NoPrivateMessage): ctx.bot.stats.incr("errors.wrong_channel_or_dm_error") await ctx.send(e) @@ -278,21 +370,24 @@ async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: """Send an error message in `ctx` for ResponseCodeError and log it.""" if e.status == 404: - await ctx.send("There does not seem to be anything matching your query.") log.debug(f"API responded with 404 for command {ctx.command}") + await ctx.send("There does not seem to be anything matching your query.") ctx.bot.stats.incr("errors.api_error_404") elif e.status == 400: - content = await e.response.json() - log.debug(f"API responded with 400 for command {ctx.command}: %r.", content) + log.error( + "API responded with 400 for command %s: %r.", + ctx.command, + e.response_json or e.response_text, + ) await ctx.send("According to the API, your request is malformed.") ctx.bot.stats.incr("errors.api_error_400") elif 500 <= e.status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") log.warning(f"API responded with {e.status} for command {ctx.command}") + await ctx.send("Sorry, there seems to be an internal issue with the API.") ctx.bot.stats.incr("errors.api_internal_server_error") else: - await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") ctx.bot.stats.incr(f"errors.api_error_{e.status}") @staticmethod @@ -305,7 +400,7 @@ async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: ctx.bot.stats.incr("errors.unexpected") - with push_scope() as scope: + with new_scope() as scope: scope.user = { "id": ctx.author.id, "username": str(ctx.author) @@ -326,6 +421,6 @@ async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None: log.error(f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}", exc_info=e) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the ErrorHandler cog.""" - bot.add_cog(ErrorHandler(bot)) + await bot.add_cog(ErrorHandler(bot)) diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py index 823f14ea48..331a7835e2 100644 --- a/bot/exts/backend/logging.py +++ b/bot/exts/backend/logging.py @@ -1,13 +1,12 @@ -import logging - from discord import Embed from discord.ext.commands import Cog +from pydis_core.utils import scheduling from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE +from bot.log import get_logger - -log = logging.getLogger(__name__) +log = get_logger(__name__) class Logging(Cog): @@ -16,7 +15,7 @@ class Logging(Cog): def __init__(self, bot: Bot): self.bot = bot - self.bot.loop.create_task(self.startup_greeting()) + scheduling.create_task(self.startup_greeting()) async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" @@ -37,6 +36,6 @@ async def startup_greeting(self) -> None: await self.bot.get_channel(Channels.dev_log).send(embed=embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Logging cog.""" - bot.add_cog(Logging(bot)) + await bot.add_cog(Logging(bot)) diff --git a/bot/exts/filters/security.py b/bot/exts/backend/security.py similarity index 86% rename from bot/exts/filters/security.py rename to bot/exts/backend/security.py index c680c5e274..27e4d97525 100644 --- a/bot/exts/filters/security.py +++ b/bot/exts/backend/security.py @@ -1,10 +1,9 @@ -import logging - from discord.ext.commands import Cog, Context, NoPrivateMessage from bot.bot import Bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class Security(Cog): @@ -26,6 +25,6 @@ def check_on_guild(self, ctx: Context) -> bool: return True -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Security cog.""" - bot.add_cog(Security(bot)) + await bot.add_cog(Security(bot)) diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py index 829098f793..1978917e6c 100644 --- a/bot/exts/backend/sync/__init__.py +++ b/bot/exts/backend/sync/__init__.py @@ -1,8 +1,8 @@ from bot.bot import Bot -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Sync cog.""" # Defer import to reduce side effects from importing the sync package. from bot.exts.backend.sync._cog import Sync - bot.add_cog(Sync(bot)) + await bot.add_cog(Sync(bot)) diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index 48d2b6f02a..8ec01828b9 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -1,16 +1,19 @@ -import logging -from typing import Any, Dict +import asyncio +from typing import Any -from discord import Member, Role, User +from discord import Guild, Member, Role, User from discord.ext import commands from discord.ext.commands import Cog, Context +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.scheduling import create_task from bot import constants -from bot.api import ResponseCodeError from bot.bot import Bot from bot.exts.backend.sync import _syncers +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) +MAX_ATTEMPTS = 3 class Sync(Cog): @@ -18,20 +21,41 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.bot.loop.create_task(self.sync_guild()) + self.guild: Guild | None = None - async def sync_guild(self) -> None: + + async def cog_load(self) -> None: """Syncs the roles/users of the guild with the database.""" await self.bot.wait_until_guild_available() - guild = self.bot.get_guild(constants.Guild.id) - if guild is None: - return + self.guild = self.bot.get_guild(constants.Guild.id) + if self.guild is None: + raise ValueError("Could not fetch guild from cache, not loading sync cog.") + + attempts = 0 + while True: + attempts += 1 + if self.guild.chunked: + log.info("Guild was found to be chunked after %d attempt(s).", attempts) + break + + if attempts == MAX_ATTEMPTS: + log.info("Guild not chunked after %d attempts, calling chunk manually.", MAX_ATTEMPTS) + await self.guild.chunk() + break + + log.info("Attempt %d/%d: Guild not yet chunked, checking again in 10s.", attempts, MAX_ATTEMPTS) + await asyncio.sleep(10) + create_task(self.sync()) + + async def sync(self) -> None: + await asyncio.sleep(10) # Give time to other cogs starting up + log.info("Starting syncers.") for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer): - await syncer.sync(guild) + await syncer.sync(self.guild) - async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: + async def patch_user(self, user_id: int, json: dict[str, Any], ignore_404: bool = False) -> None: """Send a PATCH request to partially update a user in the database.""" try: await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) @@ -48,13 +72,13 @@ async def on_guild_role_create(self, role: Role) -> None: return await self.bot.api_client.post( - 'bot/roles', + "bot/roles", json={ - 'colour': role.colour.value, - 'id': role.id, - 'name': role.name, - 'permissions': role.permissions.value, - 'position': role.position, + "colour": role.colour.value, + "id": role.id, + "name": role.name, + "permissions": role.permissions.value, + "position": role.position, } ) @@ -64,7 +88,7 @@ async def on_guild_role_delete(self, role: Role) -> None: if role.guild.id != constants.Guild.id: return - await self.bot.api_client.delete(f'bot/roles/{role.id}') + await self.bot.api_client.delete(f"bot/roles/{role.id}") @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: @@ -81,13 +105,13 @@ async def on_guild_role_update(self, before: Role, after: Role) -> None: if was_updated: await self.bot.api_client.put( - f'bot/roles/{after.id}', + f"bot/roles/{after.id}", json={ - 'colour': after.colour.value, - 'id': after.id, - 'name': after.name, - 'permissions': after.permissions.value, - 'position': after.position, + "colour": after.colour.value, + "id": after.id, + "name": after.name, + "permissions": after.permissions.value, + "position": after.position, } ) @@ -104,11 +128,11 @@ async def on_member_join(self, member: Member) -> None: return packed = { - 'discriminator': int(member.discriminator), - 'id': member.id, - 'in_guild': True, - 'name': member.name, - 'roles': sorted(role.id for role in member.roles) + "discriminator": int(member.discriminator), + "id": member.id, + "in_guild": True, + "name": member.name, + "roles": sorted(role.id for role in member.roles) } got_error = False @@ -116,7 +140,7 @@ async def on_member_join(self, member: Member) -> None: try: # First try an update of the user to set the `in_guild` field and other # fields that may have changed since the last time we've seen them. - await self.bot.api_client.put(f'bot/users/{member.id}', json=packed) + await self.bot.api_client.put(f"bot/users/{member.id}", json=packed) except ResponseCodeError as e: # If we didn't get 404, something else broke - propagate it up. @@ -127,7 +151,7 @@ async def on_member_join(self, member: Member) -> None: if got_error: # If we got `404`, the user is new. Create them. - await self.bot.api_client.post('bot/users', json=packed) + await self.bot.api_client.post("bot/users", json=packed) @Cog.listener() async def on_member_remove(self, member: Member) -> None: @@ -159,18 +183,18 @@ async def on_user_update(self, before: User, after: User) -> None: # A 404 likely means the user is in another guild. await self.patch_user(after.id, json=updated_information, ignore_404=True) - @commands.group(name='sync') + @commands.group(name="sync") @commands.has_permissions(administrator=True) async def sync_group(self, ctx: Context) -> None: """Run synchronizations between the bot and site manually.""" - @sync_group.command(name='roles') + @sync_group.command(name="roles") @commands.has_permissions(administrator=True) async def sync_roles_command(self, ctx: Context) -> None: """Manually synchronise the guild's roles with the roles on the site.""" await _syncers.RoleSyncer.sync(ctx.guild, ctx) - @sync_group.command(name='users') + @sync_group.command(name="users") @commands.has_permissions(administrator=True) async def sync_users_command(self, ctx: Context) -> None: """Manually synchronise the guild's users with the users on the site.""" diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index c9f2d2da87..9a5671deb6 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -1,23 +1,24 @@ import abc -import logging import typing as t from collections import namedtuple +from itertools import batched +import discord.errors from discord import Guild from discord.ext.commands import Context -from more_itertools import chunked +from pydis_core.site_api import ResponseCodeError import bot -from bot.api import ResponseCodeError +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) CHUNK_SIZE = 1000 # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. -_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) +_Role = namedtuple("Role", ("id", "name", "colour", "permissions", "position")) +_Diff = namedtuple("Diff", ("created", "updated", "deleted")) # Implementation of static abstract methods are not enforced if the subclass is never instantiated. @@ -45,7 +46,7 @@ async def _sync(diff: _Diff) -> None: raise NotImplementedError # pragma: no cover @classmethod - async def sync(cls, guild: Guild, ctx: t.Optional[Context] = None) -> None: + async def sync(cls, guild: Guild, ctx: Context | None = None) -> None: """ Synchronise the database with the cache of `guild`. @@ -88,7 +89,7 @@ class RoleSyncer(Syncer): async def _get_diff(guild: Guild) -> _Diff: """Return the difference of roles between the cache of `guild` and the database.""" log.trace("Getting the diff for roles.") - roles = await bot.instance.api_client.get('bot/roles') + roles = await bot.instance.api_client.get("bot/roles") # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -122,15 +123,15 @@ async def _sync(diff: _Diff) -> None: """Synchronise the database with the role cache of `guild`.""" log.trace("Syncing created roles...") for role in diff.created: - await bot.instance.api_client.post('bot/roles', json=role._asdict()) + await bot.instance.api_client.post("bot/roles", json=role._asdict()) log.trace("Syncing updated roles...") for role in diff.updated: - await bot.instance.api_client.put(f'bot/roles/{role.id}', json=role._asdict()) + await bot.instance.api_client.put(f"bot/roles/{role.id}", json=role._asdict()) log.trace("Syncing deleted roles...") for role in diff.deleted: - await bot.instance.api_client.delete(f'bot/roles/{role.id}') + await bot.instance.api_client.delete(f"bot/roles/{role.id}") class UserSyncer(Syncer): @@ -151,15 +152,25 @@ async def _get_diff(guild: Guild) -> _Diff: # Store user fields which are to be updated. updated_fields = {} - def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: + def maybe_update(db_field: str, guild_value: str | int) -> None: # Equalize DB user and guild user attributes. - if db_user[db_field] != guild_value: - updated_fields[db_field] = guild_value - - if guild_user := guild.get_member(db_user["id"]): + if db_user[db_field] != guild_value: # noqa: B023 + updated_fields[db_field] = guild_value # noqa: B023 + + guild_user = guild.get_member(db_user["id"]) + if not guild_user and db_user["in_guild"]: + # The member was in the guild during the last sync. + # We try to fetch them to verify cache integrity. + try: + guild_user = await guild.fetch_member(db_user["id"]) + except discord.errors.NotFound: + guild_user = None + + if guild_user: seen_guild_users.add(guild_user.id) maybe_update("name", guild_user.name) + maybe_update("display_name", guild_user.display_name) maybe_update("discriminator", int(guild_user.discriminator)) maybe_update("in_guild", True) @@ -186,6 +197,7 @@ def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: new_user = { "id": member.id, "name": member.name, + "display_name": member.display_name, "discriminator": int(member.discriminator), "roles": [role.id for role in member.roles], "in_guild": True @@ -213,10 +225,10 @@ async def _sync(diff: _Diff) -> None: # Using asyncio.gather would still consume too many resources on the site. log.trace("Syncing created users...") if diff.created: - for chunk in chunked(diff.created, CHUNK_SIZE): + for chunk in batched(diff.created, CHUNK_SIZE, strict=False): await bot.instance.api_client.post("bot/users", json=chunk) log.trace("Syncing updated users...") if diff.updated: - for chunk in chunked(diff.updated, CHUNK_SIZE): + for chunk in batched(diff.updated, CHUNK_SIZE, strict=False): await bot.instance.api_client.patch("bot/users/bulk_patch", json=chunk) diff --git a/bot/exts/filtering/FILTERS-DEVELOPMENT.md b/bot/exts/filtering/FILTERS-DEVELOPMENT.md new file mode 100644 index 0000000000..c6237b60c0 --- /dev/null +++ b/bot/exts/filtering/FILTERS-DEVELOPMENT.md @@ -0,0 +1,63 @@ +# Filters Development +This file gives a short overview of the extension, and shows how to perform some basic changes/additions to it. + +## Overview +The main idea is that there is a list of filters each deciding whether they apply to the given content. +For example, there can be a filter that decides it will trigger when the content contains the string "lemon". + +There are several types of filters, and two filters of the same type differ by their content. +For example, filters of type "token" search for a specific token inside the provided string. +One token filter might look for the string "lemon", while another will look for the string "joe". + +Each filter has a set of settings that decide when it triggers (e.g. in which channels, in which categories, etc.), and what happens if it does (e.g. delete the message, ping specific roles/users, etc.). +Filters of a specific type can have additional settings that are special to them. + +A list of filters is contained within a filter list. +The filter list gets content to filter, and dispatches it to each of its filters. +It takes the answers from its filters and returns a unified response (e.g. if at least one of the filters says it should be deleted, then the filter list response will include it). + +A filter list has the same set of possible settings, which act as defaults. +If a filter in the list doesn't define a value for a setting (meaning it has a value of None), it will use the value of the containing filter list. + +The cog receives "filtering events". For example, a new message is sent. +It creates a "filtering context" with everything a filtering list needs to know to provide an answer for what should be done. +For example, if the event is a new message, then the content to filter is the content of the message, embeds if any exist, etc. + +The cog dispatches the event to each filter list, gets the result from each, compiles them, and takes any action dictated by them. +For example, if any of the filter lists want the message to be deleted, then the cog will delete it. + +## Example Changes +### Creating a new type of filter list +1. Head over to `bot.exts.filtering._filter_lists` and create a new Python file. +2. Subclass the FilterList class in `bot.exts.filtering._filter_lists.filter_list` and implement its abstract methods. Make sure to set the `name` class attribute. + +You can now add filter lists to the database with the same name defined in the new FilterList subclass. + +### Creating a new type of filter +1. Head over to `bot.exts.filtering._filters` and create a new Python file. +2. Subclass the Filter class in `bot.exts.filtering._filters.filter` and implement its abstract methods. +3. Make sure to set the `name` class attribute, and have one of the FilterList subclasses return this new Filter subclass in `get_filter_type`. + +### Creating a new type of setting +1. Head over to `bot.exts.filtering._settings_types`, and open a new Python file in either `actions` or `validations`, depending on whether you want to subclass `ActionEntry` or `ValidationEntry`. +2. Subclass one of the aforementioned classes, and implement its abstract methods. Make sure to set the `name` and `description` class attributes. + +You can now make the appropriate changes to the site repo: +1. Add a new field in the `Filter` and `FilterList` models. Make sure that on `Filter` it's nullable, and on `FilterList` it isn't. +2. In `serializers.py`, add the new field to `SETTINGS_FIELDS`, and to `ALLOW_BLANK_SETTINGS` or `ALLOW_EMPTY_SETTINGS` if appropriate. If it's not a part of any group of settings, add it `BASE_SETTINGS_FIELDS`, otherwise add it to the appropriate group or create a new one. +3. If you created a new group, make sure it's used in `to_representation`. +4. Update the docs in the filter viewsets. + +You can merge the changes to the bot first - if no such field is loaded from the database it'll just be ignored. + +You can define entries that are a group of fields in the database. +In that case the created subclass should have fields whose names are the names of the fields in the database. +Then, the description will be a dictionary, whose keys are the names of the fields, and values are the descriptions for each field. + +### Creating a new type of filtering event +1. Head over to `bot.exts.filtering._filter_context` and add a new value to the `Event` enum. +2. Implement the dispatching and actioning of the new event in the cog, by either adding it to an existing even listener, or creating a new one. +3. Have the appropriate filter lists subscribe to the event, so they receive it. +4. Have the appropriate unique filters (currently under `unique` and `antispam` in `bot.exts.filtering._filters`) subscribe to the event, so they receive it. + +It should be noted that the filtering events don't need to correspond to Discord events. For example, `nickname` isn't a Discord event and is dispatched when a message is sent. diff --git a/bot/exts/filters/__init__.py b/bot/exts/filtering/__init__.py similarity index 100% rename from bot/exts/filters/__init__.py rename to bot/exts/filtering/__init__.py diff --git a/bot/exts/filtering/_filter_context.py b/bot/exts/filtering/_filter_context.py new file mode 100644 index 0000000000..5e43b0eef3 --- /dev/null +++ b/bot/exts/filtering/_filter_context.py @@ -0,0 +1,84 @@ +import typing +from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass, field, replace +from enum import Enum, auto + +import discord +from discord import DMChannel, Embed, Member, Message, StageChannel, TextChannel, Thread, User, VoiceChannel + +from bot.utils.message_cache import MessageCache + +if typing.TYPE_CHECKING: + from bot.exts.filtering._filter_lists import FilterList + from bot.exts.filtering._filters.filter import Filter + from bot.exts.utils.snekbox._io import FileAttachment + + +class Event(Enum): + """Types of events that can trigger filtering. Note this does not have to align with gateway event types.""" + + MESSAGE = auto() + MESSAGE_EDIT = auto() + NICKNAME = auto() + THREAD_NAME = auto() + SNEKBOX = auto() + + +@dataclass +class FilterContext: + """A dataclass containing the information that should be filtered, and output information of the filtering.""" + + # Input context + event: Event # The type of event + author: User | Member | None # Who triggered the event + channel: TextChannel | VoiceChannel | StageChannel | Thread | DMChannel | None # The channel involved + content: str | Iterable # What actually needs filtering. The Iterable type depends on the filter list. + message: Message | None # The message involved + embeds: list[Embed] = field(default_factory=list) # Any embeds involved + attachments: list[discord.Attachment | FileAttachment] = field(default_factory=list) # Any attachments sent. + before_message: Message | None = None + message_cache: MessageCache | None = None + # Output context + dm_content: str = "" # The content to DM the invoker + dm_embed: str = "" # The embed description to DM the invoker + send_alert: bool = False # Whether to send an alert for the moderators + alert_content: str = "" # The content of the alert + alert_embeds: list[Embed] = field(default_factory=list) # Any embeds to add to the alert + action_descriptions: list[str] = field(default_factory=list) # What actions were taken + matches: list[str] = field(default_factory=list) # What exactly was found + notification_domain: str = "" # A domain to send the user for context + filter_info: dict[Filter, str] = field(default_factory=dict) # Additional info from a filter. + messages_deletion: bool = False # Whether the messages were deleted. Can't upload deletion log otherwise. + blocked_exts: set[str] = field(default_factory=set) # Any extensions blocked (used for snekbox) + potential_phish: dict[FilterList, set[str]] = field(default_factory=dict) + # Additional actions to perform + additional_actions: list[Callable[[FilterContext], Coroutine]] = field(default_factory=list) + related_messages: set[Message] = field(default_factory=set) # Deletion will include these. + related_channels: set[TextChannel | Thread | DMChannel] = field(default_factory=set) + uploaded_attachments: dict[int, list[str]] = field(default_factory=dict) # Message ID to attachment URLs. + upload_deletion_logs: bool = True # Whether it's allowed to upload deletion logs. + + def __post_init__(self): + # If it's in the context of a DM channel, self.channel won't be None, but self.channel.guild will. + self.in_guild = self.channel is None or self.channel.guild is not None + + @classmethod + def from_message( + cls, event: Event, message: Message, before: Message | None = None, cache: MessageCache | None = None + ) -> FilterContext: + """Create a filtering context from the attributes of a message.""" + return cls( + event, + message.author, + message.channel, + message.content, + message, + message.embeds, + message.attachments, + before, + cache + ) + + def replace(self, **changes) -> FilterContext: + """Return a new context object assigning new values to the specified fields.""" + return replace(self, **changes) diff --git a/bot/exts/filtering/_filter_lists/__init__.py b/bot/exts/filtering/_filter_lists/__init__.py new file mode 100644 index 0000000000..1273e5588a --- /dev/null +++ b/bot/exts/filtering/_filter_lists/__init__.py @@ -0,0 +1,9 @@ +from os.path import dirname + +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType, ListTypeConverter +from bot.exts.filtering._utils import subclasses_in_package + +filter_list_types = subclasses_in_package(dirname(__file__), f"{__name__}.", FilterList) +filter_list_types = {filter_list.name: filter_list for filter_list in filter_list_types} + +__all__ = [filter_list_types, FilterList, ListType, ListTypeConverter] diff --git a/bot/exts/filtering/_filter_lists/antispam.py b/bot/exts/filtering/_filter_lists/antispam.py new file mode 100644 index 0000000000..ecb895e013 --- /dev/null +++ b/bot/exts/filtering/_filter_lists/antispam.py @@ -0,0 +1,197 @@ +import asyncio +import typing +from collections import Counter +from collections.abc import Callable, Coroutine +from dataclasses import dataclass, field +from datetime import timedelta +from functools import reduce +from itertools import takewhile +from operator import add, or_ + +import arrow +from discord import Member +from pydis_core.utils import scheduling +from pydis_core.utils.logging import get_logger + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filter_lists.filter_list import ListType, SubscribingAtomicList, UniquesListBase +from bot.exts.filtering._filters.antispam import antispam_filter_types +from bot.exts.filtering._filters.filter import Filter, UniqueFilter +from bot.exts.filtering._settings import ActionSettings +from bot.exts.filtering._settings_types.actions.infraction_and_notification import Infraction, InfractionAndNotification +from bot.exts.filtering._ui.ui import AlertView, build_mod_alert + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +log = get_logger(__name__) + +ALERT_DELAY = 6 + + +class AntispamList(UniquesListBase): + """ + A list of anti-spam rules. + + Messages from the last X seconds are passed to each rule, which decides whether it triggers across those messages. + + The infraction reason is set dynamically. + """ + + name = "antispam" + + def __init__(self, filtering_cog: Filtering): + super().__init__(filtering_cog) + self.message_deletion_queue: dict[Member, DeletionContext] = dict() + + def get_filter_type(self, content: str) -> type[UniqueFilter] | None: + """Get a subclass of filter matching the filter list and the filter's content.""" + try: + return antispam_filter_types[content] + except KeyError: + if content not in self._already_warned: + log.warning(f"An antispam filter named {content} was supplied, but no matching implementation found.") + self._already_warned.add(content) + return None + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + if not ctx.message or not ctx.message_cache: + return None, [], {} + + sublist: SubscribingAtomicList = self[ListType.DENY] + potential_filters = [sublist.filters[id_] for id_ in sublist.subscriptions[ctx.event]] + max_interval = max(filter_.extra_fields.interval for filter_ in potential_filters) + + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=max_interval) + relevant_messages = list( + takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.message_cache) + ) + new_ctx = ctx.replace(content=relevant_messages) + triggers = await sublist.filter_list_result(new_ctx) + if not triggers: + return None, [], {} + + if ctx.author not in self.message_deletion_queue: + self.message_deletion_queue[ctx.author] = DeletionContext() + ctx.additional_actions.append(self._create_deletion_context_handler(ctx.author)) + ctx.related_channels |= {msg.channel for msg in ctx.related_messages} + else: # The additional messages found are already part of a deletion context + ctx.related_messages = set() + current_infraction = self.message_deletion_queue[ctx.author].current_infraction + # In case another filter wants an alert, prevent deleted messages from being uploaded now and also for + # the spam alert (upload happens during alerting). + # Deleted messages API doesn't accept duplicates and will error. + # Additional messages are necessarily part of the deletion. + ctx.upload_deletion_logs = False + self.message_deletion_queue[ctx.author].add(ctx, triggers) + + current_actions = sublist.merge_actions(triggers) + # Don't alert yet. + current_actions.pop("ping", None) + current_actions.pop("send_alert", None) + + new_infraction = current_actions[InfractionAndNotification.name].model_copy() + # Smaller infraction value => higher in hierarchy. + if not current_infraction or new_infraction.infraction_type.value < current_infraction.value: + # Pick the first triggered filter for the reason, there's no good way to decide between them. + new_infraction.infraction_reason = ( + f"{triggers[0].name.replace('_', ' ')} spam - {ctx.filter_info[triggers[0]]}" + ) + current_actions[InfractionAndNotification.name] = new_infraction + self.message_deletion_queue[ctx.author].current_infraction = new_infraction.infraction_type + else: + current_actions.pop(InfractionAndNotification.name, None) + + # Provide some message in case another filter list wants there to be an alert. + return current_actions, ["Handling spam event..."], {ListType.DENY: triggers} + + def _create_deletion_context_handler(self, member: Member) -> Callable[[FilterContext], Coroutine]: + async def schedule_processing(ctx: FilterContext) -> None: + """ + Schedule a coroutine to process the deletion context. + + It cannot be awaited directly, as it waits ALERT_DELAY seconds, and actioning a filtering context depends on + all actions finishing. + + This is async and takes a context to adhere to the type of ctx.additional_actions. + """ + async def process_deletion_context() -> None: + """Processes the Deletion Context queue.""" + log.trace("Sleeping before processing message deletion queue.") + await asyncio.sleep(ALERT_DELAY) + + if member not in self.message_deletion_queue: + log.error(f"Started processing deletion queue for context `{member}`, but it was not found!") + return + + deletion_context = self.message_deletion_queue.pop(member) + await deletion_context.send_alert(self) + + scheduling.create_task(process_deletion_context()) + + return schedule_processing + + +@dataclass +class DeletionContext: + """Represents a Deletion Context for a single spam event.""" + + contexts: list[FilterContext] = field(default_factory=list) + rules: set[UniqueFilter] = field(default_factory=set) + current_infraction: Infraction | None = None + + def add(self, ctx: FilterContext, rules: list[UniqueFilter]) -> None: + """Adds new rule violation events to the deletion context.""" + self.contexts.append(ctx) + self.rules.update(rules) + + async def send_alert(self, antispam_list: AntispamList) -> None: + """Post the mod alert.""" + if not self.contexts or not self.rules: + return + + webhook = antispam_list.filtering_cog.webhook + if not webhook: + return + + ctx, *other_contexts = self.contexts + new_ctx = FilterContext(ctx.event, ctx.author, ctx.channel, ctx.content, ctx.message) + all_descriptions_counts = Counter(reduce( + add, (other_ctx.action_descriptions for other_ctx in other_contexts), ctx.action_descriptions + )) + new_ctx.action_descriptions = [ + f"{action} X {count}" if count > 1 else action for action, count in all_descriptions_counts.items() + ] + # It shouldn't ever come to this, but just in case. + if (descriptions_num := len(new_ctx.action_descriptions)) > 20: + new_ctx.action_descriptions = new_ctx.action_descriptions[:20] + new_ctx.action_descriptions[-1] += f" (+{descriptions_num - 20} other actions)" + new_ctx.related_messages = reduce( + or_, (other_ctx.related_messages for other_ctx in other_contexts), ctx.related_messages + ) | {ctx.message for ctx in other_contexts} + new_ctx.related_channels = reduce( + or_, (other_ctx.related_channels for other_ctx in other_contexts), ctx.related_channels + ) | {ctx.channel for ctx in other_contexts} + new_ctx.uploaded_attachments = reduce( + or_, (other_ctx.uploaded_attachments for other_ctx in other_contexts), ctx.uploaded_attachments + ) + new_ctx.upload_deletion_logs = True + new_ctx.messages_deletion = all(ctx.messages_deletion for ctx in self.contexts) + + rules = list(self.rules) + actions = antispam_list[ListType.DENY].merge_actions(rules) + for action in list(actions): + if action not in ("ping", "send_alert"): + actions.pop(action, None) + await actions.action(new_ctx) + + messages = antispam_list[ListType.DENY].format_messages(rules) + embed = await build_mod_alert(new_ctx, {antispam_list: messages}) + if other_contexts: + embed.set_footer( + text="The list of actions taken includes actions from additional contexts after deletion began." + ) + await webhook.send(username="Anti-Spam", content=ctx.alert_content, embeds=[embed], view=AlertView(new_ctx)) diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py new file mode 100644 index 0000000000..e601f6922d --- /dev/null +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -0,0 +1,68 @@ +import re +import typing + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.domain import DomainFilter +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._settings import ActionSettings +from bot.exts.filtering._utils import clean_input + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +# Matches words that start with the http(s) protocol prefix +# Will not include, if present, the trailing closing parenthesis +URL_RE = re.compile(r"https?://(\S+)(?=\)|\b)", flags=re.IGNORECASE) + + +class DomainsList(FilterList[DomainFilter]): + """ + A list of filters, each looking for a specific domain given by URL. + + The blacklist defaults dictate what happens by default when a filter is matched, and can be overridden by + individual filters. + + Domains are found by looking for a URL schema (http or https). + Filters will also trigger for subdomains. + """ + + name = "domain" + + def __init__(self, filtering_cog: Filtering): + super().__init__() + filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) + + def get_filter_type(self, content: str) -> type[Filter]: + """Get a subclass of filter matching the filter list and the filter's content.""" + return DomainFilter + + @property + def filter_types(self) -> set[type[Filter]]: + """Return the types of filters used by this list.""" + return {DomainFilter} + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + text = ctx.content + if not text: + return None, [], {} + + text = clean_input(text) + urls = {match.group(1).lower().rstrip("/") for match in URL_RE.finditer(text)} + new_ctx = ctx.replace(content=urls) + + triggers = await self[ListType.DENY].filter_list_result(new_ctx) + ctx.notification_domain = new_ctx.notification_domain + unknown_urls = urls - {filter_.content.lower() for filter_ in triggers} + if unknown_urls: + ctx.potential_phish[self] = unknown_urls + + actions = None + messages = [] + if triggers: + actions = self[ListType.DENY].merge_actions(triggers) + messages = self[ListType.DENY].format_messages(triggers) + return actions, messages, {ListType.DENY: triggers} diff --git a/bot/exts/filtering/_filter_lists/extension.py b/bot/exts/filtering/_filter_lists/extension.py new file mode 100644 index 0000000000..2e6e69c020 --- /dev/null +++ b/bot/exts/filtering/_filter_lists/extension.py @@ -0,0 +1,116 @@ +import typing +from os.path import splitext + +import bot +from bot.constants import Channels +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.extension import ExtensionFilter +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._settings import ActionSettings + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +PASTE_URL = "https://paste.pythondiscord.com" +PY_EMBED_DESCRIPTION = ( + "It looks like you tried to attach a Python file - " + f"please use a code-pasting service such as {PASTE_URL}" +) + +TXT_LIKE_FILES = {".txt", ".csv", ".json"} +TXT_EMBED_DESCRIPTION = ( + "You either uploaded a `{blocked_extension}` file or entered a message that was too long. " + f"Please use our [paste bin]({PASTE_URL}) instead." +) + +DISALLOWED_EMBED_DESCRIPTION = ( + "It looks like you tried to attach file type(s) that we do not allow ({joined_blacklist}). " + "We currently allow the following file types: **{joined_whitelist}**.\n\n" + "Feel free to ask in {meta_channel_mention} if you think this is a mistake." +) + + +class ExtensionsList(FilterList[ExtensionFilter]): + """ + A list of filters, each looking for a file attachment with a specific extension. + + If an extension is not explicitly allowed, it will be blocked. + + Whitelist defaults dictate what happens when an extension is *not* explicitly allowed, + and whitelist filters overrides have no effect. + + Items should be added as file extensions preceded by a dot. + """ + + name = "extension" + + def __init__(self, filtering_cog: Filtering): + super().__init__() + filtering_cog.subscribe(self, Event.MESSAGE, Event.SNEKBOX) + self._whitelisted_description = None + + def get_filter_type(self, content: str) -> type[Filter]: + """Get a subclass of filter matching the filter list and the filter's content.""" + return ExtensionFilter + + @property + def filter_types(self) -> set[type[Filter]]: + """Return the types of filters used by this list.""" + return {ExtensionFilter} + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + # Return early if the message doesn't have attachments. + if not ctx.message or not ctx.attachments: + return None, [], {} + + _, failed = self[ListType.ALLOW].defaults.validations.evaluate(ctx) + if failed: # There's no extension filtering in this context. + return None, [], {} + + # Find all extensions in the message. + all_ext = { + (splitext(attachment.filename.lower())[1], attachment.filename) for attachment in ctx.attachments + } + new_ctx = ctx.replace(content={ext for ext, _ in all_ext}) # And prepare the context for the filters to read. + triggered = [ + filter_ for filter_ in self[ListType.ALLOW].filters.values() if await filter_.triggered_on(new_ctx) + ] + allowed_ext = {filter_.content for filter_ in triggered} # Get the extensions in the message that are allowed. + + # See if there are any extensions left which aren't allowed. + not_allowed = {ext: filename for ext, filename in all_ext if ext not in allowed_ext} + + if ctx.event == Event.SNEKBOX: + not_allowed = {ext: filename for ext, filename in not_allowed.items() if ext not in TXT_LIKE_FILES} + + if not not_allowed: # Yes, it's a double negative. Meaning all attachments are allowed :) + return None, [], {ListType.ALLOW: triggered} + + # At this point, something is disallowed. + if ctx.event != Event.SNEKBOX: # Don't post the embed if it's a snekbox response. + if ".py" in not_allowed: + # Provide a pastebin link for .py files. + ctx.dm_embed = PY_EMBED_DESCRIPTION + elif txt_extensions := {ext for ext in TXT_LIKE_FILES if ext in not_allowed}: + # Work around Discord auto-conversion of messages longer than 2000 chars to .txt + ctx.dm_embed = TXT_EMBED_DESCRIPTION.format(blocked_extension=txt_extensions.pop()) + else: + meta_channel = bot.instance.get_channel(Channels.meta) + if not self._whitelisted_description: + self._whitelisted_description = ", ".join( + filter_.content for filter_ in self[ListType.ALLOW].filters.values() + ) + ctx.dm_embed = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=self._whitelisted_description, + joined_blacklist=", ".join(not_allowed), + meta_channel_mention=meta_channel.mention, + ) + + ctx.matches += not_allowed.values() + ctx.blocked_exts |= set(not_allowed) + actions = self[ListType.ALLOW].defaults.actions if ctx.event != Event.SNEKBOX else None + return actions, [f"`{ext}`" if ext else "`No Extension`" for ext in not_allowed], {ListType.ALLOW: triggered} diff --git a/bot/exts/filtering/_filter_lists/filter_list.py b/bot/exts/filtering/_filter_lists/filter_list.py new file mode 100644 index 0000000000..9c47a03c1b --- /dev/null +++ b/bot/exts/filtering/_filter_lists/filter_list.py @@ -0,0 +1,310 @@ +import dataclasses +import typing +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum +from functools import reduce +from typing import Any + +import arrow +from discord.ext.commands import BadArgument, Context, Converter + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import Filter, UniqueFilter +from bot.exts.filtering._settings import ActionSettings, Defaults, create_settings +from bot.exts.filtering._utils import FieldRequiring, past_tense +from bot.log import get_logger + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +log = get_logger(__name__) + + +class ListType(Enum): + """An enumeration of list types.""" + + DENY = 0 + ALLOW = 1 + + +# Alternative names with which each list type can be specified in commands. +aliases = ( + (ListType.DENY, {"deny", "blocklist", "blacklist", "denylist", "bl", "dl"}), + (ListType.ALLOW, {"allow", "allowlist", "whitelist", "al", "wl"}) +) + + +class ListTypeConverter(Converter): + """A converter to get the appropriate list type.""" + + async def convert(self, ctx: Context, argument: str) -> ListType: + argument = argument.lower() + for list_type, list_aliases in aliases: + if argument in list_aliases or argument in map(past_tense, list_aliases): + return list_type + raise BadArgument(f"No matching list type found for {argument!r}.") + + +# AtomicList and its subclasses must have eq=False, otherwise the dataclass deco will replace the hash function. +@dataclass(frozen=True, eq=False) +class AtomicList: + """ + Represents the atomic structure of a single filter list as it appears in the database. + + This is as opposed to the FilterList class which is a combination of several list types. + """ + + id: int + created_at: arrow.Arrow + updated_at: arrow.Arrow + name: str + list_type: ListType + defaults: Defaults + filters: dict[int, Filter] + + @property + def label(self) -> str: + """Provide a short description identifying the list with its name and type.""" + return f"{past_tense(self.list_type.name.lower())} {self.name.lower()}" + + async def filter_list_result(self, ctx: FilterContext) -> list[Filter]: + """ + Sift through the list of filters, and return only the ones which apply to the given context. + + The strategy is as follows: + 1. The default settings are evaluated on the given context. The default answer for whether the filter is + relevant in the given context is whether there aren't any validation settings which returned False. + 2. For each filter, its overrides are considered: + - If there are no overrides, then the filter is relevant if that is the default answer. + - Otherwise it is relevant if there are no failed overrides, and any failing default is overridden by a + successful override. + + If the filter is relevant in context, see if it actually triggers. + """ + return await self._create_filter_list_result(ctx, self.defaults, self.filters.values()) + + async def _create_filter_list_result( + self, ctx: FilterContext, defaults: Defaults, filters: Iterable[Filter] + ) -> list[Filter]: + """A helper function to evaluate the result of `filter_list_result`.""" + _passed_by_default, failed_by_default = defaults.validations.evaluate(ctx) + default_answer = not bool(failed_by_default) + + relevant_filters = [] + for filter_ in filters: + if not filter_.validations: + if default_answer and await filter_.triggered_on(ctx): + relevant_filters.append(filter_) + else: + passed, failed = filter_.validations.evaluate(ctx) + if not failed and failed_by_default < passed: + if await filter_.triggered_on(ctx): + relevant_filters.append(filter_) + + if ctx.event == Event.MESSAGE_EDIT and ctx.message and self.list_type == ListType.DENY: + previously_triggered = ctx.message_cache.get_message_metadata(ctx.message.id) + # The message might not be cached. + if previously_triggered and self in previously_triggered: + ignore_filters = previously_triggered[self] + # This updates the cache. Some filters are ignored, but they're necessary if there's another edit. + previously_triggered[self] = relevant_filters + relevant_filters = [filter_ for filter_ in relevant_filters if filter_ not in ignore_filters] + return relevant_filters + + def default(self, setting_name: str) -> Any: + """Get the default value of a specific setting.""" + missing = object() + value = self.defaults.actions.get_setting(setting_name, missing) + if value is missing: + value = self.defaults.validations.get_setting(setting_name, missing) + if value is missing: + raise ValueError(f"Couldn't find a setting named {setting_name!r}.") + return value + + def merge_actions(self, filters: list[Filter]) -> ActionSettings | None: + """ + Merge the settings of the given filters, with the list's defaults as fallback. + + If `merge_default` is True, include it in the merge instead of using it as a fallback. + """ + if not filters: # Nothing to action. + return None + try: + return reduce( + ActionSettings.union, (filter_.actions or self.defaults.actions for filter_ in filters) + ).fallback_to(self.defaults.actions) + except TypeError: + # The sequence fed to reduce is empty, meaning none of the filters have actions, + # meaning they all use the defaults. + return self.defaults.actions + + @staticmethod + def format_messages(triggers: list[Filter], *, expand_single_filter: bool = True) -> list[str]: + """Convert the filters into strings that can be added to the alert embed.""" + if len(triggers) == 1 and expand_single_filter: + message = f"#{triggers[0].id} (`{triggers[0].content}`)" + if triggers[0].description: + message += f" - {triggers[0].description}" + messages = [message] + else: + messages = [f"{filter_.id} (`{filter_.content}`)" for filter_ in triggers] + return messages + + def __hash__(self): + return hash(id(self)) + + +T = typing.TypeVar("T", bound=Filter) + + +class FilterList[T](dict[ListType, AtomicList], FieldRequiring): + """Dispatches events to lists of _filters, and aggregates the responses into a single list of actions to take.""" + + # Each subclass must define a name matching the filter_list name we're expecting to receive from the database. + # Names must be unique across all filter lists. + name = FieldRequiring.MUST_SET_UNIQUE + + _already_warned = set() + + def add_list(self, list_data: dict) -> AtomicList: + """Add a new type of list (such as a whitelist or a blacklist) this filter list.""" + actions, validations = create_settings(list_data["settings"], keep_empty=True) + list_type = ListType(list_data["list_type"]) + defaults = Defaults(actions, validations) + + filters = {} + for filter_data in list_data["filters"]: + new_filter = self._create_filter(filter_data, defaults) + if new_filter: + filters[filter_data["id"]] = new_filter + + self[list_type] = AtomicList( + list_data["id"], + arrow.get(list_data["created_at"]), + arrow.get(list_data["updated_at"]), + self.name, + list_type, + defaults, + filters + ) + return self[list_type] + + def add_filter(self, list_type: ListType, filter_data: dict) -> T | None: + """Add a filter to the list of the specified type.""" + new_filter = self._create_filter(filter_data, self[list_type].defaults) + if new_filter: + self[list_type].filters[filter_data["id"]] = new_filter + return new_filter + + @abstractmethod + def get_filter_type(self, content: str) -> type[T]: + """Get a subclass of filter matching the filter list and the filter's content.""" + + @property + @abstractmethod + def filter_types(self) -> set[type[T]]: + """Return the types of filters used by this list.""" + + @abstractmethod + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + + def _create_filter(self, filter_data: dict, defaults: Defaults) -> T | None: + """Create a filter from the given data.""" + try: + content = filter_data["content"] + filter_type = self.get_filter_type(content) + if filter_type: + return filter_type(filter_data, defaults) + if content not in self._already_warned: + log.warning(f"A filter named {content} was supplied, but no matching implementation found.") + self._already_warned.add(content) + return None + except TypeError as e: + log.warning(e) + + def __hash__(self): + return hash(id(self)) + + +@dataclass(frozen=True, eq=False) +class SubscribingAtomicList(AtomicList): + """ + A base class for a list of unique filters. + + Unique filters are ones that should only be run once in a given context. + Each unique filter is subscribed to a subset of events to respond to. + """ + + subscriptions: defaultdict[Event, list[int]] = dataclasses.field(default_factory=lambda: defaultdict(list)) + + def subscribe(self, filter_: UniqueFilter, *events: Event) -> None: + """ + Subscribe a unique filter to the given events. + + The filter is added to a list for each event. When the event is triggered, the filter context will be + dispatched to the subscribed filters. + """ + for event in events: + if filter_ not in self.subscriptions[event]: + self.subscriptions[event].append(filter_.id) + + async def filter_list_result(self, ctx: FilterContext) -> list[Filter]: + """Sift through the list of filters, and return only the ones which apply to the given context.""" + event_filters = [self.filters[id_] for id_ in self.subscriptions[ctx.event]] + return await self._create_filter_list_result(ctx, self.defaults, event_filters) + + +class UniquesListBase(FilterList[UniqueFilter], ABC): + """ + A list of unique filters. + + Unique filters are ones that should only be run once in a given context. + Each unique filter subscribes to a subset of events to respond to. + """ + + def __init__(self, filtering_cog: Filtering): + super().__init__() + self.filtering_cog = filtering_cog + self.loaded_types: dict[str, type[UniqueFilter]] = {} + + def add_list(self, list_data: dict) -> SubscribingAtomicList: + """Add a new type of list (such as a whitelist or a blacklist) this filter list.""" + actions, validations = create_settings(list_data["settings"], keep_empty=True) + list_type = ListType(list_data["list_type"]) + defaults = Defaults(actions, validations) + new_list = SubscribingAtomicList( + list_data["id"], + arrow.get(list_data["created_at"]), + arrow.get(list_data["updated_at"]), + self.name, + list_type, + defaults, + {} + ) + self[list_type] = new_list + + filters = {} + events = set() + for filter_data in list_data["filters"]: + new_filter = self._create_filter(filter_data, defaults) + if new_filter: + new_list.subscribe(new_filter, *new_filter.events) + filters[filter_data["id"]] = new_filter + self.loaded_types[new_filter.name] = type(new_filter) + events.update(new_filter.events) + + new_list.filters.update(filters) + if hasattr(self.filtering_cog, "subscribe"): # Subscribe the filter list to any new events found. + self.filtering_cog.subscribe(self, *events) + return new_list + + @property + def filter_types(self) -> set[type[UniqueFilter]]: + """Return the types of filters used by this list.""" + return set(self.loaded_types.values()) diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py new file mode 100644 index 0000000000..f8b5b3189c --- /dev/null +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -0,0 +1,170 @@ +import re +import typing + +from discord import Embed, Invite +from discord.errors import NotFound +from pydis_core.utils.regex import DISCORD_INVITE + +import bot +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._filters.invite import InviteFilter +from bot.exts.filtering._settings import ActionSettings +from bot.exts.filtering._utils import clean_input + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + + +REFINED_INVITE_CODE = re.compile( + r"(?P[a-zA-Z0-9/_-]+)" # The supposedly real invite code. + r"(?:[^a-zA-Z0-9/].*)?" # Ignoring anything that may come after an invalid character. + r"$" # Up until the end of the string. +) + + +class InviteList(FilterList[InviteFilter]): + """ + A list of filters, each looking for guild invites to a specific guild. + + If the invite is not whitelisted, it will be blocked. Partnered and verified servers are allowed unless blacklisted. + + Whitelist defaults dictate what happens when an invite is *not* explicitly allowed, + and whitelist filters overrides have no effect. + + Blacklist defaults dictate what happens by default when an explicitly blocked invite is found. + + Items in the list are added through invites for the purpose of fetching the guild info. + Items are stored as guild IDs, guild invites are *not* stored. + """ + + name = "invite" + + def __init__(self, filtering_cog: Filtering): + super().__init__() + filtering_cog.subscribe(self, Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) + + def get_filter_type(self, content: str) -> type[Filter]: + """Get a subclass of filter matching the filter list and the filter's content.""" + return InviteFilter + + @property + def filter_types(self) -> set[type[Filter]]: + """Return the types of filters used by this list.""" + return {InviteFilter} + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + text = clean_input(ctx.content, keep_newlines=True) + + matches = list(DISCORD_INVITE.finditer(text)) + invite_codes = {m.group("invite") for m in matches} + if not invite_codes: + return None, [], {} + all_triggers = {} + + refined_invites = {} + for invite_code in invite_codes: + # Attempt to overcome an obfuscated invite. + # If the result is incorrect, it won't make the whitelist more permissive or the blacklist stricter. + refined_invite_code = invite_code + if match := REFINED_INVITE_CODE.search(invite_code): + refined_invite_code = match.group("invite") + refined_invites[invite_code] = refined_invite_code + + _, failed = self[ListType.ALLOW].defaults.validations.evaluate(ctx) + # If the allowed list doesn't operate in the context, unknown invites are allowed. + check_if_allowed = not failed + + # Sort the invites into two categories: + invites_for_inspection = dict() # Found guild invites requiring further inspection. + unknown_invites = dict() # Either don't resolve or group DMs. + for invite_code in refined_invites.values(): + try: + invite = await bot.instance.fetch_invite(invite_code) + except NotFound: + if check_if_allowed: + unknown_invites[invite_code] = None + else: + if invite.guild: + invites_for_inspection[invite_code] = invite + elif check_if_allowed: # Group DM + unknown_invites[invite_code] = invite + + # Find any blocked invites + new_ctx = ctx.replace(content={invite.guild.id for invite in invites_for_inspection.values()}) + triggered = await self[ListType.DENY].filter_list_result(new_ctx) + blocked_guilds = {filter_.content for filter_ in triggered} + blocked_invites = { + code: invite for code, invite in invites_for_inspection.items() if invite.guild.id in blocked_guilds + } + + # Remove the ones which are already confirmed as blocked, or otherwise ones which are partnered or verified. + invites_for_inspection = { + code: invite for code, invite in invites_for_inspection.items() + if invite.guild.id not in blocked_guilds + and "PARTNERED" not in invite.guild.features and "VERIFIED" not in invite.guild.features + } + + # Remove any remaining invites which are allowed + guilds_for_inspection = {invite.guild.id for invite in invites_for_inspection.values()} + + if check_if_allowed: # Whether unknown invites need to be checked. + new_ctx = ctx.replace(content=guilds_for_inspection) + all_triggers[ListType.ALLOW] = [ + filter_ for filter_ in self[ListType.ALLOW].filters.values() + if await filter_.triggered_on(new_ctx) + ] + allowed = {filter_.content for filter_ in all_triggers[ListType.ALLOW]} + unknown_invites.update({ + code: invite for code, invite in invites_for_inspection.items() if invite.guild.id not in allowed + }) + + if not triggered and not unknown_invites: + return None, [], all_triggers + + actions = None + if unknown_invites: # There are invites which weren't allowed but aren't explicitly blocked. + actions = self[ListType.ALLOW].defaults.actions + # Blocked invites come second so that their actions have preference. + if triggered: + if actions: + actions = actions.union(self[ListType.DENY].merge_actions(triggered)) + else: + actions = self[ListType.DENY].merge_actions(triggered) + all_triggers[ListType.DENY] = triggered + + blocked_invites |= unknown_invites + ctx.matches += {match[0] for match in matches if refined_invites.get(match.group("invite")) in blocked_invites} + ctx.alert_embeds += (self._guild_embed(invite) for invite in blocked_invites.values() if invite) + if unknown_invites: + ctx.potential_phish[self] = set(unknown_invites) + + messages = self[ListType.DENY].format_messages(triggered) + messages += [ + f"`{code} - {invite.guild.id}`" if invite else f"`{code}`" for code, invite in unknown_invites.items() + ] + return actions, messages, all_triggers + + @staticmethod + def _guild_embed(invite: Invite) -> Embed: + """Return an embed representing the guild invites to.""" + embed = Embed() + if invite.guild: + embed.title = invite.guild.name + embed.set_footer(text=f"Guild ID: {invite.guild.id}") + if invite.guild.icon is not None: + embed.set_thumbnail(url=invite.guild.icon.url) + else: + embed.title = "Group DM" + + embed.description = ( + f"**Invite Code:** {invite.code}\n" + f"**Members:** {invite.approximate_member_count}\n" + f"**Active:** {invite.approximate_presence_count}" + ) + + return embed diff --git a/bot/exts/filtering/_filter_lists/token.py b/bot/exts/filtering/_filter_lists/token.py new file mode 100644 index 0000000000..abe48dd612 --- /dev/null +++ b/bot/exts/filtering/_filter_lists/token.py @@ -0,0 +1,72 @@ +import re +import typing + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._filters.token import TokenFilter +from bot.exts.filtering._settings import ActionSettings +from bot.exts.filtering._utils import clean_input + +if typing.TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) + + +class TokensList(FilterList[TokenFilter]): + """ + A list of filters, each looking for a specific token in the given content given as regex. + + The blacklist defaults dictate what happens by default when a filter is matched, and can be overridden by + individual filters. + + Usually, if blocking literal strings, the literals themselves can be specified as the filter's value. + But since this is a list of regex patterns, be careful of the items added. For example, a dot needs to be escaped + to function as a literal dot. + """ + + name = "token" + + def __init__(self, filtering_cog: Filtering): + super().__init__() + filtering_cog.subscribe( + self, Event.MESSAGE, Event.MESSAGE_EDIT, Event.NICKNAME, Event.THREAD_NAME, Event.SNEKBOX + ) + + def get_filter_type(self, content: str) -> type[Filter]: + """Get a subclass of filter matching the filter list and the filter's content.""" + return TokenFilter + + @property + def filter_types(self) -> set[type[Filter]]: + """Return the types of filters used by this list.""" + return {TokenFilter} + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + text = ctx.content + if not text: + return None, [], {} + if SPOILER_RE.search(text): + text = self._expand_spoilers(text) + text = clean_input(text) + ctx = ctx.replace(content=text) + + triggers = await self[ListType.DENY].filter_list_result(ctx) + actions = None + messages = [] + if triggers: + actions = self[ListType.DENY].merge_actions(triggers) + messages = self[ListType.DENY].format_messages(triggers) + return actions, messages, {ListType.DENY: triggers} + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return "".join( + split_text[0::2] + split_text[1::2] + split_text + ) diff --git a/bot/exts/filtering/_filter_lists/unique.py b/bot/exts/filtering/_filter_lists/unique.py new file mode 100644 index 0000000000..a5a04d25af --- /dev/null +++ b/bot/exts/filtering/_filter_lists/unique.py @@ -0,0 +1,39 @@ +from pydis_core.utils.logging import get_logger + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filter_lists.filter_list import ListType, UniquesListBase +from bot.exts.filtering._filters.filter import Filter, UniqueFilter +from bot.exts.filtering._filters.unique import unique_filter_types +from bot.exts.filtering._settings import ActionSettings + +log = get_logger(__name__) + + +class UniquesList(UniquesListBase): + """ + A list of unique filters. + + Unique filters are ones that should only be run once in a given context. + Each unique filter subscribes to a subset of events to respond to. + """ + + name = "unique" + + def get_filter_type(self, content: str) -> type[UniqueFilter] | None: + """Get a subclass of filter matching the filter list and the filter's content.""" + try: + return unique_filter_types[content] + except KeyError: + return None + + async def actions_for( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]: + """Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods.""" + triggers = await self[ListType.DENY].filter_list_result(ctx) + actions = None + messages = [] + if triggers: + actions = self[ListType.DENY].merge_actions(triggers) + messages = self[ListType.DENY].format_messages(triggers) + return actions, messages, {ListType.DENY: triggers} diff --git a/tests/bot/exts/filters/__init__.py b/bot/exts/filtering/_filters/__init__.py similarity index 100% rename from tests/bot/exts/filters/__init__.py rename to bot/exts/filtering/_filters/__init__.py diff --git a/bot/exts/filtering/_filters/antispam/__init__.py b/bot/exts/filtering/_filters/antispam/__init__.py new file mode 100644 index 0000000000..637bcd4103 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/__init__.py @@ -0,0 +1,9 @@ +from os.path import dirname + +from bot.exts.filtering._filters.filter import UniqueFilter +from bot.exts.filtering._utils import subclasses_in_package + +antispam_filter_types = subclasses_in_package(dirname(__file__), f"{__name__}.", UniqueFilter) +antispam_filter_types = {filter_.name: filter_ for filter_ in antispam_filter_types} + +__all__ = [antispam_filter_types] diff --git a/bot/exts/filtering/_filters/antispam/attachments.py b/bot/exts/filtering/_filters/antispam/attachments.py new file mode 100644 index 0000000000..216d9b886e --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/attachments.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraAttachmentsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of attachments before the filter is triggered." + + interval: int = 10 + threshold: int = 6 + + +class AttachmentsFilter(UniqueFilter): + """Detects too many attachments sent by a single user.""" + + name = "attachments" + events = (Event.MESSAGE,) + extra_fields_type = ExtraAttachmentsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author and len(msg.attachments) > 0} + total_recent_attachments = sum(len(msg.attachments) for msg in detected_messages) + + if total_recent_attachments > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_attachments} attachments" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/burst.py b/bot/exts/filtering/_filters/antispam/burst.py new file mode 100644 index 0000000000..d78107d0a4 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/burst.py @@ -0,0 +1,41 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraBurstSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of messages before the filter is triggered." + + interval: int = 10 + threshold: int = 7 + + +class BurstFilter(UniqueFilter): + """Detects too many messages sent by a single user.""" + + name = "burst" + events = (Event.MESSAGE,) + extra_fields_type = ExtraBurstSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + if len(detected_messages) > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {len(detected_messages)} messages" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/chars.py b/bot/exts/filtering/_filters/antispam/chars.py new file mode 100644 index 0000000000..1b28b781cd --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/chars.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraCharsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of characters before the filter is triggered." + + interval: int = 5 + threshold: int = 4_200 + + +class CharsFilter(UniqueFilter): + """Detects too many characters sent by a single user.""" + + name = "chars" + events = (Event.MESSAGE,) + extra_fields_type = ExtraCharsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + total_recent_chars = sum(len(msg.content) for msg in detected_messages) + + if total_recent_chars > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_chars} characters" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/duplicates.py b/bot/exts/filtering/_filters/antispam/duplicates.py new file mode 100644 index 0000000000..60d5c322cf --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/duplicates.py @@ -0,0 +1,44 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraDuplicatesSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of duplicate messages before the filter is triggered." + + interval: int = 10 + threshold: int = 3 + + +class DuplicatesFilter(UniqueFilter): + """Detects duplicated messages sent by a single user.""" + + name = "duplicates" + events = (Event.MESSAGE,) + extra_fields_type = ExtraDuplicatesSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + + detected_messages = { + msg for msg in relevant_messages + if msg.author == ctx.author and msg.content == ctx.message.content and msg.content + } + if len(detected_messages) > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {len(detected_messages)} duplicate messages" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/emoji.py b/bot/exts/filtering/_filters/antispam/emoji.py new file mode 100644 index 0000000000..b3bc63c1d0 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/emoji.py @@ -0,0 +1,53 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from emoji import demojize +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) + + +class ExtraEmojiSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of emojis before the filter is triggered." + + interval: int = 10 + threshold: int = 20 + + +class EmojiFilter(UniqueFilter): + """Detects too many emojis sent by a single user.""" + + name = "emoji" + events = (Event.MESSAGE,) + extra_fields_type = ExtraEmojiSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + # Get rid of code blocks in the message before searching for emojis. + # Convert Unicode emojis to :emoji: format to get their count. + total_emojis = sum( + len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content)))) + for msg in detected_messages + ) + + if total_emojis > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_emojis} emojis" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/links.py b/bot/exts/filtering/_filters/antispam/links.py new file mode 100644 index 0000000000..0a2a98fc84 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/links.py @@ -0,0 +1,52 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +LINK_RE = re.compile(r"(https?://\S+)") + + +class ExtraLinksSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of links before the filter is triggered." + + interval: int = 10 + threshold: int = 10 + + +class LinksFilter(UniqueFilter): + """Detects too many links sent by a single user.""" + + name = "links" + events = (Event.MESSAGE,) + extra_fields_type = ExtraLinksSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + total_links = 0 + messages_with_links = 0 + for msg in detected_messages: + total_matches = len(LINK_RE.findall(msg.content)) + if total_matches: + messages_with_links += 1 + total_links += total_matches + + if total_links > self.extra_fields.threshold and messages_with_links > 1: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_links} links" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/mentions.py b/bot/exts/filtering/_filters/antispam/mentions.py new file mode 100644 index 0000000000..1f26163284 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/mentions.py @@ -0,0 +1,90 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from discord import DeletedReferencedMessage, MessageType, NotFound +from pydantic import BaseModel +from pydis_core.utils.logging import get_logger + +import bot +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +log = get_logger(__name__) + + +class ExtraMentionsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of distinct mentions before the filter is triggered." + + interval: int = 10 + threshold: int = 5 + + +class MentionsFilter(UniqueFilter): + """ + Detects total mentions exceeding the limit sent by a single user. + + Excludes mentions that are bots, themselves, or replied users. + + In very rare cases, may not be able to determine a + mention was to a reply, in which case it is not ignored. + """ + + name = "mentions" + events = (Event.MESSAGE,) + extra_fields_type = ExtraMentionsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + # We use `msg.mentions` here as that is supplied by the api itself, to determine who was mentioned. + # Additionally, `msg.mentions` includes the user replied to, even if the mention doesn't occur in the body. + # In order to exclude users who are mentioned as a reply, we check if the msg has a reference + # + # While we could use regex to parse the message content, and get a list of + # the mentions, that solution is very prone to breaking. + # We would need to deal with codeblocks, escaping markdown, and any discrepancies between + # our implementation and discord's Markdown parser which would cause false positives or false negatives. + total_recent_mentions = 0 + for msg in detected_messages: + # We check if the message is a reply, and if it is try to get the author + # since we ignore mentions of a user that we're replying to + reply_author = None + + if msg.type == MessageType.reply: + ref = msg.reference + + if not (resolved := ref.resolved): + # It is possible, in a very unusual situation, for a message to have a reference + # that is both not in the cache, and deleted while running this function. + # In such a situation, this will throw an error which we catch. + try: + resolved = await bot.instance.get_partial_messageable(ref.channel_id).fetch_message( + ref.message_id + ) + except NotFound: + log.info("Could not fetch the reference message as it has been deleted.") + + if resolved and not isinstance(resolved, DeletedReferencedMessage): + reply_author = resolved.author + + for user in msg.mentions: + # Don't count bot or self mentions, or the user being replied to (if applicable) + if user.bot or user in {msg.author, reply_author}: + continue + total_recent_mentions += 1 + + if total_recent_mentions > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_mentions} mentions" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/newlines.py b/bot/exts/filtering/_filters/antispam/newlines.py new file mode 100644 index 0000000000..f3830fdd75 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/newlines.py @@ -0,0 +1,61 @@ +import re +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +NEWLINES = re.compile(r"(\n+)") + + +class ExtraNewlinesSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of newlines before the filter is triggered." + consecutive_threshold_description: ClassVar[str] = ( + "Maximum number of consecutive newlines before the filter is triggered." + ) + + interval: int = 10 + threshold: int = 100 + consecutive_threshold: int = 10 + + +class NewlinesFilter(UniqueFilter): + """Detects too many newlines sent by a single user.""" + + name = "newlines" + events = (Event.MESSAGE,) + extra_fields_type = ExtraNewlinesSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + + # Identify groups of newline characters and get group & total counts + newline_counts = [] + for msg in detected_messages: + newline_counts += [len(group) for group in NEWLINES.findall(msg.content)] + total_recent_newlines = sum(newline_counts) + # Get maximum newline group size + max_newline_group = max(newline_counts, default=0) + + # Check first for total newlines, if this passes then check for large groupings + if total_recent_newlines > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_newlines} newlines" + return True + if max_newline_group > self.extra_fields.consecutive_threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {max_newline_group} consecutive newlines" + return True + return False diff --git a/bot/exts/filtering/_filters/antispam/role_mentions.py b/bot/exts/filtering/_filters/antispam/role_mentions.py new file mode 100644 index 0000000000..af8bf9bf95 --- /dev/null +++ b/bot/exts/filtering/_filters/antispam/role_mentions.py @@ -0,0 +1,42 @@ +from datetime import timedelta +from itertools import takewhile +from typing import ClassVar + +import arrow +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + + +class ExtraRoleMentionsSettings(BaseModel): + """Extra settings for when to trigger the antispam rule.""" + + interval_description: ClassVar[str] = ( + "Look for rule violations in messages from the last `interval` number of seconds." + ) + threshold_description: ClassVar[str] = "Maximum number of role mentions before the filter is triggered." + + interval: int = 10 + threshold: int = 3 + + +class RoleMentionsFilter(UniqueFilter): + """Detects too many role mentions sent by a single user.""" + + name = "role_mentions" + events = (Event.MESSAGE,) + extra_fields_type = ExtraRoleMentionsSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.extra_fields.interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, ctx.content)) + detected_messages = {msg for msg in relevant_messages if msg.author == ctx.author} + total_recent_mentions = sum(len(msg.role_mentions) for msg in detected_messages) + + if total_recent_mentions > self.extra_fields.threshold: + ctx.related_messages |= detected_messages + ctx.filter_info[self] = f"sent {total_recent_mentions} role mentions" + return True + return False diff --git a/bot/exts/filtering/_filters/domain.py b/bot/exts/filtering/_filters/domain.py new file mode 100644 index 0000000000..c3f7f28865 --- /dev/null +++ b/bot/exts/filtering/_filters/domain.py @@ -0,0 +1,62 @@ +import re +from typing import ClassVar +from urllib.parse import urlparse + +import tldextract +from discord.ext.commands import BadArgument +from pydantic import BaseModel + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filters.filter import Filter + +URL_RE = re.compile(r"(?:https?://)?(\S+?)[\\/]*", flags=re.IGNORECASE) + + +class ExtraDomainSettings(BaseModel): + """Extra settings for how domains should be matched in a message.""" + + only_subdomains_description: ClassVar[str] = ( + "A boolean. If True, will only trigger for subdomains and subpaths, and not for the domain itself." + ) + + # Whether to trigger only for subdomains and subpaths, and not the specified domain itself. + only_subdomains: bool = False + + +class DomainFilter(Filter): + """ + A filter which looks for a specific domain given by URL. + + The schema (http, https) does not need to be included in the filter. + Will also match subdomains. + """ + + name = "domain" + extra_fields_type = ExtraDomainSettings + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Searches for a domain within a given context.""" + domain = tldextract.extract(self.content).registered_domain.lower() + + for found_url in ctx.content: + extract = tldextract.extract(found_url) + if self.content.lower() in found_url and extract.registered_domain == domain: + if self.extra_fields.only_subdomains: + if not extract.subdomain and not urlparse(f"https://{found_url}").path: + return False + ctx.matches.append(found_url) + ctx.notification_domain = self.content + return True + return False + + @classmethod + async def process_input(cls, content: str, description: str) -> tuple[str, str]: + """ + Process the content and description into a form which will work with the filtering. + + A BadArgument should be raised if the content can't be used. + """ + match = URL_RE.fullmatch(content) + if not match or not match.group(1): + raise BadArgument(f"`{content}` is not a URL.") + return match.group(1), description diff --git a/bot/exts/filtering/_filters/extension.py b/bot/exts/filtering/_filters/extension.py new file mode 100644 index 0000000000..97eddc406d --- /dev/null +++ b/bot/exts/filtering/_filters/extension.py @@ -0,0 +1,27 @@ +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filters.filter import Filter + + +class ExtensionFilter(Filter): + """ + A filter which looks for a specific attachment extension in messages. + + The filter stores the extension preceded by a dot. + """ + + name = "extension" + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Searches for an attachment extension in the context content, given as a set of extensions.""" + return self.content in ctx.content + + @classmethod + async def process_input(cls, content: str, description: str) -> tuple[str, str]: + """ + Process the content and description into a form which will work with the filtering. + + A BadArgument should be raised if the content can't be used. + """ + if not content.startswith("."): + content = f".{content}" + return content, description diff --git a/bot/exts/filtering/_filters/filter.py b/bot/exts/filtering/_filters/filter.py new file mode 100644 index 0000000000..3f201cfde4 --- /dev/null +++ b/bot/exts/filtering/_filters/filter.py @@ -0,0 +1,94 @@ +from abc import ABC, abstractmethod +from typing import Any + +import arrow +from pydantic import ValidationError + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._settings import Defaults, create_settings +from bot.exts.filtering._utils import FieldRequiring + + +class Filter(FieldRequiring): + """ + A class representing a filter. + + Each filter looks for a specific attribute within an event (such as message sent), + and defines what action should be performed if it is triggered. + """ + + # Each subclass must define a name which will be used to fetch its description. + # Names must be unique across all types of filters. + name = FieldRequiring.MUST_SET_UNIQUE + # If a subclass uses extra fields, it should assign the pydantic model type to this variable. + extra_fields_type = None + + def __init__(self, filter_data: dict, defaults: Defaults | None = None): + self.id = filter_data["id"] + self.content = filter_data["content"] + self.description = filter_data["description"] + self.created_at = arrow.get(filter_data["created_at"]) + self.updated_at = arrow.get(filter_data["updated_at"]) + self.actions, self.validations = create_settings(filter_data["settings"], defaults=defaults) + if self.extra_fields_type: + self.extra_fields = self.extra_fields_type.model_validate(filter_data["additional_settings"]) + else: + self.extra_fields = None + + @property + def overrides(self) -> tuple[dict[str, Any], dict[str, Any]]: + """Return a tuple of setting overrides and filter setting overrides.""" + settings = {} + if self.actions: + settings = self.actions.overrides + if self.validations: + settings |= self.validations.overrides + + filter_settings = {} + if self.extra_fields: + filter_settings = self.extra_fields.model_dump(exclude_unset=True) + + return settings, filter_settings + + @abstractmethod + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + + @classmethod + def validate_filter_settings(cls, extra_fields: dict) -> tuple[bool, str | None]: + """Validate whether the supplied fields are valid for the filter, and provide the error message if not.""" + if cls.extra_fields_type is None: + return True, None + + try: + cls.extra_fields_type(**extra_fields) + except ValidationError as e: + return False, repr(e) + else: + return True, None + + @classmethod + async def process_input(cls, content: str, description: str) -> tuple[str, str]: + """ + Process the content and description into a form which will work with the filtering. + + A BadArgument should be raised if the content can't be used. + """ + return content, description + + def __str__(self) -> str: + """A string representation of the filter.""" + string = fr"{self.id}\. `{self.content}`" + if self.description: + string += f" - {self.description}" + return string + + +class UniqueFilter(Filter, ABC): + """ + Unique filters are ones that should only be run once in a given context. + + This is as opposed to say running many domain filters on the same message. + """ + + events: tuple[Event, ...] = FieldRequiring.MUST_SET diff --git a/bot/exts/filtering/_filters/invite.py b/bot/exts/filtering/_filters/invite.py new file mode 100644 index 0000000000..d3a2621f0b --- /dev/null +++ b/bot/exts/filtering/_filters/invite.py @@ -0,0 +1,54 @@ +import re + +from discord import NotFound +from discord.ext.commands import BadArgument +from pydis_core.utils.regex import DISCORD_INVITE + +import bot +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filters.filter import Filter + + +class InviteFilter(Filter): + """ + A filter which looks for invites to a specific guild in messages. + + The filter stores the guild ID which is allowed or denied. + """ + + name = "invite" + + def __init__(self, filter_data: dict, defaults_data: dict | None = None): + super().__init__(filter_data, defaults_data) + self.content = int(self.content) + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Searches for a guild ID in the context content, given as a set of IDs.""" + return self.content in ctx.content + + @classmethod + async def process_input(cls, content: str, description: str) -> tuple[str, str]: + """ + Process the content and description into a form which will work with the filtering. + + A BadArgument should be raised if the content can't be used. + """ + match = DISCORD_INVITE.fullmatch(content) + if not match or not match.group("invite"): + if not re.fullmatch(r"\S+", content): + raise BadArgument(f"`{content}` is not a valid Discord invite.") + invite_code = content + else: + invite_code = match.group("invite") + + try: + invite = await bot.instance.fetch_invite(invite_code) + except NotFound: + raise BadArgument(f"`{invite_code}` is not a valid Discord invite code.") + if not invite.guild: + raise BadArgument("Did you just try to add a group DM?") + + guild_name = invite.guild.name if hasattr(invite.guild, "name") else "" + if guild_name.lower() not in description.lower(): + description = " - ".join(part for part in (f'Guild "{guild_name}"', description) if part) + return str(invite.guild.id), description diff --git a/bot/exts/filtering/_filters/token.py b/bot/exts/filtering/_filters/token.py new file mode 100644 index 0000000000..3cd9b909d1 --- /dev/null +++ b/bot/exts/filtering/_filters/token.py @@ -0,0 +1,35 @@ +import re + +from discord.ext.commands import BadArgument + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filters.filter import Filter + + +class TokenFilter(Filter): + """A filter which looks for a specific token given by regex.""" + + name = "token" + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Searches for a regex pattern within a given context.""" + pattern = self.content + + match = re.search(pattern, ctx.content, flags=re.IGNORECASE) + if match: + ctx.matches.append(match[0]) + return True + return False + + @classmethod + async def process_input(cls, content: str, description: str) -> tuple[str, str]: + """ + Process the content and description into a form which will work with the filtering. + + A BadArgument should be raised if the content can't be used. + """ + try: + re.compile(content) + except re.error as e: + raise BadArgument(str(e)) + return content, description diff --git a/bot/exts/filtering/_filters/unique/__init__.py b/bot/exts/filtering/_filters/unique/__init__.py new file mode 100644 index 0000000000..ce78d69228 --- /dev/null +++ b/bot/exts/filtering/_filters/unique/__init__.py @@ -0,0 +1,9 @@ +from os.path import dirname + +from bot.exts.filtering._filters.filter import UniqueFilter +from bot.exts.filtering._utils import subclasses_in_package + +unique_filter_types = subclasses_in_package(dirname(__file__), f"{__name__}.", UniqueFilter) +unique_filter_types = {filter_.name: filter_ for filter_ in unique_filter_types} + +__all__ = [unique_filter_types] diff --git a/bot/exts/filtering/_filters/unique/discord_token.py b/bot/exts/filtering/_filters/unique/discord_token.py new file mode 100644 index 0000000000..1745fa86cc --- /dev/null +++ b/bot/exts/filtering/_filters/unique/discord_token.py @@ -0,0 +1,217 @@ +import base64 +import re +from collections.abc import Callable, Coroutine +from typing import ClassVar, NamedTuple + +import discord +from pydantic import BaseModel, Field +from pydis_core.utils.logging import get_logger +from pydis_core.utils.members import get_or_fetch_member + +import bot +from bot import constants, utils +from bot.constants import Guild +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter +from bot.exts.filtering._utils import resolve_mention +from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user + +log = get_logger(__name__) + + +LOG_MESSAGE = ( + "Censored a seemingly valid token sent by {author} in {channel}. " + "Token was: `{user_id}.{timestamp}.{hmac}`." +) +UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." +KNOWN_USER_LOG_MESSAGE = ( + "Decoded user ID: `{user_id}` **(Present in server)**.\n" + "This matches `{user_name}` and means this is likely a valid **{kind}** token." +) +DISCORD_EPOCH = 1_420_070_400 +TOKEN_EPOCH = 1_293_840_000 + +# Three parts delimited by dots: user ID, creation timestamp, HMAC. +# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. +# Each part only matches base64 URL-safe characters. +# These regexes were taken from discord-developers, which are used by the client itself. +TOKEN_RE = re.compile(r"([\w-]{10,})\.([\w-]{5,})\.([\w-]{10,})") + + +class ExtraDiscordTokenSettings(BaseModel): + """Extra settings for who should be pinged when a Discord token is detected.""" + + pings_for_bot_description: ClassVar[str] = "A sequence. Who should be pinged if the token found belongs to a bot." + pings_for_user_description: ClassVar[str] = "A sequence. Who should be pinged if the token found belongs to a user." + + pings_for_bot: set[str] = Field(default_factory=set) + pings_for_user: set[str] = Field(default_factory=lambda: {"Moderators"}) + + +class Token(NamedTuple): + """A Discord Bot token.""" + + user_id: str + timestamp: str + hmac: str + + +class DiscordTokenFilter(UniqueFilter): + """Scans messages for potential discord client tokens and removes them.""" + + name = "discord_token" + events = (Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) + extra_fields_type = ExtraDiscordTokenSettings + + @property + def mod_log(self) -> ModLog | None: + """Get currently loaded ModLog cog instance.""" + return bot.instance.get_cog("ModLog") + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Return whether the message contains Discord client tokens.""" + found_token = self.find_token_in_message(ctx.content) + if not found_token: + return False + + if ctx.message and (mod_log := self.mod_log): + mod_log.ignore(constants.Event.message_delete, ctx.message.id) + ctx.content = ctx.content.replace(found_token.hmac, self.censor_hmac(found_token.hmac)) + ctx.additional_actions.append(self._create_token_alert_embed_wrapper(found_token)) + return True + + def _create_token_alert_embed_wrapper(self, found_token: Token) -> Callable[[FilterContext], Coroutine]: + """Create the action to perform when an alert should be sent for a message containing a Discord token.""" + async def _create_token_alert_embed(ctx: FilterContext) -> None: + """Add an alert embed to the context with info about the token sent.""" + userid_message, is_user = await self.format_userid_log_message(found_token) + log_message = self.format_log_message(ctx.author, ctx.channel, found_token) + log.debug(log_message) + + if is_user: + mentions = map(resolve_mention, self.extra_fields.pings_for_user) + color = discord.Colour.red() + else: + mentions = map(resolve_mention, self.extra_fields.pings_for_bot) + color = discord.Colour.blue() + unmentioned = [mention for mention in mentions if mention not in ctx.alert_content] + if unmentioned: + ctx.alert_content = f"{' '.join(unmentioned)} {ctx.alert_content}" + ctx.alert_embeds.append(discord.Embed(colour=color, description=userid_message)) + + return _create_token_alert_embed + + @classmethod + async def format_userid_log_message(cls, token: Token) -> tuple[str, bool]: + """ + Format the portion of the log message that includes details about the detected user ID. + + If the user is resolved to a member, the format includes the user ID, name, and the + kind of user detected. + If it is resolved to a user or a member, and it is not a bot, also return True. + Returns a tuple of (log_message, is_user) + """ + user_id = cls.extract_user_id(token.user_id) + guild = bot.instance.get_guild(Guild.id) + user = await get_or_fetch_member(guild, user_id) + + if user: + return KNOWN_USER_LOG_MESSAGE.format( + user_id=user_id, + user_name=str(user), + kind="BOT" if user.bot else "USER", + ), True + return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False + + @staticmethod + def censor_hmac(hmac: str) -> str: + """Return a censored version of the hmac.""" + return "x" * (len(hmac) - 3) + hmac[-3:] + + @classmethod + def format_log_message(cls, author: discord.User, channel: discord.abc.GuildChannel, token: Token) -> str: + """Return the generic portion of the log message to send for `token` being censored in `msg`.""" + return LOG_MESSAGE.format( + author=format_user(author), + channel=channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac=cls.censor_hmac(token.hmac), + ) + + @classmethod + def find_token_in_message(cls, content: str) -> Token | None: + """Return a seemingly valid token found in `content` or `None` if no token is found.""" + # Use finditer rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + for match in TOKEN_RE.finditer(content): + token = Token(*match.groups()) + if ( + (cls.extract_user_id(token.user_id) is not None) + and cls.is_valid_timestamp(token.timestamp) + and cls.is_maybe_valid_hmac(token.hmac) + ): + # Short-circuit on first match + return token + + # No matching substring + return None + + @staticmethod + def extract_user_id(b64_content: str) -> int | None: + """Return a user ID integer from part of a potential token, or None if it couldn't be decoded.""" + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + string = decoded_bytes.decode("utf-8") + if not (string.isascii() and string.isdigit()): + # This case triggers if there are fancy unicode digits in the base64 encoding, + # that means it's not a valid user id. + return None + return int(string) + except ValueError: + return None + + @staticmethod + def is_valid_timestamp(b64_content: str) -> bool: + """ + Return True if `b64_content` decodes to a valid timestamp. + + If the timestamp is greater than the Discord epoch, it's probably valid. + See: https://i.imgur.com/7WdehGn.png + """ + b64_content = utils.pad_base64(b64_content) + + try: + decoded_bytes = base64.urlsafe_b64decode(b64_content) + timestamp = int.from_bytes(decoded_bytes, byteorder="big") + except ValueError as e: + log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") + return False + + # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound + # is not checked. + if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: + return True + + log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") + return False + + @staticmethod + def is_maybe_valid_hmac(b64_content: str) -> bool: + """ + Determine if a given HMAC portion of a token is potentially valid. + + If the HMAC has 3 or fewer characters, it's probably a dummy value like "xxxxxxxxxx", + and thus the token can probably be skipped. + """ + unique = len(set(b64_content.lower())) + if unique <= 3: + log.debug( + f"Considering the HMAC {b64_content} a dummy because it has {unique}" + " case-insensitively unique characters" + ) + return False + return True diff --git a/bot/exts/filtering/_filters/unique/everyone.py b/bot/exts/filtering/_filters/unique/everyone.py new file mode 100644 index 0000000000..e49ede82fd --- /dev/null +++ b/bot/exts/filtering/_filters/unique/everyone.py @@ -0,0 +1,28 @@ +import re + +from bot.constants import Guild +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter + +EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here") +CODE_BLOCK_RE = re.compile( + r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock + r"|```(.+?)```", # Multiline codeblock + re.DOTALL | re.MULTILINE +) + + +class EveryoneFilter(UniqueFilter): + """Filter messages which contain `@everyone` and `@here` tags outside a codeblock.""" + + name = "everyone" + events = (Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for the filter's content within a given context.""" + # First pass to avoid running re.sub on every message + if not EVERYONE_PING_RE.search(ctx.content): + return False + + content_without_codeblocks = CODE_BLOCK_RE.sub("", ctx.content) + return bool(EVERYONE_PING_RE.search(content_without_codeblocks)) diff --git a/bot/exts/filtering/_filters/unique/webhook.py b/bot/exts/filtering/_filters/unique/webhook.py new file mode 100644 index 0000000000..4e1e2e44df --- /dev/null +++ b/bot/exts/filtering/_filters/unique/webhook.py @@ -0,0 +1,63 @@ +import re +from collections.abc import Callable, Coroutine + +from pydis_core.utils.logging import get_logger + +import bot +from bot import constants +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.filter import UniqueFilter +from bot.exts.moderation.modlog import ModLog + +log = get_logger(__name__) + + +WEBHOOK_URL_RE = re.compile( + r"((?:https?://)?(?:ptb\.|canary\.)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", + re.IGNORECASE +) + + +class WebhookFilter(UniqueFilter): + """Scan messages to detect Discord webhooks links.""" + + name = "webhook" + events = (Event.MESSAGE, Event.MESSAGE_EDIT, Event.SNEKBOX) + + @property + def mod_log(self) -> ModLog | None: + """Get current instance of `ModLog`.""" + return bot.instance.get_cog("ModLog") + + async def triggered_on(self, ctx: FilterContext) -> bool: + """Search for a webhook in the given content. If found, attempt to delete it.""" + matches = set(WEBHOOK_URL_RE.finditer(ctx.content)) + if not matches: + return False + + # Don't log this. + if ctx.message and (mod_log := self.mod_log): + mod_log.ignore(constants.Event.message_delete, ctx.message.id) + + for i, match in enumerate(matches, start=1): + extra = "" if len(matches) == 1 else f" ({i})" + # Queue the webhook for deletion. + ctx.additional_actions.append(self._delete_webhook_wrapper(match[0], extra)) + # Don't show the full webhook in places such as the mod alert. + ctx.content = ctx.content.replace(match[0], match[1] + "xxx") + + return True + + @staticmethod + def _delete_webhook_wrapper(webhook_url: str, extra_message: str) -> Callable[[FilterContext], Coroutine]: + """Create the action to perform when a webhook should be deleted.""" + async def _delete_webhook(ctx: FilterContext) -> None: + """Delete the given webhook and update the filter context.""" + async with bot.instance.http_session.delete(webhook_url) as resp: + # The Discord API Returns a 204 NO CONTENT response on success. + if resp.status == 204: + ctx.action_descriptions.append("webhook deleted" + extra_message) + else: + ctx.action_descriptions.append("failed to delete webhook" + extra_message) + + return _delete_webhook diff --git a/bot/exts/filtering/_settings.py b/bot/exts/filtering/_settings.py new file mode 100644 index 0000000000..7bf308f9f2 --- /dev/null +++ b/bot/exts/filtering/_settings.py @@ -0,0 +1,229 @@ +import operator +import traceback +from abc import abstractmethod +from copy import copy +from functools import reduce +from typing import Any, NamedTuple, Self, TypeVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types import settings_types +from bot.exts.filtering._settings_types.settings_entry import ActionEntry, SettingsEntry, ValidationEntry +from bot.exts.filtering._utils import FieldRequiring +from bot.log import get_logger + +TSettings = TypeVar("TSettings", bound="Settings") + +log = get_logger(__name__) + +_already_warned: set[str] = set() + +T = TypeVar("T", bound=SettingsEntry) + + +def create_settings( + settings_data: dict, *, defaults: Defaults | None = None, keep_empty: bool = False +) -> tuple[ActionSettings | None, ValidationSettings | None]: + """ + Create and return instances of the Settings subclasses from the given data. + + Additionally, warn for data entries with no matching class. + + In case these are setting overrides, the defaults can be provided to keep track of the correct values. + """ + action_data = {} + validation_data = {} + for entry_name, entry_data in settings_data.items(): + if entry_name in settings_types["ActionEntry"]: + action_data[entry_name] = entry_data + elif entry_name in settings_types["ValidationEntry"]: + validation_data[entry_name] = entry_data + elif entry_name not in _already_warned: + log.warning( + f"A setting named {entry_name} was loaded from the database, but no matching class." + ) + _already_warned.add(entry_name) + if defaults is None: + default_actions = None + default_validations = None + else: + default_actions, default_validations = defaults + return ( + ActionSettings.create(action_data, defaults=default_actions, keep_empty=keep_empty), + ValidationSettings.create(validation_data, defaults=default_validations, keep_empty=keep_empty) + ) + + +class Settings(FieldRequiring, dict[str, T]): + """ + A collection of settings. + + For processing the settings parts in the database and evaluating them on given contexts. + + Each filter list and filter has its own settings. + + A filter doesn't have to have its own settings. For every undefined setting, it falls back to the value defined in + the filter list which contains the filter. + """ + + entry_type: type[T] + + _already_warned: set[str] = set() + + @abstractmethod # ABCs have to have at least once abstract method to actually count as such. + def __init__(self, settings_data: dict, *, defaults: Settings | None = None, keep_empty: bool = False): + super().__init__() + + entry_classes = settings_types.get(self.entry_type.__name__) + for entry_name, entry_data in settings_data.items(): + try: + entry_cls = entry_classes[entry_name] + except KeyError: + if entry_name not in self._already_warned: + log.warning( + f"A setting named {entry_name} was loaded from the database, " + f"but no matching {self.entry_type.__name__} class." + ) + self._already_warned.add(entry_name) + else: + try: + entry_defaults = None if defaults is None else defaults[entry_name] + new_entry = entry_cls.create( + entry_data, defaults=entry_defaults, keep_empty=keep_empty + ) + if new_entry: + self[entry_name] = new_entry + except TypeError as e: + raise TypeError( + f"Attempted to load a {entry_name} setting, but the response is malformed: {entry_data}" + ) from e + + @property + def overrides(self) -> dict[str, Any]: + """Return a dictionary of overrides across all entries.""" + return reduce(operator.or_, (entry.overrides for entry in self.values() if entry), {}) + + def copy(self: TSettings) -> TSettings: + """Create a shallow copy of the object.""" + return copy(self) + + def get_setting(self, key: str, default: Any | None = None) -> Any: + """Get the setting matching the key, or fall back to the default value if the key is missing.""" + for entry in self.values(): + if hasattr(entry, key): + return getattr(entry, key) + return default + + @classmethod + def create( + cls, settings_data: dict, *, defaults: Settings | None = None, keep_empty: bool = False + ) -> Settings | None: + """ + Returns a Settings object from `settings_data` if it holds any value, None otherwise. + + Use this method to create Settings objects instead of the init. + The None value is significant for how a filter list iterates over its filters. + """ + settings = cls(settings_data, defaults=defaults, keep_empty=keep_empty) + # If an entry doesn't hold any values, its `create` method will return None. + # If all entries are None, then the settings object holds no values. + if not keep_empty and not any(settings.values()): + return None + + return settings + + +class ValidationSettings(Settings[ValidationEntry]): + """ + A collection of validation settings. + + A filter is triggered only if all of its validation settings (e.g whether to invoke in DM) approve + (the check returns True). + """ + + entry_type = ValidationEntry + + def __init__(self, settings_data: dict, *, defaults: Settings | None = None, keep_empty: bool = False): + super().__init__(settings_data, defaults=defaults, keep_empty=keep_empty) + + def evaluate(self, ctx: FilterContext) -> tuple[set[str], set[str]]: + """Evaluates for each setting whether the context is relevant to the filter.""" + passed = set() + failed = set() + + for name, validation in self.items(): + if validation: + if validation.triggers_on(ctx): + passed.add(name) + else: + failed.add(name) + + return passed, failed + + +class ActionSettings(Settings[ActionEntry]): + """ + A collection of action settings. + + If a filter is triggered, its action settings (e.g how to infract the user) are combined with the action settings of + other triggered filters in the same event, and action is taken according to the combined action settings. + """ + + entry_type = ActionEntry + + def __init__(self, settings_data: dict, *, defaults: Settings | None = None, keep_empty: bool = False): + super().__init__(settings_data, defaults=defaults, keep_empty=keep_empty) + + def union(self, other: Self) -> Self: + """Combine the entries of two collections of settings into a new ActionsSettings.""" + actions = {} + # A settings object doesn't necessarily have all types of entries (e.g in the case of filter overrides). + for entry in self: + if entry in other: + actions[entry] = self[entry].union(other[entry]) + else: + actions[entry] = self[entry] + for entry in other: + if entry not in actions: + actions[entry] = other[entry] + + result = ActionSettings({}) + result.update(actions) + return result + + async def action(self, ctx: FilterContext) -> None: + """Execute the action of every action entry stored, as well as any additional actions in the context.""" + for entry in self.values(): + try: + await entry.action(ctx) + # Filtering should not stop even if one type of action raised an exception. + # For example, if deleting the message raised somehow, it should still try to infract the user. + except Exception: + log.exception(traceback.format_exc()) + + for action in ctx.additional_actions: + try: + await action(ctx) + except Exception: + log.exception(traceback.format_exc()) + + def fallback_to(self, fallback: ActionSettings) -> ActionSettings: + """Fill in missing entries from `fallback`.""" + new_actions = self.copy() + for entry_name, entry_value in fallback.items(): + if entry_name not in self: + new_actions[entry_name] = entry_value + return new_actions + + +class Defaults(NamedTuple): + """Represents an atomic list's default settings.""" + + actions: ActionSettings + validations: ValidationSettings + + def dict(self) -> dict[str, Any]: + """Return a dict representation of the stored fields across all entries.""" + dict_ = {} + for settings in self: + dict_ = reduce(operator.or_, (entry.model_dump() for entry in settings.values()), dict_) + return dict_ diff --git a/bot/exts/filtering/_settings_types/__init__.py b/bot/exts/filtering/_settings_types/__init__.py new file mode 100644 index 0000000000..61b5737d41 --- /dev/null +++ b/bot/exts/filtering/_settings_types/__init__.py @@ -0,0 +1,9 @@ +from bot.exts.filtering._settings_types.actions import action_types +from bot.exts.filtering._settings_types.validations import validation_types + +settings_types = { + "ActionEntry": {settings_type.name: settings_type for settings_type in action_types}, + "ValidationEntry": {settings_type.name: settings_type for settings_type in validation_types} +} + +__all__ = [settings_types] diff --git a/bot/exts/filtering/_settings_types/actions/__init__.py b/bot/exts/filtering/_settings_types/actions/__init__.py new file mode 100644 index 0000000000..a8175b9764 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/__init__.py @@ -0,0 +1,8 @@ +from os.path import dirname + +from bot.exts.filtering._settings_types.settings_entry import ActionEntry +from bot.exts.filtering._utils import subclasses_in_package + +action_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ActionEntry) + +__all__ = [action_types] diff --git a/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py new file mode 100644 index 0000000000..359aa7bc34 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/infraction_and_notification.py @@ -0,0 +1,254 @@ +from enum import Enum, auto +from typing import ClassVar, Self + +import arrow +import discord.abc +from dateutil.relativedelta import relativedelta +from discord import Colour, Embed, Member, User +from discord.errors import Forbidden +from pydantic import field_validator +from pydis_core.utils.logging import get_logger +from pydis_core.utils.members import get_or_fetch_member + +import bot as bot_module +from bot.constants import Channels +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry +from bot.exts.filtering._utils import CustomIOField, FakeContext +from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta + +log = get_logger(__name__) + +passive_form = { + "BAN": "banned", + "KICK": "kicked", + "TIMEOUT": "timed out", + "VOICE_MUTE": "voice muted", + "SUPERSTAR": "superstarred", + "WARNING": "warned", + "WATCH": "watch", + "NOTE": "noted", +} + + +class InfractionDuration(CustomIOField): + """A field that converts a string to a duration and presents it in a human-readable format.""" + + @classmethod + def process_value(cls, v: str | relativedelta) -> relativedelta: + """ + Transform the given string into a relativedelta. + + Raise a ValueError if the conversion is not possible. + """ + if isinstance(v, relativedelta): + return v + + try: + v = float(v) + except ValueError: # Not a float. + if not (delta := parse_duration_string(v)): + raise ValueError(f"`{v}` is not a valid duration string.") + else: + delta = relativedelta(seconds=float(v)).normalized() + + return delta + + def serialize(self) -> float: + """The serialized value is the total number of seconds this duration represents.""" + return relativedelta_to_timedelta(self.value).total_seconds() + + def __str__(self): + """Represent the stored duration in a human-readable format.""" + return humanize_delta(self.value, max_units=2) if self.value else "Permanent" + + +class Infraction(Enum): + """An enumeration of infraction types. The lower the value, the higher it is on the hierarchy.""" + + BAN = auto() + KICK = auto() + TIMEOUT = auto() + VOICE_MUTE = auto() + SUPERSTAR = auto() + WARNING = auto() + WATCH = auto() + NOTE = auto() + NONE = auto() + + def __str__(self) -> str: + return self.name + + async def invoke( + self, + user: Member | User, + message: discord.Message, + channel: discord.abc.GuildChannel | discord.DMChannel, + alerts_channel: discord.TextChannel, + duration: InfractionDuration, + reason: str + ) -> None: + """Invokes the command matching the infraction name.""" + command_name = self.name.lower() + command = bot_module.instance.get_command(command_name) + if not command: + await alerts_channel.send(f":warning: Could not apply {command_name} to {user.mention}: command not found.") + log.warning(f":warning: Could not apply {command_name} to {user.mention}: command not found.") + return + + if isinstance(user, discord.User): # For example because a message was sent in a DM. + member = await get_or_fetch_member(channel.guild, user.id) + if member: + user = member + else: + log.warning( + f"The user {user} were set to receive an automatic {command_name}, " + "but they were not found in the guild." + ) + return + + ctx = FakeContext(message, channel, command) + if self.name in ("KICK", "WARNING", "WATCH", "NOTE"): + await command(ctx, user, reason=reason or None) + else: + duration = arrow.utcnow().datetime + duration.value if duration.value else None + await command(ctx, user, duration, reason=reason or None) + + +class InfractionAndNotification(ActionEntry): + """ + A setting entry which specifies what infraction to issue and the notification to DM the user. + + Since a DM cannot be sent when a user is banned or kicked, these two functions need to be grouped together. + """ + + name: ClassVar[str] = "infraction_and_notification" + description: ClassVar[dict[str, str]] = { + "infraction_type": ( + "The type of infraction to issue when the filter triggers, or 'NONE'. " + "If two infractions are triggered for the same message, " + "the harsher one will be applied (by type or duration).\n\n" + "Valid infraction types in order of harshness: " + ) + ", ".join(infraction.name for infraction in Infraction), + "infraction_duration": ( + "How long the infraction should last for in seconds. 0 for permanent. " + "Also supports durations as in an infraction invocation (such as `10d`)." + ), + "infraction_reason": "The reason delivered with the infraction.", + "infraction_channel": ( + "The channel ID in which to invoke the infraction (and send the confirmation message). " + "If 0, the infraction will be sent in the context channel. If the ID otherwise fails to resolve, " + "it will default to the mod-alerts channel." + ), + "dm_content": "The contents of a message to be DMed to the offending user. Doesn't send when invoked in DMs.", + "dm_embed": "The contents of the embed to be DMed to the offending user. Doesn't send when invoked in DMs." + } + + dm_content: str + dm_embed: str + infraction_type: Infraction + infraction_reason: str + infraction_duration: InfractionDuration + infraction_channel: int + + @field_validator("infraction_type", mode="before") + @classmethod + def convert_infraction_name(cls, infr_type: str | Infraction) -> Infraction: + """Convert the string to an Infraction by name.""" + if isinstance(infr_type, Infraction): + return infr_type + return Infraction[infr_type.replace(" ", "_").upper()] + + async def send_message(self, ctx: FilterContext) -> None: + """Send the notification to the user.""" + # If there is no infraction to apply, any DM contents already provided in the context take precedence. + if self.infraction_type == Infraction.NONE and (ctx.dm_content or ctx.dm_embed): + dm_content = ctx.dm_content + dm_embed = ctx.dm_embed + else: + dm_content = self.dm_content + dm_embed = self.dm_embed + + if dm_content or dm_embed: + formatting = {"domain": ctx.notification_domain} + dm_content = f"Hey {ctx.author.mention}!\n{dm_content.format(**formatting)}" + if dm_embed: + dm_embed = Embed(description=dm_embed.format(**formatting), colour=Colour.og_blurple()) + else: + dm_embed = None + + try: + await ctx.author.send(dm_content, embed=dm_embed) + ctx.action_descriptions.append("notified") + except Forbidden: + ctx.action_descriptions.append("failed to notify") + + async def action(self, ctx: FilterContext) -> None: + """Send the notification to the user, and apply any specified infractions.""" + if ctx.in_guild: # Don't DM the user for filters invoked in DMs. + await self.send_message(ctx) + + if self.infraction_type != Infraction.NONE: + alerts_channel = bot_module.instance.get_channel(Channels.mod_alerts) + if self.infraction_channel: + channel = bot_module.instance.get_channel(self.infraction_channel) + if not channel: + log.info(f"Could not find a channel with ID {self.infraction_channel}, infracting in mod-alerts.") + channel = alerts_channel + elif not ctx.channel: + channel = alerts_channel + else: + channel = ctx.channel + if not channel: # If somehow it's set to `alerts_channel` and it can't be found. + log.error(f"Unable to apply infraction as the context channel {channel} can't be found.") + return + + await self.infraction_type.invoke( + ctx.author, ctx.message, channel, alerts_channel, self.infraction_duration, self.infraction_reason + ) + ctx.action_descriptions.append(passive_form[self.infraction_type.name]) + + def union(self, other: Self) -> Self: + """ + Combines two actions of the same type. Each type of action is executed once per filter. + + If the infractions are different, take the data of the one higher up the hierarchy. + + There is no clear way to properly combine several notification messages, especially when it's in two parts. + To avoid bombarding the user with several notifications, the message with the more significant infraction + is used. If the more significant infraction has no accompanying message, use the one from the other infraction, + if it exists. + """ + # Lower number -> higher in the hierarchy + if self.infraction_type is None: + return other.model_copy() + if other.infraction_type is None: + return self.model_copy() + + if self.infraction_type.value < other.infraction_type.value: + result = self.model_copy() + elif self.infraction_type.value > other.infraction_type.value: + result = other.model_copy() + other = self + else: + now = arrow.utcnow().datetime + if self.infraction_duration is None or ( + other.infraction_duration is not None + and now + self.infraction_duration.value > now + other.infraction_duration.value + ): + result = self.model_copy() + else: + result = other.model_copy() + other = self + + # If the winner has no message but the loser does, copy the message to the winner. + result_overrides = result.overrides + # Either take both or nothing, don't mix content from one filter and embed from another. + if "dm_content" not in result_overrides and "dm_embed" not in result_overrides: + other_overrides = other.overrides + if "dm_content" in other_overrides: + result.dm_content = other_overrides["dm_content"] + if "dm_embed" in other_overrides: + result.dm_content = other_overrides["dm_embed"] + + return result diff --git a/bot/exts/filtering/_settings_types/actions/ping.py b/bot/exts/filtering/_settings_types/actions/ping.py new file mode 100644 index 0000000000..4b38a19f34 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/ping.py @@ -0,0 +1,44 @@ +from typing import ClassVar, Self + +from pydantic import field_validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry +from bot.exts.filtering._utils import resolve_mention + + +class Ping(ActionEntry): + """A setting entry which adds the appropriate pings to the alert.""" + + name: ClassVar[str] = "mentions" + description: ClassVar[dict[str, str]] = { + "guild_pings": ( + "A list of role IDs/role names/user IDs/user names/here/everyone. " + "If a mod-alert is generated for a filter triggered in a public channel, these will be pinged." + ), + "dm_pings": ( + "A list of role IDs/role names/user IDs/user names/here/everyone. " + "If a mod-alert is generated for a filter triggered in DMs, these will be pinged." + ) + } + + guild_pings: set[str] + dm_pings: set[str] + + @field_validator("*", mode="before") + @classmethod + def init_sequence_if_none(cls, pings: list[str] | None) -> list[str]: + """Initialize an empty sequence if the value is None.""" + if pings is None: + return [] + return pings + + async def action(self, ctx: FilterContext) -> None: + """Add the stored pings to the alert message content.""" + mentions = self.guild_pings if not ctx.channel or ctx.channel.guild else self.dm_pings + new_content = " ".join([resolve_mention(mention) for mention in mentions]) + ctx.alert_content = f"{new_content} {ctx.alert_content}" + + def union(self, other: Self) -> Self: + """Combines two actions of the same type. Each type of action is executed once per filter.""" + return Ping(guild_pings=self.guild_pings | other.guild_pings, dm_pings=self.dm_pings | other.dm_pings) diff --git a/bot/exts/filtering/_settings_types/actions/remove_context.py b/bot/exts/filtering/_settings_types/actions/remove_context.py new file mode 100644 index 0000000000..b833978fa8 --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/remove_context.py @@ -0,0 +1,125 @@ +from collections import defaultdict +from typing import ClassVar, Self + +from discord import Message, Thread +from discord.errors import HTTPException +from pydis_core.utils import scheduling +from pydis_core.utils.logging import get_logger + +import bot +from bot.constants import Channels +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry +from bot.exts.filtering._utils import FakeContext +from bot.utils.messages import send_attachments + +log = get_logger(__name__) + +SUPERSTAR_REASON = ( + "Your nickname was found to be in violation of our code of conduct. " + "If you believe this is a mistake, please let us know." +) + + +async def upload_messages_attachments(ctx: FilterContext, messages: list[Message]) -> None: + """Re-upload the messages' attachments for future logging.""" + if not messages: + return + destination = messages[0].guild.get_channel(Channels.attachment_log) + for message in messages: + if message.attachments and message.id not in ctx.uploaded_attachments: + ctx.uploaded_attachments[message.id] = await send_attachments(message, destination, link_large=False) + + +class RemoveContext(ActionEntry): + """A setting entry which tells whether to delete the offending message(s).""" + + name: ClassVar[str] = "remove_context" + description: ClassVar[str] = ( + "A boolean field. If True, the filter being triggered will cause the offending context to be removed. " + "An offending message will be deleted, while an offending nickname will be superstarified." + ) + + remove_context: bool + + async def action(self, ctx: FilterContext) -> None: + """Remove the offending context.""" + if not self.remove_context: + return + + if ctx.event in (Event.MESSAGE, Event.MESSAGE_EDIT): + await self._handle_messages(ctx) + elif ctx.event == Event.NICKNAME: + await self._handle_nickname(ctx) + elif ctx.event == Event.THREAD_NAME: + await self._handle_thread(ctx) + + @staticmethod + async def _handle_messages(ctx: FilterContext) -> None: + """Delete any messages involved in this context.""" + if not ctx.message or not ctx.message.guild: + return + + # If deletion somehow fails at least this will allow scheduling for deletion. + ctx.messages_deletion = True + channel_messages = defaultdict(set) # Duplicates will cause batch deletion to fail. + for message in {ctx.message} | ctx.related_messages: + channel_messages[message.channel].add(message) + + success = fail = 0 + deleted = list() + for channel, messages in channel_messages.items(): + try: + await channel.delete_messages(messages) + except HTTPException: + fail += len(messages) + else: + success += len(messages) + deleted.extend(messages) + scheduling.create_task(upload_messages_attachments(ctx, deleted)) + + if not fail: + if success == 1: + ctx.action_descriptions.append("deleted") + else: + ctx.action_descriptions.append("deleted all") + elif not success: + if fail == 1: + ctx.action_descriptions.append("failed to delete") + else: + ctx.action_descriptions.append("all failed to delete") + else: + ctx.action_descriptions.append(f"{success} deleted, {fail} failed to delete") + + @staticmethod + async def _handle_nickname(ctx: FilterContext) -> None: + """Apply a superstar infraction to remove the user's nickname.""" + alerts_channel = bot.instance.get_channel(Channels.mod_alerts) + if not alerts_channel: + log.error(f"Unable to apply superstar as the context channel {alerts_channel} can't be found.") + return + command = bot.instance.get_command("superstar") + if not command: + user = ctx.author + await alerts_channel.send(f":warning: Could not apply superstar to {user.mention}: command not found.") + log.warning(f":warning: Could not apply superstar to {user.mention}: command not found.") + ctx.action_descriptions.append("failed to superstar") + return + + await command(FakeContext(ctx.message, alerts_channel, command), ctx.author, None, reason=SUPERSTAR_REASON) + ctx.action_descriptions.append("superstarred") + + @staticmethod + async def _handle_thread(ctx: FilterContext) -> None: + """Delete the context thread.""" + if isinstance(ctx.channel, Thread): + try: + await ctx.channel.delete() + except HTTPException: + ctx.action_descriptions.append("failed to delete thread") + else: + ctx.action_descriptions.append("deleted thread") + + def union(self, other: Self) -> Self: + """Combines two actions of the same type. Each type of action is executed once per filter.""" + return RemoveContext(remove_context=self.remove_context or other.remove_context) diff --git a/bot/exts/filtering/_settings_types/actions/send_alert.py b/bot/exts/filtering/_settings_types/actions/send_alert.py new file mode 100644 index 0000000000..515edb0e6f --- /dev/null +++ b/bot/exts/filtering/_settings_types/actions/send_alert.py @@ -0,0 +1,21 @@ +from typing import ClassVar, Self + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ActionEntry + + +class SendAlert(ActionEntry): + """A setting entry which tells whether to send an alert message.""" + + name: ClassVar[str] = "send_alert" + description: ClassVar[str] = "A boolean. If all filters triggered set this to False, no mod-alert will be created." + + send_alert: bool + + async def action(self, ctx: FilterContext) -> None: + """Add the stored pings to the alert message content.""" + ctx.send_alert = self.send_alert + + def union(self, other: Self) -> Self: + """Combines two actions of the same type. Each type of action is executed once per filter.""" + return SendAlert(send_alert=self.send_alert or other.send_alert) diff --git a/bot/exts/filtering/_settings_types/settings_entry.py b/bot/exts/filtering/_settings_types/settings_entry.py new file mode 100644 index 0000000000..ca123f911e --- /dev/null +++ b/bot/exts/filtering/_settings_types/settings_entry.py @@ -0,0 +1,87 @@ +from abc import abstractmethod +from typing import Any, ClassVar, Self + +from pydantic import BaseModel, PrivateAttr + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._utils import FieldRequiring + + +class SettingsEntry(BaseModel, FieldRequiring): + """ + A basic entry in the settings field appearing in every filter list and filter. + + For a filter list, this is the default setting for it. For a filter, it's an override of the default entry. + """ + + # Each subclass must define a name matching the entry name we're expecting to receive from the database. + # Names must be unique across all filter lists. + name: ClassVar[str] = FieldRequiring.MUST_SET_UNIQUE + # Each subclass must define a description of what it does. If the data an entry type receives comprises + # several DB fields, the value should a dictionary of field names and their descriptions. + description: ClassVar[str | dict[str, str]] = FieldRequiring.MUST_SET + + _overrides: set[str] = PrivateAttr(default_factory=set) + + def __init__(self, defaults: SettingsEntry | None = None, /, **data): + overrides = set() + if defaults: + defaults_dict = defaults.model_dump() + for field_name, field_value in list(data.items()): + if field_value is None: + data[field_name] = defaults_dict[field_name] + else: + overrides.add(field_name) + super().__init__(**data) + self._overrides |= overrides + + @property + def overrides(self) -> dict[str, Any]: + """Return a dictionary of overrides.""" + return {name: getattr(self, name) for name in self._overrides} + + @classmethod + def create( + cls, entry_data: dict[str, Any] | None, *, defaults: SettingsEntry | None = None, keep_empty: bool = False + ) -> SettingsEntry | None: + """ + Returns a SettingsEntry object from `entry_data` if it holds any value, None otherwise. + + Use this method to create SettingsEntry objects instead of the init. + The None value is significant for how a filter list iterates over its filters. + """ + if entry_data is None: + return None + if not keep_empty and hasattr(entry_data, "values") and all(value is None for value in entry_data.values()): + return None + + if not isinstance(entry_data, dict): + entry_data = {cls.name: entry_data} + return cls(defaults, **entry_data) + + +class ValidationEntry(SettingsEntry): + """A setting entry to validate whether the filter should be triggered in the given context.""" + + @abstractmethod + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter should be triggered with this setting in the given context.""" + ... + + +class ActionEntry(SettingsEntry): + """A setting entry defining what the bot should do if the filter it belongs to is triggered.""" + + @abstractmethod + async def action(self, ctx: FilterContext) -> None: + """Execute an action that should be taken when the filter this setting belongs to is triggered.""" + ... + + @abstractmethod + def union(self, other: Self) -> Self: + """ + Combine two actions of the same type. Each type of action is executed once per filter. + + The following condition must hold: if self == other, then self | other == self. + """ + ... diff --git a/bot/exts/filtering/_settings_types/validations/__init__.py b/bot/exts/filtering/_settings_types/validations/__init__.py new file mode 100644 index 0000000000..5c44e8b276 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/__init__.py @@ -0,0 +1,8 @@ +from os.path import dirname + +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry +from bot.exts.filtering._utils import subclasses_in_package + +validation_types = subclasses_in_package(dirname(__file__), f"{__name__}.", ValidationEntry) + +__all__ = [validation_types] diff --git a/bot/exts/filtering/_settings_types/validations/bypass_roles.py b/bot/exts/filtering/_settings_types/validations/bypass_roles.py new file mode 100644 index 0000000000..0ddb2fdb5b --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/bypass_roles.py @@ -0,0 +1,45 @@ +from collections.abc import Sequence +from typing import ClassVar + +from discord import Member +from pydantic import field_validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class RoleBypass(ValidationEntry): + """A setting entry which tells whether the roles the member has allow them to bypass the filter.""" + + name: ClassVar[str] = "bypass_roles" + description: ClassVar[str] = "A list of role IDs or role names. Users with these roles will not trigger the filter." + + bypass_roles: set[int | str] + + @field_validator("bypass_roles", mode="before") + @classmethod + def init_if_bypass_roles_none(cls, bypass_roles: Sequence[int | str] | None) -> Sequence[int | str]: + """ + Initialize an empty sequence if the value is None. + + This also coerces each element of bypass_roles to an int, if possible. + """ + if bypass_roles is None: + return [] + + def _coerce_to_int(input: int | str) -> int | str: + try: + return int(input) + except ValueError: + return input + + return map(_coerce_to_int, bypass_roles) + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter should be triggered on this user given their roles.""" + if not isinstance(ctx.author, Member): + return True + return all( + member_role.id not in self.bypass_roles and member_role.name not in self.bypass_roles + for member_role in ctx.author.roles + ) diff --git a/bot/exts/filtering/_settings_types/validations/channel_scope.py b/bot/exts/filtering/_settings_types/validations/channel_scope.py new file mode 100644 index 0000000000..3d31131af4 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/channel_scope.py @@ -0,0 +1,82 @@ +from collections.abc import Sequence +from typing import ClassVar + +from pydantic import field_validator + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class ChannelScope(ValidationEntry): + """A setting entry which tells whether the filter was invoked in a whitelisted channel or category.""" + + name: ClassVar[str] = "channel_scope" + description: ClassVar[dict[str, str]] = { + "disabled_channels": ( + "A list of channel IDs or channel names. " + "The filter will not trigger in these channels even if the category is expressly enabled." + ), + "disabled_categories": ( + "A list of category IDs or category names. The filter will not trigger in these categories." + ), + "enabled_channels": ( + "A list of channel IDs or channel names. " + "The filter can trigger in these channels even if the category is disabled or not expressly enabled." + ), + "enabled_categories": ( + "A list of category IDs or category names. " + "If the list is not empty, filters will trigger only in channels of these categories, " + "unless the channel is expressly disabled." + ) + } + + disabled_channels: set[int | str] + disabled_categories: set[int | str] + enabled_channels: set[int | str] + enabled_categories: set[int | str] + + @field_validator("*", mode="before") + @classmethod + def init_if_sequence_none(cls, sequence: Sequence[int | str] | None) -> Sequence[int | str]: + """ + Initialize an empty sequence if the value is None. + + This also coerces each element of sequence to an int, if possible. + """ + if sequence is None: + return [] + + def _coerce_to_int(input: int | str) -> int | str: + try: + return int(input) + except ValueError: + return input + + return map(_coerce_to_int, sequence) + + def triggers_on(self, ctx: FilterContext) -> bool: + """ + Return whether the filter should be triggered in the given channel. + + The filter is invoked by default. + If the channel is explicitly enabled, it bypasses the set disabled channels and categories. + """ + channel = ctx.channel + + if not channel: + return True + if not ctx.in_guild: # This is not a guild channel, outside the scope of this setting. + return True + if hasattr(channel, "parent"): + channel = channel.parent + + enabled_channel = channel.id in self.enabled_channels or channel.name in self.enabled_channels + disabled_channel = channel.id in self.disabled_channels or channel.name in self.disabled_channels + enabled_category = channel.category and (not self.enabled_categories or ( + channel.category.id in self.enabled_categories or channel.category.name in self.enabled_categories + )) + disabled_category = channel.category and ( + channel.category.id in self.disabled_categories or channel.category.name in self.disabled_categories + ) + + return enabled_channel or (enabled_category and not disabled_channel and not disabled_category) diff --git a/bot/exts/filtering/_settings_types/validations/enabled.py b/bot/exts/filtering/_settings_types/validations/enabled.py new file mode 100644 index 0000000000..3b5e3e4461 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/enabled.py @@ -0,0 +1,19 @@ +from typing import ClassVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class Enabled(ValidationEntry): + """A setting entry which tells whether the filter is enabled.""" + + name: ClassVar[str] = "enabled" + description: ClassVar[str] = ( + "A boolean field. Setting it to False allows disabling the filter without deleting it entirely." + ) + + enabled: bool + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter is enabled.""" + return self.enabled diff --git a/bot/exts/filtering/_settings_types/validations/filter_dm.py b/bot/exts/filtering/_settings_types/validations/filter_dm.py new file mode 100644 index 0000000000..9961984d69 --- /dev/null +++ b/bot/exts/filtering/_settings_types/validations/filter_dm.py @@ -0,0 +1,20 @@ +from typing import ClassVar + +from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._settings_types.settings_entry import ValidationEntry + + +class FilterDM(ValidationEntry): + """A setting entry which tells whether to apply the filter to DMs.""" + + name: ClassVar[str] = "filter_dm" + description: ClassVar[str] = "A boolean field. If True, the filter can trigger for messages sent to the bot in DMs." + + filter_dm: bool + + def triggers_on(self, ctx: FilterContext) -> bool: + """Return whether the filter should be triggered even if it was triggered in DMs.""" + if not ctx.channel: # No channel - out of scope for this setting. + return True + + return ctx.channel.guild is not None or self.filter_dm diff --git a/bot/exts/filtering/_ui/__init__.py b/bot/exts/filtering/_ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bot/exts/filtering/_ui/filter.py b/bot/exts/filtering/_ui/filter.py new file mode 100644 index 0000000000..bf19ed414d --- /dev/null +++ b/bot/exts/filtering/_ui/filter.py @@ -0,0 +1,471 @@ +from collections.abc import Callable +from typing import Any + +import discord +import discord.ui +from discord import Embed, Interaction, User +from discord.ext.commands import BadArgument +from discord.ui.select import SelectOption +from pydis_core.site_api import ResponseCodeError + +from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._ui.ui import ( + COMPONENT_TIMEOUT, + CustomCallbackSelect, + EditBaseView, + MAX_EMBED_DESCRIPTION, + MISSING, + SETTINGS_DELIMITER, + SINGLE_SETTING_PATTERN, + format_response_error, + parse_value, + populate_embed_from_dict, +) +from bot.exts.filtering._utils import repr_equals, to_serializable +from bot.log import get_logger + +log = get_logger(__name__) + + +def build_filter_repr_dict( + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + settings_overrides: dict, + extra_fields_overrides: dict +) -> dict: + """Build a dictionary of field names and values to pass to `populate_embed_from_dict`.""" + # Get filter list settings + default_setting_values = {} + for settings_group in filter_list[list_type].defaults: + for _, setting in settings_group.items(): + default_setting_values.update(to_serializable(setting.model_dump(), ui_repr=True)) + + # Add overrides. It's done in this way to preserve field order, since the filter won't have all settings. + total_values = {} + for name, value in default_setting_values.items(): + if name not in settings_overrides or repr_equals(settings_overrides[name], value): + total_values[name] = value + else: + total_values[f"{name}*"] = settings_overrides[name] + + # Add the filter-specific settings. + if filter_type.extra_fields_type: + # This iterates over the default values of the extra fields model. + for name, value in filter_type.extra_fields_type().model_dump().items(): + if name not in extra_fields_overrides or repr_equals(extra_fields_overrides[name], value): + total_values[f"{filter_type.name}/{name}"] = value + else: + total_values[f"{filter_type.name}/{name}*"] = extra_fields_overrides[name] + + return total_values + + +class EditContentModal(discord.ui.Modal, title="Edit Content"): + """A modal to input a filter's content.""" + + content = discord.ui.TextInput(label="Content") + + def __init__(self, embed_view: FilterEditView, message: discord.Message): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.embed_view = embed_view + self.message = message + + async def on_submit(self, interaction: Interaction) -> None: + """Update the embed with the new content.""" + await interaction.response.defer() + await self.embed_view.update_embed(self.message, content=self.content.value) + + +class EditDescriptionModal(discord.ui.Modal, title="Edit Description"): + """A modal to input a filter's description.""" + + description = discord.ui.TextInput(label="Description") + + def __init__(self, embed_view: FilterEditView, message: discord.Message): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.embed_view = embed_view + self.message = message + + async def on_submit(self, interaction: Interaction) -> None: + """Update the embed with the new description.""" + await interaction.response.defer() + await self.embed_view.update_embed(self.message, description=self.description.value) + + +class TemplateModal(discord.ui.Modal, title="Template"): + """A modal to enter a filter ID to copy its overrides over.""" + + template = discord.ui.TextInput(label="Template Filter ID") + + def __init__(self, embed_view: FilterEditView, message: discord.Message): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.embed_view = embed_view + self.message = message + + async def on_submit(self, interaction: Interaction) -> None: + """Update the embed with the new description.""" + await self.embed_view.apply_template(self.template.value, self.message, interaction) + + +class FilterEditView(EditBaseView): + """A view used to edit a filter's settings before updating the database.""" + + class _REMOVE: + """Sentinel value for when an override should be removed.""" + + def __init__( + self, + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + content: str | None, + description: str | None, + settings_overrides: dict, + filter_settings_overrides: dict, + loaded_settings: dict, + loaded_filter_settings: dict, + author: User, + embed: Embed, + confirm_callback: Callable + ): + super().__init__(author) + self.filter_list = filter_list + self.list_type = list_type + self.filter_type = filter_type + self.content = content + self.description = description + self.settings_overrides = settings_overrides + self.filter_settings_overrides = filter_settings_overrides + self.loaded_settings = loaded_settings + self.loaded_filter_settings = loaded_filter_settings + self.embed = embed + self.confirm_callback = confirm_callback + + all_settings_repr_dict = build_filter_repr_dict( + filter_list, list_type, filter_type, settings_overrides, filter_settings_overrides + ) + populate_embed_from_dict(embed, all_settings_repr_dict) + + self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + self.type_per_setting_name.update({ + f"{filter_type.name}/{name}": type_ + for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items() + }) + + add_select = CustomCallbackSelect( + self._prompt_new_value, + placeholder="Select a setting to edit", + options=[SelectOption(label=name) for name in sorted(self.type_per_setting_name)], + row=1 + ) + self.add_item(add_select) + + if settings_overrides or filter_settings_overrides: + override_names = ( + list(settings_overrides) + [f"{filter_list.name}/{setting}" for setting in filter_settings_overrides] + ) + remove_select = CustomCallbackSelect( + self._remove_override, + placeholder="Select an override to remove", + options=[SelectOption(label=name) for name in sorted(override_names)], + row=2 + ) + self.add_item(remove_select) + + @discord.ui.button(label="Edit Content", row=3) + async def edit_content(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to edit the filter's content. Pressing the button invokes a modal.""" + modal = EditContentModal(self, interaction.message) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="Edit Description", row=3) + async def edit_description(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to edit the filter's description. Pressing the button invokes a modal.""" + modal = EditDescriptionModal(self, interaction.message) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="Empty Description", row=3) + async def empty_description(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to empty the filter's description.""" + await self.update_embed(interaction, description=self._REMOVE) + + @discord.ui.button(label="Template", row=3) + async def enter_template(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to enter a filter template ID and copy its overrides over.""" + modal = TemplateModal(self, interaction.message) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=4) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Confirm the content, description, and settings, and update the filters database.""" + if self.content is None: + await interaction.response.send_message( + ":x: Cannot add a filter with no content.", ephemeral=True, reference=interaction.message + ) + if self.description is None: + self.description = "" + await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first. + try: + await self.confirm_callback( + interaction.message, + self.filter_list, + self.list_type, + self.filter_type, + self.content, + self.description, + self.settings_overrides, + self.filter_settings_overrides + ) + except ResponseCodeError as e: + await interaction.message.reply(embed=format_response_error(e)) + await interaction.message.edit(view=self) + except BadArgument as e: + await interaction.message.reply( + embed=Embed(colour=discord.Colour.red(), title="Bad Argument", description=str(e)) + ) + await interaction.message.edit(view=self) + else: + self.stop() + + @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red, row=4) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None) + self.stop() + + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + if setting_name in self.settings_overrides: + return self.settings_overrides[setting_name] + if "/" in setting_name: + _, setting_name = setting_name.split("/", maxsplit=1) + if setting_name in self.filter_settings_overrides: + return self.filter_settings_overrides[setting_name] + return MISSING + + async def update_embed( + self, + interaction_or_msg: discord.Interaction | discord.Message, + *, + content: str | None = None, + description: str | type[FilterEditView._REMOVE] | None = None, + setting_name: str | None = None, + setting_value: str | type[FilterEditView._REMOVE] | None = None, + ) -> None: + """ + Update the embed with the new information. + + If a setting name is provided with a _REMOVE value, remove the override. + If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. + """ + if content is not None or description is not None: + if content is not None: + filter_type = self.filter_list.get_filter_type(content) + if not filter_type: + if isinstance(interaction_or_msg, discord.Message): + send_method = interaction_or_msg.channel.send + else: + send_method = interaction_or_msg.response.send_message + await send_method(f":x: Could not find a filter type appropriate for `{content}`.") + return + self.content = content + self.filter_type = filter_type + else: + content = self.content # If there's no content or description, use the existing values. + if description is self._REMOVE: + self.description = None + elif description is not None: + self.description = description + else: + description = self.description + + # Update the embed with the new content and/or description. + self.embed.description = f"`{content}`" if content else "*No content*" + if description and description is not self._REMOVE: + self.embed.description += f" - {description}" + if len(self.embed.description) > MAX_EMBED_DESCRIPTION: + self.embed.description = self.embed.description[:MAX_EMBED_DESCRIPTION - 5] + "[...]" + + if setting_name: + # Find the right dictionary to update. + if "/" in setting_name: + _filter_name, setting_name = setting_name.split("/", maxsplit=1) + dict_to_edit = self.filter_settings_overrides + default_value = self.filter_type.extra_fields_type().model_dump()[setting_name] + else: + dict_to_edit = self.settings_overrides + default_value = self.filter_list[self.list_type].default(setting_name) + # Update the setting override value or remove it + if setting_value is not self._REMOVE: + if not repr_equals(setting_value, default_value): + dict_to_edit[setting_name] = setting_value + # If there's already an override, remove it, since the new value is the same as the default. + elif setting_name in dict_to_edit: + dict_to_edit.pop(setting_name) + elif setting_name in dict_to_edit: + dict_to_edit.pop(setting_name) + + # This is inefficient, but otherwise the selects go insane if the user attempts to edit the same setting + # multiple times, even when replacing the select with a new one. + self.embed.clear_fields() + new_view = self.copy() + + try: + if isinstance(interaction_or_msg, discord.Interaction): + await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view) + else: + await interaction_or_msg.edit(embed=self.embed, view=new_view) + except discord.errors.HTTPException: # Various unexpected errors. + pass + else: + self.stop() + + async def edit_setting_override(self, interaction: Interaction, setting_name: str, override_value: Any) -> None: + """ + Update the overrides with the new value and edit the embed. + + The interaction needs to be the selection of the setting attached to the embed. + """ + await self.update_embed(interaction, setting_name=setting_name, setting_value=override_value) + + async def apply_template(self, template_id: str, embed_message: discord.Message, interaction: Interaction) -> None: + """Replace any non-overridden settings with overrides from the given filter.""" + try: + settings, filter_settings = template_settings( + template_id, self.filter_list, self.list_type, self.filter_type + ) + except BadArgument as e: # The interaction object is necessary to send an ephemeral message. + await interaction.response.send_message(f":x: {e}", ephemeral=True) + return + else: + await interaction.response.defer() + + self.settings_overrides = settings | self.settings_overrides + self.filter_settings_overrides = filter_settings | self.filter_settings_overrides + self.embed.clear_fields() + await embed_message.edit(embed=self.embed, view=self.copy()) + self.stop() + + async def _remove_override(self, interaction: Interaction, select: discord.ui.Select) -> None: + """ + Remove the override for the setting the user selected, and edit the embed. + + The interaction needs to be the selection of the setting attached to the embed. + """ + await self.update_embed(interaction, setting_name=select.values[0], setting_value=self._REMOVE) + + def copy(self) -> FilterEditView: + """Create a copy of this view.""" + return FilterEditView( + self.filter_list, + self.list_type, + self.filter_type, + self.content, + self.description, + self.settings_overrides, + self.filter_settings_overrides, + self.loaded_settings, + self.loaded_filter_settings, + self.author, + self.embed, + self.confirm_callback + ) + + +def description_and_settings_converter( + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + loaded_settings: dict, + loaded_filter_settings: dict, + input_data: str +) -> tuple[str, dict[str, Any], dict[str, Any]]: + """Parse a string representing a possible description and setting overrides, and validate the setting names.""" + if not input_data: + return "", {}, {} + + parsed = SETTINGS_DELIMITER.split(input_data) + if not parsed: + return "", {}, {} + + description = "" + if not SINGLE_SETTING_PATTERN.match(parsed[0]): + description, *parsed = parsed + + settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} # noqa: C416 + template = None + if "--template" in settings: + template = settings.pop("--template") + + filter_settings = {} + for setting, _ in list(settings.items()): + if setting in loaded_settings: # It's a filter list setting + type_ = loaded_settings[setting][2] + try: + parsed_value = parse_value(settings.pop(setting), type_) + if not repr_equals(parsed_value, filter_list[list_type].default(setting)): + settings[setting] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + elif "/" not in setting: + raise BadArgument(f"{setting!r} is not a recognized setting.") + else: # It's a filter setting + filter_name, filter_setting_name = setting.split("/", maxsplit=1) + if filter_name.lower() != filter_type.name.lower(): + raise BadArgument( + f"A setting for a {filter_name!r} filter was provided, but the filter name is {filter_type.name!r}" + ) + if filter_setting_name not in loaded_filter_settings[filter_type.name]: + raise BadArgument(f"{setting!r} is not a recognized setting.") + type_ = loaded_filter_settings[filter_type.name][filter_setting_name][2] + try: + parsed_value = parse_value(settings.pop(setting), type_) + if not repr_equals(parsed_value, getattr(filter_type.extra_fields_type(), filter_setting_name)): + filter_settings[filter_setting_name] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + + # Pull templates settings and apply them. + if template is not None: + try: + t_settings, t_filter_settings = template_settings(template, filter_list, list_type, filter_type) + except ValueError as e: + raise BadArgument(str(e)) + else: + # The specified settings go on top of the template + settings = t_settings | settings + filter_settings = t_filter_settings | filter_settings + + return description, settings, filter_settings + + +def filter_overrides_for_ui(filter_: Filter) -> tuple[dict, dict]: + """Get the filter's overrides in a format that can be displayed in the UI.""" + overrides_values, extra_fields_overrides = filter_.overrides + return to_serializable(overrides_values, ui_repr=True), to_serializable(extra_fields_overrides, ui_repr=True) + + +def template_settings( + filter_id: str, filter_list: FilterList, list_type: ListType, filter_type: type[Filter] +) -> tuple[dict, dict]: + """Find the filter with specified ID, and return its settings.""" + try: + filter_id = int(filter_id) + if filter_id < 0: + raise ValueError + except ValueError: + raise BadArgument("Template value must be a non-negative integer.") + + if filter_id not in filter_list[list_type].filters: + raise BadArgument( + f"Could not find filter with ID `{filter_id}` in the {list_type.name} {filter_list.name} list." + ) + filter_ = filter_list[list_type].filters[filter_id] + + if not isinstance(filter_, filter_type): + raise BadArgument( + f"The template filter name is {filter_.name!r}, but the target filter is {filter_type.name!r}" + ) + return filter_.overrides diff --git a/bot/exts/filtering/_ui/filter_list.py b/bot/exts/filtering/_ui/filter_list.py new file mode 100644 index 0000000000..c652098e17 --- /dev/null +++ b/bot/exts/filtering/_ui/filter_list.py @@ -0,0 +1,275 @@ +from collections.abc import Callable +from typing import Any + +import discord +from discord import Embed, Interaction, SelectOption, User +from discord.ext.commands import BadArgument +from pydis_core.site_api import ResponseCodeError + +from bot.exts.filtering._filter_lists import FilterList, ListType +from bot.exts.filtering._ui.ui import ( + CustomCallbackSelect, + EditBaseView, + MISSING, + SETTINGS_DELIMITER, + format_response_error, + parse_value, + populate_embed_from_dict, +) +from bot.exts.filtering._utils import repr_equals, to_serializable + + +def settings_converter(loaded_settings: dict, input_data: str) -> dict[str, Any]: + """Parse a string representing settings, and validate the setting names.""" + if not input_data: + return {} + + parsed = SETTINGS_DELIMITER.split(input_data) + if not parsed: + return {} + + try: + settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} # noqa: C416 + except ValueError: + raise BadArgument("The settings provided are not in the correct format.") + + for setting in settings: + if setting not in loaded_settings: + raise BadArgument(f"{setting!r} is not a recognized setting.") + + type_ = loaded_settings[setting][2] + try: + parsed_value = parse_value(settings.pop(setting), type_) + settings[setting] = parsed_value + except (TypeError, ValueError) as e: + raise BadArgument(e) + + return settings + + +def build_filterlist_repr_dict(filter_list: FilterList, list_type: ListType, new_settings: dict) -> dict: + """Build a dictionary of field names and values to pass to `_build_embed_from_dict`.""" + # Get filter list settings + default_setting_values = {} + for settings_group in filter_list[list_type].defaults: + for _, setting in settings_group.items(): + default_setting_values.update(to_serializable(setting.model_dump(), ui_repr=True)) + + # Add new values. It's done in this way to preserve field order, since the new_values won't have all settings. + total_values = {} + for name, value in default_setting_values.items(): + if name not in new_settings or repr_equals(new_settings[name], value): + total_values[name] = value + else: + total_values[f"{name}~"] = new_settings[name] + + return total_values + + +class FilterListAddView(EditBaseView): + """A view used to add a new filter list.""" + + def __init__( + self, + list_name: str, + list_type: ListType, + settings: dict, + loaded_settings: dict, + author: User, + embed: Embed, + confirm_callback: Callable + ): + super().__init__(author) + self.list_name = list_name + self.list_type = list_type + self.settings = settings + self.loaded_settings = loaded_settings + self.embed = embed + self.confirm_callback = confirm_callback + + self.settings_repr_dict = {name: to_serializable(value) for name, value in settings.items()} + populate_embed_from_dict(embed, self.settings_repr_dict) + + self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + + edit_select = CustomCallbackSelect( + self._prompt_new_value, + placeholder="Select a setting to edit", + options=[SelectOption(label=name) for name in sorted(settings)], + row=0 + ) + self.add_item(edit_select) + + @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=1) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Confirm the content, description, and settings, and update the filters database.""" + await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first. + try: + await self.confirm_callback(interaction.message, self.list_name, self.list_type, self.settings) + except ResponseCodeError as e: + await interaction.message.reply(embed=format_response_error(e)) + await interaction.message.edit(view=self) + else: + self.stop() + + @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red, row=1) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None) + self.stop() + + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + if setting_name in self.settings: + return self.settings[setting_name] + return MISSING + + async def update_embed( + self, + interaction_or_msg: discord.Interaction | discord.Message, + *, + setting_name: str | None = None, + setting_value: str | None = None, + ) -> None: + """ + Update the embed with the new information. + + If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. + """ + if not setting_name: # Obligatory check to match the signature in the parent class. + return + + self.settings[setting_name] = setting_value + + self.embed.clear_fields() + new_view = self.copy() + + try: + if isinstance(interaction_or_msg, discord.Interaction): + await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view) + else: + await interaction_or_msg.edit(embed=self.embed, view=new_view) + except discord.errors.HTTPException: # Various unexpected errors. + pass + else: + self.stop() + + def copy(self) -> FilterListAddView: + """Create a copy of this view.""" + return FilterListAddView( + self.list_name, + self.list_type, + self.settings, + self.loaded_settings, + self.author, + self.embed, + self.confirm_callback + ) + + +class FilterListEditView(EditBaseView): + """A view used to edit a filter list's settings before updating the database.""" + + def __init__( + self, + filter_list: FilterList, + list_type: ListType, + new_settings: dict, + loaded_settings: dict, + author: User, + embed: Embed, + confirm_callback: Callable + ): + super().__init__(author) + self.filter_list = filter_list + self.list_type = list_type + self.settings = new_settings + self.loaded_settings = loaded_settings + self.embed = embed + self.confirm_callback = confirm_callback + + self.settings_repr_dict = build_filterlist_repr_dict(filter_list, list_type, new_settings) + populate_embed_from_dict(embed, self.settings_repr_dict) + + self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + + edit_select = CustomCallbackSelect( + self._prompt_new_value, + placeholder="Select a setting to edit", + options=[SelectOption(label=name) for name in sorted(self.type_per_setting_name)], + row=0 + ) + self.add_item(edit_select) + + @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=1) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Confirm the content, description, and settings, and update the filters database.""" + await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first. + try: + await self.confirm_callback(interaction.message, self.filter_list, self.list_type, self.settings) + except ResponseCodeError as e: + await interaction.message.reply(embed=format_response_error(e)) + await interaction.message.edit(view=self) + else: + self.stop() + + @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red, row=1) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None) + self.stop() + + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + if setting_name in self.settings: + return self.settings[setting_name] + if setting_name in self.settings_repr_dict: + return self.settings_repr_dict[setting_name] + return MISSING + + async def update_embed( + self, + interaction_or_msg: discord.Interaction | discord.Message, + *, + setting_name: str | None = None, + setting_value: str | None = None, + ) -> None: + """ + Update the embed with the new information. + + If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. + """ + if not setting_name: # Obligatory check to match the signature in the parent class. + return + + default_value = self.filter_list[self.list_type].default(setting_name) + if not repr_equals(setting_value, default_value): + self.settings[setting_name] = setting_value + # If there's already a new value, remove it, since the new value is the same as the default. + elif setting_name in self.settings: + self.settings.pop(setting_name) + + self.embed.clear_fields() + new_view = self.copy() + + try: + if isinstance(interaction_or_msg, discord.Interaction): + await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view) + else: + await interaction_or_msg.edit(embed=self.embed, view=new_view) + except discord.errors.HTTPException: # Various errors such as embed description being too long. + pass + else: + self.stop() + + def copy(self) -> FilterListEditView: + """Create a copy of this view.""" + return FilterListEditView( + self.filter_list, + self.list_type, + self.settings, + self.loaded_settings, + self.author, + self.embed, + self.confirm_callback + ) diff --git a/bot/exts/filtering/_ui/search.py b/bot/exts/filtering/_ui/search.py new file mode 100644 index 0000000000..352db00326 --- /dev/null +++ b/bot/exts/filtering/_ui/search.py @@ -0,0 +1,368 @@ +from collections.abc import Callable +from typing import Any + +import discord +from discord import Interaction, SelectOption +from discord.ext.commands import BadArgument + +from bot.exts.filtering._filter_lists import FilterList, ListType +from bot.exts.filtering._filters.filter import Filter +from bot.exts.filtering._settings_types.settings_entry import SettingsEntry +from bot.exts.filtering._ui.filter import filter_overrides_for_ui +from bot.exts.filtering._ui.ui import ( + COMPONENT_TIMEOUT, + CustomCallbackSelect, + EditBaseView, + MISSING, + SETTINGS_DELIMITER, + parse_value, + populate_embed_from_dict, +) + + +def search_criteria_converter( + filter_lists: dict, + loaded_filters: dict, + loaded_settings: dict, + loaded_filter_settings: dict, + filter_type: type[Filter] | None, + input_data: str +) -> tuple[dict[str, Any], dict[str, Any], type[Filter]]: + """Parse a string representing setting overrides, and validate the setting names.""" + if not input_data: + return {}, {}, filter_type + + parsed = SETTINGS_DELIMITER.split(input_data) + if not parsed: + return {}, {}, filter_type + + try: + settings = {setting: value for setting, value in [part.split("=", maxsplit=1) for part in parsed]} # noqa: C416 + except ValueError: + raise BadArgument("The settings provided are not in the correct format.") + + template = None + if "--template" in settings: + template = settings.pop("--template") + + filter_settings = {} + for setting, _ in list(settings.items()): + if setting in loaded_settings: # It's a filter list setting + type_ = loaded_settings[setting][2] + try: + settings[setting] = parse_value(settings[setting], type_) + except (TypeError, ValueError) as e: + raise BadArgument(e) + elif "/" not in setting: + raise BadArgument(f"{setting!r} is not a recognized setting.") + else: # It's a filter setting + filter_name, filter_setting_name = setting.split("/", maxsplit=1) + if not filter_type: + if filter_name in loaded_filters: + filter_type = loaded_filters[filter_name] + else: + raise BadArgument(f"There's no filter type named {filter_name!r}.") + if filter_name.lower() != filter_type.name.lower(): + raise BadArgument( + f"A setting for a {filter_name!r} filter was provided, " + f"but the filter name is {filter_type.name!r}" + ) + if filter_setting_name not in loaded_filter_settings[filter_type.name]: + raise BadArgument(f"{setting!r} is not a recognized setting.") + type_ = loaded_filter_settings[filter_type.name][filter_setting_name][2] + try: + filter_settings[filter_setting_name] = parse_value(settings.pop(setting), type_) + except (TypeError, ValueError) as e: + raise BadArgument(e) + + # Pull templates settings and apply them. + if template is not None: + try: + t_settings, t_filter_settings, filter_type = template_settings(template, filter_lists, filter_type) + except ValueError as e: + raise BadArgument(str(e)) + else: + # The specified settings go on top of the template + settings = t_settings | settings + filter_settings = t_filter_settings | filter_settings + + return settings, filter_settings, filter_type + + +def get_filter(filter_id: int, filter_lists: dict) -> tuple[Filter, FilterList, ListType] | None: + """Return a filter with the specific filter_id, if found.""" + for filter_list in filter_lists.values(): + for list_type, sublist in filter_list.items(): + if filter_id in sublist.filters: + return sublist.filters[filter_id], filter_list, list_type + return None + + +def template_settings( + filter_id: str, filter_lists: dict, filter_type: type[Filter] | None +) -> tuple[dict, dict, type[Filter]]: + """Find a filter with the specified ID and filter type, and return its settings and (maybe newly found) type.""" + try: + filter_id = int(filter_id) + if filter_id < 0: + raise ValueError + except ValueError: + raise BadArgument("Template value must be a non-negative integer.") + + result = get_filter(filter_id, filter_lists) + if not result: + raise BadArgument(f"Could not find a filter with ID `{filter_id}`.") + filter_, _filter_list, _list_type = result + + if filter_type and not isinstance(filter_, filter_type): + raise BadArgument(f"The filter with ID `{filter_id}` is not of type {filter_type.name!r}.") + + settings, filter_settings = filter_overrides_for_ui(filter_) + return settings, filter_settings, type(filter_) + + +def build_search_repr_dict( + settings: dict[str, Any], filter_settings: dict[str, Any], filter_type: type[Filter] | None +) -> dict: + """Build a dictionary of field names and values to pass to `populate_embed_from_dict`.""" + total_values = settings.copy() + if filter_type: + for setting_name, value in filter_settings.items(): + total_values[f"{filter_type.name}/{setting_name}"] = value + + return total_values + + +class SearchEditView(EditBaseView): + """A view used to edit the search criteria before performing the search.""" + + class _REMOVE: + """Sentinel value for when an override should be removed.""" + + def __init__( + self, + filter_type: type[Filter] | None, + settings: dict[str, Any], + filter_settings: dict[str, Any], + loaded_filter_lists: dict[str, FilterList], + loaded_filters: dict[str, type[Filter]], + loaded_settings: dict[str, tuple[str, SettingsEntry, type]], + loaded_filter_settings: dict[str, dict[str, tuple[str, SettingsEntry, type]]], + author: discord.User | discord.Member, + embed: discord.Embed, + confirm_callback: Callable + ): + super().__init__(author) + self.filter_type = filter_type + self.settings = settings + self.filter_settings = filter_settings + self.loaded_filter_lists = loaded_filter_lists + self.loaded_filters = loaded_filters + self.loaded_settings = loaded_settings + self.loaded_filter_settings = loaded_filter_settings + self.embed = embed + self.confirm_callback = confirm_callback + + title = "Filters Search" + if filter_type: + title += f" - {filter_type.name.title()}" + embed.set_author(name=title) + + settings_repr_dict = build_search_repr_dict(settings, filter_settings, filter_type) + populate_embed_from_dict(embed, settings_repr_dict) + + self.type_per_setting_name = {setting: info[2] for setting, info in loaded_settings.items()} + if filter_type: + self.type_per_setting_name.update({ + f"{filter_type.name}/{name}": type_ + for name, (_, _, type_) in loaded_filter_settings.get(filter_type.name, {}).items() + }) + + add_select = CustomCallbackSelect( + self._prompt_new_value, + placeholder="Add or edit criterion", + options=[SelectOption(label=name) for name in sorted(self.type_per_setting_name)], + row=0 + ) + self.add_item(add_select) + + if settings_repr_dict: + remove_select = CustomCallbackSelect( + self._remove_criterion, + placeholder="Select a criterion to remove", + options=[SelectOption(label=name) for name in sorted(settings_repr_dict)], + row=1 + ) + self.add_item(remove_select) + + @discord.ui.button(label="Template", row=2) + async def enter_template(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to enter a filter template ID and copy its overrides over.""" + modal = TemplateModal(self, interaction.message) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="Filter Type", row=2) + async def enter_filter_type(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to enter a filter type.""" + modal = FilterTypeModal(self, interaction.message) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green, row=3) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Confirm the search criteria and perform the search.""" + await interaction.response.edit_message(view=None) # Make sure the interaction succeeds first. + try: + await self.confirm_callback(interaction.message, self.filter_type, self.settings, self.filter_settings) + except BadArgument as e: + await interaction.message.reply( + embed=discord.Embed(colour=discord.Colour.red(), title="Bad Argument", description=str(e)) + ) + await interaction.message.edit(view=self) + else: + self.stop() + + @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red, row=3) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + await interaction.response.edit_message(content="🚫 Operation canceled.", embed=None, view=None) + self.stop() + + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + if setting_name in self.settings: + return self.settings[setting_name] + if "/" in setting_name: + _, setting_name = setting_name.split("/", maxsplit=1) + if setting_name in self.filter_settings: + return self.filter_settings[setting_name] + return MISSING + + async def update_embed( + self, + interaction_or_msg: discord.Interaction | discord.Message, + *, + setting_name: str | None = None, + setting_value: str | type[SearchEditView._REMOVE] | None = None, + ) -> None: + """ + Update the embed with the new information. + + If a setting name is provided with a _REMOVE value, remove the override. + If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. + """ + if not setting_name: # Can be None just to make the function signature compatible with the parent class. + return + + if "/" in setting_name: + _filter_name, setting_name = setting_name.split("/", maxsplit=1) + dict_to_edit = self.filter_settings + else: + dict_to_edit = self.settings + + # Update the criterion value or remove it + if setting_value is not self._REMOVE: + dict_to_edit[setting_name] = setting_value + elif setting_name in dict_to_edit: + dict_to_edit.pop(setting_name) + + self.embed.clear_fields() + new_view = self.copy() + + try: + if isinstance(interaction_or_msg, discord.Interaction): + await interaction_or_msg.response.edit_message(embed=self.embed, view=new_view) + else: + await interaction_or_msg.edit(embed=self.embed, view=new_view) + except discord.errors.HTTPException: # Just in case of faulty input. + pass + else: + self.stop() + + async def _remove_criterion(self, interaction: Interaction, select: discord.ui.Select) -> None: + """ + Remove the criterion the user selected, and edit the embed. + + The interaction needs to be the selection of the setting attached to the embed. + """ + await self.update_embed(interaction, setting_name=select.values[0], setting_value=self._REMOVE) + + async def apply_template(self, template_id: str, embed_message: discord.Message, interaction: Interaction) -> None: + """Set any unset criteria with settings values from the given filter.""" + try: + settings, filter_settings, self.filter_type = template_settings( + template_id, self.loaded_filter_lists, self.filter_type + ) + except BadArgument as e: # The interaction object is necessary to send an ephemeral message. + await interaction.response.send_message(f":x: {e}", ephemeral=True) + return + else: + await interaction.response.defer() + + self.settings = settings | self.settings + self.filter_settings = filter_settings | self.filter_settings + self.embed.clear_fields() + await embed_message.edit(embed=self.embed, view=self.copy()) + self.stop() + + async def apply_filter_type(self, type_name: str, embed_message: discord.Message, interaction: Interaction) -> None: + """Set a new filter type and reset any criteria for settings of the old filter type.""" + if type_name.lower() not in self.loaded_filters: + if type_name.lower()[:-1] not in self.loaded_filters: # In case the user entered the plural form. + await interaction.response.send_message(f":x: No such filter type {type_name!r}.", ephemeral=True) + return + type_name = type_name[:-1] + type_name = type_name.lower() + await interaction.response.defer() + + if self.filter_type and type_name == self.filter_type.name: + return + self.filter_type = self.loaded_filters[type_name] + self.filter_settings = {} + self.embed.clear_fields() + await embed_message.edit(embed=self.embed, view=self.copy()) + self.stop() + + def copy(self) -> SearchEditView: + """Create a copy of this view.""" + return SearchEditView( + self.filter_type, + self.settings, + self.filter_settings, + self.loaded_filter_lists, + self.loaded_filters, + self.loaded_settings, + self.loaded_filter_settings, + self.author, + self.embed, + self.confirm_callback + ) + + +class TemplateModal(discord.ui.Modal, title="Template"): + """A modal to enter a filter ID to copy its overrides over.""" + + template = discord.ui.TextInput(label="Template Filter ID", required=False) + + def __init__(self, embed_view: SearchEditView, message: discord.Message): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.embed_view = embed_view + self.message = message + + async def on_submit(self, interaction: Interaction) -> None: + """Update the embed with the new description.""" + await self.embed_view.apply_template(self.template.value, self.message, interaction) + + +class FilterTypeModal(discord.ui.Modal, title="Template"): + """A modal to enter a filter ID to copy its overrides over.""" + + filter_type = discord.ui.TextInput(label="Filter Type") + + def __init__(self, embed_view: SearchEditView, message: discord.Message): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.embed_view = embed_view + self.message = message + + async def on_submit(self, interaction: Interaction) -> None: + """Update the embed with the new description.""" + await self.embed_view.apply_filter_type(self.filter_type.value, self.message, interaction) diff --git a/bot/exts/filtering/_ui/ui.py b/bot/exts/filtering/_ui/ui.py new file mode 100644 index 0000000000..5382f846b2 --- /dev/null +++ b/bot/exts/filtering/_ui/ui.py @@ -0,0 +1,699 @@ +import re +from abc import ABC, abstractmethod +from collections.abc import Callable, Coroutine +from enum import EnumMeta +from functools import partial +from typing import Any, TypeVar, get_origin + +import discord +from discord import Embed, Interaction, Member, User +from discord.ext.commands import BadArgument, Context, Converter +from discord.ui.select import MISSING as SELECT_MISSING, SelectOption +from discord.utils import escape_markdown +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling +from pydis_core.utils.logging import get_logger +from pydis_core.utils.members import get_or_fetch_member +from pydis_core.utils.regex import DISCORD_INVITE + +import bot +from bot.constants import Colours +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists import FilterList +from bot.exts.filtering._utils import FakeContext, normalize_type +from bot.utils.lock import lock_arg +from bot.utils.messages import format_channel, format_user, upload_log + +log = get_logger(__name__) + + +# Max number of characters in a Discord embed field value, minus 6 characters for a placeholder. +MAX_FIELD_SIZE = 1018 +# Max number of characters for an embed field's value before it should take its own line. +MAX_INLINE_SIZE = 50 +# Number of seconds before a settings editing view timeout. +EDIT_TIMEOUT = 600 +# Number of seconds before timeout of an editing component. +COMPONENT_TIMEOUT = 180 +# Amount of seconds to confirm the operation. +DELETION_TIMEOUT = 60 +# Max length of modal title +MAX_MODAL_TITLE_LENGTH = 45 +# Max number of items in a select +MAX_SELECT_ITEMS = 25 +MAX_EMBED_DESCRIPTION = 4080 +# Number of seconds before timeout of the alert view +ALERT_VIEW_TIMEOUT = 3600 + +SETTINGS_DELIMITER = re.compile(r"\s+(?=\S+=\S+)") +SINGLE_SETTING_PATTERN = re.compile(r"(--)?[\w/]+=.+") + +EDIT_CONFIRMED_MESSAGE = "✅ Edit for `{0}` confirmed" + +# Sentinel value to denote that a value is missing +MISSING = object() + +T = TypeVar("T") + + +async def _build_alert_message_content(ctx: FilterContext, current_message_length: int) -> str: + """Build the content section of the alert.""" + # For multiple messages and those with attachments or excessive newlines, use the logs API + if ctx.messages_deletion and ctx.upload_deletion_logs and any(( + ctx.related_messages, + len(ctx.uploaded_attachments) > 0, + ctx.content.count("\n") > 15 + )): + to_upload = {ctx.message} | ctx.related_messages if ctx.message else ctx.related_messages + url = await upload_log(to_upload, bot.instance.user.id, ctx.uploaded_attachments) + return f"A complete log of the offending messages can be found [here]({url})" + + alert_content = escape_markdown(ctx.content) + remaining_chars = MAX_EMBED_DESCRIPTION - current_message_length + + if len(alert_content) > remaining_chars: + if ctx.messages_deletion and ctx.upload_deletion_logs: + url = await upload_log([ctx.message], bot.instance.user.id, ctx.uploaded_attachments) + log_site_msg = f"The full message can be found [here]({url})" + # 7 because that's the length of "[...]\n\n" + return alert_content[:remaining_chars - (7 + len(log_site_msg))] + "[...]\n\n" + log_site_msg + return alert_content[:remaining_chars - 5] + "[...]" + + return alert_content + + +async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList, list[str]]) -> Embed: + """Build an alert message from the filter context.""" + embed = Embed(color=Colours.soft_orange) + embed.set_thumbnail(url=ctx.author.display_avatar.url) + triggered_by = f"**Triggered by:** {format_user(ctx.author)}" + if ctx.channel: + if ctx.channel.guild: + triggered_in = f"**Triggered in:** {format_channel(ctx.channel)}\n" + else: + triggered_in = "**Triggered in:** :warning:**DM**:warning:\n" + if len(ctx.related_channels) > 1: + triggered_in += f"**Channels:** {', '.join(channel.mention for channel in ctx.related_channels)}\n" + else: + triggered_by += "\n" + triggered_in = "" + + filters = [] + for filter_list, list_message in triggered_filters.items(): + if list_message: + filters.append(f"**{filter_list.name.title()} Filters:** {', '.join(list_message)}") + filters = "\n".join(filters) + + matches = "**Matches:** " + escape_markdown(", ".join(repr(match) for match in ctx.matches)) if ctx.matches else "" + actions = "\n**Actions Taken:** " + (", ".join(ctx.action_descriptions) if ctx.action_descriptions else "-") + + mod_alert_message = "\n".join(part for part in (triggered_by, triggered_in, filters, matches, actions) if part) + log.debug(f"{ctx.event.name} Filter:\n{mod_alert_message}") + + if ctx.message: + mod_alert_message += f"\n**[Original Content]({ctx.message.jump_url})**:\n" + else: + mod_alert_message += "\n**Original Content**:\n" + mod_alert_message += await _build_alert_message_content(ctx, len(mod_alert_message)) + + embed.description = mod_alert_message + return embed + + +def populate_embed_from_dict(embed: Embed, data: dict) -> None: + """Populate a Discord embed by populating fields from the given dict.""" + for setting, value in data.items(): + if setting.startswith("_"): + continue + if isinstance(value, list | set | tuple): + value = f"[{', '.join(map(str, value))}]" + else: + value = str(value) if value not in ("", None) else "-" + if len(value) > MAX_FIELD_SIZE: + value = value[:MAX_FIELD_SIZE] + " [...]" + embed.add_field(name=setting, value=value, inline=len(value) < MAX_INLINE_SIZE) + + +def parse_value[T](value: str, type_: type[T]) -> T: + """Parse the value provided in the CLI and attempt to convert it to the provided type.""" + blank = value == '""' + type_ = normalize_type(type_, prioritize_nonetype=blank) + + if blank or isinstance(None, type_): + return type_() + if type_ in (tuple, list, set): + return list(value.split(",")) + if type_ is bool: + return value.lower() == "true" or value == "1" + if isinstance(type_, EnumMeta): + return type_[value.upper()] + + return type_(value) + + +def format_response_error(e: ResponseCodeError) -> Embed: + """Format the response error into an embed.""" + description = "" + if isinstance(e.response_json, list): + description = "\n".join(f"- {error}" for error in e.response_json) + elif isinstance(e.response_json, dict): + if "non_field_errors" in e.response_json: + non_field_errors = e.response_json.pop("non_field_errors") + description += "\n".join(f"- {error}" for error in non_field_errors) + "\n" + for field, errors in e.response_json.items(): + description += "\n".join(f"- {field} - {error}" for error in errors) + "\n" + + description = description.strip() + if len(description) > MAX_EMBED_DESCRIPTION: + description = description[:MAX_EMBED_DESCRIPTION] + "[...]" + if not description: + description = "Something unexpected happened, check the logs." + + embed = Embed(colour=discord.Colour.red(), title="Oops...", description=description) + return embed + + +class ArgumentCompletionSelect(discord.ui.Select): + """A select detailing the options that can be picked to assign to a missing argument.""" + + def __init__( + self, + ctx: Context, + args: list, + arg_name: str, + options: list[str], + position: int, + converter: Converter | None = None + ): + super().__init__( + placeholder=f"Select a value for {arg_name!r}", + options=[discord.SelectOption(label=option) for option in options] + ) + self.ctx = ctx + self.args = args + self.position = position + self.converter = converter + + async def callback(self, interaction: discord.Interaction) -> None: + """re-invoke the context command with the completed argument value.""" + await interaction.response.defer() + value = interaction.data["values"][0] + if self.converter: + value = await self.converter().convert(self.ctx, value) + args = self.args.copy() # This makes the view reusable. + args.insert(self.position, value) + log.trace(f"Argument filled with the value {value}. Re-invoking command") + await self.ctx.invoke(self.ctx.command, *args) + + +class ArgumentCompletionView(discord.ui.View): + """A view used to complete a missing argument in an in invoked command.""" + + def __init__( + self, + ctx: Context, + args: list, + arg_name: str, + options: list[str], + position: int, + converter: Converter | None = None + ): + super().__init__() + log.trace(f"The {arg_name} argument was designated missing in the invocation {ctx.view.buffer!r}") + self.add_item(ArgumentCompletionSelect(ctx, args, arg_name, options, position, converter)) + self.ctx = ctx + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check to ensure that the interacting user is the user who invoked the command.""" + if interaction.user != self.ctx.author: + embed = discord.Embed(description="Sorry, but this dropdown menu can only be used by the original author.") + await interaction.response.send_message(embed=embed, ephemeral=True) + return False + return True + + +class CustomCallbackSelect(discord.ui.Select): + """A selection which calls the provided callback on interaction.""" + + def __init__( + self, + callback: Callable[[Interaction, discord.ui.Select], Coroutine[None]], + *, + custom_id: str = SELECT_MISSING, + placeholder: str | None = None, + min_values: int = 1, + max_values: int = 1, + options: list[SelectOption] = SELECT_MISSING, + disabled: bool = False, + row: int | None = None, + ): + super().__init__( + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + options=options, + disabled=disabled, + row=row + ) + self.custom_callback = callback + + async def callback(self, interaction: Interaction) -> Any: + """Invoke the provided callback.""" + await self.custom_callback(interaction, self) + + +class BooleanSelectView(discord.ui.View): + """A view containing an instance of BooleanSelect.""" + + class BooleanSelect(discord.ui.Select): + """Select a true or false value and send it to the supplied callback.""" + + def __init__(self, setting_name: str, update_callback: Callable): + super().__init__(options=[SelectOption(label="True"), SelectOption(label="False")]) + self.setting_name = setting_name + self.update_callback = update_callback + + async def callback(self, interaction: Interaction) -> Any: + """Respond to the interaction by sending the boolean value to the update callback.""" + value = self.values[0] == "True" + await self.update_callback(setting_name=self.setting_name, setting_value=value) + await interaction.response.edit_message(content=EDIT_CONFIRMED_MESSAGE.format(self.setting_name), view=None) + + def __init__(self, setting_name: str, update_callback: Callable): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.add_item(self.BooleanSelect(setting_name, update_callback)) + + +class FreeInputModal(discord.ui.Modal): + """A modal to freely enter a value for a setting.""" + + def __init__(self, setting_name: str, type_: type, update_callback: Callable): + title = f"{setting_name} Input" if len(setting_name) < MAX_MODAL_TITLE_LENGTH - 6 else "Setting Input" + super().__init__(timeout=COMPONENT_TIMEOUT, title=title) + + self.setting_name = setting_name + self.type_ = type_ + self.update_callback = update_callback + + label = setting_name if len(setting_name) < MAX_MODAL_TITLE_LENGTH else "Value" + self.setting_input = discord.ui.TextInput(label=label, style=discord.TextStyle.paragraph, required=False) + self.add_item(self.setting_input) + + async def on_submit(self, interaction: Interaction) -> None: + """Update the setting with the new value in the embed.""" + try: + if not self.setting_input.value: + value = self.type_() + else: + value = self.type_(self.setting_input.value) + except (ValueError, TypeError): + await interaction.response.send_message( + f"Could not process the input value for `{self.setting_name}`.", ephemeral=True + ) + else: + await self.update_callback(setting_name=self.setting_name, setting_value=value) + await interaction.response.send_message( + content=EDIT_CONFIRMED_MESSAGE.format(self.setting_name), ephemeral=True + ) + + +class SequenceEditView(discord.ui.View): + """A view to modify the contents of a sequence of values.""" + + class SingleItemModal(discord.ui.Modal): + """A modal to enter a single list item.""" + + new_item = discord.ui.TextInput(label="New Item") + + def __init__(self, view: SequenceEditView): + super().__init__(title="Item Addition", timeout=COMPONENT_TIMEOUT) + self.view = view + + async def on_submit(self, interaction: Interaction) -> None: + """Send the submitted value to be added to the list.""" + await self.view.apply_addition(interaction, self.new_item.value) + + class NewListModal(discord.ui.Modal): + """A modal to enter new contents for the list.""" + + new_value = discord.ui.TextInput(label="Enter comma separated values", style=discord.TextStyle.paragraph) + + def __init__(self, view: SequenceEditView): + super().__init__(title="New List", timeout=COMPONENT_TIMEOUT) + self.view = view + + async def on_submit(self, interaction: Interaction) -> None: + """Send the submitted value to be added to the list.""" + await self.view.apply_edit(interaction, self.new_value.value) + + def __init__(self, setting_name: str, starting_value: list, update_callback: Callable): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.setting_name = setting_name + self.stored_value = starting_value + self.update_callback = update_callback + + options = [SelectOption(label=item) for item in self.stored_value[:MAX_SELECT_ITEMS]] + self.removal_select = CustomCallbackSelect( + self.apply_removal, placeholder="Enter an item to remove", options=options, row=1 + ) + if self.stored_value: + self.add_item(self.removal_select) + + async def apply_removal(self, interaction: Interaction, select: discord.ui.Select) -> None: + """Remove an item from the list.""" + # The value might not be stored as a string. + _i = len(self.stored_value) + for _i, element in enumerate(self.stored_value): + if str(element) == select.values[0]: + break + if _i != len(self.stored_value): + self.stored_value.pop(_i) + + await interaction.response.edit_message( + content=f"Current list: [{', '.join(self.stored_value)}]", view=self.copy() + ) + self.stop() + + async def apply_addition(self, interaction: Interaction, item: str) -> None: + """Add an item to the list.""" + if item in self.stored_value: # Ignore duplicates + await interaction.response.defer() + return + + self.stored_value.append(item) + await interaction.response.edit_message( + content=f"Current list: [{', '.join(self.stored_value)}]", view=self.copy() + ) + self.stop() + + async def apply_edit(self, interaction: Interaction, new_list: str) -> None: + """Change the contents of the list.""" + self.stored_value = list(set(part.strip() for part in new_list.split(",") if part.strip())) + await interaction.response.edit_message( + content=f"Current list: [{', '.join(self.stored_value)}]", view=self.copy() + ) + self.stop() + + @discord.ui.button(label="Add Value") + async def add_value(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to add an item to the list.""" + await interaction.response.send_modal(self.SingleItemModal(self)) + + @discord.ui.button(label="Free Input") + async def free_input(self, interaction: Interaction, button: discord.ui.Button) -> None: + """A button to change the entire list.""" + await interaction.response.send_modal(self.NewListModal(self)) + + @discord.ui.button(label="✅ Confirm", style=discord.ButtonStyle.green) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Send the final value to the embed editor.""" + # Edit first, it might time out otherwise. + await self.update_callback(setting_name=self.setting_name, setting_value=self.stored_value) + await interaction.response.edit_message(content=EDIT_CONFIRMED_MESSAGE.format(self.setting_name), view=None) + self.stop() + + @discord.ui.button(label="🚫 Cancel", style=discord.ButtonStyle.red) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the list editing.""" + await interaction.response.edit_message(content="🚫 Canceled", view=None) + self.stop() + + def copy(self) -> SequenceEditView: + """Return a copy of this view.""" + return SequenceEditView(self.setting_name, self.stored_value, self.update_callback) + + +class EnumSelectView(discord.ui.View): + """A view containing an instance of EnumSelect.""" + + class EnumSelect(discord.ui.Select): + """Select an enum value and send it to the supplied callback.""" + + def __init__(self, setting_name: str, enum_cls: EnumMeta, update_callback: Callable): + super().__init__(options=[SelectOption(label=elem.name) for elem in enum_cls]) + self.setting_name = setting_name + self.enum_cls = enum_cls + self.update_callback = update_callback + + async def callback(self, interaction: Interaction) -> Any: + """Respond to the interaction by sending the enum value to the update callback.""" + await self.update_callback(setting_name=self.setting_name, setting_value=self.values[0]) + await interaction.response.edit_message(content=EDIT_CONFIRMED_MESSAGE.format(self.setting_name), view=None) + + def __init__(self, setting_name: str, enum_cls: EnumMeta, update_callback: Callable): + super().__init__(timeout=COMPONENT_TIMEOUT) + self.add_item(self.EnumSelect(setting_name, enum_cls, update_callback)) + + +class EditBaseView(ABC, discord.ui.View): + """A view used to edit embed fields based on a provided type.""" + + def __init__(self, author: discord.User): + super().__init__(timeout=EDIT_TIMEOUT) + self.author = author + self.type_per_setting_name = {} + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.author.id + + async def _prompt_new_value(self, interaction: Interaction, select: discord.ui.Select) -> None: + """Prompt the user to give an override value for the setting they selected, and respond to the interaction.""" + setting_name = select.values[0] + type_ = self.type_per_setting_name[setting_name] + if origin := get_origin(type_): # In case this is a types.GenericAlias or a typing._GenericAlias + type_ = origin + new_view = self.copy() + # This is in order to not block the interaction response. There's a potential race condition here, since + # a view's method is used without guaranteeing the task completed, but since it depends on user input + # realistically it shouldn't happen. + scheduling.create_task(interaction.message.edit(view=new_view)) + update_callback = partial(new_view.update_embed, interaction_or_msg=interaction.message) + if type_ is bool: + view = BooleanSelectView(setting_name, update_callback) + await interaction.response.send_message(f"Choose a value for `{setting_name}`:", view=view, ephemeral=True) + elif type_ in (set, list, tuple): + if (current_value := self.current_value(setting_name)) is not MISSING: + current_list = [str(elem) for elem in current_value] + else: + current_list = [] + await interaction.response.send_message( + f"Current list: [{', '.join(current_list)}]", + view=SequenceEditView(setting_name, current_list, update_callback), + ephemeral=True + ) + elif isinstance(type_, EnumMeta): + view = EnumSelectView(setting_name, type_, update_callback) + await interaction.response.send_message(f"Choose a value for `{setting_name}`:", view=view, ephemeral=True) + else: + await interaction.response.send_modal(FreeInputModal(setting_name, type_, update_callback)) + self.stop() + + @abstractmethod + def current_value(self, setting_name: str) -> Any: + """Get the current value stored for the setting or MISSING if none found.""" + + @abstractmethod + async def update_embed(self, interaction_or_msg: Interaction | discord.Message) -> None: + """ + Update the embed with the new information. + + If `interaction_or_msg` is a Message, the invoking Interaction must be deferred before calling this function. + """ + + @abstractmethod + def copy(self) -> EditBaseView: + """Create a copy of this view.""" + + +class DeleteConfirmationView(discord.ui.View): + """A view to confirm a deletion.""" + + def __init__(self, author: discord.Member | discord.User, callback: Callable): + super().__init__(timeout=DELETION_TIMEOUT) + self.author = author + self.callback = callback + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.author.id + + @discord.ui.button(label="Delete", style=discord.ButtonStyle.red, row=0) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Invoke the filter list deletion.""" + await interaction.response.edit_message(view=None) + await self.callback() + + @discord.ui.button(label="Cancel", row=0) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the filter list deletion.""" + await interaction.response.edit_message(content="🚫 Operation canceled.", view=None) + + +class PhishConfirmationView(discord.ui.View): + """Confirmation buttons for whether the alert was for a phishing attempt.""" + + def __init__( + self, mod: Member, offender: User | Member | None, phishing_content: str, target_filter_list: FilterList + ): + super().__init__() + self.mod = mod + self.offender = offender + self.phishing_content = phishing_content + self.target_filter_list = target_filter_list + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.mod.id + + @discord.ui.button(label="Do it", style=discord.ButtonStyle.green) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Auto-ban the user and add the phishing content to the appropriate filter list as an auto-ban filter.""" + await interaction.response.edit_message(view=None) + + if self.offender: + compban_command = bot.instance.get_command("compban") + if not compban_command: + await interaction.followup.send(':warning: Could not find the command "compban".') + else: + ctx = FakeContext(interaction.message, interaction.channel, compban_command, author=self.mod) + await compban_command(ctx, self.offender) + + compf_command = bot.instance.get_command("compfilter") + if not compf_command: + message = ':warning: Could not find the command "compfilter".' + await interaction.followup.send(message) + else: + ctx = FakeContext(interaction.message, interaction.channel, compf_command) + try: + await compf_command(ctx, self.target_filter_list.name, self.phishing_content) + except BadArgument as e: + await interaction.followup.send(f":x: Could not add the filter: {e}") + + + @discord.ui.button(label="Cancel") + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + new_message_content = f"~~{interaction.message.content}~~ 🚫 Operation canceled." + await interaction.response.edit_message(content=new_message_content, view=None) + +class PhishHandlingButton(discord.ui.Button): + """ + A button that handles a phishing attempt. + + When pressed, ask for confirmation. + If confirmed, comp-ban the offending user, and add the appropriate domain or invite as an auto-ban filter. + """ + + def __init__(self, offender: User | Member | None, phishing_content: str, target_filter_list: FilterList): + super().__init__(emoji="🎣") + self.offender = offender + self.phishing_content = phishing_content + self.target_filter_list = target_filter_list + + @lock_arg("phishing", "interaction", lambda interaction: interaction.message.id) + async def callback(self, interaction: Interaction) -> Any: + """Ask for confirmation for handling the phish.""" + message_content = f"{interaction.user.mention} Is this a phishing attempt? " + if self.offender: + message_content += f"The user {self.offender.mention} will be comp-banned, and " + else: + message_content += "The user was not found, but " + message_content += ( + f"`{escape_markdown(self.phishing_content)}` will be added as an auto-ban filter to the " + f"denied *{self.target_filter_list.name}s* list." + ) + confirmation_view = PhishConfirmationView( + interaction.user, self.offender, self.phishing_content, self.target_filter_list + ) + await interaction.response.send_message(message_content, view=confirmation_view) + + +class AlertView(discord.ui.View): + """A view providing info about the offending user.""" + + def __init__(self, ctx: FilterContext, triggered_filters: dict[FilterList, list[str]] | None = None): + super().__init__(timeout=ALERT_VIEW_TIMEOUT) + self.ctx = ctx + if "banned" in self.ctx.action_descriptions: + # If the user has already been banned, do not attempt to add phishing button since the URL or guild invite + # is probably already added as a filter + return + phishing_content, target_filter_list = self._extract_potential_phish(triggered_filters) + if phishing_content: + self.add_item(PhishHandlingButton(ctx.author, phishing_content, target_filter_list)) + + @discord.ui.button(label="ID") + async def user_id(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Reply with the ID of the offending user.""" + await interaction.response.send_message(self.ctx.author.id, ephemeral=True) + + @discord.ui.button(emoji="👤") + async def user_info(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Send the info embed of the offending user.""" + command = bot.instance.get_command("user") + if not command: + await interaction.response.send_message("The command `user` is not loaded.", ephemeral=True) + return + + await interaction.response.defer() + fake_ctx = FakeContext(interaction.message, interaction.channel, command, author=interaction.user) + # Get the most updated user/member object every time the button is pressed. + author = await get_or_fetch_member(interaction.guild, self.ctx.author.id) + if author is None: + author = await bot.instance.fetch_user(self.ctx.author.id) + await command(fake_ctx, author) + + @discord.ui.button(emoji="🗒️") + async def user_infractions(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Send the infractions embed of the offending user.""" + command = bot.instance.get_command("infraction search") + if not command: + await interaction.response.send_message("The command `infraction search` is not loaded.", ephemeral=True) + return + + await interaction.response.defer() + fake_ctx = FakeContext(interaction.message, interaction.channel, command, author=interaction.user) + await command(fake_ctx, self.ctx.author) + + def _extract_potential_phish( + self, triggered_filters: dict[FilterList, list[str]] | None + ) -> tuple[str, FilterList | None]: + """ + Check if the alert is potentially for phishing. + + If it is, return the phishing content and the filter list to add it to. + Otherwise, return an empty string and None. + + A potential phish is a message event where a single invite or domain is found, and nothing else. + Everyone filters are an exception. + """ + if self.ctx.event != Event.MESSAGE or not self.ctx.potential_phish: + return "", None + + if triggered_filters: + for filter_list, messages in triggered_filters.items(): + if messages and (filter_list.name != "unique" or len(messages) > 1 or "everyone" not in messages[0]): + return "", None + + encountered = False + content = "" + target_filter_list = None + for filter_list, content_list in self.ctx.potential_phish.items(): + if len(content_list) > 1: + return "", None + if content_list: + current_content = next(iter(content_list)) + if filter_list.name == "domain" and re.fullmatch(DISCORD_INVITE, current_content): + # Leave invites to the invite filterlist. + continue + if encountered: + return "", None + target_filter_list = filter_list + content = current_content + encountered = True + + if encountered: + return content, target_filter_list + return "", None diff --git a/bot/exts/filtering/_utils.py b/bot/exts/filtering/_utils.py new file mode 100644 index 0000000000..d364bb4432 --- /dev/null +++ b/bot/exts/filtering/_utils.py @@ -0,0 +1,306 @@ +import importlib +import importlib.util +import inspect +import pkgutil +import types +import urllib.parse +import warnings +from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from functools import cache +from typing import Any, Self, TypeVar, Union, get_args, get_origin + +import discord +import regex +from discord.ext.commands import Command +from pydantic import PydanticDeprecatedSince20 +from pydantic_core import core_schema + +import bot +from bot.bot import Bot +from bot.constants import Guild + +VARIATION_SELECTORS = r"\uFE00-\uFE0F\U000E0100-\U000E01EF" +INVISIBLE_RE = regex.compile(rf"[{VARIATION_SELECTORS}\p{{UNASSIGNED}}\p{{FORMAT}}\p{{CONTROL}}--\s]", regex.V1) +ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIATION_SELECTORS}]]", regex.V1) + + +T = TypeVar("T") + +Serializable = bool | int | float | str | list | dict | None + + +def subclasses_in_package[T](package: str, prefix: str, parent: T) -> set[T]: + """Return all the subclasses of class `parent`, found in the top-level of `package`, given by absolute path.""" + subclasses = set() + + # Find all modules in the package. + for module_info in pkgutil.iter_modules([package], prefix): + if not module_info.ispkg: + module = importlib.import_module(module_info.name) + # Find all classes in each module... + for _, class_ in inspect.getmembers(module, inspect.isclass): + # That are a subclass of the given class. + if parent in class_.__mro__: + subclasses.add(class_) + + return subclasses + + +def clean_input(string: str, *, keep_newlines: bool = False) -> str: + """Remove zalgo and invisible characters from `string`.""" + # For future consideration: remove characters in the Mc, Sk, and Lm categories too. + # Can be normalised with form C to merge char + combining char into a single char to avoid + # removing legit diacritics, but this would open up a way to bypass _filters. + content = ZALGO_RE.sub("", string) + + # URL quoted strings can be used to hide links to servers + content = urllib.parse.unquote(content) + if not keep_newlines: # Drop newlines that can be used to bypass filter + content = content.replace("\n", "") + # Avoid escape characters + content = content.replace("\\", "") + + return INVISIBLE_RE.sub("", content) + + +def past_tense(word: str) -> str: + """Return the past tense form of the input word.""" + if not word: + return word + if word.endswith("e"): + return word + "d" + if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou": + return word[:-1] + "ied" + return word + "ed" + + +def to_serializable(item: Any, *, ui_repr: bool = False) -> Serializable: + """ + Convert the item into an object that can be converted to JSON. + + `ui_repr` dictates whether to use the UI representation of `CustomIOField` instances (if any) + or the DB-oriented representation. + """ + if isinstance(item, bool | int | float | str | type(None)): + return item + if isinstance(item, dict): + result = {} + for key, value in item.items(): + if not isinstance(key, bool | int | float | str | type(None)): + key = str(key) + result[key] = to_serializable(value, ui_repr=ui_repr) + return result + if isinstance(item, Iterable): + return [to_serializable(subitem, ui_repr=ui_repr) for subitem in item] + if not ui_repr and hasattr(item, "serialize"): + return item.serialize() + return str(item) + + +@cache +def resolve_mention(mention: str) -> str: + """Return the appropriate formatting for the mention, be it a literal, a user ID, or a role ID.""" + guild = bot.instance.get_guild(Guild.id) + if mention in ("here", "everyone"): + return f"@{mention}" + try: + mention = int(mention) # It's an ID. + except ValueError: + pass + else: + if any(mention == role.id for role in guild.roles): + return f"<@&{mention}>" + return f"<@{mention}>" + + # It's a name + for role in guild.roles: + if role.name == mention: + return role.mention + for member in guild.members: + if str(member) == mention: + return member.mention + return mention + + +def repr_equals(override: Any, default: Any) -> bool: + """Return whether the override and the default have the same representation.""" + if override is None: # It's not an override + return True + + override_is_sequence = isinstance(override, tuple | list | set) + default_is_sequence = isinstance(default, tuple | list | set) + if override_is_sequence != default_is_sequence: # One is a sequence and the other isn't. + return False + if override_is_sequence: + if len(override) != len(default): + return False + return all(str(item1) == str(item2) for item1, item2 in zip(set(override), set(default), strict=True)) + return str(override) == str(default) + + +def normalize_type(type_: type, *, prioritize_nonetype: bool = True) -> type: + """Reduce a given type to one that can be initialized.""" + if get_origin(type_) in (Union, types.UnionType): # In case of a Union + args = get_args(type_) + if type(None) in args: + if prioritize_nonetype: + return type(None) + args = tuple(set(args) - {type(None)}) + type_ = args[0] # Pick one, doesn't matter + if origin := get_origin(type_): # In case of a parameterized List, Set, Dict etc. + return origin + return type_ + + +def starting_value[T](type_: type[T]) -> T: + """Return a value of the given type.""" + type_ = normalize_type(type_) + try: + return type_() + except TypeError: # In case it all fails, return a string and let the user handle it. + return "" + + +class FieldRequiring(ABC): + """A mixin class that can force its concrete subclasses to set a value for specific class attributes.""" + + # Sentinel value that mustn't remain in a concrete subclass. + MUST_SET = object() + + # Sentinel value that mustn't remain in a concrete subclass. + # Overriding value must be unique in the subclasses of the abstract class in which the attribute was set. + MUST_SET_UNIQUE = object() + + # A mapping of the attributes which must be unique, and their unique values, per FieldRequiring subclass. + __unique_attributes: defaultdict[type, dict[str, set]] = defaultdict(dict) + + @abstractmethod + def __init__(self): + ... + + def __init_subclass__(cls, **kwargs): + def inherited(attr: str) -> bool: + """True if `attr` was inherited from a parent class.""" + # The first element of cls.__mro__ is the class itself, last element is object, skip those. + # hasattr(parent, attr) means the attribute was inherited. + return any(hasattr(parent, attr) for parent in cls.__mro__[1:-1]) + + # If a new attribute with the value MUST_SET_UNIQUE was defined in an abstract class, record it. + if inspect.isabstract(cls): + with warnings.catch_warnings(): + # The code below will raise a warning about the use the __fields__ attr on a pydantic model + # This will continue to be warned about until removed in pydantic 3.0 + # This warning is a false-positive as only the custom MUST_SET_UNIQUE attr is used here + warnings.simplefilter("ignore", category=PydanticDeprecatedSince20) + for attribute in dir(cls): + if getattr(cls, attribute, None) is FieldRequiring.MUST_SET_UNIQUE: + if not inherited(attribute): + # A new attribute with the value MUST_SET_UNIQUE. + FieldRequiring.__unique_attributes[cls][attribute] = set() + return + + for attribute in dir(cls): + if attribute.startswith("__") or attribute in ("MUST_SET", "MUST_SET_UNIQUE"): + continue + value = getattr(cls, attribute) + if value is FieldRequiring.MUST_SET and inherited(attribute): + raise ValueError(f"You must set attribute {attribute!r} when creating {cls!r}") + if value is FieldRequiring.MUST_SET_UNIQUE and inherited(attribute): + raise ValueError(f"You must set a unique value to attribute {attribute!r} when creating {cls!r}") + + # Check if the value needs to be unique. + for parent in cls.__mro__[1:-1]: + # Find the parent class the attribute was first defined in. + if attribute in FieldRequiring.__unique_attributes[parent]: + if value in FieldRequiring.__unique_attributes[parent][attribute]: + raise ValueError(f"Value of {attribute!r} in {cls!r} is not unique for parent {parent!r}.") + + # Add to the set of unique values for that field. + FieldRequiring.__unique_attributes[parent][attribute].add(value) + + +@dataclass +class FakeContext: + """ + A class representing a context-like object that can be sent to infraction commands. + + The goal is to be able to apply infractions without depending on the existence of a message or an interaction + (which are the two ways to create a Context), e.g. in API events which aren't message-driven, or in custom filtering + events. + """ + + message: discord.Message + channel: discord.abc.Messageable + command: Command | None + bot: Bot | None = None + guild: discord.Guild | None = None + author: discord.Member | discord.User | None = None + me: discord.Member | None = None + + def __post_init__(self): + """Initialize the missing information.""" + if not self.bot: + self.bot = bot.instance + if not self.guild: + self.guild = self.bot.get_guild(Guild.id) + if not self.me: + self.me = self.guild.me + if not self.author: + self.author = self.me + + async def send(self, *args, **kwargs) -> discord.Message: + """A wrapper for channel.send.""" + return await self.channel.send(*args, **kwargs) + + +class CustomIOField: + """ + A class to be used as a data type in SettingEntry subclasses. + + Its subclasses can have custom methods to read and represent the value, which will be used by the UI. + """ + + def __init__(self, value: Any): + self.value = self.process_value(value) + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source: type[Any], + _handler: Callable[[Any], core_schema.CoreSchema], + ) -> core_schema.CoreSchema: + """Boilerplate for Pydantic.""" + return core_schema.with_info_plain_validator_function(cls.validate) + + @classmethod + def validate(cls, v: Any, _info: core_schema.ValidationInfo) -> Self: + """Takes the given value and returns a class instance with that value.""" + if isinstance(v, CustomIOField): + return cls(v.value) + + return cls(v) + + def __eq__(self, other: CustomIOField): + if not isinstance(other, CustomIOField): + return NotImplemented + return self.value == other.value + + @classmethod + def process_value(cls, v: str) -> Any: + """ + Perform any necessary transformations before the value is stored in a new instance. + + Override this method to customize the input behavior. + """ + return v + + def serialize(self) -> Serializable: + """Override this method to customize how the value will be serialized.""" + return self.value + + def __str__(self): + """Override this method to change how the value will be displayed by the UI.""" + return self.value diff --git a/bot/exts/filtering/filtering.py b/bot/exts/filtering/filtering.py new file mode 100644 index 0000000000..210ae3fb05 --- /dev/null +++ b/bot/exts/filtering/filtering.py @@ -0,0 +1,1516 @@ +import datetime +import io +import json +import re +import unicodedata +from collections import defaultdict +from collections.abc import Iterable, Mapping +from functools import partial, reduce +from io import BytesIO +from operator import attrgetter +from typing import Literal, get_type_hints + +import arrow +import discord +from async_rediscache import RedisCache +from discord import Colour, Embed, HTTPException, Message, MessageType, Thread +from discord.ext import commands, tasks +from discord.ext.commands import BadArgument, Cog, Context, command, has_any_role +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling +from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service + +import bot +import bot.exts.filtering._ui.filter as filters_ui +from bot import constants +from bot.bot import Bot +from bot.constants import BaseURLs, Channels, Guild, MODERATION_ROLES, Roles +from bot.exts.backend.branding._repository import HEADERS, PARAMS +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists import FilterList, ListType, ListTypeConverter, filter_list_types +from bot.exts.filtering._filter_lists.filter_list import AtomicList +from bot.exts.filtering._filters.filter import Filter, UniqueFilter +from bot.exts.filtering._settings import ActionSettings +from bot.exts.filtering._settings_types.actions.infraction_and_notification import Infraction +from bot.exts.filtering._ui.filter import ( + build_filter_repr_dict, + description_and_settings_converter, + filter_overrides_for_ui, + populate_embed_from_dict, +) +from bot.exts.filtering._ui.filter_list import FilterListAddView, FilterListEditView, settings_converter +from bot.exts.filtering._ui.search import SearchEditView, search_criteria_converter +from bot.exts.filtering._ui.ui import ( + AlertView, + ArgumentCompletionView, + DeleteConfirmationView, + build_mod_alert, + format_response_error, +) +from bot.exts.filtering._utils import past_tense, repr_equals, starting_value, to_serializable +from bot.exts.moderation.infraction.infractions import COMP_BAN_DURATION, COMP_BAN_REASON +from bot.exts.utils.snekbox._io import FileAttachment +from bot.log import get_logger +from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel +from bot.utils.lock import lock_arg +from bot.utils.message_cache import MessageCache + +log = get_logger(__name__) + +WEBHOOK_ICON_URL = r"https://github.com/python-discord/branding/raw/main/icons/filter/filter_pfp.png" +WEBHOOK_NAME = "Filtering System" +CACHE_SIZE = 1000 +HOURS_BETWEEN_NICKNAME_ALERTS = 1 +OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7) +WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday + + +async def _extract_text_file_content(att: discord.Attachment) -> str: + """Extract up to the first 30 lines or first 2000 characters (whichever is shorter) of an attachment.""" + file_encoding = re.search(r"charset=(\S+)", att.content_type).group(1) + file_content_bytes = await att.read() + file_lines = file_content_bytes.decode(file_encoding).splitlines() + first_n_lines = "\n".join(file_lines[:30])[:2_000] + return f"{att.filename}: {first_n_lines}" + + +class Filtering(Cog): + """Filtering and alerting for content posted on the server.""" + + # A set of filter list names with missing implementations that already caused a warning. + already_warned = set() + + # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent. + name_alerts = RedisCache() + + # region: init + + def __init__(self, bot: Bot): + self.bot = bot + self.filter_lists: dict[str, FilterList] = {} + self._subscriptions = defaultdict[Event, list[FilterList]](list) + self.delete_scheduler = scheduling.Scheduler(self.__class__.__name__) + self.webhook: discord.Webhook | None = None + + self.loaded_settings = {} + self.loaded_filters = {} + self.loaded_filter_settings = {} + + self.message_cache = MessageCache(CACHE_SIZE, newest_first=True) + + async def cog_load(self) -> None: + """ + Fetch the filter data from the API, parse it, and load it to the appropriate data structures. + + Additionally, fetch the alerting webhook. + """ + await self.bot.wait_until_guild_available() + + log.trace("Loading filtering information from the database.") + raw_filter_lists = await self.bot.api_client.get("bot/filter/filter_lists") + example_list = None + for raw_filter_list in raw_filter_lists: + loaded_list = self._load_raw_filter_list(raw_filter_list) + if not example_list and loaded_list: + example_list = loaded_list + + # The webhook must be generated by the bot to send messages with components through it. + self.webhook = await self._fetch_or_generate_filtering_webhook() + + self.collect_loaded_types(example_list) + await self.schedule_offending_messages_deletion() + self.weekly_auto_infraction_report_task.start() + + def subscribe(self, filter_list: FilterList, *events: Event) -> None: + """ + Subscribe a filter list to the given events. + + The filter list is added to a list for each event. When the event is triggered, the filter context will be + dispatched to the subscribed filter lists. + + While it's possible to just make each filter list check the context's event, these are only the events a filter + list expects to receive from the filtering cog, there isn't an actual limitation on the kinds of events a filter + list can handle as long as the filter context is built properly. If for whatever reason we want to invoke a + filter list outside of the usual procedure with the filtering cog, it will be more problematic if the events are + hard-coded into each filter list. + """ + for event in events: + if filter_list not in self._subscriptions[event]: + self._subscriptions[event].append(filter_list) + + def unsubscribe(self, filter_list: FilterList, *events: Event) -> None: + """Unsubscribe a filter list from the given events. If no events given, unsubscribe from every event.""" + if not events: + events = list(self._subscriptions) + + for event in events: + if filter_list in self._subscriptions.get(event, []): + self._subscriptions[event].remove(filter_list) + + def collect_loaded_types(self, example_list: AtomicList) -> None: + """ + Go over the classes used in initialization and collect them to dictionaries. + + The information that is collected is about the types actually used to load the API response, not all types + available in the filtering extension. + + Any filter list has the fields for all settings in the DB schema, so picking any one of them is enough. + """ + # Get the filter types used by each filter list. + for filter_list in self.filter_lists.values(): + self.loaded_filters.update({filter_type.name: filter_type for filter_type in filter_list.filter_types}) + + # Get the setting types used by each filter list. + if self.filter_lists: + settings_entries = set() + # The settings are split between actions and validations. + for settings_group in example_list.defaults: + settings_entries.update(type(setting) for _, setting in settings_group.items()) + + for setting_entry in settings_entries: + type_hints = get_type_hints(setting_entry) + # The description should be either a string or a dictionary. + if isinstance(setting_entry.description, str): + # If it's a string, then the settings entry matches a single field in the DB, + # and its name is the setting type's name attribute. + self.loaded_settings[setting_entry.name] = ( + setting_entry.description, setting_entry, type_hints[setting_entry.name] + ) + else: + # Otherwise, the setting entry works with compound settings. + self.loaded_settings.update({ + subsetting: (description, setting_entry, type_hints[subsetting]) + for subsetting, description in setting_entry.description.items() + }) + + # Get the settings per filter as well. + for filter_name, filter_type in self.loaded_filters.items(): + extra_fields_type = filter_type.extra_fields_type + if not extra_fields_type: + continue + type_hints = get_type_hints(extra_fields_type) + # A class var with a `_description` suffix is expected per field name. + self.loaded_filter_settings[filter_name] = { + field_name: ( + getattr(extra_fields_type, f"{field_name}_description", ""), + extra_fields_type, + type_hints[field_name] + ) + for field_name in extra_fields_type.model_fields + } + + async def schedule_offending_messages_deletion(self) -> None: + """Load the messages that need to be scheduled for deletion from the database.""" + response = await self.bot.api_client.get("bot/offensive-messages") + + now = arrow.utcnow() + for msg in response: + delete_at = arrow.get(msg["delete_date"]) + if delete_at < now: + await self._delete_offensive_msg(msg) + else: + self._schedule_msg_delete(msg) + + async def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return await has_any_role(*MODERATION_ROLES).predicate(ctx) + + # endregion + # region: listeners and event handlers + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Filter the contents of a sent message.""" + if msg.author.bot or msg.webhook_id or msg.type == MessageType.auto_moderation_action: + return + if msg.type == MessageType.channel_name_change and isinstance(msg.channel, Thread): + ctx = FilterContext.from_message(Event.THREAD_NAME, msg) + await self._check_bad_name(ctx) + return + + self.message_cache.append(msg) + + ctx = FilterContext.from_message(Event.MESSAGE, msg, None, self.message_cache) + + text_contents = [ + await _extract_text_file_content(a) + for a in msg.attachments if a.content_type and "charset" in a.content_type + ] + + if text_contents: + attachment_content = "\n\n".join(text_contents) + ctx = ctx.replace(content=f"{ctx.content}\n\n{attachment_content}") + + result_actions, list_messages, triggers = await self._resolve_action(ctx) + self.message_cache.update(msg, metadata=triggers) + if result_actions: + await result_actions.action(ctx) + if ctx.send_alert: + await self._send_alert(ctx, list_messages) + + nick_ctx = FilterContext.from_message(Event.NICKNAME, msg) + nick_ctx.content = msg.author.display_name + await self._check_bad_display_name(nick_ctx) + + await self._maybe_schedule_msg_delete(ctx, result_actions) + self._increment_stats(triggers) + + @Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: + """Filter the contents of an edited message. Don't reinvoke filters already invoked on the `before` version.""" + if before.author.bot or before.webhook_id or before.type == MessageType.auto_moderation_action: + return + + # Only check changes to the message contents/attachments and embed additions, not pin status etc. + if all(( + before.content == after.content, # content hasn't changed + before.attachments == after.attachments, # attachments haven't changed + len(before.embeds) >= len(after.embeds) # embeds haven't been added + )): + return + + # Update the cache first, it might be used by the antispam filter. + # No need to update the triggers, they're going to be updated inside the sublists if necessary. + self.message_cache.update(after) + ctx = FilterContext.from_message(Event.MESSAGE_EDIT, after, before, self.message_cache) + result_actions, list_messages, triggers = await self._resolve_action(ctx) + if result_actions: + await result_actions.action(ctx) + if ctx.send_alert: + await self._send_alert(ctx, list_messages) + await self._maybe_schedule_msg_delete(ctx, result_actions) + self._increment_stats(triggers) + + @Cog.listener() + async def on_voice_state_update(self, member: discord.Member, *_) -> None: + """Checks for bad words in usernames when users join, switch or leave a voice channel.""" + ctx = FilterContext(Event.NICKNAME, member, None, member.display_name, None) + await self._check_bad_display_name(ctx) + + @Cog.listener() + async def on_thread_create(self, thread: Thread) -> None: + """Check for bad words in new thread names.""" + ctx = FilterContext(Event.THREAD_NAME, thread.owner, thread, thread.name, None) + await self._check_bad_name(ctx) + + async def filter_snekbox_output( + self, stdout: str, files: list[FileAttachment], msg: Message + ) -> tuple[bool, set[str]]: + """ + Filter the result of a snekbox command to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for alerting. + Any action (deletion, infraction) will be applied in the context of the original message. + + Returns whether the output should be blocked, as well as a list of blocked file extensions. + """ + content = stdout + if files: # Filter the filenames as well. + content += "\n\n" + "\n".join(file.filename for file in files) + ctx = FilterContext.from_message(Event.SNEKBOX, msg).replace(content=content, attachments=files) + + result_actions, list_messages, triggers = await self._resolve_action(ctx) + if result_actions: + await result_actions.action(ctx) + if ctx.send_alert: + await self._send_alert(ctx, list_messages) + + self._increment_stats(triggers) + return result_actions is not None, ctx.blocked_exts + + # endregion + # region: blacklist commands + + @commands.group(aliases=("bl", "blacklist", "denylist", "dl")) + async def blocklist(self, ctx: Context) -> None: + """Group for managing blacklisted items.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @blocklist.command(name="list", aliases=("get",)) + async def bl_list(self, ctx: Context, list_name: str | None = None) -> None: + """List the contents of a specified blacklist.""" + result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name, exclude="list_type") + if not result: + return + list_type, filter_list = result + await self._send_list(ctx, filter_list, list_type) + + @blocklist.command(name="add", aliases=("a",)) + async def bl_add( + self, + ctx: Context, + noui: Literal["noui"] | None, + list_name: str | None, + content: str, + *, + description_and_settings: str | None = None + ) -> None: + """ + Add a blocked filter to the specified filter list. + + Unless `noui` is specified, a UI will be provided to edit the content, description, and settings + before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + """ + result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name, exclude="list_type") + if result is None: + return + list_type, filter_list = result + await self._add_filter(ctx, noui, list_type, filter_list, content, description_and_settings) + + # endregion + # region: whitelist commands + + @commands.group(aliases=("wl", "whitelist", "al")) + async def allowlist(self, ctx: Context) -> None: + """Group for managing blacklisted items.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @allowlist.command(name="list", aliases=("get",)) + async def al_list(self, ctx: Context, list_name: str | None = None) -> None: + """List the contents of a specified whitelist.""" + result = await self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name, exclude="list_type") + if not result: + return + list_type, filter_list = result + await self._send_list(ctx, filter_list, list_type) + + @allowlist.command(name="add", aliases=("a",)) + async def al_add( + self, + ctx: Context, + noui: Literal["noui"] | None, + list_name: str | None, + content: str, + *, + description_and_settings: str | None = None + ) -> None: + """ + Add an allowed filter to the specified filter list. + + Unless `noui` is specified, a UI will be provided to edit the content, description, and settings + before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + """ + result = await self._resolve_list_type_and_name(ctx, ListType.ALLOW, list_name, exclude="list_type") + if result is None: + return + list_type, filter_list = result + await self._add_filter(ctx, noui, list_type, filter_list, content, description_and_settings) + + # endregion + # region: filter commands + + @commands.group(aliases=("filters", "f"), invoke_without_command=True) + async def filter(self, ctx: Context, id_: int | None = None) -> None: + """ + Group for managing filters. + + If a valid filter ID is provided, an embed describing the filter will be posted. + """ + if not ctx.invoked_subcommand and not id_: + await ctx.send_help(ctx.command) + return + + result = self._get_filter_by_id(id_) + if result is None: + await ctx.send(f":x: Could not find a filter with ID `{id_}`.") + return + filter_, filter_list, list_type = result + + overrides_values, extra_fields_overrides = filter_overrides_for_ui(filter_) + + all_settings_repr_dict = build_filter_repr_dict( + filter_list, list_type, type(filter_), overrides_values, extra_fields_overrides + ) + embed = Embed(colour=Colour.blue()) + populate_embed_from_dict(embed, all_settings_repr_dict) + embed.description = f"`{filter_.content}`" + if filter_.description: + embed.description += f" - {filter_.description}" + embed.set_author(name=f"Filter {id_} - " + f"{filter_list[list_type].label}".title()) + embed.set_footer(text=( + "Field names with an asterisk have values which override the defaults of the containing filter list. " + f"To view all defaults of the list, " + f"run `{constants.Bot.prefix}filterlist describe {list_type.name} {filter_list.name}`." + )) + await ctx.send(embed=embed) + + @filter.command(name="list", aliases=("get",)) + async def f_list( + self, + ctx: Context, + list_type: ListTypeConverter | None = None, + list_name: str | None = None, + ) -> None: + """List the contents of a specified list of filters.""" + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + + await self._send_list(ctx, filter_list, list_type) + + @filter.command(name="describe", aliases=("explain", "manual")) + async def f_describe(self, ctx: Context, filter_name: str | None) -> None: + """Show a description of the specified filter, or a list of possible values if no name is specified.""" + if not filter_name: + filter_names = [f"» {f}" for f in self.loaded_filters] + embed = Embed(colour=Colour.blue()) + embed.set_author(name="List of filter names") + await LinePaginator.paginate(filter_names, ctx, embed, max_lines=10, empty=False) + else: + filter_type = self.loaded_filters.get(filter_name) + if not filter_type: + filter_type = self.loaded_filters.get(filter_name[:-1]) # A plural form or a typo. + if not filter_type: + await ctx.send(f":x: There's no filter type named {filter_name!r}.") + return + # Use the class's docstring, and ignore single newlines. + embed = Embed(description=re.sub(r"(? None: + """ + Add a filter to the specified filter list. + + Unless `noui` is specified, a UI will be provided to edit the content, description, and settings + before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + + A template filter can be specified in the settings area to copy overrides from. The setting name is "--template" + and the value is the filter ID. The template will be used before applying any other override. + + Example: `!filter add denied token "Scaleios is great" remove_context=True send_alert=False --template=100` + """ + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + await self._add_filter(ctx, noui, list_type, filter_list, content, description_and_settings) + + @filter.command(name="edit", aliases=("e",)) + async def f_edit( + self, + ctx: Context, + noui: Literal["noui"] | None, + filter_id: int, + *, + description_and_settings: str | None = None + ) -> None: + """ + Edit a filter specified by its ID. + + Unless `noui` is specified, a UI will be provided to edit the content, description, and settings + before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + + A template filter can be specified in the settings area to copy overrides from. The setting name is "--template" + and the value is the filter ID. The template will be used before applying any other override. + + To edit the filter's content, use the UI. + """ + result = self._get_filter_by_id(filter_id) + if result is None: + await ctx.send(f":x: Could not find a filter with ID `{filter_id}`.") + return + filter_, filter_list, list_type = result + filter_type = type(filter_) + settings, filter_settings = filter_overrides_for_ui(filter_) + description, new_settings, new_filter_settings = description_and_settings_converter( + filter_list, + list_type, filter_type, + self.loaded_settings, + self.loaded_filter_settings, + description_and_settings + ) + + content = filter_.content + description = description or filter_.description + settings.update(new_settings) + filter_settings.update(new_filter_settings) + patch_func = partial(self._patch_filter, filter_) + + if noui: + try: + await patch_func( + ctx.message, filter_list, list_type, filter_type, content, description, settings, filter_settings + ) + except ResponseCodeError as e: + await ctx.reply(embed=format_response_error(e)) + return + + embed = Embed(colour=Colour.blue()) + embed.description = f"`{filter_.content}`" + if description: + embed.description += f" - {description}" + embed.set_author( + name=f"Filter {filter_id} - {filter_list[list_type].label}".title()) + embed.set_footer(text=( + "Field names with an asterisk have values which override the defaults of the containing filter list. " + f"To view all defaults of the list, " + f"run `{constants.Bot.prefix}filterlist describe {list_type.name} {filter_list.name}`." + )) + + view = filters_ui.FilterEditView( + filter_list, + list_type, + filter_type, + content, + description, + settings, + filter_settings, + self.loaded_settings, + self.loaded_filter_settings, + ctx.author, + embed, + patch_func + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + + @filter.command(name="delete", aliases=("d", "remove")) + async def f_delete(self, ctx: Context, filter_id: int) -> None: + """Delete the filter specified by its ID.""" + async def delete_list() -> None: + """The actual removal routine.""" + await bot.instance.api_client.delete(f"bot/filter/filters/{filter_id}") + log.info(f"Successfully deleted filter with ID {filter_id}.") + filter_list[list_type].filters.pop(filter_id) + await ctx.reply(f"✅ Deleted filter: {filter_}") + + result = self._get_filter_by_id(filter_id) + if result is None: + await ctx.send(f":x: Could not find a filter with ID `{filter_id}`.") + return + filter_, filter_list, list_type = result + await ctx.reply( + f"Are you sure you want to delete filter {filter_}?", + view=DeleteConfirmationView(ctx.author, delete_list) + ) + + @filter.command(aliases=("settings",)) + async def setting(self, ctx: Context, setting_name: str | None) -> None: + """Show a description of the specified setting, or a list of possible settings if no name is specified.""" + if not setting_name: + settings_list = [f"» {setting_name}" for setting_name in self.loaded_settings] + for filter_name, filter_settings in self.loaded_filter_settings.items(): + settings_list.extend(f"» {filter_name}/{setting}" for setting in filter_settings) + embed = Embed(colour=Colour.blue()) + embed.set_author(name="List of setting names") + await LinePaginator.paginate(settings_list, ctx, embed, max_lines=10, empty=False) + + else: + # The setting is either in a SettingsEntry subclass, or a pydantic model. + setting_data = self.loaded_settings.get(setting_name) + description = None + if setting_data: + description = setting_data[0] + elif "/" in setting_name: # It's a filter specific setting. + filter_name, filter_setting_name = setting_name.split("/", maxsplit=1) + if filter_name in self.loaded_filter_settings: + if filter_setting_name in self.loaded_filter_settings[filter_name]: + description = self.loaded_filter_settings[filter_name][filter_setting_name][0] + if description is None: + await ctx.send(f":x: There's no setting type named {setting_name!r}.") + return + embed = Embed(colour=Colour.blue(), description=description) + embed.set_author(name=f"Description of the {setting_name} setting") + await ctx.send(embed=embed) + + @filter.command(name="match") + async def f_match( + self, ctx: Context, no_user: bool | None, message: Message | None, *, string: str | None + ) -> None: + """ + List the filters triggered for the given message or string. + + If there's a `message`, the `string` will be ignored. Note that if a `message` is provided, it will go through + all validations appropriate to where it was sent and who sent it. To check for matches regardless of the author + (for example if the message was sent by another staff member or yourself) set `no_user` to '1' or 'True'. + + If a `string` is provided, it will be validated in the context of a user with no roles in python-general. + """ + if not message and not string: + raise BadArgument("Please provide input.") + if message: + user = None if no_user else message.author + filter_ctx = FilterContext(Event.MESSAGE, user, message.channel, message.content, message, message.embeds) + else: + python_general = ctx.guild.get_channel(Channels.python_general) + filter_ctx = FilterContext(Event.MESSAGE, None, python_general, string, None) + + _, _, triggers = await self._resolve_action(filter_ctx) + lines = [] + for sublist, sublist_triggers in triggers.items(): + if sublist_triggers: + triggers_repr = map(str, sublist_triggers) + lines.extend([f"**{sublist.label.title()}s**", *triggers_repr, "\n"]) + lines = lines[:-1] # Remove last newline. + + embed = Embed(colour=Colour.blue(), title="Match results") + await LinePaginator.paginate(lines, ctx, embed, max_lines=10, empty=False) + + @filter.command(name="search") + async def f_search( + self, + ctx: Context, + noui: Literal["noui"] | None, + filter_type_name: str | None, + *, + settings: str = "" + ) -> None: + """ + Find filters with the provided settings. The format is identical to that of the add and edit commands. + + If a list type and/or a list name are provided, the search will be limited to those parameters. A list name must + be provided in order to search by filter-specific settings. + """ + filter_type = None + if filter_type_name: + filter_type_name = filter_type_name.lower() + filter_type = self.loaded_filters.get(filter_type_name) + if not filter_type: + self.loaded_filters.get(filter_type_name[:-1]) # In case the user tried to specify the plural form. + # If settings were provided with no filter_type, discord.py will capture the first word as the filter type. + if filter_type is None and filter_type_name is not None: + if settings: + settings = f"{filter_type_name} {settings}" + else: + settings = filter_type_name + filter_type_name = None + + settings, filter_settings, filter_type = search_criteria_converter( + self.filter_lists, + self.loaded_filters, + self.loaded_settings, + self.loaded_filter_settings, + filter_type, + settings + ) + + if noui: + await self._search_filters(ctx.message, filter_type, settings, filter_settings) + return + + embed = Embed(colour=Colour.blue()) + view = SearchEditView( + filter_type, + settings, + filter_settings, + self.filter_lists, + self.loaded_filters, + self.loaded_settings, + self.loaded_filter_settings, + ctx.author, + embed, + self._search_filters + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + + @filter.command(root_aliases=("compfilter", "compf")) + async def compadd( + self, ctx: Context, list_name: str | None, content: str, *, description: str | None = "Phishing" + ) -> None: + """Add a filter to detect a compromised account. Will apply the equivalent of a compban if triggered.""" + result = await self._resolve_list_type_and_name(ctx, ListType.DENY, list_name, exclude="list_type") + if result is None: + return + list_type, filter_list = result + + settings = ( + "remove_context=True " + 'guild_pings="" ' + 'dm_pings="" ' + "infraction_type=BAN " + "infraction_channel=1 " # Post the ban in #mod-alerts + f"infraction_duration={COMP_BAN_DURATION.total_seconds()} " + f"infraction_reason={COMP_BAN_REASON}" + ) + description_and_settings = f"{description} {settings}" + await self._add_filter(ctx, "noui", list_type, filter_list, content, description_and_settings) + + # endregion + # region: filterlist group + + @commands.group(aliases=("fl",)) + async def filterlist(self, ctx: Context) -> None: + """Group for managing filter lists.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @filterlist.command(name="describe", aliases=("explain", "manual", "id")) + async def fl_describe( + self, ctx: Context, list_type: ListTypeConverter | None = None, list_name: str | None = None + ) -> None: + """Show a description of the specified filter list, or a list of possible values if no values are provided.""" + if not list_type and not list_name: + list_names = [f"» {fl}" for fl in self.filter_lists] + embed = Embed(colour=Colour.blue()) + embed.set_author(name="List of filter lists names") + await LinePaginator.paginate(list_names, ctx, embed, max_lines=10, empty=False) + return + + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + + setting_values = {} + for settings_group in filter_list[list_type].defaults: + for _, setting in settings_group.items(): + setting_values.update(to_serializable(setting.model_dump(), ui_repr=True)) + + embed = Embed(colour=Colour.blue()) + populate_embed_from_dict(embed, setting_values) + # Use the class's docstring, and ignore single newlines. + embed.description = re.sub(r"(? None: + """Add a new filter list.""" + # Check if there's an implementation. + if list_name.lower() not in filter_list_types: + if list_name.lower()[:-1] not in filter_list_types: # Maybe the name was given with uppercase or in plural? + await ctx.reply(f":x: Cannot add a `{list_name}` filter list, as there is no matching implementation.") + return + list_name = list_name.lower()[:-1] + + # Check it doesn't already exist. + list_description = f"{past_tense(list_type.name.lower())} {list_name.lower()}" + if list_name in self.filter_lists: + filter_list = self.filter_lists[list_name] + if list_type in filter_list: + await ctx.reply(f":x: The {list_description} filter list already exists.") + return + + embed = Embed(colour=Colour.blue()) + embed.set_author(name=f"New Filter List - {list_description.title()}") + settings = {name: starting_value(value[2]) for name, value in self.loaded_settings.items()} + + view = FilterListAddView( + list_name, + list_type, + settings, + self.loaded_settings, + ctx.author, + embed, + self._post_filter_list + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + + @filterlist.command(name="edit", aliases=("e",)) + @has_any_role(Roles.admins) + async def fl_edit( + self, + ctx: Context, + noui: Literal["noui"] | None, + list_type: ListTypeConverter | None = None, + list_name: str | None = None, + *, + settings: str | None + ) -> None: + """ + Edit the filter list. + + Unless `noui` is specified, a UI will be provided to edit the settings before confirmation. + + The settings can be provided in the command itself, in the format of `setting_name=value` (no spaces around the + equal sign). The value doesn't need to (shouldn't) be surrounded in quotes even if it contains spaces. + """ + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + settings = settings_converter(self.loaded_settings, settings) + if noui: + try: + await self._patch_filter_list(ctx.message, filter_list, list_type, settings) + except ResponseCodeError as e: + await ctx.reply(embed=format_response_error(e)) + return + + embed = Embed(colour=Colour.blue()) + embed.set_author(name=f"{filter_list[list_type].label.title()} Filter List") + embed.set_footer(text="Field names with a ~ have values which change the existing value in the filter list.") + + view = FilterListEditView( + filter_list, + list_type, + settings, + self.loaded_settings, + ctx.author, + embed, + self._patch_filter_list + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + + @filterlist.command(name="delete", aliases=("remove",)) + @has_any_role(Roles.admins) + async def fl_delete( + self, ctx: Context, list_type: ListTypeConverter | None = None, list_name: str | None = None + ) -> None: + """Remove the filter list and all of its filters from the database.""" + async def delete_list() -> None: + """The actual removal routine.""" + list_data = await bot.instance.api_client.get(f"bot/filter/filter_lists/{list_id}") + file = discord.File(BytesIO(json.dumps(list_data, indent=4).encode("utf-8")), f"{list_description}.json") + message = await ctx.send("⏳ Annihilation in progress, please hold...", file=file) + # Unload the filter list. + filter_list.pop(list_type) + if not filter_list: # There's nothing left, remove from the cog. + self.filter_lists.pop(filter_list.name) + self.unsubscribe(filter_list) + + await bot.instance.api_client.delete(f"bot/filter/filter_lists/{list_id}") + log.info(f"Successfully deleted the {filter_list[list_type].label} filterlist.") + await message.edit(content=f"✅ The {list_description} list has been deleted.") + + result = await self._resolve_list_type_and_name(ctx, list_type, list_name) + if result is None: + return + list_type, filter_list = result + list_id = filter_list[list_type].id + list_description = filter_list[list_type].label + await ctx.reply( + f"Are you sure you want to delete the {list_description} list?", + view=DeleteConfirmationView(ctx.author, delete_list) + ) + + # endregion + # region: utility commands + + @command(name="filter_report") + async def force_send_weekly_report(self, ctx: Context) -> None: + """Respond with a list of auto-infractions added in the last 7 days.""" + await self.send_weekly_auto_infraction_report(ctx.channel) + + # endregion + # region: helper functions + + def _load_raw_filter_list(self, list_data: dict) -> AtomicList | None: + """Load the raw list data to the cog.""" + list_name = list_data["name"] + if list_name not in self.filter_lists: + if list_name not in filter_list_types: + if list_name not in self.already_warned: + log.warning( + f"A filter list named {list_name} was loaded from the database, but no matching class." + ) + self.already_warned.add(list_name) + return None + self.filter_lists[list_name] = filter_list_types[list_name](self) + return self.filter_lists[list_name].add_list(list_data) + + async def _fetch_or_generate_filtering_webhook(self) -> discord.Webhook | None: + """Generate a webhook with the filtering avatar.""" + alerts_channel = self.bot.get_guild(Guild.id).get_channel(Channels.mod_alerts) + # Try to find an existing webhook. + for webhook in await alerts_channel.webhooks(): + if webhook.name == WEBHOOK_NAME and webhook.user == self.bot.user and webhook.is_authenticated(): + log.trace(f"Found existing filters webhook with ID {webhook.id}.") + return webhook + + # Download the filtering avatar from the branding repository. + webhook_icon = None + async with self.bot.http_session.get(WEBHOOK_ICON_URL, params=PARAMS, headers=HEADERS) as response: + if response.status == 200: + log.debug("Successfully fetched filtering webhook icon, reading payload.") + webhook_icon = await response.read() + else: + log.warning(f"Failed to fetch filtering webhook icon due to status: {response.status}") + + # Generate a new webhook. + try: + webhook = await alerts_channel.create_webhook(name=WEBHOOK_NAME, avatar=webhook_icon) + log.trace(f"Generated new filters webhook with ID {webhook.id},") + return webhook + except HTTPException as e: + log.error(f"Failed to create filters webhook: {e}") + return None + + async def _resolve_action( + self, ctx: FilterContext + ) -> tuple[ActionSettings | None, dict[FilterList, list[str]], dict[AtomicList, list[Filter]]]: + """ + Return the actions that should be taken for all filter lists in the given context. + + Additionally, a message is possibly provided from each filter list describing the triggers, + which should be relayed to the moderators. + """ + actions = [] + messages = {} + triggers = {} + for filter_list in self._subscriptions[ctx.event]: + list_actions, list_message, list_triggers = await filter_list.actions_for(ctx) + triggers.update({filter_list[list_type]: filters for list_type, filters in list_triggers.items()}) + if list_actions: + actions.append(list_actions) + if list_message: + messages[filter_list] = list_message + + result_actions = None + if actions: + result_actions = reduce(ActionSettings.union, actions) + # If the action is a ban, mods don't want to be pinged. + if infr_action := result_actions.get("infraction_and_notification"): + if infr_action.infraction_type == Infraction.BAN: + result_actions.pop("mentions", None) + return result_actions, messages, triggers + + async def _send_alert(self, ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> None: + """Build an alert message from the filter context, and send it via the alert webhook.""" + if not self.webhook: + return + + name = f"{ctx.event.name.replace('_', ' ').title()} Filter" + embed = await build_mod_alert(ctx, triggered_filters) + # There shouldn't be more than 10, but if there are it's not very useful to send them all. + await self.webhook.send( + username=name, content=ctx.alert_content, embeds=[embed, *ctx.alert_embeds][:10], view=AlertView(ctx) + ) + + def _increment_stats(self, triggered_filters: dict[AtomicList, list[Filter]]) -> None: + """Increment the stats for every filter triggered.""" + for filters in triggered_filters.values(): + for filter_ in filters: + if isinstance(filter_, UniqueFilter): + self.bot.stats.incr(f"filters.{filter_.name}") + + async def _recently_alerted_name(self, member: discord.Member) -> bool: + """When it hasn't been `HOURS_BETWEEN_NICKNAME_ALERTS` since last alert, return False, otherwise True.""" + if last_alert := await self.name_alerts.get(member.id): + last_alert = arrow.get(last_alert) + if arrow.utcnow() - last_alert < datetime.timedelta(days=HOURS_BETWEEN_NICKNAME_ALERTS): + log.trace(f"Last alert was too recent for {member}'s nickname.") + return True + + return False + + @lock_arg("filtering.check_bad_name", "ctx", attrgetter("author.id")) + async def _check_bad_display_name(self, ctx: FilterContext) -> None: + """Check filter triggers in the passed context - a member's display name.""" + if await self._recently_alerted_name(ctx.author): + return + new_ctx = await self._check_bad_name(ctx) + if new_ctx.send_alert: + # Update time when alert sent + await self.name_alerts.set(ctx.author.id, arrow.utcnow().timestamp()) + + async def _check_bad_name(self, ctx: FilterContext) -> FilterContext: + """Check filter triggers for some given name (thread name, a member's display name).""" + name = ctx.content + normalised_name = unicodedata.normalize("NFKC", name) + cleaned_normalised_name = "".join([c for c in normalised_name if not unicodedata.combining(c)]) + + # Run filters against normalised, cleaned normalised and the original name, + # in case there are filters for one but not another. + names_to_check = (name, normalised_name, cleaned_normalised_name) + + new_ctx = ctx.replace(content=" ".join(names_to_check)) + result_actions, list_messages, triggers = await self._resolve_action(new_ctx) + new_ctx = new_ctx.replace(content=ctx.content) # Alert with the original content. + if result_actions: + await result_actions.action(new_ctx) + if new_ctx.send_alert: + await self._send_alert(new_ctx, list_messages) + self._increment_stats(triggers) + return new_ctx + + async def _resolve_list_type_and_name( + self, ctx: Context, list_type: ListType | None = None, list_name: str | None = None, *, exclude: str = "" + ) -> tuple[ListType, FilterList] | None: + """Prompt the user to complete the list type or list name if one of them is missing.""" + if list_name is None: + args = [list_type] if exclude != "list_type" else [] + await ctx.send( + "The **list_name** argument is unspecified. Please pick a value from the options below:", + view=ArgumentCompletionView(ctx, args, "list_name", list(self.filter_lists), 1, None) + ) + return None + + filter_list = self._get_list_by_name(list_name) + if list_type is None: + if len(filter_list) > 1: + args = [list_name] if exclude != "list_name" else [] + await ctx.send( + "The **list_type** argument is unspecified. Please pick a value from the options below:", + view=ArgumentCompletionView( + ctx, args, "list_type", [option.name for option in ListType], 0, ListTypeConverter + ) + ) + return None + list_type = list(filter_list)[0] + return list_type, filter_list + + def _get_list_by_name(self, list_name: str) -> FilterList: + """Get a filter list by its name, or raise an error if there's no such list.""" + log.trace(f"Getting the filter list matching the name {list_name}") + filter_list = self.filter_lists.get(list_name) + if not filter_list: + if list_name.endswith("s"): # The user may have attempted to use the plural form. + filter_list = self.filter_lists.get(list_name[:-1]) + if not filter_list: + raise BadArgument(f"There's no filter list named {list_name!r}.") + log.trace(f"Found list named {filter_list.name}") + return filter_list + + @staticmethod + async def _send_list(ctx: Context, filter_list: FilterList, list_type: ListType) -> None: + """Show the list of filters identified by the list name and type.""" + if list_type not in filter_list: + await ctx.send(f":x: There is no list of {past_tense(list_type.name.lower())} {filter_list.name}s.") + return + + lines = list(map(str, filter_list[list_type].filters.values())) + log.trace(f"Sending a list of {len(lines)} filters.") + + embed = Embed(colour=Colour.blue()) + embed.set_author(name=f"List of {filter_list[list_type].label}s ({len(lines)} total)") + + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False, reply=True) + + def _get_filter_by_id(self, id_: int) -> tuple[Filter, FilterList, ListType] | None: + """Get the filter object corresponding to the provided ID, along with its containing list and list type.""" + for filter_list in self.filter_lists.values(): + for list_type, sublist in filter_list.items(): + if id_ in sublist.filters: + return sublist.filters[id_], filter_list, list_type + return None + + async def _add_filter( + self, + ctx: Context, + noui: Literal["noui"] | None, + list_type: ListType, + filter_list: FilterList, + content: str, + description_and_settings: str | None = None + ) -> None: + """Add a filter to the database.""" + # Validations. + if list_type not in filter_list: + await ctx.reply(f":x: There is no list of {past_tense(list_type.name.lower())} {filter_list.name}s.") + return + filter_type = filter_list.get_filter_type(content) + if not filter_type: + await ctx.reply(f":x: Could not find a filter type appropriate for `{content}`.") + return + # Parse the description and settings. + description, settings, filter_settings = description_and_settings_converter( + filter_list, + list_type, + filter_type, + self.loaded_settings, + self.loaded_filter_settings, + description_and_settings + ) + + if noui: # Add directly with no UI. + try: + await self._post_new_filter( + ctx.message, filter_list, list_type, filter_type, content, description, settings, filter_settings + ) + except ResponseCodeError as e: + await ctx.reply(embed=format_response_error(e)) + except ValueError as e: + raise BadArgument(str(e)) + return + # Bring up the UI. + embed = Embed(colour=Colour.blue()) + embed.description = f"`{content}`" if content else "*No content*" + if description: + embed.description += f" - {description}" + embed.set_author( + name=f"New Filter - {filter_list[list_type].label}".title()) + embed.set_footer(text=( + "Field names with an asterisk have values which override the defaults of the containing filter list. " + f"To view all defaults of the list, " + f"run `{constants.Bot.prefix}filterlist describe {list_type.name} {filter_list.name}`." + )) + + view = filters_ui.FilterEditView( + filter_list, + list_type, + filter_type, + content, + description, + settings, + filter_settings, + self.loaded_settings, + self.loaded_filter_settings, + ctx.author, + embed, + self._post_new_filter + ) + await ctx.send(embed=embed, reference=ctx.message, view=view) + + @staticmethod + def _identical_filters_message(content: str, filter_list: FilterList, list_type: ListType, filter_: Filter) -> str: + """Returns all the filters in the list with content identical to the content supplied.""" + if list_type not in filter_list: + return "" + duplicates = [ + f for f in filter_list[list_type].filters.values() + if f.content == content and f.id != filter_.id + ] + msg = "" + if duplicates: + msg = f"\n:warning: The filter(s) #{', #'.join(str(dup.id) for dup in duplicates)} have the same content. " + msg += "Please make sure this is intentional." + + return msg + + @staticmethod + async def _maybe_alert_auto_infraction( + filter_list: FilterList, list_type: ListType, filter_: Filter, old_filter: Filter | None = None + ) -> None: + """If the filter is new and applies an auto-infraction, or was edited to apply a different one, log it.""" + infraction_type = filter_.overrides[0].get("infraction_type") + if not infraction_type: + infraction_type = filter_list[list_type].default("infraction_type") + if old_filter: + old_infraction_type = old_filter.overrides[0].get("infraction_type") + if not old_infraction_type: + old_infraction_type = filter_list[list_type].default("infraction_type") + if infraction_type == old_infraction_type: + return + + if infraction_type != Infraction.NONE: + filter_log = bot.instance.get_channel(Channels.filter_log) + if filter_log: + await filter_log.send( + f":warning: Heads up! The new {filter_list[list_type].label} filter " + f"({filter_}) will automatically {infraction_type.name.lower()} users." + ) + + async def _post_new_filter( + self, + msg: Message, + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + content: str, + description: str | None, + settings: dict, + filter_settings: dict + ) -> None: + """POST the data of the new filter to the site API.""" + valid, error_msg = filter_type.validate_filter_settings(filter_settings) + if not valid: + raise BadArgument(f"Error while validating filter-specific settings: {error_msg}") + + content, description = await filter_type.process_input(content, description) + + list_id = filter_list[list_type].id + description = description or None + payload = { + "filter_list": list_id, "content": content, "description": description, + "additional_settings": filter_settings, **settings + } + response = await bot.instance.api_client.post("bot/filter/filters", json=to_serializable(payload)) + new_filter = filter_list.add_filter(list_type, response) + log.info(f"Added new filter: {new_filter}.") + if new_filter: + await self._maybe_alert_auto_infraction(filter_list, list_type, new_filter) + extra_msg = Filtering._identical_filters_message(content, filter_list, list_type, new_filter) + await msg.reply(f"✅ Added filter: {new_filter}" + extra_msg) + else: + await msg.reply(":x: Could not create the filter. Are you sure it's implemented?") + + async def _patch_filter( + self, + filter_: Filter, + msg: Message, + filter_list: FilterList, + list_type: ListType, + filter_type: type[Filter], + content: str, + description: str | None, + settings: dict, + filter_settings: dict + ) -> None: + """PATCH the new data of the filter to the site API.""" + valid, error_msg = filter_type.validate_filter_settings(filter_settings) + if not valid: + raise BadArgument(f"Error while validating filter-specific settings: {error_msg}") + + if content != filter_.content: + content, description = await filter_type.process_input(content, description) + + # If the setting is not in `settings`, the override was either removed, or there wasn't one in the first place. + for current_settings in (filter_.actions, filter_.validations): + if current_settings: + for setting_entry in current_settings.values(): + settings.update( + { + setting: None + for setting in setting_entry.model_dump() + if setting not in settings + } + ) + + # Even though the list ID remains unchanged, it still needs to be provided for correct serializer validation. + list_id = filter_list[list_type].id + description = description or None + payload = { + "filter_list": list_id, "content": content, "description": description, + "additional_settings": filter_settings, **settings + } + response = await bot.instance.api_client.patch( + f"bot/filter/filters/{filter_.id}", json=to_serializable(payload) + ) + # Return type can be None, but if it's being edited then it's not supposed to be. + edited_filter = filter_list.add_filter(list_type, response) + log.info(f"Successfully patched filter {edited_filter}.") + await self._maybe_alert_auto_infraction(filter_list, list_type, edited_filter, filter_) + extra_msg = Filtering._identical_filters_message(content, filter_list, list_type, edited_filter) + await msg.reply(f"✅ Edited filter: {edited_filter}" + extra_msg) + + async def _post_filter_list(self, msg: Message, list_name: str, list_type: ListType, settings: dict) -> None: + """POST the new data of the filter list to the site API.""" + payload = {"name": list_name, "list_type": list_type.value, **to_serializable(settings)} + filterlist_name = f"{past_tense(list_type.name.lower())} {list_name}" + response = await bot.instance.api_client.post("bot/filter/filter_lists", json=payload) + log.info(f"Successfully posted the new {filterlist_name} filterlist.") + self._load_raw_filter_list(response) + await msg.reply(f"✅ Added a new filter list: {filterlist_name}") + + @staticmethod + async def _patch_filter_list(msg: Message, filter_list: FilterList, list_type: ListType, settings: dict) -> None: + """PATCH the new data of the filter list to the site API.""" + list_id = filter_list[list_type].id + response = await bot.instance.api_client.patch( + f"bot/filter/filter_lists/{list_id}", json=to_serializable(settings) + ) + log.info(f"Successfully patched the {filter_list[list_type].label} filterlist, reloading...") + filter_list.pop(list_type, None) + filter_list.add_list(response) + await msg.reply(f"✅ Edited filter list: {filter_list[list_type].label}") + + def _filter_match_query( + self, filter_: Filter, settings_query: dict, filter_settings_query: dict, differ_by_default: set[str] + ) -> bool: + """Return whether the given filter matches the query.""" + override_matches = set() + overrides, _ = filter_.overrides + for setting_name, setting_value in settings_query.items(): + if setting_name not in overrides: + continue + if repr_equals(overrides[setting_name], setting_value): + override_matches.add(setting_name) + else: # If an override doesn't match then the filter doesn't match. + return False + if not (differ_by_default <= override_matches): # The overrides didn't cover for the default mismatches. + return False + + filter_settings = filter_.extra_fields.model_dump() if filter_.extra_fields else {} + # If the dict changes then some fields were not the same. + return (filter_settings | filter_settings_query) == filter_settings + + def _search_filter_list( + self, atomic_list: AtomicList, filter_type: type[Filter] | None, settings: dict, filter_settings: dict + ) -> list[Filter]: + """Find all filters in the filter list which match the settings.""" + # If the default answers are known, only the overrides need to be checked for each filter. + all_defaults = atomic_list.defaults.dict() + match_by_default = set() + differ_by_default = set() + for setting_name, setting_value in settings.items(): + if repr_equals(all_defaults[setting_name], setting_value): + match_by_default.add(setting_name) + else: + differ_by_default.add(setting_name) + + result_filters = [] + for filter_ in atomic_list.filters.values(): + if filter_type and not isinstance(filter_, filter_type): + continue + if self._filter_match_query(filter_, settings, filter_settings, differ_by_default): + result_filters.append(filter_) + + return result_filters + + async def _search_filters( + self, message: Message, filter_type: type[Filter] | None, settings: dict, filter_settings: dict + ) -> None: + """Find all filters which match the settings and display them.""" + lines = [] + result_count = 0 + for filter_list in self.filter_lists.values(): + if filter_type and filter_type not in filter_list.filter_types: + continue + for atomic_list in filter_list.values(): + list_results = self._search_filter_list(atomic_list, filter_type, settings, filter_settings) + if list_results: + lines.append(f"**{atomic_list.label.title()}**") + lines.extend(map(str, list_results)) + lines.append("") + result_count += len(list_results) + + embed = Embed(colour=Colour.blue()) + embed.set_author(name=f"Search Results ({result_count} total)") + ctx = await bot.instance.get_context(message) + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False, reply=True) + + async def _delete_offensive_msg(self, msg: Mapping[str, int]) -> None: + """Delete an offensive message, and then delete it from the DB.""" + try: + channel = self.bot.get_channel(msg["channel_id"]) + if channel: + msg_obj = await channel.fetch_message(msg["id"]) + await msg_obj.delete() + except discord.NotFound: + log.info( + f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted)." + ) + except HTTPException as e: + log.warning(f"Failed to delete message {msg['id']}: status {e.status}") + + await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') + log.info(f"Deleted the offensive message with id {msg['id']}.") + + def _schedule_msg_delete(self, msg: dict) -> None: + """Delete an offensive message once its deletion date is reached.""" + delete_at = arrow.get(msg["delete_date"]).datetime + self.delete_scheduler.schedule_at(delete_at, msg["id"], self._delete_offensive_msg(msg)) + + async def _maybe_schedule_msg_delete(self, ctx: FilterContext, actions: ActionSettings | None) -> None: + """Post the message to the database and schedule it for deletion if it's not set to be deleted already.""" + msg = ctx.message + if not msg or not actions or actions.get_setting("remove_context", True): + return + + delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() + data = { + "id": msg.id, + "channel_id": msg.channel.id, + "delete_date": delete_date + } + + try: + await self.bot.api_client.post("bot/offensive-messages", json=data) + except ResponseCodeError as e: + if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: + log.debug(f"Offensive message {msg.id} already exists.") + else: + log.error(f"Offensive message {msg.id} failed to post: {e}") + else: + self._schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + + # endregion + # region: tasks + + @tasks.loop(time=datetime.time(hour=18)) + async def weekly_auto_infraction_report_task(self) -> None: + """Trigger an auto-infraction report to be sent if it is the desired day of the week (WEEKLY_REPORT_ISO_DAY).""" + if arrow.utcnow().isoweekday() != WEEKLY_REPORT_ISO_DAY: + return + + await self.send_weekly_auto_infraction_report() + + async def send_weekly_auto_infraction_report( + self, + channel: discord.TextChannel | discord.Thread | None = None, + ) -> None: + """ + Send a list of auto-infractions added in the last 7 days to the specified channel. + + If `channel` is not specified, the report is sent to #mod-meta instead. + """ + log.trace("Preparing weekly auto-infraction report.") + seven_days_ago = arrow.utcnow().shift(days=-7) + if not channel: + log.info("Auto-infraction report: the channel to report to is missing.") + channel = self.bot.get_channel(Channels.mod_meta) + elif not is_mod_channel(channel): + # Silently fail if output is going to be a non-mod channel. + log.info(f"Auto-infraction report: the channel {channel} is not a mod channel.") + return + + found_filters = defaultdict(list) + # Extract all auto-infraction filters added in the past 7 days from each filter type + for filter_list in self.filter_lists.values(): + for sublist in filter_list.values(): + default_infraction_type = sublist.default("infraction_type") + for filter_ in sublist.filters.values(): + if max(filter_.created_at, filter_.updated_at) < seven_days_ago: + continue + infraction_type = filter_.overrides[0].get("infraction_type") + if ( + (infraction_type and infraction_type != Infraction.NONE) + or (not infraction_type and default_infraction_type != Infraction.NONE) + ): + found_filters[sublist.label].append((filter_, infraction_type or default_infraction_type)) + + # Nicely format the output so each filter list type is grouped + lines = [f"**Auto-infraction filters added since {seven_days_ago.format('YYYY-MM-DD')}**"] + for list_label, filters in found_filters.items(): + lines.append("\n".join([f"**{list_label.title()}**"]+[f"{filter_} ({infr})" for filter_, infr in filters])) + + if len(lines) == 1: + lines.append("Nothing to show") + + report = "\n\n".join(lines) + try: + await channel.send(report) + except discord.HTTPException as e: + if e.code != 50035: # Content too long + raise + report = discord.utils.remove_markdown(report) + file = PasteFile(content=report, lexer="text") + try: + resp = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, + ) + paste_resp = resp.link + except (ValueError, PasteTooLongError, PasteUploadError): + paste_resp = ":warning: Failed to upload report to paste service" + file_buffer = io.StringIO(report) + await channel.send( + f"**{lines[0]}**\n\n{paste_resp}", + file=discord.File(file_buffer, "last_weeks_autoban_filters.txt"), + ) + + log.info("Successfully sent auto-infraction report.") + # endregion + + async def cog_unload(self) -> None: + """Cancel the weekly auto-infraction filter report and deletion scheduling on cog unload.""" + self.weekly_auto_infraction_report_task.cancel() + self.delete_scheduler.cancel_all() + + +async def setup(bot: Bot) -> None: + """Load the Filtering cog.""" + await bot.add_cog(Filtering(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py deleted file mode 100644 index 89e539e7b4..0000000000 --- a/bot/exts/filters/antimalware.py +++ /dev/null @@ -1,107 +0,0 @@ -import logging -import typing as t -from os.path import splitext - -from discord import Embed, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, Filter, URLs - -log = logging.getLogger(__name__) - -PY_EMBED_DESCRIPTION = ( - "It looks like you tried to attach a Python file - " - f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" -) - -TXT_LIKE_FILES = {".txt", ".csv", ".json"} -TXT_EMBED_DESCRIPTION = ( - "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `{blocked_extension}` attachments, " - "so here are some tips to help you travel safely: \n\n" - "• If you attempted to send a message longer than 2000 characters, try shortening your message " - "to fit within the character limit or use a pasting service (see below) \n\n" - "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " - "{cmd_channel_mention} for more information) or use a pasting service like: " - f"\n\n{URLs.site_schema}{URLs.site_paste}" -) - -DISALLOWED_EMBED_DESCRIPTION = ( - "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - "We currently allow the following file types: **{joined_whitelist}**.\n\n" - "Feel free to ask in {meta_channel_mention} if you think this is a mistake." -) - - -class AntiMalware(Cog): - """Delete messages which contain attachments with non-whitelisted file extensions.""" - - def __init__(self, bot: Bot): - self.bot = bot - - def _get_whitelisted_file_formats(self) -> list: - """Get the file formats currently on the whitelist.""" - return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() - - def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) - return extensions_blocked - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Identify messages with prohibited attachments.""" - # Return when message don't have attachment and don't moderate DMs - if not message.attachments or not message.guild: - return - - # Ignore webhook and bot messages - if message.webhook_id or message.author.bot: - return - - # Check if user is staff, if is, return - # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): - return - - embed = Embed() - extensions_blocked = self._get_disallowed_extensions(message) - blocked_extensions_str = ', '.join(extensions_blocked) - if ".py" in extensions_blocked: - # Short-circuit on *.py files to provide a pastebin link - embed.description = PY_EMBED_DESCRIPTION - elif extensions := TXT_LIKE_FILES.intersection(extensions_blocked): - # Work around Discord AutoConversion of messages longer than 2000 chars to .txt - cmd_channel = self.bot.get_channel(Channels.bot_commands) - embed.description = TXT_EMBED_DESCRIPTION.format( - blocked_extension=extensions.pop(), - cmd_channel_mention=cmd_channel.mention - ) - elif extensions_blocked: - meta_channel = self.bot.get_channel(Channels.meta) - embed.description = DISALLOWED_EMBED_DESCRIPTION.format( - joined_whitelist=', '.join(self._get_whitelisted_file_formats()), - blocked_extensions_str=blocked_extensions_str, - meta_channel_mention=meta_channel.mention, - ) - - if embed.description: - log.info( - f"User '{message.author}' ({message.author.id}) uploaded blacklisted file(s): {blocked_extensions_str}", - extra={"attachment_list": [attachment.filename for attachment in message.attachments]} - ) - - await message.channel.send(f"Hey {message.author.mention}!", embed=embed) - - # Delete the offending message: - try: - await message.delete() - except NotFound: - log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - - -def setup(bot: Bot) -> None: - """Load the AntiMalware cog.""" - bot.add_cog(AntiMalware(bot)) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py deleted file mode 100644 index 7555e25a20..0000000000 --- a/bot/exts/filters/antispam.py +++ /dev/null @@ -1,290 +0,0 @@ -import asyncio -import logging -from collections.abc import Mapping -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from operator import attrgetter, itemgetter -from typing import Dict, Iterable, List, Set - -from discord import Colour, Member, Message, NotFound, Object, TextChannel -from discord.ext.commands import Cog - -from bot import rules -from bot.bot import Bot -from bot.constants import ( - AntiSpam as AntiSpamConfig, Channels, - Colours, DEBUG_MODE, Event, Filter, - Guild as GuildConfig, Icons, -) -from bot.converters import Duration -from bot.exts.moderation.modlog import ModLog -from bot.utils import lock, scheduling -from bot.utils.messages import format_user, send_attachments - - -log = logging.getLogger(__name__) - -RULE_FUNCTION_MAPPING = { - 'attachments': rules.apply_attachments, - 'burst': rules.apply_burst, - # burst shared is temporarily disabled due to a bug - # 'burst_shared': rules.apply_burst_shared, - 'chars': rules.apply_chars, - 'discord_emojis': rules.apply_discord_emojis, - 'duplicates': rules.apply_duplicates, - 'links': rules.apply_links, - 'mentions': rules.apply_mentions, - 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions, -} - - -@dataclass -class DeletionContext: - """Represents a Deletion Context for a single spam event.""" - - channel: TextChannel - members: Dict[int, Member] = field(default_factory=dict) - rules: Set[str] = field(default_factory=set) - messages: Dict[int, Message] = field(default_factory=dict) - attachments: List[List[str]] = field(default_factory=list) - - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: - """Adds new rule violation events to the deletion context.""" - self.rules.add(rule_name) - - for member in members: - if member.id not in self.members: - self.members[member.id] = member - - for message in messages: - if message.id not in self.messages: - self.messages[message.id] = message - - # Re-upload attachments - destination = message.guild.get_channel(Channels.attachment_log) - urls = await send_attachments(message, destination, link_large=False) - self.attachments.append(urls) - - async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: - """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) - - mod_alert_message = ( - f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" - f"**Rules:** {', '.join(rule for rule in self.rules)}\n" - ) - - # For multiple messages or those with excessive newlines, use the logs API - if len(self.messages) > 1 or 'newlines' in self.rules: - url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) - mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" - else: - mod_alert_message += "Message:\n" - [message] = self.messages.values() - content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) - - if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." - - mod_alert_message += f"{content}" - - *_, last_message = self.messages.values() - await modlog.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title="Spam detected!", - text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone - ) - - -class AntiSpam(Cog): - """Cog that controls our anti-spam measures.""" - - def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None: - self.bot = bot - self.validation_errors = validation_errors - role_id = AntiSpamConfig.punishment['role_id'] - self.muted_role = Object(role_id) - self.expiration_date_converter = Duration() - - self.message_deletion_queue = dict() - - self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") - - @property - def mod_log(self) -> ModLog: - """Allows for easy access of the ModLog cog.""" - return self.bot.get_cog("ModLog") - - async def alert_on_validation_error(self) -> None: - """Unloads the cog and alerts admins if configuration validation failed.""" - await self.bot.wait_until_guild_available() - if self.validation_errors: - body = "**The following errors were encountered:**\n" - body += "\n".join(f"- {error}" for error in self.validation_errors.values()) - body += "\n\n**The cog has been unloaded.**" - - await self.mod_log.send_log_message( - title="Error: AntiSpam configuration validation failed!", - text=body, - ping_everyone=True, - icon_url=Icons.token_removed, - colour=Colour.red() - ) - - self.bot.remove_cog(self.__class__.__name__) - return - - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Applies the antispam rules to each received message.""" - if ( - not message.guild - or message.guild.id != GuildConfig.id - or message.author.bot - or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) - ): - return - - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] - - for rule_name in AntiSpamConfig.rules: - rule_config = AntiSpamConfig.rules[rule_name] - rule_function = RULE_FUNCTION_MAPPING[rule_name] - - # Create a list of messages that were sent in the interval that the rule cares about. - latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] - result = await rule_function(message, messages_for_rule, rule_config) - - # If the rule returns `None`, that means the message didn't violate it. - # If it doesn't, it returns a tuple in the form `(str, Iterable[discord.Member])` - # which contains the reason for why the message violated the rule and - # an iterable of all members that violated the rule. - if result is not None: - self.bot.stats.incr(f"mod_alerts.{rule_name}") - reason, members, relevant_messages = result - full_reason = f"`{rule_name}` rule: {reason}" - - # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) - scheduling.create_task( - self._process_deletion_context(message.channel.id), - name=f"AntiSpam._process_deletion_context({message.channel.id})" - ) - - # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( - rule_name=rule_name, - members=members, - messages=relevant_messages - ) - - for member in members: - scheduling.create_task( - self.punish(message, member, full_reason), - name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" - ) - - await self.maybe_delete_messages(channel, relevant_messages) - break - - @lock.lock_arg("antispam.punish", "member", attrgetter("id")) - async def punish(self, msg: Message, member: Member, reason: str) -> None: - """Punishes the given member for triggering an antispam rule.""" - if not any(role.id == self.muted_role.id for role in member.roles): - remove_role_after = AntiSpamConfig.punishment['remove_after'] - - # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes - context = await self.bot.get_context(msg) - context.author = self.bot.user - - # Since we're going to invoke the tempmute command directly, we need to manually call the converter. - dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") - await context.invoke( - self.bot.get_command('tempmute'), - member, - dt_remove_role_after, - reason=reason - ) - - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: - """Cleans the messages if cleaning is configured.""" - if AntiSpamConfig.clean_offending: - # If we have more than one message, we can use bulk delete. - if len(messages) > 1: - message_ids = [message.id for message in messages] - self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) - - # Otherwise, the bulk delete endpoint will throw up. - # Delete the message directly instead. - else: - self.mod_log.ignore(Event.message_delete, messages[0].id) - try: - await messages[0].delete() - except NotFound: - log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - - async def _process_deletion_context(self, context_id: int) -> None: - """Processes the Deletion Context queue.""" - log.trace("Sleeping before processing message deletion queue.") - await asyncio.sleep(10) - - if context_id not in self.message_deletion_queue: - log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!") - return - - deletion_context = self.message_deletion_queue.pop(context_id) - await deletion_context.upload_messages(self.bot.user.id, self.mod_log) - - -def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: - """Validates the antispam configs.""" - validation_errors = {} - for name, config in rules_.items(): - if name not in RULE_FUNCTION_MAPPING: - log.error( - f"Unrecognized antispam rule `{name}`. " - f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}" - ) - validation_errors[name] = f"`{name}` is not recognized as an antispam rule." - continue - for required_key in ('interval', 'max'): - if required_key not in config: - log.error( - f"`{required_key}` is required but was not " - f"set in rule `{name}`'s configuration." - ) - validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`" - return validation_errors - - -def setup(bot: Bot) -> None: - """Validate the AntiSpam configs and load the AntiSpam cog.""" - validation_errors = validate_config() - bot.add_cog(AntiSpam(bot, validation_errors)) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py deleted file mode 100644 index 232c1e48b6..0000000000 --- a/bot/exts/filters/filter_lists.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidDiscordServerInvite, ValidFilterListType -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class FilterLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - methods_with_filterlist_types = [ - "allow_add", - "allow_delete", - "allow_get", - "deny_add", - "deny_delete", - "deny_get", - ] - - def __init__(self, bot: Bot) -> None: - self.bot = bot - self.bot.loop.create_task(self._amend_docstrings()) - - async def _amend_docstrings(self) -> None: - """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" - await self.bot.wait_until_guild_available() - - # Add valid filterlist types to the docstrings - valid_types = await ValidFilterListType.get_valid_types(self.bot) - valid_types = [f"`{type_.lower()}`" for type_ in valid_types] - - for method_name in self.methods_with_filterlist_types: - command = getattr(self, method_name) - command.help = ( - f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." - ) - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - "allowed": allowed, - "type": list_type, - "content": content, - "comment": comment, - } - - try: - item = await self.bot.api_client.post( - "bot/filter-lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 400: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - self.bot.insert_item_into_filter_list_cache(item) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): - guild_data = await self._validate_guild_invite(ctx, content) - content = guild_data.get("id") - - # If it's a file format, let's make sure it has a leading dot. - elif list_type == "FILE_FORMAT" and not content.startswith("."): - content = f".{content}" - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) - - if item is not None: - try: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to delete an item with the id {item['id']}, but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - else: - await ctx.message.add_reaction("❌") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: - """Paginate and display all items in a filterlist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] - - # Build a list of lines we want to show in the paginator - lines = [] - for content, metadata in result.items(): - line = f"• `{content}`" - - if comment := metadata.get("comment"): - line += f" - {comment}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - await ctx.message.add_reaction("❌") - - async def _sync_data(self, ctx: Context) -> None: - """Syncs the filterlists with the API.""" - try: - log.trace("Attempting to sync FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() - await ctx.message.add_reaction("✅") - except ResponseCodeError as e: - log.debug( - f"{ctx.author} tried to sync FilterList cache data but " - f"the API raised an unexpected error: {e}" - ) - await ctx.message.add_reaction("❌") - - @staticmethod - async def _validate_guild_invite(ctx: Context, invite: str) -> dict: - """ - Validates a guild invite, and returns the guild info as a dict. - - Will raise a BadArgument if the guild invite is invalid. - """ - log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, invite) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's return a dict of guild information. - log.trace(f"{invite} validated as server invite. Converting to ID.") - return guild_data - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, ctx: Context) -> None: - """Syncs both allowlists and denylists with the API.""" - await self._sync_data(ctx) - - async def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx) - - -def setup(bot: Bot) -> None: - """Load the FilterLists cog.""" - bot.add_cog(FilterLists(bot)) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py deleted file mode 100644 index 464732453c..0000000000 --- a/bot/exts/filters/filtering.py +++ /dev/null @@ -1,654 +0,0 @@ -import asyncio -import logging -import re -from datetime import datetime, timedelta -from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union - -import dateutil -import discord.errors -import regex -from async_rediscache import RedisCache -from dateutil.relativedelta import relativedelta -from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel -from discord.ext.commands import Cog -from discord.utils import escape_markdown - -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.constants import ( - Channels, Colours, Filter, - Guild, Icons, URLs -) -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user -from bot.utils.regex import INVITE_RE -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) - -# Regular expressions -CODE_BLOCK_RE = re.compile( - r"(?P``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock - r"|```(.+?)```", # Multiline codeblock - re.DOTALL | re.MULTILINE -) -EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here") -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) -URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) - -# Exclude variation selectors from zalgo because they're actually invisible. -VARIATION_SELECTORS = r"\uFE00-\uFE0F\U000E0100-\U000E01EF" -INVISIBLE_RE = regex.compile(rf"[{VARIATION_SELECTORS}\p{{UNASSIGNED}}\p{{FORMAT}}\p{{CONTROL}}--\s]", regex.V1) -ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIATION_SELECTORS}]]", regex.V1) - -# Other constants. -DAYS_BETWEEN_ALERTS = 3 -OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) - -FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] - - -class Stats(NamedTuple): - """Additional stats on a triggered filter to append to a mod log.""" - - message_content: str - additional_embeds: Optional[List[discord.Embed]] - - -class Filtering(Cog): - """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" - - # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent - name_alerts = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.name_lock = asyncio.Lock() - - staff_mistake_str = "If you believe this was a mistake, please let staff know!" - self.filters = { - "filter_zalgo": { - "enabled": Filter.filter_zalgo, - "function": self._has_zalgo, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_zalgo, - "notification_msg": ( - "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " - f"{staff_mistake_str}" - ), - "schedule_deletion": False - }, - "filter_invites": { - "enabled": Filter.filter_invites, - "function": self._has_invites, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_invites, - "notification_msg": ( - f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n" - r"Our server rules can be found here: " - ), - "schedule_deletion": False - }, - "filter_domains": { - "enabled": Filter.filter_domains, - "function": self._has_urls, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_domains, - "notification_msg": ( - f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" - ), - "schedule_deletion": False - }, - "filter_everyone_ping": { - "enabled": Filter.filter_everyone_ping, - "function": self._has_everyone_ping, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_everyone_ping, - "notification_msg": ( - "Please don't try to ping `@everyone` or `@here`. " - f"Your message has been removed. {staff_mistake_str}" - ), - "schedule_deletion": False, - "ping_everyone": False - }, - "watch_regex": { - "enabled": Filter.watch_regex, - "function": self._has_watch_regex_match, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, - "watch_rich_embeds": { - "enabled": Filter.watch_rich_embeds, - "function": self._has_rich_embed, - "type": "watchlist", - "content_only": False, - "schedule_deletion": False - } - } - - self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - - def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() - - def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: - """Fetch items from the filter_list_cache.""" - return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() - - def _get_filterlist_value(self, list_type: str, value: Any, *, allowed: bool) -> dict: - """Fetch one specific value from filter_list_cache.""" - return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"][value] - - @staticmethod - def _expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Invoke message filter for new messages.""" - await self._filter_message(msg) - - # Ignore webhook messages. - if msg.webhook_id is None: - await self.check_bad_words_in_name(msg.author) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Invoke message filter for message edits. - - If there have been multiple edits, calculate the time delta from the previous edit. - """ - if not before.edited_at: - delta = relativedelta(after.edited_at, before.created_at).microseconds - else: - delta = relativedelta(after.edited_at, before.edited_at).microseconds - await self._filter_message(after, delta) - - def get_name_matches(self, name: str) -> List[re.Match]: - """Check bad words from passed string (name). Return list of matches.""" - name = self.clean_input(name) - matches = [] - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) - return matches - - async def check_send_alert(self, member: Member) -> bool: - """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" - if last_alert := await self.name_alerts.get(member.id): - last_alert = datetime.utcfromtimestamp(last_alert) - if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: - log.trace(f"Last alert was too recent for {member}'s nickname.") - return False - - return True - - async def check_bad_words_in_name(self, member: Member) -> None: - """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" - # Use lock to avoid race conditions - async with self.name_lock: - # Check whether the users display name contains any words in our blacklist - matches = self.get_name_matches(member.display_name) - - if not matches or not await self.check_send_alert(member): - return - - log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") - - log_string = ( - f"**User:** {format_user(member)}\n" - f"**Display Name:** {escape_markdown(member.display_name)}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts, - thumbnail=member.avatar_url - ) - - # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - - async def filter_eval(self, result: str, msg: Message) -> bool: - """ - Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. - - Also requires the original message, to check whether to filter and for mod logs. - Returns whether a filter was triggered or not. - """ - filter_triggered = False - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - # We also do not need to worry about filters that take the full message, - # since all we have is an arbitrary string. - if _filter["enabled"] and _filter["content_only"]: - filter_result = await _filter["function"](result) - reason = None - - if isinstance(filter_result, tuple): - match, reason = filter_result - else: - match = filter_result - - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - stats = self._add_stats(filter_name, match, result) - await self._send_log(filter_name, _filter, msg, stats, reason, is_eval=True) - - break # We don't want multiple filters to trigger - - return filter_triggered - - async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: - """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" - # Should we filter this message? - if self._check_filter(msg): - for filter_name, _filter in self.filters.items(): - # Is this specific filter enabled in the config? - if _filter["enabled"]: - # Double trigger check for the embeds filter - if filter_name == "watch_rich_embeds": - # If the edit delta is less than 0.001 seconds, then we're probably dealing - # with a double filter trigger. - if delta is not None and delta < 100: - continue - - # Does the filter only need the message content or the full message? - if _filter["content_only"]: - payload = msg.content - else: - payload = msg - - result = await _filter["function"](payload) - reason = None - - if isinstance(result, tuple): - match, reason = result - else: - match = result - - if match: - is_private = msg.channel.type is discord.ChannelType.private - - # If this is a filter (not a watchlist) and not in a DM, delete the message. - if _filter["type"] == "filter" and not is_private: - try: - # Embeds (can?) trigger both the `on_message` and `on_message_edit` - # event handlers, triggering filtering twice for the same message. - # - # If `on_message`-triggered filtering already deleted the message - # then `on_message_edit`-triggered filtering will raise exception - # since the message no longer exists. - # - # In addition, to avoid sending two notifications to the user, the - # logs, and mod_alert, we return if the message no longer exists. - await msg.delete() - except discord.errors.NotFound: - return - - # Notify the user if the filter specifies - if _filter["user_notification"]: - await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - - # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. - if _filter["schedule_deletion"] and not is_private: - delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() - data = { - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date - } - - try: - await self.bot.api_client.post('bot/offensive-messages', json=data) - except ResponseCodeError as e: - if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: - log.debug(f"Offensive message {msg.id} already exists.") - else: - log.error(f"Offensive message {msg.id} failed to post: {e}") - else: - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") - - stats = self._add_stats(filter_name, match, msg.content) - await self._send_log(filter_name, _filter, msg, stats, reason) - - break # We don't want multiple filters to trigger - - async def _send_log( - self, - filter_name: str, - _filter: Dict[str, Any], - msg: discord.Message, - stats: Stats, - reason: Optional[str] = None, - *, - is_eval: bool = False, - ) -> None: - """Send a mod log for a triggered filter.""" - if msg.channel.type is discord.ChannelType.private: - channel_str = "via DM" - ping_everyone = False - else: - channel_str = f"in {msg.channel.mention}" - # Allow specific filters to override ping_everyone - ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) - - eval_msg = "using !eval " if is_eval else "" - footer = f"Reason: {reason}" if reason else None - message = ( - f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} " - f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n" - f"{stats.message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=ping_everyone, - additional_embeds=stats.additional_embeds, - footer=footer, - ) - - def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats: - """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" - # Word and match stats for watch_regex - if name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(content)}" - ) - else: # Use original content - message_content = content - - additional_embeds = None - - self.bot.stats.incr(f"filters.{name}") - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if name == "filter_invites" and match is not True: - additional_embeds = [] - for _, data in match.items(): - reason = f"Reason: {data['reason']} | " if data.get('reason') else "" - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"{reason}Guild ID: {data['id']}") - additional_embeds.append(embed) - - elif name == "watch_rich_embeds": - additional_embeds = match - - return Stats(message_content, additional_embeds) - - @staticmethod - def _check_filter(msg: Message) -> bool: - """Check whitelists to see if we should filter this message.""" - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - return ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - async def _has_watch_regex_match(self, text: str) -> Tuple[Union[bool, re.Match], Optional[str]]: - """ - Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - - `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is - matched as-is. Spoilers are expanded, if any, and URLs are ignored. - Second return value is a reason written to database about blacklist entry (can be None). - """ - if SPOILER_RE.search(text): - text = self._expand_spoilers(text) - - text = self.clean_input(text) - - # Make sure it's not a URL - if URL_RE.search(text): - return False, None - - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) - for pattern in watchlist_patterns: - match = re.search(pattern, text, flags=re.IGNORECASE) - if match: - return match, self._get_filterlist_value('filter_token', pattern, allowed=False)['comment'] - - return False, None - - async def _has_urls(self, text: str) -> Tuple[bool, Optional[str]]: - """ - Returns True if the text contains one of the blacklisted URLs from the config file. - - Second return value is a reason of URL blacklisting (can be None). - """ - text = self.clean_input(text) - if not URL_RE.search(text): - return False, None - - text = text.lower() - domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - - for url in domain_blacklist: - if url.lower() in text: - return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] - - return False, None - - @staticmethod - async def _has_zalgo(text: str) -> bool: - """ - Returns True if the text contains zalgo characters. - - Zalgo range is \u0300 – \u036F and \u0489. - """ - return bool(ZALGO_RE.search(text)) - - async def _has_invites(self, text: str) -> Union[dict, bool]: - """ - Checks if there's any invites in the text content that aren't in the guild whitelist. - - If any are detected, a dictionary of invite data is returned, with a key per invite. - If none are detected, False is returned. - - Attempts to catch some of common ways to try to cheat the system. - """ - text = self.clean_input(text) - - # Remove backslashes to prevent escape character aroundfuckery like - # discord\.gg/gdudes-pony-farm - text = text.replace("\\", "") - - invites = INVITE_RE.findall(text) - invite_data = dict() - for invite in invites: - if invite in invite_data: - continue - - response = await self.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"} - ) - response = await response.json() - guild = response.get("guild") - if guild is None: - # Lack of a "guild" key in the JSON response indicates either an group DM invite, an - # expired invite, or an invalid invite. The API does not currently differentiate - # between invalid and expired invites - return True - - guild_id = guild.get("id") - guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) - - # Is this invite allowed? - guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features", []) - or 'VERIFIED' in guild.get("features", []) - ) - invite_not_allowed = ( - guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. - or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. - and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. - ) - - if invite_not_allowed: - reason = None - if guild_id in guild_invite_blacklist: - reason = self._get_filterlist_value("guild_invite", guild_id, allowed=False)["comment"] - - guild_icon_hash = guild["icon"] - guild_icon = ( - "https://cdn.discordapp.com/icons/" - f"{guild_id}/{guild_icon_hash}.png?size=512" - ) - - invite_data[invite] = { - "name": guild["name"], - "id": guild['id'], - "icon": guild_icon, - "members": response["approximate_member_count"], - "active": response["approximate_presence_count"], - "reason": reason - } - - return invite_data if invite_data else False - - @staticmethod - async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: - """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" - if msg.embeds: - for embed in msg.embeds: - if embed.type == "rich": - urls = URL_RE.findall(msg.content) - if not embed.url or embed.url not in urls: - # If `embed.url` does not exist or if `embed.url` is not part of the content - # of the message, it's unlikely to be an auto-generated embed by Discord. - return msg.embeds - else: - log.trace( - "Found a rich embed sent by a regular user account, " - "but it was likely just an automatic URL embed." - ) - return False - return False - - @staticmethod - async def _has_everyone_ping(text: str) -> bool: - """Determines if `msg` contains an @everyone or @here ping outside of a codeblock.""" - # First pass to avoid running re.sub on every message - if not EVERYONE_PING_RE.search(text): - return False - - content_without_codeblocks = CODE_BLOCK_RE.sub("", text) - return bool(EVERYONE_PING_RE.search(content_without_codeblocks)) - - async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None: - """ - Notify filtered_member about a moderation action with the reason str. - - First attempts to DM the user, fall back to in-channel notification if user has DMs disabled - """ - try: - await filtered_member.send(reason) - except discord.errors.Forbidden: - await channel.send(f"{filtered_member.mention} {reason}") - - def schedule_msg_delete(self, msg: dict) -> None: - """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) - - async def reschedule_offensive_msg_deletion(self) -> None: - """Get all the pending message deletion from the API and reschedule them.""" - await self.bot.wait_until_ready() - response = await self.bot.api_client.get('bot/offensive-messages',) - - now = datetime.utcnow() - - for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - if delete_at < now: - await self.delete_offensive_msg(msg) - else: - self.schedule_msg_delete(msg) - - async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: - """Delete an offensive message, and then delete it from the db.""" - try: - channel = self.bot.get_channel(msg['channel_id']) - if channel: - msg_obj = await channel.fetch_message(msg['id']) - await msg_obj.delete() - except NotFound: - log.info( - f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted)." - ) - except HTTPException as e: - log.warning(f"Failed to delete message {msg['id']}: status {e.status}") - - await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') - log.info(f"Deleted the offensive message with id {msg['id']}.") - - @staticmethod - def clean_input(string: str) -> str: - """Remove zalgo and invisible characters from `string`.""" - # For future consideration: remove characters in the Mc, Sk, and Lm categories too. - # Can be normalised with form C to merge char + combining char into a single char to avoid - # removing legit diacritics, but this would open up a way to bypass filters. - no_zalgo = ZALGO_RE.sub("", string) - return INVISIBLE_RE.sub("", no_zalgo) - - -def setup(bot: Bot) -> None: - """Load the Filtering cog.""" - bot.add_cog(Filtering(bot)) diff --git a/bot/exts/filters/pixels_token_remover.py b/bot/exts/filters/pixels_token_remover.py deleted file mode 100644 index 2356491e56..0000000000 --- a/bot/exts/filters/pixels_token_remover.py +++ /dev/null @@ -1,108 +0,0 @@ -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, Colours, Event, Icons -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user - -log = logging.getLogger(__name__) - -LOG_MESSAGE = "Censored a valid Pixels token sent by {author} in {channel}, token was `{token}`" -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a valid Pixels API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "I have taken the liberty of invalidating the token for you. " - "You can go to to get a new key." -) - -PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]{30,}\.[A-Za-z0-9-_=]{50,}\.[A-Za-z0-9-_.+\=]{30,}") - - -class PixelsTokenRemover(Cog): - """Scans messages for Pixels API tokens, removes and invalidates them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check each message for a string that matches the RS-256 token pattern.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = await self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check each edit for a string that matches the RS-256 token pattern.""" - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: str) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=False, - ) - - self.bot.stats.incr("tokens.removed_pixels_tokens") - - @staticmethod - def format_log_message(msg: Message, token: str) -> str: - """Return the generic portion of the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=format_user(msg.author), - channel=msg.channel.mention, - token=token - ) - - async def find_token_in_message(self, msg: Message) -> t.Optional[str]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in PIXELS_TOKEN_RE.finditer(msg.content): - auth_header = {"Authorization": f"Bearer {match[0]}"} - async with self.bot.http_session.delete("https://pixels.pythondiscord.com/token", headers=auth_header) as r: - if r.status == 204: - # Short curcuit on first match. - return match[0] - - # No matching substring - return - - -def setup(bot: Bot) -> None: - """Load the PixelsTokenRemover cog.""" - bot.add_cog(PixelsTokenRemover(bot)) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py deleted file mode 100644 index 93f1f3c33b..0000000000 --- a/bot/exts/filters/token_remover.py +++ /dev/null @@ -1,233 +0,0 @@ -import base64 -import binascii -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot import utils -from bot.bot import Bot -from bot.constants import Channels, Colours, Event, Icons -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user - -log = logging.getLogger(__name__) - -LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} in {channel}, " - "token was `{user_id}.{timestamp}.{hmac}`" -) -UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." -KNOWN_USER_LOG_MESSAGE = ( - "Decoded user ID: `{user_id}` **(Present in server)**.\n" - "This matches `{user_name}` and means this is likely a valid **{kind}** token." -) -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a seemingly valid Discord API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "Please change your token **immediately** at: " - "\n\n" - "Feel free to re-post it with the token removed. " - "If you believe this was a mistake, please let us know!" -) -DISCORD_EPOCH = 1_420_070_400 -TOKEN_EPOCH = 1_293_840_000 - -# Three parts delimited by dots: user ID, creation timestamp, HMAC. -# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. -# Each part only matches base64 URL-safe characters. -# Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) - - -class Token(t.NamedTuple): - """A Discord Bot token.""" - - user_id: str - timestamp: str - hmac: str - - -class TokenRemover(Cog): - """Scans messages for potential discord.py bot tokens and removes them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Check each message for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """ - Check each edit for a string that matches Discord's token pattern. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: Token) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - userid_message, mention_everyone = self.format_userid_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message + "\n" + userid_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=mention_everyone, - ) - - self.bot.stats.incr("tokens.removed_tokens") - - @classmethod - def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: - """ - Format the portion of the log message that includes details about the detected user ID. - - If the user is resolved to a member, the format includes the user ID, name, and the - kind of user detected. - - If we resolve to a member and it is not a bot, we also return True to ping everyone. - - Returns a tuple of (log_message, mention_everyone) - """ - user_id = cls.extract_user_id(token.user_id) - user = msg.guild.get_member(user_id) - - if user: - return KNOWN_USER_LOG_MESSAGE.format( - user_id=user_id, - user_name=str(user), - kind="BOT" if user.bot else "USER", - ), True - else: - return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False - - @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the generic portion of the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=format_user(msg.author), - channel=msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac='x' * (len(token.hmac) - 3) + token.hmac[-3:], - ) - - @classmethod - def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in TOKEN_RE.finditer(msg.content): - token = Token(*match.groups()) - if ( - (cls.extract_user_id(token.user_id) is not None) - and cls.is_valid_timestamp(token.timestamp) - and cls.is_maybe_valid_hmac(token.hmac) - ): - # Short-circuit on first match - return token - - # No matching substring - return - - @staticmethod - def extract_user_id(b64_content: str) -> t.Optional[int]: - """Return a user ID integer from part of a potential token, or None if it couldn't be decoded.""" - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - string = decoded_bytes.decode('utf-8') - if not (string.isascii() and string.isdigit()): - # This case triggers if there are fancy unicode digits in the base64 encoding, - # that means it's not a valid user id. - return None - return int(string) - except (binascii.Error, ValueError): - return None - - @staticmethod - def is_valid_timestamp(b64_content: str) -> bool: - """ - Return True if `b64_content` decodes to a valid timestamp. - - If the timestamp is greater than the Discord epoch, it's probably valid. - See: https://i.imgur.com/7WdehGn.png - """ - b64_content = utils.pad_base64(b64_content) - - try: - decoded_bytes = base64.urlsafe_b64decode(b64_content) - timestamp = int.from_bytes(decoded_bytes, byteorder="big") - except (binascii.Error, ValueError) as e: - log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") - return False - - # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound - # is not checked. - if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: - return True - else: - log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") - return False - - @staticmethod - def is_maybe_valid_hmac(b64_content: str) -> bool: - """ - Determine if a given HMAC portion of a token is potentially valid. - - If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", - and thus the token can probably be skipped. - """ - unique = len(set(b64_content.lower())) - if unique <= 3: - log.debug( - f"Considering the HMAC {b64_content} a dummy because it has {unique}" - " case-insensitively unique characters" - ) - return False - else: - return True - - -def setup(bot: Bot) -> None: - """Load the TokenRemover cog.""" - bot.add_cog(TokenRemover(bot)) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py deleted file mode 100644 index f11fc89124..0000000000 --- a/bot/exts/filters/webhook_remover.py +++ /dev/null @@ -1,85 +0,0 @@ -import logging -import re - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, Colours, Event, Icons -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user - -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) - -ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was a " - "mistake, please let us know." -) - -log = logging.getLogger(__name__) - - -class WebhookRemover(Cog): - """Scan messages to detect Discord webhooks links.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get current instance of `ModLog`.""" - return self.bot.get_cog("ModLog") - - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" - # Don't log this, due internal delete, not by user. Will make different entry. - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") - return - - await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - - message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " - f"Webhook URL was `{redacted_url}`" - ) - log.debug(message) - - # Send entry to moderation alerts. - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Discord webhook URL removed!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts - ) - - self.bot.stats.incr("tokens.removed_webhooks") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check if a Discord webhook URL is in `message`.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check if a Discord webhook URL is in the edited message `after`.""" - await self.on_message(after) - - -def setup(bot: Bot) -> None: - """Load `WebhookRemover` cog.""" - bot.add_cog(WebhookRemover(bot)) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c78b9c141c..2abe726784 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -1,18 +1,18 @@ import asyncio -import logging -from typing import Union import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors +from discord import Color, Embed, Message, RawReactionActionEvent, errors from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.converters import MemberOrUser +from bot.log import get_logger from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook -log = logging.getLogger(__name__) +log = get_logger(__name__) class DuckPond(Cog): @@ -20,23 +20,13 @@ class DuckPond(Cog): def __init__(self, bot: Bot): self.bot = bot - self.webhook_id = constants.Webhooks.duck_pond + self.webhook_id = constants.Webhooks.duck_pond.id self.webhook = None self.ducked_messages = [] - self.bot.loop.create_task(self.fetch_webhook()) self.relay_lock = None - async def fetch_webhook(self) -> None: - """Fetches the webhook object, so we can post to it.""" - await self.bot.wait_until_guild_available() - - try: - self.webhook = await self.bot.fetch_webhook(self.webhook_id) - except discord.HTTPException: - log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - @staticmethod - def is_staff(member: Union[User, Member]) -> bool: + def is_staff(member: MemberOrUser) -> bool: """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: @@ -44,17 +34,6 @@ def is_staff(member: Union[User, Member]) -> bool: return True return False - @staticmethod - def is_helper_viewable(channel: TextChannel) -> bool: - """Check if helpers can view a specific channel.""" - guild = channel.guild - helper_role = guild.get_role(constants.Roles.helpers) - # check channel overwrites for both the Helper role and @everyone and - # return True for channels that they have permissions to view. - helper_overwrites = channel.overwrites_for(helper_role) - default_overwrites = channel.overwrites_for(guild.default_role) - return default_overwrites.view_channel is None or helper_overwrites.view_channel is True - async def has_green_checkmark(self, message: Message) -> bool: """Check if the message has a green checkmark reaction.""" for reaction in message.reactions: @@ -65,12 +44,11 @@ async def has_green_checkmark(self, message: Message) -> bool: return False @staticmethod - def _is_duck_emoji(emoji: Union[str, discord.PartialEmoji, discord.Emoji]) -> bool: + def _is_duck_emoji(emoji: str | discord.PartialEmoji | discord.Emoji) -> bool: """Check if the emoji is a valid duck emoji.""" if isinstance(emoji, str): return emoji == "🦆" - else: - return hasattr(emoji, "name") and emoji.name.startswith("ducky_") + return hasattr(emoji, "name") and emoji.name.startswith("ducky_") async def count_ducks(self, message: Message) -> int: """ @@ -87,12 +65,18 @@ async def count_ducks(self, message: Message) -> int: async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" + if not self.webhook: + await self.bot.wait_until_guild_available() + # Fetching this can fail if using an invalid webhook id. + # Letting this error bubble up is fine as it will cause an error log and sentry event. + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + if message.clean_content: await send_webhook( webhook=self.webhook, content=message.clean_content, username=message.author.display_name, - avatar_url=message.author.avatar_url + avatar_url=message.author.display_avatar.url ) if message.attachments: @@ -107,7 +91,7 @@ async def relay_message(self, message: Message) -> None: webhook=self.webhook, embed=e, username=message.author.display_name, - avatar_url=message.author.avatar_url + avatar_url=message.author.display_avatar.url ) except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") @@ -163,16 +147,25 @@ async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: if not self._payload_has_duckpond_emoji(payload.emoji): return - channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(payload.guild_id) + channel = guild.get_channel_or_thread(payload.channel_id) if channel is None: return # Was the message sent in a channel Helpers can see? - if not self.is_helper_viewable(channel): + helper_role = guild.get_role(constants.Roles.helpers) + if not channel.permissions_for(helper_role).view_channel: return - message = await channel.fetch_message(payload.message_id) + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return # Message was deleted. + member = discord.utils.get(message.guild.members, id=payload.user_id) + if not member: + return # Member left or wasn't in the cache. # Was the message sent by a human staff member? if not self.is_staff(message.author) or message.author.bot: @@ -218,6 +211,6 @@ async def duckify(self, ctx: Context, message: Message) -> None: await ctx.message.add_reaction("❌") -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the DuckPond cog.""" - bot.add_cog(DuckPond(bot)) + await bot.add_cog(DuckPond(bot)) diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 845b8175ca..ba7cc2914d 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,46 +1,30 @@ +import asyncio +import datetime import difflib -import logging -from datetime import datetime, timedelta +import json +import random +from functools import partial -from discord import Colour, Embed +from discord import ButtonStyle, Colour, Embed, HTTPException, Interaction +from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role -from discord.utils import sleep_until +from discord.ui import Button, View +from pydis_core.site_api import ResponseCodeError -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES +from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES, NEGATIVE_REPLIES from bot.converters import OffTopicName +from bot.log import get_logger from bot.pagination import LinePaginator CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) -log = logging.getLogger(__name__) +# In case, the off-topic channel name format is modified. +OTN_FORMATTER = "ot{number}-{name}" +OT_NUMBER_INDEX = 2 +NAME_START_INDEX = 4 -async def update_names(bot: Bot) -> None: - """Background updater task that performs the daily channel name update.""" - while True: - # Since we truncate the compute timedelta to seconds, we add one second to ensure - # we go past midnight in the `seconds_to_sleep` set below. - today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) - next_midnight = today_at_midnight + timedelta(days=1) - await sleep_until(next_midnight) - - try: - channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( - 'bot/off-topic-channel-names', params={'random_items': 3} - ) - except ResponseCodeError as e: - log.error(f"Failed to get new off topic channel names: code {e.response.status}") - continue - channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS) - - await channel_0.edit(name=f'ot0-{channel_0_name}') - await channel_1.edit(name=f'ot1-{channel_1_name}') - await channel_2.edit(name=f'ot2-{channel_2_name}') - log.debug( - "Updated off-topic channel names to" - f" {channel_0_name}, {channel_1_name} and {channel_2_name}" - ) +log = get_logger(__name__) class OffTopicNames(Cog): @@ -48,29 +32,82 @@ class OffTopicNames(Cog): def __init__(self, bot: Bot): self.bot = bot - self.updater_task = None - self.bot.loop.create_task(self.init_offtopic_updater()) + # What errors to handle and restart the task using an exponential back-off algorithm + self.update_names.add_exception_type(ResponseCodeError) + self.update_names.start() + + async def cog_unload(self) -> None: + """ + Gracefully stop the update_names task. - def cog_unload(self) -> None: - """Cancel any running updater tasks on cog unload.""" - if self.updater_task is not None: - self.updater_task.cancel() + Clear the exception types first, so that if the task hits any errors it is not re-attempted. + """ + self.update_names.clear_exception_types() + self.update_names.stop() - async def init_offtopic_updater(self) -> None: - """Start off-topic channel updating event loop if it hasn't already started.""" + @tasks.loop(time=datetime.time(), reconnect=True) + async def update_names(self) -> None: + """Background updater task that performs the daily channel name update.""" await self.bot.wait_until_guild_available() - if self.updater_task is None: - coro = update_names(self.bot) - self.updater_task = self.bot.loop.create_task(coro) - @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) + try: + channel_0_name, channel_1_name, channel_2_name = await self.bot.api_client.get( + "bot/off-topic-channel-names", params={"random_items": 3} + ) + except ResponseCodeError as e: + log.error(f"Failed to get new off-topic channel names: code {e.response.status}") + raise + + channel_0, channel_1, channel_2 = (self.bot.get_channel(channel_id) for channel_id in CHANNELS) + + try: + await channel_0.edit(name=OTN_FORMATTER.format(number=0, name=channel_0_name)) + await channel_1.edit(name=OTN_FORMATTER.format(number=1, name=channel_1_name)) + await channel_2.edit(name=OTN_FORMATTER.format(number=2, name=channel_2_name)) + except HTTPException: + await self.bot.get_channel(Channels.mod_meta).send( + ":exclamation: Got an error trying to update OT names to" + f"{channel_0_name}, {channel_1_name} and {channel_2_name}." + "\nI will retry with new names, but please delete the ones that sound bad." + ) + await self.update_names() + return + + log.debug( + "Updated off-topic channel names to" + f" {channel_0_name}, {channel_1_name} and {channel_2_name}" + ) + + async def toggle_ot_name_activity(self, ctx: Context, name: str, active: bool) -> None: + """Toggle active attribute for an off-topic name.""" + data = { + "active": active + } + await self.bot.api_client.patch(f"bot/off-topic-channel-names/{name}", data=data) + await ctx.send(f"Off-topic name `{name}` has been {'activated' if active else 'deactivated'}.") + + async def list_ot_names(self, ctx: Context, active: bool = True) -> None: + """Send an embed containing active/deactivated off-topic channel names.""" + result = await self.bot.api_client.get("bot/off-topic-channel-names", params={"active": json.dumps(active)}) + lines = sorted(f"- {name}" for name in result) + embed = Embed( + title=f"{'Active' if active else 'Deactivated'} off-topic names (`{len(result)}` total)", + colour=Colour.blue() + ) + if result: + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @group(name="otname", aliases=("otnames", "otn"), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" await ctx.send_help(ctx.command) - @otname_group.command(name='add', aliases=('a',)) + @otname_group.command(name="add", aliases=("a",)) @has_any_role(*MODERATION_ROLES) async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: """ @@ -78,7 +115,7 @@ async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: The name is not added if it is too similar to an existing name. """ - existing_names = await self.bot.api_client.get('bot/off-topic-channel-names') + existing_names = await self.bot.api_client.get("bot/off-topic-channel-names") close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8) if close_match: @@ -88,12 +125,12 @@ async def add_command(self, ctx: Context, *, name: OffTopicName) -> None: ) await ctx.send( f":x: The channel name `{name}` is too similar to `{match}`, and thus was not added. " - "Use `!otn forceadd` to override this check." + f"Use `{BotConfig.prefix}otn forceadd` to override this check." ) else: await self._add_name(ctx, name) - @otname_group.command(name='forceadd', aliases=('fa',)) + @otname_group.command(name="forceadd", aliases=("fa",)) @has_any_role(*MODERATION_ROLES) async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: """Forcefully adds a new off-topic name to the rotation.""" @@ -101,21 +138,125 @@ async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None: async def _add_name(self, ctx: Context, name: str) -> None: """Adds an off-topic channel name to the site storage.""" - await self.bot.api_client.post('bot/off-topic-channel-names', params={'name': name}) + await self.bot.api_client.post("bot/off-topic-channel-names", params={"name": name}) log.info(f"{ctx.author} added the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Added `{name}` to the names list.") - @otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd')) + @otname_group.command(name="delete", aliases=("remove", "rm", "del", "d")) @has_any_role(*MODERATION_ROLES) async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None: """Removes a off-topic name from the rotation.""" - await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}') + await self.bot.api_client.delete(f"bot/off-topic-channel-names/{name}") log.info(f"{ctx.author} deleted the off-topic channel name '{name}'") await ctx.send(f":ok_hand: Removed `{name}` from the names list.") - @otname_group.command(name='list', aliases=('l',)) + @otname_group.command(name="activate", aliases=("whitelist",)) + @has_any_role(*MODERATION_ROLES) + async def activate_ot_name(self, ctx: Context, name: OffTopicName) -> None: + """Activate an existing off-topic name.""" + await self.toggle_ot_name_activity(ctx, name, True) + + @otname_group.command(name="deactivate", aliases=("blacklist",)) + @has_any_role(*MODERATION_ROLES) + async def de_activate_ot_name(self, ctx: Context, name: OffTopicName) -> None: + """Deactivate a specific off-topic name.""" + await self.toggle_ot_name_activity(ctx, name, False) + + @otname_group.command(name="reroll") + @has_any_role(*MODERATION_ROLES) + async def re_roll_command(self, ctx: Context, ot_channel_index: int | None = None) -> None: + """ + Re-roll an off-topic name for a specific off-topic channel and deactivate the current name. + + ot_channel_index: [0, 1, 2, ...] + """ + if ot_channel_index is not None: + try: + channel = self.bot.get_channel(CHANNELS[ot_channel_index]) + except IndexError: + await ctx.send(f":x: No off-topic channel found with index {ot_channel_index}.") + return + elif ctx.channel.id in CHANNELS: + channel = ctx.channel + + else: + await ctx.send("Please specify channel for which the off-topic name should be re-rolled.") + return + + old_channel_name = channel.name + old_ot_name = old_channel_name[NAME_START_INDEX:] # ot1-name-of-ot -> name-of-ot + + await self.de_activate_ot_name(ctx, old_ot_name) + + response = await self.bot.api_client.get( + "bot/off-topic-channel-names", params={"random_items": 1} + ) + try: + new_channel_name = response[0] + except IndexError: + await ctx.send("Out of active off-topic names. Add new names to reroll.") + return + + async def rename_channel() -> None: + """Rename off-topic channel and log events.""" + await channel.edit( + name=OTN_FORMATTER.format(number=old_channel_name[OT_NUMBER_INDEX], name=new_channel_name) + ) + log.info( + f"{ctx.author} Off-topic channel re-named from `{old_ot_name}` " + f"to `{new_channel_name}`." + ) + + await ctx.message.reply( + f":ok_hand: Off-topic channel re-named from `{old_ot_name}` " + f"to `{new_channel_name}`. " + ) + + try: + await asyncio.wait_for(rename_channel(), 3) + except TimeoutError: + # Channel rename endpoint rate limited. The task was cancelled by asyncio. + btn_yes = Button(label="Yes", style=ButtonStyle.success) + btn_no = Button(label="No", style=ButtonStyle.danger) + + embed = Embed( + title=random.choice(NEGATIVE_REPLIES), + description=( + "Re-naming the channel is being rate-limited. " + "Would you like to schedule an asyncio task to rename the channel within the current bot session ?" + ), + colour=Colour.blurple() + ) + + async def btn_call_back(schedule: bool, interaction: Interaction) -> None: + if ctx.author != interaction.user: + log.info("User is not author, skipping.") + return + message = interaction.message + + embed.description = ( + "Scheduled a channel re-name process within the current bot session." + if schedule + else + "Channel not re-named due to rate limit. Please try again later." + ) + await message.edit(embed=embed, view=None) + + if schedule: + await rename_channel() + + btn_yes.callback = partial(btn_call_back, True) + btn_no.callback = partial(btn_call_back, False) + + view = View() + view.add_item(btn_yes) + view.add_item(btn_no) + + await ctx.message.reply(embed=embed, view=view) + + @otname_group.group(name="list", aliases=("l",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def list_command(self, ctx: Context) -> None: """ @@ -123,19 +264,21 @@ async def list_command(self, ctx: Context) -> None: Restricted to Moderator and above to not spoil the surprise. """ - result = await self.bot.api_client.get('bot/off-topic-channel-names') - lines = sorted(f"• {name}" for name in result) - embed = Embed( - title=f"Known off-topic names (`{len(result)}` total)", - colour=Colour.blue() - ) - if result: - await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) + await self.active_otnames_command(ctx) + + @list_command.command(name="active", aliases=("a",)) + @has_any_role(*MODERATION_ROLES) + async def active_otnames_command(self, ctx: Context) -> None: + """List active off-topic channel names.""" + await self.list_ot_names(ctx, True) + + @list_command.command(name="deactivated", aliases=("d",)) + @has_any_role(*MODERATION_ROLES) + async def deactivated_otnames_command(self, ctx: Context) -> None: + """List deactivated off-topic channel names.""" + await self.list_ot_names(ctx, False) - @otname_group.command(name='search', aliases=('s',)) + @otname_group.command(name="search", aliases=("s",)) @has_any_role(*MODERATION_ROLES) async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: """Search for an off-topic name.""" @@ -144,15 +287,15 @@ async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: # Map normalized names to returned names for search purposes result = { OffTopicName.translate_name(name, from_unicode=False).lower(): name - for name in await self.bot.api_client.get('bot/off-topic-channel-names') + for name in await self.bot.api_client.get("bot/off-topic-channel-names") } # Search normalized keys - in_matches = {name for name in result.keys() if query in name} + in_matches = {name for name in result if query in name} close_matches = difflib.get_close_matches(query, result.keys(), n=10, cutoff=0.70) # Send Results - lines = sorted(f"• {result[name]}" for name in in_matches.union(close_matches)) + lines = sorted(f"- {result[name]}" for name in in_matches.union(close_matches)) embed = Embed( title="Query results", colour=Colour.blue() @@ -165,6 +308,6 @@ async def search_command(self, ctx: Context, *, query: OffTopicName) -> None: await ctx.send(embed=embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the OffTopicNames cog.""" - bot.add_cog(OffTopicNames(bot)) + await bot.add_cog(OffTopicNames(bot)) diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 781f404496..643497b9ff 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,41 +1,15 @@ -import logging -from bot import constants from bot.bot import Bot -from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY +from bot.constants import HelpChannels +from bot.exts.help_channels._cog import HelpForum +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) -def validate_config() -> None: - """Raise a ValueError if the cog's config is invalid.""" - log.trace("Validating config.") - total = constants.HelpChannels.max_total_channels - available = constants.HelpChannels.max_available - - if total == 0 or available == 0: - raise ValueError("max_total_channels and max_available and must be greater than 0.") - - if total < available: - raise ValueError( - f"max_total_channels ({total}) must be greater than or equal to max_available " - f"({available})." - ) - - if total > MAX_CHANNELS_PER_CATEGORY: - raise ValueError( - f"max_total_channels ({total}) must be less than or equal to " - f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category." - ) - - -def setup(bot: Bot) -> None: - """Load the HelpChannels cog.""" - # Defer import to reduce side effects from importing the help_channels package. - from bot.exts.help_channels._cog import HelpChannels - try: - validate_config() - except ValueError as e: - log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}") - else: - bot.add_cog(HelpChannels(bot)) +async def setup(bot: Bot) -> None: + """Load the HelpForum cog.""" + if not HelpChannels.enable: + log.warning("HelpChannel.enabled set to false, not loading help channel cog.") + return + await bot.add_cog(HelpForum(bot)) diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py index c5e4ee9173..ef7b3887f0 100644 --- a/bot/exts/help_channels/_caches.py +++ b/bot/exts/help_channels/_caches.py @@ -1,26 +1,5 @@ from async_rediscache import RedisCache -# This dictionary maps a help channel to the time it was claimed -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -claim_times = RedisCache(namespace="HelpChannels.claim_times") - -# This cache tracks which channels are claimed by which members. -# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] -claimants = RedisCache(namespace="HelpChannels.help_channel_claimants") - -# Stores the timestamp of the last message from the claimant of a help channel -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -claimant_last_message_times = RedisCache(namespace="HelpChannels.claimant_last_message_times") - -# This cache maps a help channel to the timestamp of the last non-claimant message. -# This cache being empty for a given help channel indicates the question is unanswered. -# RedisCache[discord.TextChannel.id, UtcPosixTimestamp] -non_claimant_last_message_times = RedisCache(namespace="HelpChannels.non_claimant_last_message_times") - -# This cache maps a help channel to original question message in same channel. -# RedisCache[discord.TextChannel.id, discord.Message.id] -question_messages = RedisCache(namespace="HelpChannels.question_messages") - -# This cache keeps track of the dynamic message ID for -# the continuously updated message in the #How-to-get-help channel. -dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message") +# Stores posts that have had a non-claimant, non-bot, reply. +# Currently only used to determine whether the post was answered or not when collecting stats. +posts_with_non_claimant_messages = RedisCache(namespace="HelpChannels.posts_with_non_claimant_messages") diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 0846b28c83..dcd0e3c327 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,159 +1,224 @@ -import logging -import typing as t +"""Contains all logic to handle changes to posts in the help forum.""" from datetime import timedelta -from enum import Enum import arrow import discord -from arrow import Arrow +from pydis_core.utils import scheduling +from pydis_core.utils.channel import get_or_fetch_channel import bot from bot import constants -from bot.exts.help_channels import _caches, _message -from bot.utils.channel import try_get_channel - -log = logging.getLogger(__name__) - -MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.cooldown,) - - -class ClosingReason(Enum): - """All possible closing reasons for help channels.""" - - COMMAND = "command" - LATEST_MESSSAGE = "auto.latest_message" - CLAIMANT_TIMEOUT = "auto.claimant_timeout" - OTHER_TIMEOUT = "auto.other_timeout" - DELETED = "auto.deleted" - CLEANUP = "auto.cleanup" - - -def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]: - """Yield the text channels of the `category` in an unsorted manner.""" - log.trace(f"Getting text channels in the category '{category}' ({category.id}).") - - # This is faster than using category.channels because the latter sorts them. - for channel in category.guild.channels: - if channel.category_id == category.id and not is_excluded_channel(channel): - yield channel - - -async def get_closing_time(channel: discord.TextChannel, init_done: bool) -> t.Tuple[Arrow, ClosingReason]: - """ - Return the time at which the given help `channel` should be closed along with the reason. - - `init_done` is True if the cog has finished loading and False otherwise. - - The time is calculated as follows: - - * If `init_done` is True or the cached time for the claimant's last message is unavailable, - add the configured `idle_minutes_claimant` to the time the most recent message was sent. - * If the help session is empty (see `is_empty`), do the above but with `deleted_idle_minutes`. - * If either of the above is attempted but the channel is completely empty, close the channel - immediately. - * Otherwise, retrieve the times of the claimant's and non-claimant's last messages from the - cache. Add the configured `idle_minutes_claimant` and idle_minutes_others`, respectively, and - choose the time which is furthest in the future. - """ - log.trace(f"Getting the closing time for #{channel} ({channel.id}).") - - is_empty = await _message.is_empty(channel) - if is_empty: - idle_minutes_claimant = constants.HelpChannels.deleted_idle_minutes - else: - idle_minutes_claimant = constants.HelpChannels.idle_minutes_claimant - - claimant_time = await _caches.claimant_last_message_times.get(channel.id) - - # The current session lacks messages, the cog is still starting, or the cache is empty. - if is_empty or not init_done or claimant_time is None: - msg = await _message.get_last_message(channel) - if not msg: - log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages, closing now.") - return Arrow.min, ClosingReason.DELETED - - # Use the greatest offset to avoid the possibility of prematurely closing the channel. - time = Arrow.fromdatetime(msg.created_at) + timedelta(minutes=idle_minutes_claimant) - reason = ClosingReason.DELETED if is_empty else ClosingReason.LATEST_MESSSAGE - return time, reason - - claimant_time = Arrow.utcfromtimestamp(claimant_time) - others_time = await _caches.non_claimant_last_message_times.get(channel.id) - - if others_time: - others_time = Arrow.utcfromtimestamp(others_time) - else: - # The help session hasn't received any answers (messages from non-claimants) yet. - # Set to min value so it isn't considered when calculating the closing time. - others_time = Arrow.min - - # Offset the cached times by the configured values. - others_time += timedelta(minutes=constants.HelpChannels.idle_minutes_others) - claimant_time += timedelta(minutes=idle_minutes_claimant) - - # Use the time which is the furthest into the future. - if claimant_time >= others_time: - closing_time = claimant_time - reason = ClosingReason.CLAIMANT_TIMEOUT - else: - closing_time = others_time - reason = ClosingReason.OTHER_TIMEOUT - - log.trace(f"#{channel} ({channel.id}) should be closed at {closing_time} due to {reason}.") - return closing_time, reason - - -async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]: - """Return the duration `channel_id` has been in use. Return None if it's not in use.""" - log.trace(f"Calculating in use time for channel {channel_id}.") - - claimed_timestamp = await _caches.claim_times.get(channel_id) - if claimed_timestamp: - claimed = Arrow.utcfromtimestamp(claimed_timestamp) - return arrow.utcnow() - claimed - +from bot.exts.help_channels import _stats +from bot.log import get_logger + +log = get_logger(__name__) + +ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" +BRANDING_REPO_RAW_URL = "https://raw.githubusercontent.com/python-discord/branding" + +NEW_POST_MSG = """ +**Remember to:** +- **Ask** your Python question, not if you can ask or if there's an expert who can help. +- **Show** a code sample as text (rather than a screenshot) and the error message, if you've got one. +- **Explain** what you expect to happen and what actually happens. + +:warning: Do not pip install anything that isn't related to your question, especially if asked to over DMs. +""" +NEW_POST_FOOTER = f"Closes after a period of inactivity, or when you send {constants.Bot.prefix}close." +NEW_POST_ICON_URL = f"{BRANDING_REPO_RAW_URL}/main/icons/checkmark/green-checkmark-dist.png" + +CLOSED_POST_MSG = f""" +This help channel has been closed. \ +Feel free to create a new post in <#{constants.Channels.python_help}>. \ +To maximize your chances of getting a response, check out this guide on [asking good questions]({ASKING_GUIDE_URL}). +""" +CLOSED_POST_ICON_URL = f"{BRANDING_REPO_RAW_URL}/main/icons/zzz/zzz-dist.png" + + +def is_help_forum_post(channel: discord.abc.GuildChannel) -> bool: + """Return True if `channel` is a post in the help forum.""" + log.trace(f"Checking if #{channel} is a help channel.") + return getattr(channel, "parent_id", None) == constants.Channels.python_help + + +async def _close_help_post( + closed_post: discord.Thread, + closing_reason: _stats.ClosingReason, + scheduler: scheduling.Scheduler, +) -> None: + """Close the help post and record stats.""" + embed = discord.Embed(description=CLOSED_POST_MSG) + close_title = "Python help channel closed" + if closing_reason == _stats.ClosingReason.CLEANUP: + close_title += " as OP left server" + elif closing_reason == _stats.ClosingReason.COMMAND: + close_title += f" with {constants.Bot.prefix}close" + elif closing_reason == _stats.ClosingReason.INACTIVE: + close_title += " for inactivity" + elif closing_reason == _stats.ClosingReason.NATIVE: + close_title += " using Discord native close action" + + + embed.set_author(name=close_title, icon_url=CLOSED_POST_ICON_URL) + message = "" + + # Include a ping in the close message if no one else engages, to encourage them + # to read the guide for asking better questions + if closing_reason == _stats.ClosingReason.INACTIVE and closed_post.owner is not None: + participant_ids = { + message.author.id async for message in closed_post.history(limit=100, oldest_first=False) + if not message.author.bot + } + if participant_ids == {closed_post.owner_id}: + message = closed_post.owner.mention + + try: + await closed_post.send(message, embed=embed) + except discord.errors.HTTPException: + log.info("Could not send closing message in %s (%d), closing anyway", closed_post, closed_post.id) + + await closed_post.edit( + name=f"🔒 {closed_post.name}"[:100], + archived=True, + locked=True, + reason="Locked a closed help post", + ) + if closed_post.id in scheduler: + scheduler.cancel(closed_post.id) -def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool: - """Check if a channel should be excluded from the help channel system.""" - return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS + _stats.report_post_count() + await _stats.report_complete_session(closed_post, closing_reason) -async def move_to_bottom(channel: discord.TextChannel, category_id: int, **options) -> None: +async def send_opened_post_message(post: discord.Thread) -> None: + """Send the opener message in the new help post.""" + embed = discord.Embed( + color=constants.Colours.bright_green, + description=NEW_POST_MSG, + ) + embed.set_author(name="Python help channel opened", icon_url=NEW_POST_ICON_URL) + embed.set_footer(text=NEW_POST_FOOTER) + await post.send(embed=embed, content=post.owner.mention) + + +async def help_post_opened( + opened_post: discord.Thread, + *, + scheduler: scheduling.Scheduler, +) -> None: + """Apply new post logic to a new help forum post.""" + _stats.report_post_count() + bot.instance.stats.incr("help.claimed") + + if not isinstance(opened_post.owner, discord.Member): + log.debug(f"{opened_post.owner_id} isn't a member. Closing post.") + await _close_help_post(opened_post, _stats.ClosingReason.CLEANUP, scheduler) + return + + try: + await opened_post.starter_message.pin() + except (discord.HTTPException, AttributeError) as e: + # Suppress if the message or post were not found, most likely deleted + # The message being deleted could be surfaced as an AttributeError on .starter_message, + # or as an exception from the Discord API, depending on timing and cache status. + # The post being deleting would happen if it had a bad name that would cause the filtering system to delete it. + if isinstance(e, discord.HTTPException): + if e.code == 10003: # Post not found. + return + if e.code != 10008: # 10008 - Starter message not found. + raise e + + await send_opened_post_message(opened_post) + + +async def help_post_closed(closed_post: discord.Thread, scheduler: scheduling.Scheduler) -> None: + """Apply archive logic to a manually closed help forum post.""" + await _close_help_post(closed_post, _stats.ClosingReason.COMMAND, scheduler) + + +async def help_post_archived(archived_post: discord.Thread, scheduler: scheduling.Scheduler) -> None: + """Apply archive logic to an archived help forum post.""" + async for thread_update in archived_post.guild.audit_logs(limit=50, action=discord.AuditLogAction.thread_update): + if thread_update.target.id != archived_post.id: + continue + + # Don't apply close logic if the post was archived by the bot, as it + # would have been done so via _close_help_thread. + if thread_update.user.id == bot.instance.user.id: + return + + await _close_help_post(archived_post, _stats.ClosingReason.NATIVE, scheduler) + + +async def help_post_deleted(deleted_post_event: discord.RawThreadDeleteEvent) -> None: + """Record appropriate stats when a help post is deleted.""" + _stats.report_post_count() + cached_post = deleted_post_event.thread + if cached_post and not cached_post.archived: + # If the post is in the bot's cache, and it was not archived before deleting, + # report a complete session. + await _stats.report_complete_session(cached_post, _stats.ClosingReason.DELETED) + + +async def get_closing_time(post: discord.Thread) -> tuple[arrow.Arrow, _stats.ClosingReason]: """ - Move the `channel` to the bottom position of `category` and edit channel attributes. + Return the time at which the given help `post` should be closed along with the reason. - To ensure "stable sorting", we use the `bulk_channel_update` endpoint and provide the current - positions of the other channels in the category as-is. This should make sure that the channel - really ends up at the bottom of the category. + The time is calculated by first checking if the opening message is deleted. + If it is, then get the last 100 messages (the most that can be fetched in one API call). + If less than 100 message are returned, and none are from the post owner, then assume the poster + has sent no further messages and close deleted_idle_minutes after the post creation time. - If `options` are provided, the channel will be edited after the move is completed. This is the - same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documentation on `discord.TextChannel.edit`. While possible, position-related - options should be avoided, as it may interfere with the category move we perform. + Otherwise, use the most recent message's create_at date and add `idle_minutes_claimant`. """ - # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await try_get_channel(category_id) - - payload = [{"id": c.id, "position": c.position} for c in category.channels] - - # Calculate the bottom position based on the current highest position in the category. If the - # category is currently empty, we simply use the current position of the channel to avoid making - # unnecessary changes to positions in the guild. - bottom_position = payload[-1]["position"] + 1 if payload else channel.position - - payload.append( - { - "id": channel.id, - "position": bottom_position, - "parent_id": category.id, - "lock_permissions": True, - } - ) - - # We use d.py's method to ensure our request is processed by d.py's rate limit manager - await bot.instance.http.bulk_channel_update(category.guild.id, payload) - - # Now that the channel is moved, we can edit the other attributes - if options: - await channel.edit(**options) + try: + starter_message = post.starter_message or await post.fetch_message(post.id) + except discord.NotFound: + starter_message = None + + last_100_messages = [message async for message in post.history(limit=100, oldest_first=False)] + + if starter_message is None and len(last_100_messages) < 100: + if not discord.utils.get(last_100_messages, author__id=post.owner_id): + time = arrow.Arrow.fromdatetime(post.created_at) + time += timedelta(minutes=constants.HelpChannels.deleted_idle_minutes) + return time, _stats.ClosingReason.DELETED + + time = arrow.Arrow.fromdatetime(last_100_messages[0].created_at) + time += timedelta(minutes=constants.HelpChannels.idle_minutes) + return time, _stats.ClosingReason.INACTIVE + + +async def maybe_archive_idle_post(post_id: int, scheduler: scheduling.Scheduler) -> None: + """Archive the `post` if idle, or schedule the archive for later if still active.""" + try: + # Fetch the post again, to ensure we have the latest info + post = await get_or_fetch_channel(bot.instance, post_id) + except discord.HTTPException: + log.trace(f"Not closing missing post #{post_id}.") + return + + if post.archived or post.locked: + log.trace(f"Not closing already closed post #{post} ({post.id}).") + return + + log.trace(f"Handling open post #{post} ({post.id}).") + + closing_time, closing_reason = await get_closing_time(post) + + if closing_time < (arrow.utcnow() + timedelta(seconds=1)): + # Closing time is in the past. + # Add 1 second due to POSIX timestamps being lower resolution than datetime objects. + log.info( + f"#{post} ({post.id}) is idle past {closing_time} and will be archived. Reason: {closing_reason.value}" + ) + await _close_help_post(post, closing_reason, scheduler) + return + + if post.id in scheduler: + # Cancel any existing close task + scheduler.cancel(post.id) + delay = (closing_time - arrow.utcnow()).seconds + log.info(f"#{post} ({post.id}) is still active; scheduling it to be archived after {delay} seconds.") + + scheduler.schedule_later(delay, post.id, maybe_archive_idle_post(post.id, scheduler)) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c410a0a11..5e0b1799fd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,537 +1,159 @@ -import asyncio -import logging -import random -import typing as t -from datetime import timedelta -from operator import attrgetter - -import arrow +"""Contains the Cog that receives discord.py events and defers most actions to other files in the module.""" + +import contextlib + import discord -import discord.abc -from discord.ext import commands +from discord.ext import commands, tasks +from pydis_core.utils import scheduling from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _message, _name, _stats -from bot.utils import channel as channel_utils, lock, scheduling +from bot.exts.help_channels import _caches, _channel +from bot.log import get_logger +from bot.utils.checks import has_any_role_check -log = logging.getLogger(__name__) +log = get_logger(__name__) -NAMESPACE = "help" -HELP_CHANNEL_TOPIC = """ -This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -""" -AVAILABLE_HELP_CHANNELS = "**Currently available help channel(s):** {available}" - -class HelpChannels(commands.Cog): +class HelpForum(commands.Cog): """ - Manage the help channel system of the guild. - - The system is based on a 3-category system: - - Available Category - - * Contains channels which are ready to be occupied by someone who needs help - * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically - from the pool of dormant channels - * Prioritise using the channels which have been dormant for the longest amount of time - * If there are no more dormant channels, the bot will automatically create a new one - * If there are no dormant channels to move, helpers will be notified (see `notify()`) - * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG` - * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes` - * To keep track of cooldowns, user which claimed a channel will have a temporary role - - In Use Category + Manage the help channel forum of the guild. - * Contains all channels which are occupied by someone needing help - * Channel moves to dormant category after - - `constants.HelpChannels.idle_minutes_other` minutes since the last user message, or - - `constants.HelpChannels.idle_minutes_claimant` minutes since the last claimant message. - * Command can prematurely mark a channel as dormant - * Channel claimant is allowed to use the command - * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist` - * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent + This system uses Discord's native forum channel feature to handle most of the logic. - Dormant Category - - * Contains channels which aren't in use - * Channels are used to refill the Available category - - Help channels are named after the foods in `bot/resources/foods.json`. + The purpose of this cog is to add additional features, such as stats collection, old post locking + and helpful automated messages. """ def __init__(self, bot: Bot): self.bot = bot self.scheduler = scheduling.Scheduler(self.__class__.__name__) + self.help_forum_channel: discord.ForumChannel = None - # Categories - self.available_category: discord.CategoryChannel = None - self.in_use_category: discord.CategoryChannel = None - self.dormant_category: discord.CategoryChannel = None - - # Queues - self.channel_queue: asyncio.Queue[discord.TextChannel] = None - self.name_queue: t.Deque[str] = None - - self.last_notification: t.Optional[arrow.Arrow] = None - - self.dynamic_message: t.Optional[int] = None - self.available_help_channels: t.Set[discord.TextChannel] = set() - - # Asyncio stuff - self.queue_tasks: t.List[asyncio.Task] = [] - self.init_task = self.bot.loop.create_task(self.init_cog()) - - def cog_unload(self) -> None: - """Cancel the init task and scheduled tasks when the cog unloads.""" - log.trace("Cog unload: cancelling the init_cog task") - self.init_task.cancel() - - log.trace("Cog unload: cancelling the channel queue tasks") - for task in self.queue_tasks: - task.cancel() - + async def cog_unload(self) -> None: + """Cancel all scheduled tasks on unload.""" self.scheduler.cancel_all() - async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None: - """ - Change `member`'s cooldown role via awaiting `coro` and handle errors. - - `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - try: - await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown)) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - - @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) - @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) - @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) - async def claim_channel(self, message: discord.Message) -> None: - """ - Claim the channel in which the question `message` was sent. - - Move the channel to the In Use category and pin the `message`. Add a cooldown to the - claimant to prevent them from asking another question. Lastly, make a new channel available. - """ - log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") - await self.move_to_in_use(message.channel) - await self._handle_role_change(message.author, message.author.add_roles) - - await _message.pin(message) - - try: - await _message.dm_on_open(message) - except Exception as e: - log.warning("Error occurred while sending DM:", exc_info=e) - - # Add user with channel for dormant check. - await _caches.claimants.set(message.channel.id, message.author.id) - - self.bot.stats.incr("help.claimed") - - # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. - timestamp = arrow.Arrow.fromdatetime(message.created_at).timestamp() - - await _caches.claim_times.set(message.channel.id, timestamp) - await _caches.claimant_last_message_times.set(message.channel.id, timestamp) - # Delete to indicate that the help session has yet to receive an answer. - await _caches.non_claimant_last_message_times.delete(message.channel.id) - - # Removing the help channel from the dynamic message, and editing/sending that message. - self.available_help_channels.remove(message.channel) - - # Not awaited because it may indefinitely hold the lock while waiting for a channel. - scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}") - - def create_channel_queue(self) -> asyncio.Queue: - """ - Return a queue of dormant channels to use for getting the next available channel. - - The channels are added to the queue in a random order. - """ - log.trace("Creating the channel queue.") - - channels = list(_channel.get_category_channels(self.dormant_category)) - random.shuffle(channels) - - log.trace("Populating the channel queue with channels.") - queue = asyncio.Queue() - for channel in channels: - queue.put_nowait(channel) - - return queue - - async def create_dormant(self) -> t.Optional[discord.TextChannel]: - """ - Create and return a new channel in the Dormant category. - - The new channel will sync its permission overwrites with the category. - - Return None if no more channel names are available. - """ - log.trace("Getting a name for a new dormant channel.") - - try: - name = self.name_queue.popleft() - except IndexError: - log.debug("No more names available for new dormant channels.") - return None - - log.debug(f"Creating a new dormant channel named {name}.") - return await self.dormant_category.create_text_channel(name, topic=HELP_CHANNEL_TOPIC) + async def cog_load(self) -> None: + """Archive all idle open posts, schedule check for later for active open posts.""" + log.trace("Initialising help forum cog.") + self.help_forum_channel = self.bot.get_channel(constants.Channels.python_help) + if not isinstance(self.help_forum_channel, discord.ForumChannel): + raise TypeError("Channels.python_help is not a forum channel!") + self.check_all_open_posts_have_close_task.start() + + @tasks.loop(minutes=5) + async def check_all_open_posts_have_close_task(self) -> None: + """Check that each open help post has a scheduled task to close, adding one if not.""" + for post in self.help_forum_channel.threads: + if post.id not in self.scheduler: + await _channel.maybe_archive_idle_post(post.id, self.scheduler) async def close_check(self, ctx: commands.Context) -> bool: - """Return True if the channel is in use and the user is the claimant or has a whitelisted role.""" - if ctx.channel.category != self.in_use_category: - log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel") + """Return True if the channel is a help post, and the user is the claimant or has a whitelisted role.""" + if not _channel.is_help_forum_post(ctx.channel): return False - if await _caches.claimants.get(ctx.channel.id) == ctx.author.id: + if ctx.author.id == ctx.channel.owner_id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") return True log.trace(f"{ctx.author} is not the help channel claimant, checking roles.") has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx) - if has_role: self.bot.stats.incr("help.dormant_invoke.staff") - return has_role - @commands.command(name="close", aliases=["dormant", "solved"], enabled=False) + @commands.group(name="help-forum", aliases=("hf",)) + async def help_forum_group(self, ctx: commands.Context) -> None: + """A group of commands that help manage our help forum system.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @help_forum_group.command(name="close", root_aliases=("close", "dormant", "solved")) async def close_command(self, ctx: commands.Context) -> None: """ - Make the current in-use help channel dormant. + Make the help post this command was called in dormant. - May only be invoked by the channel's claimant or by staff. + May only be invoked by the channel's claimant or by mods+. """ # Don't use a discord.py check because the check needs to fail silently. if await self.close_check(ctx): log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.") - await self.unclaim_channel(ctx.channel, closed_on=_channel.ClosingReason.COMMAND) - - async def get_available_candidate(self) -> discord.TextChannel: - """ - Return a dormant channel to turn into an available channel. - - If no channel is available, wait indefinitely until one becomes available. - """ - log.trace("Getting an available channel candidate.") - - try: - channel = self.channel_queue.get_nowait() - except asyncio.QueueEmpty: - log.info("No candidate channels in the queue; creating a new channel.") - channel = await self.create_dormant() - - if not channel: - log.info("Couldn't create a candidate channel; waiting to get one from the queue.") - notify_channel = self.bot.get_channel(constants.HelpChannels.notify_channel) - last_notification = await _message.notify(notify_channel, self.last_notification) - if last_notification: - self.last_notification = last_notification - self.bot.stats.incr("help.out_of_channel_alerts") - - channel = await self.wait_for_dormant_channel() - - return channel - - async def init_available(self) -> None: - """Initialise the Available category with channels.""" - log.trace("Initialising the Available category with channels.") - - channels = list(_channel.get_category_channels(self.available_category)) - missing = constants.HelpChannels.max_available - len(channels) - - # If we've got less than `max_available` channel available, we should add some. - if missing > 0: - log.trace(f"Moving {missing} missing channels to the Available category.") - for _ in range(missing): - await self.move_to_available() - - # If for some reason we have more than `max_available` channels available, - # we should move the superfluous ones over to dormant. - elif missing < 0: - log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.") - for channel in channels[:abs(missing)]: - await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) - - # Getting channels that need to be included in the dynamic message. - await self.update_available_help_channels() - log.trace("Dynamic available help message updated.") - - async def init_categories(self) -> None: - """Get the help category objects. Remove the cog if retrieval fails.""" - log.trace("Getting the CategoryChannel objects for the help categories.") - - try: - self.available_category = await channel_utils.try_get_channel( - constants.Categories.help_available - ) - self.in_use_category = await channel_utils.try_get_channel( - constants.Categories.help_in_use - ) - self.dormant_category = await channel_utils.try_get_channel( - constants.Categories.help_dormant - ) - except discord.HTTPException: - log.exception("Failed to get a category; cog will be removed") - self.bot.remove_cog(self.qualified_name) - - async def init_cog(self) -> None: - """Initialise the help channel system.""" - log.trace("Waiting for the guild to be available before initialisation.") - await self.bot.wait_until_guild_available() - - log.trace("Initialising the cog.") - await self.init_categories() - - self.channel_queue = self.create_channel_queue() - self.name_queue = _name.create_name_queue( - self.available_category, - self.in_use_category, - self.dormant_category, - ) + await _channel.help_post_closed(ctx.channel, self.scheduler) - log.trace("Moving or rescheduling in-use channels.") - for channel in _channel.get_category_channels(self.in_use_category): - await self.move_idle_channel(channel, has_task=False) - - # Prevent the command from being used until ready. - # The ready event wasn't used because channels could change categories between the time - # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet). - # This may confuse users. So would potentially long delays for the cog to become ready. - self.close_command.enabled = True - - # Acquiring the dynamic message ID, if it exists within the cache. - log.trace("Attempting to fetch How-to-get-help dynamic message ID.") - self.dynamic_message = await _caches.dynamic_message.get("message_id") - - await self.init_available() - _stats.report_counts() - - log.info("Cog is ready!") - - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: - """ - Make the `channel` dormant if idle or schedule the move if still active. - - If `has_task` is True and rescheduling is required, the extant task to make the channel - dormant will first be cancelled. - """ - log.trace(f"Handling in-use channel #{channel} ({channel.id}).") - - closing_time, closed_on = await _channel.get_closing_time(channel, self.init_task.done()) - - # Closing time is in the past. - # Add 1 second due to POSIX timestamps being lower resolution than datetime objects. - if closing_time < (arrow.utcnow() + timedelta(seconds=1)): - log.info( - f"#{channel} ({channel.id}) is idle past {closing_time} " - f"and will be made dormant. Reason: {closed_on.value}" - ) - - await self.unclaim_channel(channel, closed_on=closed_on) - else: - # Cancel the existing task, if any. - if has_task: - self.scheduler.cancel(channel.id) - - delay = (closing_time - arrow.utcnow()).seconds - log.info( - f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {delay} seconds." - ) - - self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) - - async def move_to_available(self) -> None: - """Make a channel available.""" - log.trace("Making a channel available.") - - channel = await self.get_available_candidate() - log.info(f"Making #{channel} ({channel.id}) available.") - - await _message.send_available_message(channel) - - log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") - - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_available, - ) - - # Adding the help channel to the dynamic message, and editing/sending that message. - self.available_help_channels.add(channel) - await self.update_available_help_channels() - - _stats.report_counts() + @help_forum_group.command(name="title", root_aliases=("title",)) + async def rename_help_post(self, ctx: commands.Context, *, title: str) -> None: + """Rename the help post to the provided title.""" + if not _channel.is_help_forum_post(ctx.channel): + # Silently fail in channels other than help posts + return - async def move_to_dormant(self, channel: discord.TextChannel) -> None: - """Make the `channel` dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_dormant, - ) + if not await has_any_role_check(ctx, constants.Roles.helpers): + # Silently fail for non-helpers + return - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=_message.DORMANT_MSG) - await channel.send(embed=embed) + await ctx.channel.edit(name=title) - log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") - self.channel_queue.put_nowait(channel) + @commands.Cog.listener("on_message") + async def new_post_listener(self, message: discord.Message) -> None: + """Defer application of new post logic for posts in the help forum to the _channel helper.""" + if not isinstance(message.channel, discord.Thread): + return + thread = message.channel - _stats.report_counts() + if message.id != thread.id: + # Opener messages have the same ID as the thread + return - @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel") - async def unclaim_channel(self, channel: discord.TextChannel, *, closed_on: _channel.ClosingReason) -> None: - """ - Unclaim an in-use help `channel` to make it dormant. + if thread.parent_id != self.help_forum_channel.id: + return - Unpin the claimant's question message and move the channel to the Dormant category. - Remove the cooldown role from the channel claimant if they have no other channels claimed. - Cancel the scheduled cooldown role removal task. + await _channel.help_post_opened(thread, scheduler=self.scheduler) - `closed_on` is the reason that the channel was closed. See _channel.ClosingReason for possible values. - """ - claimant_id = await _caches.claimants.get(channel.id) - _unclaim_channel = self._unclaim_channel - - # It could be possible that there is no claimant cached. In such case, it'd be useless and - # possibly incorrect to lock on None. Therefore, the lock is applied conditionally. - if claimant_id is not None: - decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True) - _unclaim_channel = decorator(_unclaim_channel) - - return await _unclaim_channel(channel, claimant_id, closed_on) - - async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: int, - closed_on: _channel.ClosingReason - ) -> None: - """Actual implementation of `unclaim_channel`. See that for full documentation.""" - await _caches.claimants.delete(channel.id) - - claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) - if claimant is None: - log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") - else: - await self._handle_role_change(claimant, claimant.remove_roles) - - await _message.unpin(channel) - await _stats.report_complete_session(channel.id, closed_on) - await self.move_to_dormant(channel) - - # Cancel the task that makes the channel dormant only if called by the close command. - # In other cases, the task is either already done or not-existent. - if closed_on == _channel.ClosingReason.COMMAND: - self.scheduler.cancel(channel.id) - - async def move_to_in_use(self, channel: discord.TextChannel) -> None: - """Make a channel in-use and schedule it to be made dormant.""" - log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") - - await _channel.move_to_bottom( - channel=channel, - category_id=constants.Categories.help_in_use, + delay = min(constants.HelpChannels.deleted_idle_minutes, constants.HelpChannels.idle_minutes) * 60 + self.scheduler.schedule_later( + delay, + thread.id, + _channel.maybe_archive_idle_post(thread.id, self.scheduler) ) - timeout = constants.HelpChannels.idle_minutes_claimant * 60 - - log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) - _stats.report_counts() - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Move an available channel to the In Use category and replace it with a dormant one.""" - if message.author.bot: - return # Ignore messages sent by bots. - - await self.init_task - - if channel_utils.is_in_category(message.channel, constants.Categories.help_available): - if not _channel.is_excluded_channel(message.channel): - await self.claim_channel(message) - else: - await _message.update_message_caches(message) - - @commands.Cog.listener() - async def on_message_delete(self, msg: discord.Message) -> None: - """ - Reschedule an in-use channel to become dormant sooner if the channel is empty. - - The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. - """ - await self.init_task - - if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): + async def on_thread_update(self, before: discord.Thread, after: discord.Thread) -> None: + """Defer application archive logic for posts in the help forum to the _channel helper.""" + if after.parent_id != self.help_forum_channel.id: return + if not before.archived and after.archived: + await _channel.help_post_archived(after, self.scheduler) + if after.id in self.scheduler: + self.scheduler.cancel(after.id) - if not await _message.is_empty(msg.channel): + @commands.Cog.listener() + async def on_raw_thread_delete(self, deleted_thread_event: discord.RawThreadDeleteEvent) -> None: + """Defer application of deleted post logic for posts in the help forum to the _channel helper.""" + if deleted_thread_event.parent_id == self.help_forum_channel.id: + await _channel.help_post_deleted(deleted_thread_event) + + @commands.Cog.listener("on_message") + async def new_post_message_listener(self, message: discord.Message) -> None: + """Defer application of new message logic for messages in the help forum to the _message helper.""" + if not _channel.is_help_forum_post(message.channel): return - log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") - - # Cancel existing dormant task before scheduling new. - self.scheduler.cancel(msg.channel.id) - - delay = constants.HelpChannels.deleted_idle_minutes * 60 - self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) - - async def wait_for_dormant_channel(self) -> discord.TextChannel: - """Wait for a dormant channel to become available in the queue and return it.""" - log.trace("Waiting for a dormant channel.") - - task = asyncio.create_task(self.channel_queue.get()) - self.queue_tasks.append(task) - channel = await task - - log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.") - self.queue_tasks.remove(task) + if not message.author.bot and message.author.id != message.channel.owner_id: + await _caches.posts_with_non_claimant_messages.set(message.channel.id, "sentinel") - return channel - - async def update_available_help_channels(self) -> None: - """Updates the dynamic message within #how-to-get-help for available help channels.""" - if not self.available_help_channels: - self.available_help_channels = set( - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - ) - - available_channels = AVAILABLE_HELP_CHANNELS.format( - available=", ".join( - c.mention for c in sorted(self.available_help_channels, key=attrgetter("position")) - ) or None - ) - - if self.dynamic_message is not None: - try: - log.trace("Help channels have changed, dynamic message has been edited.") - await self.bot.http.edit_message( - constants.Channels.how_to_get_help, self.dynamic_message, content=available_channels - ) - except discord.NotFound: - pass - else: - return - - log.trace("Dynamic message could not be edited or found. Creating a new one.") - new_dynamic_message = await self.bot.http.send_message( - constants.Channels.how_to_get_help, available_channels - ) - self.dynamic_message = new_dynamic_message["id"] - await _caches.dynamic_message.set("message_id", self.dynamic_message) + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member) -> None: + """Notify a help thread if the owner is no longer a member of the server.""" + for thread in self.help_forum_channel.threads: + if thread.owner_id != member.id: + continue + + if thread.archived: + continue + + log.debug(f"Notifying help thread {thread.id} that owner {member.id} is no longer in the server.") + with contextlib.suppress(discord.NotFound): + await thread.send(":warning: The owner of this post is no longer in the server.") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py deleted file mode 100644 index afd698ffea..0000000000 --- a/bot/exts/help_channels/_message.py +++ /dev/null @@ -1,253 +0,0 @@ -import logging -import textwrap -import typing as t - -import arrow -import discord -from arrow import Arrow - -import bot -from bot import constants -from bot.exts.help_channels import _caches -from bot.utils.channel import is_in_category - -log = logging.getLogger(__name__) - -ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" - -AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. - -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. - -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. -""" - -AVAILABLE_TITLE = "Available help channel" - -AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." - -DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ -category at the bottom of the channel list. It is no longer possible to send messages in this \ -channel until it becomes available again. - -If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ -question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. -""" - - -async def update_message_caches(message: discord.Message) -> None: - """Checks the source of new content in a help channel and updates the appropriate cache.""" - channel = message.channel - - # Confirm the channel is an in use help channel - if is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") - - claimant_id = await _caches.claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return - - # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. - timestamp = Arrow.fromdatetime(message.created_at).timestamp() - - # Overwrite the appropriate last message cache depending on the author of the message - if message.author.id == claimant_id: - await _caches.claimant_last_message_times.set(channel.id, timestamp) - else: - await _caches.non_claimant_last_message_times.set(channel.id, timestamp) - - -async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: - """Return the last message sent in the channel or None if no messages exist.""" - log.trace(f"Getting the last message in #{channel} ({channel.id}).") - - try: - return await channel.history(limit=1).next() # noqa: B305 - except discord.NoMoreItems: - log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.") - return None - - -async def is_empty(channel: discord.TextChannel) -> bool: - """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" - log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - - # A limit of 100 results in a single API call. - # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. - # Not gonna do an extensive search for it cause it's too expensive. - async for msg in channel.history(limit=100): - if not msg.author.bot: - log.trace(f"#{channel} ({channel.id}) has a non-bot message.") - return False - - if _match_bot_embed(msg, AVAILABLE_MSG): - log.trace(f"#{channel} ({channel.id}) has the available message embed.") - return True - - return False - - -async def dm_on_open(message: discord.Message) -> None: - """ - DM claimant with a link to the claimed channel's first message, with a 100 letter preview of the message. - - Does nothing if the user has DMs disabled. - """ - embed = discord.Embed( - title="Help channel opened", - description=f"You claimed {message.channel.mention}.", - colour=bot.constants.Colours.bright_green, - timestamp=message.created_at, - ) - - embed.set_thumbnail(url=constants.Icons.green_questionmark) - formatted_message = textwrap.shorten(message.content, width=100, placeholder="...") - if formatted_message: - embed.add_field(name="Your message", value=formatted_message, inline=False) - embed.add_field( - name="Conversation", - value=f"[Jump to message!]({message.jump_url})", - inline=False, - ) - - try: - await message.author.send(embed=embed) - log.trace(f"Sent DM to {message.author.id} after claiming help channel.") - except discord.errors.Forbidden: - log.trace( - f"Ignoring to send DM to {message.author.id} after claiming help channel: DMs disabled." - ) - - -async def notify(channel: discord.TextChannel, last_notification: t.Optional[Arrow]) -> t.Optional[Arrow]: - """ - Send a message in `channel` notifying about a lack of available help channels. - - If a notification was sent, return the time at which the message was sent. - Otherwise, return None. - - Configuration: - - * `HelpChannels.notify` - toggle notifications - * `HelpChannels.notify_minutes` - minimum interval between notifications - * `HelpChannels.notify_roles` - roles mentioned in notifications - """ - if not constants.HelpChannels.notify: - return - - log.trace("Notifying about lack of channels.") - - if last_notification: - elapsed = (arrow.utcnow() - last_notification).seconds - minimum_interval = constants.HelpChannels.notify_minutes * 60 - should_send = elapsed >= minimum_interval - else: - should_send = True - - if not should_send: - log.trace("Notification not sent because it's too recent since the previous one.") - return - - try: - log.trace("Sending notification message.") - - mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) - allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] - - message = await channel.send( - f"{mentions} A new available help channel is needed but there " - f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels.", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - - return Arrow.fromdatetime(message.created_at) - except Exception: - # Handle it here cause this feature isn't critical for the functionality of the system. - log.exception("Failed to send notification about lack of dormant channels!") - - -async def pin(message: discord.Message) -> None: - """Pin an initial question `message` and store it in a cache.""" - if await _pin_wrapper(message.id, message.channel, pin=True): - await _caches.question_messages.set(message.channel.id, message.id) - - -async def send_available_message(channel: discord.TextChannel) -> None: - """Send the available message by editing a dormant message or sending a new message.""" - channel_info = f"#{channel} ({channel.id})" - log.trace(f"Sending available message in {channel_info}.") - - embed = discord.Embed( - color=constants.Colours.bright_green, - description=AVAILABLE_MSG, - ) - embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark) - embed.set_footer(text=AVAILABLE_FOOTER) - - msg = await get_last_message(channel) - if _match_bot_embed(msg, DORMANT_MSG): - log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.") - await msg.edit(embed=embed) - else: - log.trace(f"Dormant message not found in {channel_info}; sending a new message.") - await channel.send(embed=embed) - - -async def unpin(channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - msg_id = await _caches.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") - else: - await _pin_wrapper(msg_id, channel, pin=False) - - -def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool: - """Return `True` if the bot's `message`'s embed description matches `description`.""" - if not message or not message.embeds: - return False - - bot_msg_desc = message.embeds[0].description - if bot_msg_desc is discord.Embed.Empty: - log.trace("Last message was a bot embed but it was empty.") - return False - return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip() - - -async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: - """ - Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - - Return True if successful and False otherwise. - """ - channel_str = f"#{channel} ({channel.id})" - if pin: - func = bot.instance.http.pin_message - verb = "pin" - else: - func = bot.instance.http.unpin_message - verb = "unpin" - - try: - await func(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") - else: - log.exception( - f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" - ) - return False - else: - log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") - return True diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py deleted file mode 100644 index 061f855ae5..0000000000 --- a/bot/exts/help_channels/_name.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import logging -import typing as t -from collections import deque -from pathlib import Path - -import discord - -from bot import constants -from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels - -log = logging.getLogger(__name__) - - -def create_name_queue(*categories: discord.CategoryChannel) -> deque: - """ - Return a queue of food names to use for creating new channels. - - Skip names that are already in use by channels in `categories`. - """ - log.trace("Creating the food name queue.") - - used_names = _get_used_names(*categories) - - log.trace("Determining the available names.") - available_names = (name for name in _get_names() if name not in used_names) - - log.trace("Populating the name queue with names.") - return deque(available_names) - - -def _get_names() -> t.List[str]: - """ - Return a truncated list of prefixed food names. - - The amount of names is configured with `HelpChannels.max_total_channels`. - The prefix is configured with `HelpChannels.name_prefix`. - """ - count = constants.HelpChannels.max_total_channels - prefix = constants.HelpChannels.name_prefix - - log.trace(f"Getting the first {count} food names from JSON.") - - with Path("bot/resources/foods.json").open(encoding="utf-8") as foods_file: - all_names = json.load(foods_file) - - if prefix: - return [prefix + name for name in all_names[:count]] - else: - return all_names[:count] - - -def _get_used_names(*categories: discord.CategoryChannel) -> t.Set[str]: - """Return names which are already being used by channels in `categories`.""" - log.trace("Getting channel names which are already being used.") - - names = set() - for cat in categories: - for channel in get_category_channels(cat): - names.add(channel.name) - - if len(names) > MAX_CHANNELS_PER_CATEGORY: - log.warning( - f"Too many help channels ({len(names)}) already exist! " - f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category." - ) - - log.trace(f"Got {len(names)} used names: {names}") - return names diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py index eb34e75e13..b024203a82 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -1,41 +1,45 @@ -import logging +from enum import Enum -from more_itertools import ilen +import arrow +import discord import bot from bot import constants -from bot.exts.help_channels import _caches, _channel +from bot.exts.help_channels import _caches +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) -def report_counts() -> None: - """Report channel count stats of each help category.""" - for name in ("in_use", "available", "dormant"): - id_ = getattr(constants.Categories, f"help_{name}") - category = bot.instance.get_channel(id_) +class ClosingReason(Enum): + """All possible closing reasons for help channels.""" - if category: - total = ilen(_channel.get_category_channels(category)) - bot.instance.stats.gauge(f"help.total.{name}", total) - else: - log.warning(f"Couldn't find category {name!r} to track channel count stats.") + COMMAND = "manual.command" + INACTIVE = "auto.inactive" + NATIVE = "auto.native" + DELETED = "auto.deleted" + CLEANUP = "auto.cleanup" -async def report_complete_session(channel_id: int, closed_on: _channel.ClosingReason) -> None: +def report_post_count() -> None: + """Report post count stats of the help forum.""" + help_forum = bot.instance.get_channel(constants.Channels.python_help) + bot.instance.stats.gauge("help.total.in_use", len(help_forum.threads)) + + +async def report_complete_session(help_session_post: discord.Thread, closed_on: ClosingReason) -> None: """ - Report stats for a completed help session channel `channel_id`. + Report stats for a completed help session post `help_session_post`. - `closed_on` is the reason why the channel was closed. See `_channel.ClosingReason` for possible reasons. + `closed_on` is the reason why the post was closed. See `ClosingReason` for possible reasons. """ bot.instance.stats.incr(f"help.dormant_calls.{closed_on.value}") - in_use_time = await _channel.get_in_use_time(channel_id) - if in_use_time: - bot.instance.stats.timing("help.in_use_time", in_use_time) + open_time = discord.utils.snowflake_time(help_session_post.id) + in_use_time = arrow.utcnow() - open_time + bot.instance.stats.timing("help.in_use_time", in_use_time) - non_claimant_last_message_time = await _caches.non_claimant_last_message_times.get(channel_id) - if non_claimant_last_message_time is None: - bot.instance.stats.incr("help.sessions.unanswered") - else: + if await _caches.posts_with_non_claimant_messages.get(help_session_post.id): bot.instance.stats.incr("help.sessions.answered") + else: + bot.instance.stats.incr("help.sessions.unanswered") diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 24a9ae28ad..f127d16508 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -4,39 +4,50 @@ from typing import Any from urllib.parse import quote_plus +import discord +import yarl from aiohttp import ClientResponseError -from discord import Message from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels +from bot.constants import Channels, Keys +from bot.log import get_logger from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) GITHUB_RE = re.compile( - r'https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/' - r'(?P[^#>]+)(\?[^#>]+)?(#L(?P\d+)(([-~:]|(\.\.))L(?P\d+))?)' + r"https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/" + r"(?P[^#>]+)(\?[^#>]+)?(#L(?P\d+)(([-~:]|(\.\.))L(?P\d+))?)" ) GITHUB_GIST_RE = re.compile( - r'https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P[a-zA-Z0-9]+)/*' - r'(?P[a-zA-Z0-9]*)/*#file-(?P[^#>]+?)(\?[^#>]+)?' - r'(-L(?P\d+)([-~:]L(?P\d+))?)' + r"https://gist\.github\.com/([a-zA-Z0-9-]+)/(?P[a-zA-Z0-9]+)/*" + r"(?P[a-zA-Z0-9]*)/*#file-(?P[^#>]+?)(\?[^#>]+)?" + r"(-L(?P\d+)([-~:]L(?P\d+))?)" ) -GITHUB_HEADERS = {'Accept': 'application/vnd.github.v3.raw'} +GITHUB_HEADERS = {"Accept": "application/vnd.github.v3.raw"} +if Keys.github: + GITHUB_HEADERS["Authorization"] = f"token {Keys.github}" GITLAB_RE = re.compile( - r'https://gitlab\.com/(?P[\w.-]+/[\w.-]+)/\-/blob/(?P[^#>]+)' - r'(\?[^#>]+)?(#L(?P\d+)(-(?P\d+))?)' + r"https://gitlab\.com/(?P[\w.-]+/[\w.-]+)/\-/blob/(?P[^#>]+)" + r"(\?[^#>]+)?(#L(?P\d+)(-(?P\d+))?)" ) BITBUCKET_RE = re.compile( - r'https://bitbucket\.org/(?P[a-zA-Z0-9-]+/[\w.-]+)/src/(?P[0-9a-zA-Z]+)' - r'/(?P[^#>]+)(\?[^#>]+)?(#lines-(?P\d+)(:(?P\d+))?)' + r"https://bitbucket\.org/(?P[a-zA-Z0-9-]+/[\w.-]+)/src/(?P[0-9a-zA-Z]+)" + r"/(?P[^#>]+)(\?[^#>]+)?(#lines-(?P\d+)(:(?P\d+))?)" ) +PYDIS_PASTEBIN_RE = re.compile( + r"https://paste\.(?:pythondiscord\.com|pydis\.wtf)/(?P[a-zA-Z0-9]+)" + r"#(?P(?:\d+L\d+-L\d+)(?:,\d+L\d+-L\d+)*)" +) + +PASTEBIN_LINE_SELECTION_RE = re.compile(r"(\d+)L(\d+)-L(\d+)") + class CodeSnippets(Cog): """ @@ -45,22 +56,35 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ + def __init__(self, bot: Bot): + """Initializes the cog's bot.""" + self.bot = bot + + self.pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet), + (PYDIS_PASTEBIN_RE, self._fetch_pastebin_snippets), + ] + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: """Makes http requests using aiohttp.""" async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: - if response_format == 'text': + if response_format == "text": return await response.text() - elif response_format == 'json': + if response_format == "json": return await response.json() + return None def _find_ref(self, path: str, refs: tuple) -> tuple: """Loops through all branches and tags to find the required ref.""" # Base case: there is no slash in the branch name - ref, file_path = path.split('/', 1) + ref, file_path = path.split("/", 1) # In case there are slashes in the branch name, we loop through all branches and tags for possible_ref in refs: - if path.startswith(possible_ref['name'] + '/'): - ref = possible_ref['name'] + if path.startswith(possible_ref["name"] + "/"): + ref = possible_ref["name"] file_path = path[len(ref) + 1:] break return ref, file_path @@ -75,17 +99,17 @@ async def _fetch_github_snippet( """Fetches a snippet from a GitHub repo.""" # Search the GitHub API for the specified branch branches = await self._fetch_response( - f'https://api.github.com/repos/{repo}/branches', - 'json', + f"https://api.github.com/repos/{repo}/branches", + "json", headers=GITHUB_HEADERS ) - tags = await self._fetch_response(f'https://api.github.com/repos/{repo}/tags', 'json', headers=GITHUB_HEADERS) + tags = await self._fetch_response(f"https://api.github.com/repos/{repo}/tags", "json", headers=GITHUB_HEADERS) refs = branches + tags ref, file_path = self._find_ref(path, refs) file_contents = await self._fetch_response( - f'https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}', - 'text', + f"https://api.github.com/repos/{repo}/contents/{file_path}?ref={ref}", + "text", headers=GITHUB_HEADERS, ) return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) @@ -101,19 +125,19 @@ async def _fetch_github_gist_snippet( """Fetches a snippet from a GitHub gist.""" gist_json = await self._fetch_response( f'https://api.github.com/gists/{gist_id}{f"/{revision}" if len(revision) > 0 else ""}', - 'json', + "json", headers=GITHUB_HEADERS, ) # Check each file in the gist for the specified file - for gist_file in gist_json['files']: - if file_path == gist_file.lower().replace('.', '-'): + for gist_file in gist_json["files"]: + if file_path == gist_file.lower().replace(".", "-"): file_contents = await self._fetch_response( - gist_json['files'][gist_file]['raw_url'], - 'text', + gist_json["files"][gist_file]["raw_url"], + "text", ) return self._snippet_to_codeblock(file_contents, gist_file, start_line, end_line) - return '' + return "" async def _fetch_gitlab_snippet( self, @@ -127,18 +151,18 @@ async def _fetch_gitlab_snippet( # Searches the GitLab API for the specified branch branches = await self._fetch_response( - f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches', - 'json' + f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/branches", + "json" ) - tags = await self._fetch_response(f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags', 'json') + tags = await self._fetch_response(f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/tags", "json") refs = branches + tags ref, file_path = self._find_ref(path, refs) enc_ref = quote_plus(ref) enc_file_path = quote_plus(file_path) file_contents = await self._fetch_response( - f'https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}', - 'text', + f"https://gitlab.com/api/v4/projects/{enc_repo}/repository/files/{enc_file_path}/raw?ref={enc_ref}", + "text", ) return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) @@ -152,12 +176,45 @@ async def _fetch_bitbucket_snippet( ) -> str: """Fetches a snippet from a BitBucket repo.""" file_contents = await self._fetch_response( - f'https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}', - 'text', + f"https://bitbucket.org/{quote_plus(repo)}/raw/{quote_plus(ref)}/{quote_plus(file_path)}", + "text", ) return self._snippet_to_codeblock(file_contents, file_path, start_line, end_line) - def _snippet_to_codeblock(self, file_contents: str, file_path: str, start_line: str, end_line: str) -> str: + async def _fetch_pastebin_snippets(self, paste_id: str, selections: str) -> list[str]: + """Fetches snippets from paste.pythondiscord.com.""" + paste_data = await self._fetch_response( + f"https://paste.pythondiscord.com/api/v1/paste/{paste_id}", + "json" + ) + + snippets = [] + for match in PASTEBIN_LINE_SELECTION_RE.finditer(selections): + file_num, start, end = match.groups() + file_num = int(file_num) - 1 + + file = paste_data["files"][file_num] + file_name = file.get("name") or f"file {file_num + 1}" + snippet = self._snippet_to_codeblock( + file["content"], + file_name, + start, + end, + language=file["lexer"], + ) + + snippets.append(snippet) + + return snippets + + def _snippet_to_codeblock( + self, + file_contents: str, + file_path: str, + start_line: str, + end_line: str|None, + language: str|None = None + ) -> str: """ Given the entire file contents and target lines, creates a code block. @@ -182,84 +239,114 @@ def _snippet_to_codeblock(self, file_contents: str, file_path: str, start_line: if start_line > end_line: start_line, end_line = end_line, start_line if start_line > len(split_file_contents) or end_line < 1: - return '' + return "" start_line = max(1, start_line) end_line = min(len(split_file_contents), end_line) # Gets the code lines, dedents them, and inserts zero-width spaces to prevent Markdown injection - required = '\n'.join(split_file_contents[start_line - 1:end_line]) - required = textwrap.dedent(required).rstrip().replace('`', '`\u200b') + required = "\n".join(split_file_contents[start_line - 1:end_line]) + required = textwrap.dedent(required).rstrip().replace("`", "`\u200b") - # Extracts the code language and checks whether it's a "valid" language - language = file_path.split('/')[-1].split('.')[-1] - trimmed_language = language.replace('-', '').replace('+', '').replace('_', '') - is_valid_language = trimmed_language.isalnum() - if not is_valid_language: - language = '' + if language is None: + # Extracts the code language and checks whether it's a "valid" language + language = file_path.split("/")[-1].split(".")[-1] + trimmed_language = language.replace("-", "").replace("+", "").replace("_", "") + is_valid_language = trimmed_language.isalnum() + if not is_valid_language: + language = "" + + if language == "pyi": + language = "py" # Adds a label showing the file path to the snippet if start_line == end_line: - ret = f'`{file_path}` line {start_line}\n' + ret = f"`{file_path}` line {start_line}\n" else: - ret = f'`{file_path}` lines {start_line} to {end_line}\n' + ret = f"`{file_path}` lines {start_line} to {end_line}\n" if len(required) != 0: - return f'{ret}```{language}\n{required}```' + return f"{ret}```{language}\n{required}```" # Returns an empty codeblock if the snippet is empty - return f'{ret}``` ```' + return f"{ret}``` ```" + + async def _parse_snippets(self, content: str) -> str: + """Parse message content and return a string with a code block for each URL found.""" + all_snippets = [] + + for pattern, handler in self.pattern_handlers: + for match in pattern.finditer(content): + # ensure that the matched URL meets url normalization rules. + # parsing an absolute url with yarl resolves all parent urls such as `/../`, + # we then check the regex again to make sure our groups stay the same + unsanitized = match.group(0) + normalized = str(yarl.URL(unsanitized)) + if normalized != unsanitized: + match = pattern.fullmatch(normalized) + if not match: + log.info( + "Received code snippet url %s which " + "attempted to circumvent url normalisation.", + unsanitized + ) + continue + try: + result = await handler(**match.groupdict()) + except ClientResponseError as error: + error_message = error.message + log.log( + logging.DEBUG if error.status == 404 else logging.ERROR, + f"Failed to fetch code snippet from {match[0]!r}: {error.status} " + f"{error_message} for GET {error.request_info.real_url.human_repr()}" + ) + continue - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot + if isinstance(result, list): + # The handler returned multiple snippets (currently only possible with our pastebin) + all_snippets.extend((match.start(), snippet) for snippet in result) + else: + all_snippets.append((match.start(), result)) - self.pattern_handlers = [ - (GITHUB_RE, self._fetch_github_snippet), - (GITHUB_GIST_RE, self._fetch_github_gist_snippet), - (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) - ] + # Sort the list of snippets by ONLY their match index + all_snippets.sort(key=lambda item: item[0]) + + # Join them into a single message + return "\n".join(x[1] for x in all_snippets) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if not message.author.bot: - all_snippets = [] - - for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): - try: - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) - except ClientResponseError as error: - error_message = error.message # noqa: B306 - log.log( - logging.DEBUG if error.status == 404 else logging.ERROR, - f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' - f'{error_message} for GET {error.request_info.real_url.human_repr()}' - ) + if message.author.bot: + return - # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + if message.guild is None: + return - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + message_to_send = await self._parse_snippets(message.content) + destination = message.channel + + if 0 < len(message_to_send) <= 2000 and message_to_send.count("\n") <= 15: + try: await message.edit(suppress=True) - if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: - # Redirects to #bot-commands if the snippet contents are too long - await self.bot.wait_until_guild_available() - await message.channel.send(('The snippet you tried to send was too long. Please ' - f'see <#{Channels.bot_commands}> for the full snippet.')) - bot_commands_channel = self.bot.get_channel(Channels.bot_commands) - await wait_for_deletion( - await bot_commands_channel.send(message_to_send), - (message.author.id,) - ) - else: - await wait_for_deletion( - await message.channel.send(message_to_send), - (message.author.id,) - ) + except discord.NotFound: + # Don't send snippets if the original message was deleted. + return + + if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: + # Redirects to #bot-commands if the snippet contents are too long + await self.bot.wait_until_guild_available() + destination = self.bot.get_channel(Channels.bot_commands) + + await message.channel.send( + "The snippet you tried to send was too long. " + f"Please see {destination.mention} for the full snippet." + ) + + await wait_for_deletion( + await destination.send(message_to_send), + (message.author.id,) + ) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the CodeSnippets cog.""" - bot.add_cog(CodeSnippets(bot)) + await bot.add_cog(CodeSnippets(bot)) diff --git a/bot/exts/info/codeblock/__init__.py b/bot/exts/info/codeblock/__init__.py index 5c55bc5e3c..dde45bd594 100644 --- a/bot/exts/info/codeblock/__init__.py +++ b/bot/exts/info/codeblock/__init__.py @@ -1,8 +1,8 @@ from bot.bot import Bot -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the CodeBlockCog cog.""" # Defer import to reduce side effects from importing the codeblock package. from bot.exts.info.codeblock._cog import CodeBlockCog - bot.add_cog(CodeBlockCog(bot)) + await bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9094d9d151..4b0936fbcf 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -1,21 +1,21 @@ -import logging import time -from typing import Optional import discord from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Cog +from pydis_core.utils import scheduling from bot import constants from bot.bot import Bot -from bot.exts.filters.token_remover import TokenRemover -from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE +from bot.exts.filtering._filters.unique.discord_token import DiscordTokenFilter +from bot.exts.filtering._filters.unique.webhook import WEBHOOK_URL_RE +from bot.exts.help_channels._channel import is_help_forum_post from bot.exts.info.codeblock._instructions import get_instructions +from bot.log import get_logger from bot.utils import has_lines -from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) class CodeBlockCog(Cog, name="Code Block"): @@ -43,13 +43,12 @@ class CodeBlockCog(Cog, name="Code Block"): When an issue is detected, an embed is sent containing specific instructions on fixing what is wrong. If the user edits their message to fix the code block, the instructions will be removed. If they fail to fix the code block with an edit, the instructions will be updated to - show what is still incorrect after the user's edit. The embed can be manually deleted with a - reaction. Otherwise, it will automatically be removed after 5 minutes. + show what is still incorrect after the user's edit. Otherwise, it will automatically be removed after 5 minutes. The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the instructions being sent. Note all help channels are also whitelisted with cooldowns enabled. - For configurable parameters, see the `code_block` section in config-default.py. + For configurable parameters, see the `_CodeBlock` class in constants.py. """ def __init__(self, bot: Bot): @@ -64,9 +63,9 @@ def __init__(self, bot: Bot): @staticmethod def create_embed(instructions: str) -> discord.Embed: """Return an embed which displays code block formatting `instructions`.""" - return discord.Embed(description=instructions) + return discord.Embed(description=instructions, title="Please edit your message to use a code block") - async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Message | None: """ Return the bot's sent instructions message associated with a user's message `payload`. @@ -97,7 +96,7 @@ def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( - is_help_channel(channel) + is_help_forum_post(channel) or channel.id in self.channel_cooldowns or channel.id in constants.CodeBlock.channel_whitelist ) @@ -114,7 +113,7 @@ async def send_instructions(self, message: discord.Message, instructions: str) - bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id - self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,))) + scheduling.create_task(wait_for_deletion(bot_message, tuple(), attach_emojis=False)) # Increase amount of codeblock correction in stats self.bot.stats.incr("codeblock_corrections") @@ -134,7 +133,7 @@ def should_parse(self, message: discord.Message) -> bool: not message.author.bot and self.is_valid_channel(message.channel) and has_lines(message.content, constants.CodeBlock.minimum_lines) - and not TokenRemover.find_token_in_message(message) + and not DiscordTokenFilter.find_token_in_message(message.content) and not WEBHOOK_URL_RE.search(message.content) ) @@ -177,10 +176,13 @@ async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: if not bot_message: return - if not instructions: - log.info("User's incorrect code block has been fixed. Removing instructions message.") - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - else: - log.info("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(embed=self.create_embed(instructions)) + try: + if not instructions: + log.info("User's incorrect code block was fixed. Removing instructions message.") + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + else: + log.info("Message edited but still has invalid code blocks; editing instructions.") + await bot_message.edit(embed=self.create_embed(instructions)) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index dadb5e1efa..7204dbeae4 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -1,11 +1,10 @@ """This module generates and formats instructional messages about fixing Markdown code blocks.""" -import logging -from typing import Optional from bot.exts.info.codeblock import _parsing +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) _EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. _EXAMPLE_CODE_BLOCKS = ( @@ -32,21 +31,20 @@ def _get_example(language: str) -> str: return _EXAMPLE_CODE_BLOCKS.format(content=content) -def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> Optional[str]: +def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> str | None: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{_parsing.BACKTICK}" * 3 instructions = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the code block should start. " - f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`." + "You are using the wrong character instead of backticks. " + f"Use {valid_ticks}, not `{code_block.ticks}`." ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = _get_bad_lang_message(code_block.content) if not addition_msg and not code_block.language: - addition_msg = _get_no_lang_message(code_block.content) + addition_msg = _get_no_lang_message(code_block) # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. @@ -54,36 +52,28 @@ def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> Optional[str]: log.trace("Language specifier issue found; appending additional instructions.") # The first line has double newlines which are not desirable when appending the msg. - addition_msg = addition_msg.replace("\n\n", " ", 1) + addition_msg = addition_msg.replace("\n\n", " ", 1).strip() # Make the first character of the addition lower case. - instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] + instructions += "\n\nAlso, " + addition_msg[0].lower() + addition_msg[1:] else: log.trace("No issues with the language specifier found.") - example_blocks = _get_example(code_block.language) - instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions -def _get_no_ticks_message(content: str) -> Optional[str]: +def _get_no_ticks_message(content: str) -> str | None: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") if _parsing.is_python_code(content): example_blocks = _get_example("py") - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n{example_blocks}" - ) - else: - log.trace("Aborting missing code block instructions: content is not Python code.") + return example_blocks + log.trace("Aborting missing code block instructions: content is not Python code.") + return None -def _get_bad_lang_message(content: str) -> Optional[str]: +def _get_bad_lang_message(content: str) -> str | None: """ Return instructions on fixing the Python language specifier for a code block. @@ -95,7 +85,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: info = _parsing.parse_bad_language(content) if not info: log.trace("Aborting bad language instructions: language specified isn't Python.") - return + return None lines = [] language = info.language @@ -116,15 +106,13 @@ def _get_bad_lang_message(content: str) -> Optional[str]: example_blocks = _get_example(language) # Note that _get_bad_ticks_message expects the first line to have two newlines. - return ( - f"It looks like you incorrectly specified a language for your code block.\n\n{lines}" - f"\n\n**Here is an example of how it should look:**\n{example_blocks}" - ) - else: - log.trace("Nothing wrong with the language specifier; no instructions to return.") + return f"\n\n{lines}\n\n**Here is an example of how it should look:**\n{example_blocks}" + + log.trace("Nothing wrong with the language specifier; no instructions to return.") + return None -def _get_no_lang_message(content: str) -> Optional[str]: +def _get_no_lang_message(code_block: _parsing.CodeBlock) -> str | None: """ Return instructions on specifying a language for a code block. @@ -132,21 +120,17 @@ def _get_no_lang_message(content: str) -> Optional[str]: """ log.trace("Creating instructions for a missing language.") - if _parsing.is_python_code(content): + if code_block.is_python: example_blocks = _get_example("py") # Note that _get_bad_ticks_message expects the first line to have two newlines. - return ( - "It looks like you pasted Python code without syntax highlighting.\n\n" - "Please use syntax highlighting to improve the legibility of your code and make " - "it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n{example_blocks}" - ) - else: - log.trace("Aborting missing language instructions: content is not Python code.") + return f"\n\nAdd a `py` after the three backticks.\n\n{example_blocks}" + log.trace("Aborting missing language instructions: content is not Python code.") + return None -def get_instructions(content: str) -> Optional[str]: + +def get_instructions(content: str) -> str | None: """ Parse `content` and return code block formatting instructions if something is wrong. @@ -154,10 +138,10 @@ def get_instructions(content: str) -> Optional[str]: """ log.trace("Getting formatting instructions.") - blocks = _parsing.find_code_blocks(content) + blocks = _parsing.find_faulty_code_blocks(content) if blocks is None: log.trace("At least one valid code block found; no instructions to return.") - return + return None if not blocks: log.trace("No code blocks were found in message.") @@ -176,9 +160,6 @@ def get_instructions(content: str) -> Optional[str]: # Check for a bad language first to avoid parsing content into an AST. instructions = _get_bad_lang_message(block.content) if not instructions: - instructions = _get_no_lang_message(block.content) - - if instructions: - instructions += "\nYou can **edit your original message** to correct your code block." + instructions = _get_no_lang_message(block) return instructions diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 73fd11b942..22cbe4344e 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -1,15 +1,18 @@ """This module provides functions for parsing Markdown code blocks.""" import ast -import logging import re import textwrap -from typing import NamedTuple, Optional, Sequence +from collections.abc import Sequence +from typing import NamedTuple + +import regex from bot import constants +from bot.log import get_logger from bot.utils import has_lines -log = logging.getLogger(__name__) +log = get_logger(__name__) BACKTICK = "`" PY_LANG_CODES = ("python-repl", "python", "pycon", "py") # Order is important; "py" is last cause it's a subset. @@ -32,16 +35,20 @@ _RE_CODE_BLOCK = re.compile( fr""" + (?:^| # the ticks need to start at the front of a line to be recognized + \s) # or need to have a preceding whitespace (to avoid detection of words like I'll). (?P (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match previous group 2 more times to ensure the same char. + \2* # Match previous group up to N more times to ensure the same char. ) - (?P[A-Za-z0-9\+\-\.]+\n)? # Optionally match a language specifier followed by a newline. + (?P[A-Za-z0-9+\-.]+\s)? # Optionally match a language specifier followed by a whitespace. (?P.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. + \1 # Match the same N ticks used at the start of the block. """, re.DOTALL | re.VERBOSE ) +# copy of _RE_CODE_BLOCK. Done like this for highlighting reasons (regex.compile doesn't properly highlight) +_REGEX_CODE_BLOCK = regex.compile(_RE_CODE_BLOCK.pattern, regex.DOTALL | regex.VERBOSE) _RE_LANGUAGE = re.compile( fr""" @@ -58,7 +65,9 @@ class CodeBlock(NamedTuple): content: str language: str + ticks: str tick: str + is_python: bool class BadLanguage(NamedTuple): @@ -69,9 +78,9 @@ class BadLanguage(NamedTuple): has_terminal_newline: bool -def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: +def find_faulty_code_blocks(message: str) -> Sequence[CodeBlock] | None: """ - Find and return all Markdown code blocks in the `message`. + Find and return all faulty Markdown code blocks in the `message`. Code blocks with 3 or fewer lines are excluded. @@ -82,19 +91,28 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: log.trace("Finding all code blocks in a message.") code_blocks = [] - for match in _RE_CODE_BLOCK.finditer(message): + for match in _REGEX_CODE_BLOCK.finditer(message, overlapped=True): # Used to ensure non-matched groups have an empty string as the default value. groups = match.groupdict("") - language = groups["lang"].strip() # Strip the newline cause it's included in the group. + language = groups["lang"].strip() # Strip the whitespace cause it's included in the group. - if groups["tick"] == BACKTICK and language: + if groups["tick"] == BACKTICK and len(groups["ticks"]) == 3 and language and ("\n" in groups["lang"]): log.trace("Message has a valid code block with a language; returning None.") return None - elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): - code_block = CodeBlock(groups["code"], language, groups["tick"]) + + if not has_lines(groups["code"], constants.CodeBlock.minimum_lines): + log.trace("Skipped a code block shorter than 4 lines.") + continue + + is_python = is_python_code(groups["code"]) + if (groups["tick"] == BACKTICK + or (language in PY_LANG_CODES and is_python) + or len(groups["ticks"]) >= 2): + log.trace("Message has an invalid code block.") + code_block = CodeBlock(groups["code"], language, groups["ticks"], groups["tick"], is_python) code_blocks.append(code_block) else: - log.trace("Skipped a code block shorter than 4 lines.") + log.trace("Skipped invalid code block due to uncertainty if it is supposed to be a code block.") return code_blocks @@ -119,9 +137,9 @@ def _is_python_code(content: str) -> bool: if not all(isinstance(node, ast.Expr) for node in tree.body): log.trace("Code is valid python.") return True - else: - log.trace("Code consists only of expressions.") - return False + + log.trace("Code consists only of expressions.") + return False def _is_repl_code(content: str, threshold: int = 3) -> bool: @@ -161,7 +179,7 @@ def is_python_code(content: str) -> bool: ) -def parse_bad_language(content: str) -> Optional[BadLanguage]: +def parse_bad_language(content: str) -> BadLanguage | None: """ Return information about a poorly formatted Python language in code block `content`. @@ -180,7 +198,7 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: ) -def _get_leading_spaces(content: str) -> int: +def _get_leading_spaces(content: str) -> int | None: """Return the number of spaces at the start of the first line in `content`.""" leading_spaces = 0 for char in content: @@ -189,6 +207,8 @@ def _get_leading_spaces(content: str) -> int: else: return leading_spaces + return None + def _fix_indentation(content: str) -> str: """ diff --git a/bot/exts/info/doc/__init__.py b/bot/exts/info/doc/__init__.py index 38a8975c0f..4cfec33d3d 100644 --- a/bot/exts/info/doc/__init__.py +++ b/bot/exts/info/doc/__init__.py @@ -1,4 +1,5 @@ from bot.bot import Bot + from ._redis_cache import DocRedisCache MAX_SIGNATURE_AMOUNT = 3 @@ -10,7 +11,7 @@ doc_cache = DocRedisCache(namespace=NAMESPACE) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Doc cog.""" from ._cog import DocCog - bot.add_cog(DocCog(bot)) + await bot.add_cog(DocCog(bot)) diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 369bb462cf..c583d793bb 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -1,30 +1,32 @@ -from __future__ import annotations - import asyncio import collections -import logging -from collections import defaultdict +from collections import defaultdict, deque from contextlib import suppress from operator import attrgetter -from typing import Deque, Dict, List, NamedTuple, Optional, Union +from typing import NamedTuple import discord from bs4 import BeautifulSoup +from pydis_core.utils import scheduling import bot from bot.constants import Channels -from bot.utils import scheduling +from bot.log import get_logger + from . import _cog, doc_cache from ._parsing import get_symbol_markdown +from ._redis_cache import StaleItemCounter -log = logging.getLogger(__name__) +log = get_logger(__name__) class StaleInventoryNotifier: """Handle sending notifications about stale inventories through `DocItem`s to dev log.""" + symbol_counter = StaleItemCounter() + def __init__(self): - self._init_task = bot.instance.loop.create_task( + self._init_task = scheduling.create_task( self._init_channel(), name="StaleInventoryNotifier channel init" ) @@ -38,13 +40,16 @@ async def _init_channel(self) -> None: async def send_warning(self, doc_item: _cog.DocItem) -> None: """Send a warning to dev log if one wasn't already sent for `item`'s url.""" if doc_item.url not in self._warned_urls: - self._warned_urls.add(doc_item.url) - await self._init_task - embed = discord.Embed( - description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " - f"not found on [site]({doc_item.url}), inventories may need to be refreshed." - ) - await self._dev_log.send(embed=embed) + # Only warn if the item got less than 3 warnings + # or if it has been more than 3 weeks since the last warning + if await self.symbol_counter.increment_for(doc_item) < 3: + self._warned_urls.add(doc_item.url) + await self._init_task + embed = discord.Embed( + description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " + f"not found on [site]({doc_item.url}), inventories may need to be refreshed." + ) + await self._dev_log.send(embed=embed) class QueueItem(NamedTuple): @@ -53,7 +58,7 @@ class QueueItem(NamedTuple): doc_item: _cog.DocItem soup: BeautifulSoup - def __eq__(self, other: Union[QueueItem, _cog.DocItem]): + def __eq__(self, other: QueueItem | _cog.DocItem): if isinstance(other, _cog.DocItem): return self.doc_item == other return NamedTuple.__eq__(self, other) @@ -82,14 +87,14 @@ class BatchParser: """ def __init__(self): - self._queue: Deque[QueueItem] = collections.deque() - self._page_doc_items: Dict[str, List[_cog.DocItem]] = defaultdict(list) - self._item_futures: Dict[_cog.DocItem, ParseResultFuture] = defaultdict(ParseResultFuture) + self._queue: deque[QueueItem] = collections.deque() + self._page_doc_items: dict[str, list[_cog.DocItem]] = defaultdict(list) + self._item_futures: dict[_cog.DocItem, ParseResultFuture] = defaultdict(ParseResultFuture) self._parse_task = None self.stale_inventory_notifier = StaleInventoryNotifier() - async def get_markdown(self, doc_item: _cog.DocItem) -> Optional[str]: + async def get_markdown(self, doc_item: _cog.DocItem) -> str | None: """ Get the result Markdown of `doc_item`. @@ -101,7 +106,7 @@ async def get_markdown(self, doc_item: _cog.DocItem) -> Optional[str]: if doc_item not in self._item_futures and doc_item not in self._queue: self._item_futures[doc_item].user_requested = True - async with bot.instance.http_session.get(doc_item.url) as response: + async with bot.instance.http_session.get(doc_item.url, raise_for_status=True) as response: soup = await bot.instance.loop.run_in_executor( None, BeautifulSoup, @@ -156,7 +161,7 @@ async def _parse_queue(self) -> None: self._parse_task = None log.trace("Finished parsing queue.") - def _move_to_front(self, item: Union[QueueItem, _cog.DocItem]) -> None: + def _move_to_front(self, item: QueueItem | _cog.DocItem) -> None: """Move `item` to the front of the parse queue.""" # The parse queue stores soups along with the doc symbols in QueueItem objects, # in case we're moving a DocItem we have to get the associated QueueItem first and then move it. diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c54a3ee1c8..4546fc14f3 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -1,29 +1,35 @@ -from __future__ import annotations - import asyncio -import logging import sys import textwrap from collections import defaultdict from contextlib import suppress from types import SimpleNamespace -from typing import Dict, NamedTuple, Optional, Tuple, Union +from typing import Literal import aiohttp import discord from discord.ext import commands +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput -from bot.converters import Inventory, PackageName, ValidURL, allowed_strings +from bot.constants import MODERATION_ROLES, RedirectOutput, Roles +from bot.converters import Inventory, PackageName, ValidURL +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.lock import SharedEvent, lock from bot.utils.messages import send_denial, wait_for_deletion -from bot.utils.scheduling import Scheduler + from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache -from ._inventory_parser import InventoryDict, fetch_inventory +from ._doc_item import DocItem +from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory -log = logging.getLogger(__name__) +log = get_logger(__name__) + +# groups to ignore from parsing +IGNORE_GROUPS = ( + "std:doc", +) # symbols with a group contained here will get the group prefixed on duplicates FORCE_PREFIX_GROUPS = ( @@ -41,21 +47,6 @@ COMMAND_LOCK_SINGLETON = "inventory refresh" -class DocItem(NamedTuple): - """Holds inventory symbol information.""" - - package: str # Name of the package name the symbol is from - group: str # Interpshinx "role" of the symbol, for example `label` or `method` - base_url: str # Absolute path to to which the relative path resolves, same for all items with the same package - relative_url_path: str # Relative path to the page where the symbol is located - symbol_id: str # Fragment id used to locate the symbol on the page - - @property - def url(self) -> str: - """Return the absolute url to the symbol.""" - return self.base_url + self.relative_url_path - - class DocCog(commands.Cog): """A set of commands for querying & displaying documentation.""" @@ -64,7 +55,7 @@ def __init__(self, bot: Bot): # Used to calculate inventory diffs on refreshes and to display all currently stored inventories. self.base_urls = {} self.bot = bot - self.doc_symbols: Dict[str, DocItem] = {} # Maps symbol names to objects containing their metadata. + self.doc_symbols: dict[str, DocItem] = {} # Maps symbol names to objects containing their metadata. self.item_fetcher = _batch_parser.BatchParser() # Maps a conflicting symbol name to a list of the new, disambiguated names created from conflicts with the name. self.renamed_symbols = defaultdict(list) @@ -75,13 +66,7 @@ def __init__(self, bot: Bot): self.refresh_event.set() self.symbol_get_event = SharedEvent() - self.init_refresh_task = self.bot.loop.create_task( - self.init_refresh_inventory(), - name="Doc inventory init" - ) - - @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def init_refresh_inventory(self) -> None: + async def cog_load(self) -> None: """Refresh documentation inventory on cog initialization.""" await self.bot.wait_until_guild_available() await self.refresh_inventories() @@ -100,6 +85,8 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi for group, items in inventory.items(): for symbol_name, relative_doc_url in items: + if group in IGNORE_GROUPS: + continue # e.g. get 'class' from 'py:class' group_name = group.split(":")[1] @@ -135,7 +122,12 @@ async def update_or_reschedule_inventory( The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts in `FETCH_RESCHEDULE_DELAY.repeated` minutes. """ - package = await fetch_inventory(inventory_url) + try: + package = await fetch_inventory(inventory_url) + except InvalidHeaderError as e: + # Do not reschedule if the header is invalid, as the request went through but the contents are invalid. + log.warning(f"Invalid inventory header at {inventory_url}. Reason: {e}") + return if not package: if api_package_name in self.inventory_scheduler: @@ -150,6 +142,8 @@ async def update_or_reschedule_inventory( self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url), ) else: + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(api_package_name, base_url, package) def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str: @@ -179,19 +173,17 @@ def rename(prefix: str, *, rename_extant: bool = False) -> str: # Instead of renaming the current symbol, rename the symbol with which it conflicts. self.doc_symbols[new_name] = self.doc_symbols[symbol_name] return symbol_name - else: - return new_name + return new_name # When there's a conflict, and the package names of the items differ, use the package name as a prefix. if package_name != item.package: if package_name in PRIORITY_PACKAGES: return rename(item.package, rename_extant=True) - else: - return rename(package_name) + return rename(package_name) # If the symbol's group is a non-priority group from FORCE_PREFIX_GROUPS, # add it as a prefix to disambiguate the symbols. - elif group_name in FORCE_PREFIX_GROUPS: + if group_name in FORCE_PREFIX_GROUPS: if item.group in FORCE_PREFIX_GROUPS: needs_moving = FORCE_PREFIX_GROUPS.index(group_name) < FORCE_PREFIX_GROUPS.index(item.group) else: @@ -200,8 +192,7 @@ def rename(prefix: str, *, rename_extant: bool = False) -> str: # If the above conditions didn't pass, either the existing symbol has its group in FORCE_PREFIX_GROUPS, # or deciding which item to rename would be arbitrary, so we rename the existing symbol. - else: - return rename(item.group, rename_extant=True) + return rename(item.group, rename_extant=True) async def refresh_inventories(self) -> None: """Refresh internal documentation inventories.""" @@ -224,7 +215,7 @@ async def refresh_inventories(self) -> None: log.debug("Finished inventory refresh.") self.refresh_event.set() - def get_symbol_item(self, symbol_name: str) -> Tuple[str, Optional[DocItem]]: + def get_symbol_item(self, symbol_name: str) -> tuple[str, DocItem | None]: """ Get the `DocItem` and the symbol name used to fetch it from the `doc_symbols` dict. @@ -233,7 +224,7 @@ def get_symbol_item(self, symbol_name: str) -> Tuple[str, Optional[DocItem]]: """ doc_item = self.doc_symbols.get(symbol_name) if doc_item is None and " " in symbol_name: - symbol_name = symbol_name.split(" ", maxsplit=1)[0] + symbol_name = symbol_name.split(maxsplit=1)[0] doc_item = self.doc_symbols.get(symbol_name) return symbol_name, doc_item @@ -264,7 +255,7 @@ async def get_symbol_markdown(self, doc_item: DocItem) -> str: return "Unable to parse the requested symbol." return markdown - async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed]: + async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -302,12 +293,12 @@ async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed] return embed @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + async def docs_group(self, ctx: commands.Context, *, symbol_name: str | None) -> None: """Look up documentation for Python symbols.""" await self.get_command(ctx, symbol_name=symbol_name) @docs_group.command(name="getdoc", aliases=("g",)) - async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + async def get_command(self, ctx: commands.Context, *, symbol_name: str | None) -> None: """ Return a documentation embed for a given symbol. @@ -325,7 +316,7 @@ async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str] colour=discord.Colour.blue() ) - lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) + lines = sorted(f"- [`{name}`]({url})" for name, url in self.base_urls.items()) if self.base_urls: await LinePaginator.paginate(lines, ctx, inventory_embed, max_size=400, empty=False) @@ -334,43 +325,51 @@ async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str] await ctx.send(embed=inventory_embed) else: - symbol = symbol_name.strip("`") + symbol = symbol_name.replace("`", "").split()[0] async with ctx.typing(): doc_embed = await self.create_symbol_embed(symbol) if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): - await ctx.message.delete() - with suppress(discord.NotFound): - await error_message.delete() + + # Make sure that we won't cause a ghost-ping by deleting the message + if not (ctx.message.mentions or ctx.message.role_mentions): + with suppress(discord.NotFound): + await ctx.message.delete() + await error_message.delete() + else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) + @staticmethod + def base_url_from_inventory_url(inventory_url: str) -> str: + """Get a base url from the url to an objects inventory by removing the last path segment.""" + return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/" + @docs_group.command(name="setdoc", aliases=("s",)) - @commands.has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES, Roles.core_developers) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def set_command( self, ctx: commands.Context, package_name: PackageName, - base_url: ValidURL, inventory: Inventory, + base_url: ValidURL = "", ) -> None: """ Adds a new documentation metadata object to the site's database. The database will update the object, should an existing item with the specified `package_name` already exist. + If the base url is not specified, a default created by removing the last segment of the inventory url is used. Example: !docs setdoc \ python \ - https://docs.python.org/3/ \ https://docs.python.org/3/objects.inv """ - if not base_url.endswith("/"): + if base_url and not base_url.endswith("/"): raise commands.BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { @@ -378,18 +377,27 @@ async def set_command( "base_url": base_url, "inventory_url": inventory_url } - await self.bot.api_client.post("bot/documentation-links", json=body) + try: + await self.bot.api_client.post("bot/documentation-links", json=body) + except ResponseCodeError as err: + if err.status == 400 and "already exists" in err.response_json.get("package", [""])[0]: + log.info(f"Ignoring HTTP 400 as package {package_name} has already been added.") + await ctx.send(f"Package {package_name} has already been added.") + return + raise log.info( f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" + "\n".join(f"{key}: {value}" for key, value in body.items()) ) + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(package_name, base_url, inventory_dict) await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @docs_group.command(name="deletedoc", aliases=("removedoc", "rm", "d")) - @commands.has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES, Roles.core_developers) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None: """ @@ -406,12 +414,12 @@ async def delete_command(self, ctx: commands.Context, package_name: PackageName) await ctx.send(f"Successfully deleted `{package_name}` and refreshed the inventories.") @docs_group.command(name="refreshdoc", aliases=("rfsh", "r")) - @commands.has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES, Roles.core_developers) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def refresh_command(self, ctx: commands.Context) -> None: """Refresh inventories and show the difference.""" old_inventories = set(self.base_urls) - with ctx.typing(): + async with ctx.typing(): await self.refresh_inventories() new_inventories = set(self.base_urls) @@ -428,20 +436,20 @@ async def refresh_command(self, ctx: commands.Context) -> None: await ctx.send(embed=embed) @docs_group.command(name="cleardoccache", aliases=("deletedoccache",)) - @commands.has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES, Roles.core_developers) async def clear_cache_command( self, ctx: commands.Context, - package_name: Union[PackageName, allowed_strings("*")] # noqa: F722 + package_name: PackageName | Literal["*"] ) -> None: """Clear the persistent redis cache for `package`.""" if await doc_cache.delete(package_name): + await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name) await ctx.send(f"Successfully cleared the cache for `{package_name}`.") else: await ctx.send("No keys matching the package found.") - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Clear scheduled inventories, queued symbols and cleanup task on cog unload.""" self.inventory_scheduler.cancel_all() - self.init_refresh_task.cancel() - asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") + await self.item_fetcher.clear() diff --git a/bot/exts/info/doc/_doc_item.py b/bot/exts/info/doc/_doc_item.py new file mode 100644 index 0000000000..40d341fee1 --- /dev/null +++ b/bot/exts/info/doc/_doc_item.py @@ -0,0 +1,25 @@ +from typing import NamedTuple + + +class DocItem(NamedTuple): + """Holds inventory symbol information.""" + + package: str + """Name of the package name the symbol is from""" + + group: str + """Interpshinx "role" of the symbol, for example `label` or `method`""" + + base_url: str + """Absolute path to to which the relative path resolves, same for all items with the same package""" + + relative_url_path: str + """Relative path to the page where the symbol is located""" + + symbol_id: str + """Fragment id used to locate the symbol on the page""" + + @property + def url(self) -> str: + """Return the absolute url to the symbol.""" + return self.base_url + self.relative_url_path diff --git a/bot/exts/info/doc/_html.py b/bot/exts/info/doc/_html.py index 94efd81b79..2ef1328f10 100644 --- a/bot/exts/info/doc/_html.py +++ b/bot/exts/info/doc/_html.py @@ -1,16 +1,15 @@ -import logging -import re +from collections.abc import Callable, Container, Iterable from functools import partial -from typing import Callable, Container, Iterable, List, Union from bs4 import BeautifulSoup from bs4.element import NavigableString, PageElement, SoupStrainer, Tag +from bot.log import get_logger + from . import MAX_SIGNATURE_AMOUNT -log = logging.getLogger(__name__) +log = get_logger(__name__) -_UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") _SEARCH_END_TAG_ATTRS = ( "data", "function", @@ -33,26 +32,26 @@ def __init__(self, *, include_strings: bool, **kwargs): log.warning("`text` is not a supported kwarg in the custom strainer.") super().__init__(**kwargs) - Markup = Union[PageElement, List["Markup"]] + Markup = PageElement | list["Markup"] - def search(self, markup: Markup) -> Union[PageElement, str]: + def search(self, markup: Markup) -> PageElement | str: """Extend default SoupStrainer behaviour to allow matching both `Tag`s` and `NavigableString`s.""" if isinstance(markup, str): # Let everything through the text filter if we're including strings and tags. if not self.name and not self.attrs and self.include_strings: return markup - else: - return super().search(markup) + return None + return super().search(markup) def _find_elements_until_tag( start_element: PageElement, - end_tag_filter: Union[Container[str], Callable[[Tag], bool]], + end_tag_filter: Container[str] | Callable[[Tag], bool], *, func: Callable, include_strings: bool = False, - limit: int = None, -) -> List[Union[Tag, NavigableString]]: + limit: int | None = None, +) -> list[Tag | NavigableString]: """ Get all elements up to `limit` or until a tag matching `end_tag_filter` is found. @@ -96,7 +95,7 @@ def match_tag(tag: Tag) -> bool: return match_tag -def get_general_description(start_element: Tag) -> List[Union[Tag, NavigableString]]: +def get_general_description(start_element: Tag) -> list[Tag | NavigableString]: """ Get page content to a table or a tag with its class in `SEARCH_END_TAG_ATTRS`. @@ -109,13 +108,13 @@ def get_general_description(start_element: Tag) -> List[Union[Tag, NavigableStri return _find_next_siblings_until_tag(start_tag, _class_filter_factory(_SEARCH_END_TAG_ATTRS), include_strings=True) -def get_dd_description(symbol: PageElement) -> List[Union[Tag, NavigableString]]: +def get_dd_description(symbol: PageElement) -> list[Tag | NavigableString]: """Get the contents of the next dd tag, up to a dt or a dl tag.""" description_tag = symbol.find_next("dd") return _find_next_children_until_tag(description_tag, ("dt", "dl"), include_strings=True) -def get_signatures(start_signature: PageElement) -> List[str]: +def get_signatures(start_signature: PageElement) -> list[str]: """ Collect up to `_MAX_SIGNATURE_AMOUNT` signatures from dt tags around the `start_signature` dt tag. @@ -128,9 +127,23 @@ def get_signatures(start_signature: PageElement) -> List[str]: start_signature, *_find_next_siblings_until_tag(start_signature, ("dd",), limit=2), )[-MAX_SIGNATURE_AMOUNT:]: - signature = _UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text) + for tag in element.find_all(_filter_signature_links, recursive=False): + tag.decompose() + signature = element.text if signature: signatures.append(signature) return signatures + + +def _filter_signature_links(tag: Tag) -> bool: + """Return True if `tag` is a headerlink, or a link to source code; False otherwise.""" + if tag.name == "a": + if "headerlink" in tag.get("class", ()): + return True + + if tag.find(class_="viewcode-link"): + return True + + return False diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py index 80d5841a04..33964be99e 100644 --- a/bot/exts/info/doc/_inventory_parser.py +++ b/bot/exts/info/doc/_inventory_parser.py @@ -1,19 +1,23 @@ -import logging import re import zlib from collections import defaultdict -from typing import AsyncIterator, DefaultDict, List, Optional, Tuple +from collections.abc import AsyncIterator import aiohttp import bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) FAILED_REQUEST_ATTEMPTS = 3 -_V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)') +_V2_LINE_RE = re.compile(r"(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)") -InventoryDict = DefaultDict[str, List[Tuple[str, str]]] +InventoryDict = defaultdict[str, list[tuple[str, str]]] + + +class InvalidHeaderError(Exception): + """Raised when an inventory file has an invalid header.""" class ZlibStreamReader: @@ -34,14 +38,14 @@ async def _read_compressed_chunks(self) -> AsyncIterator[bytes]: async def __aiter__(self) -> AsyncIterator[str]: """Yield lines of decompressed text.""" - buf = b'' + buf = b"" async for chunk in self._read_compressed_chunks(): buf += chunk - pos = buf.find(b'\n') + pos = buf.find(b"\n") while pos != -1: yield buf[:pos].decode() buf = buf[pos + 1:] - pos = buf.find(b'\n') + pos = buf.find(b"\n") async def _load_v1(stream: aiohttp.StreamReader) -> InventoryDict: @@ -65,6 +69,13 @@ async def _load_v2(stream: aiohttp.StreamReader) -> InventoryDict: async for line in ZlibStreamReader(stream): m = _V2_LINE_RE.match(line.rstrip()) + + # If we don't have a match, the package is probably doing something + # funky with new-lines and we can discount this line, it's likely a + # multi-line figure description or something similar. + if not m: + continue + name, type_, _prio, location, _dispname = m.groups() # ignore the parsed items we don't need if location.endswith("$"): location = location[:-1] + name @@ -80,22 +91,28 @@ async def _fetch_inventory(url: str) -> InventoryDict: stream = response.content inventory_header = (await stream.readline()).decode().rstrip() - inventory_version = int(inventory_header[-1:]) - await stream.readline() # skip project name - await stream.readline() # skip project version + try: + inventory_version = int(inventory_header[-1:]) + except ValueError: + raise InvalidHeaderError("Unable to convert inventory version header.") + + has_project_header = (await stream.readline()).startswith(b"# Project") + has_version_header = (await stream.readline()).startswith(b"# Version") + if not (has_project_header and has_version_header): + raise InvalidHeaderError("Inventory missing project or version header.") if inventory_version == 1: return await _load_v1(stream) - elif inventory_version == 2: + if inventory_version == 2: if b"zlib" not in await stream.readline(): - raise ValueError(f"Invalid inventory file at url {url}.") + raise InvalidHeaderError("'zlib' not found in header of compressed inventory.") return await _load_v2(stream) - raise ValueError(f"Invalid inventory file at url {url}.") + raise InvalidHeaderError("Incompatible inventory version.") -async def fetch_inventory(url: str) -> Optional[InventoryDict]: +async def fetch_inventory(url: str) -> InventoryDict | None: """ Get an inventory dict from `url`, retrying `FAILED_REQUEST_ATTEMPTS` times on errors. @@ -115,6 +132,8 @@ async def fetch_inventory(url: str) -> Optional[InventoryDict]: f"Failed to get inventory from {url}; " f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) + except InvalidHeaderError: + raise except Exception: log.exception( f"An unexpected error has occurred during fetching of {url}; " diff --git a/bot/exts/info/doc/_markdown.py b/bot/exts/info/doc/_markdown.py index 1b7d8232bb..fb0efe91df 100644 --- a/bot/exts/info/doc/_markdown.py +++ b/bot/exts/info/doc/_markdown.py @@ -1,17 +1,20 @@ from urllib.parse import urljoin +import markdownify from bs4.element import PageElement -from markdownify import MarkdownConverter -class DocMarkdownConverter(MarkdownConverter): +class DocMarkdownConverter(markdownify.MarkdownConverter): """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" def __init__(self, *, page_url: str, **options): - super().__init__(**options) + # Reflow text to avoid unwanted line breaks. + default_options = {"wrap": True, "wrap_width": None} + + super().__init__(**default_options | options) self.page_url = page_url - def convert_li(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_li(self, el: PageElement, text: str, parent_tags: set[str]) -> str: """Fix markdownify's erroneous indexing in ol tags.""" parent = el.parent if parent is not None and parent.name == "ol": @@ -27,32 +30,38 @@ def convert_li(self, el: PageElement, text: str, convert_as_inline: bool) -> str bullet = bullets[depth % len(bullets)] return f"{bullet} {text}\n" - def convert_hn(self, _n: int, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_hN(self, _n: int, el: PageElement, text: str, parent_tags: set[str]) -> str: # noqa: N802 """Convert h tags to bold text with ** instead of adding #.""" - if convert_as_inline: + if "_inline" in parent_tags: return text return f"**{text}**\n\n" - def convert_code(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_code(self, el: PageElement, text: str, parent_tags: set[str]) -> str: """Undo `markdownify`s underscore escaping.""" return f"`{text}`".replace("\\", "") - def convert_pre(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_pre(self, el: PageElement, text: str, parent_tags: set[str]) -> str: """Wrap any codeblocks in `py` for syntax highlighting.""" code = "".join(el.strings) return f"```py\n{code}```" - def convert_a(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_a(self, el: PageElement, text: str, parent_tags: set[str]) -> str: """Resolve relative URLs to `self.page_url`.""" el["href"] = urljoin(self.page_url, el["href"]) - return super().convert_a(el, text, convert_as_inline) + # Discord doesn't handle titles properly, showing links with them as raw text. + el["title"] = None + return super().convert_a(el, text, parent_tags) - def convert_p(self, el: PageElement, text: str, convert_as_inline: bool) -> str: + def convert_p(self, el: PageElement, text: str, parent_tags: set[str]) -> str: """Include only one newline instead of two when the parent is a li tag.""" - if convert_as_inline: + if "_inline" in parent_tags: return text parent = el.parent if parent is not None and parent.name == "li": return f"{text}\n" - return super().convert_p(el, text, convert_as_inline) + return super().convert_p(el, text, parent_tags) + + def convert_hr(self, el: PageElement, text: str, parent_tags: set[str]) -> str: + """Ignore `hr` tag.""" + return "" diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index bf840b96f1..b64f659671 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -1,23 +1,24 @@ -from __future__ import annotations - -import logging import re import string import textwrap from collections import namedtuple -from typing import Collection, Iterable, Iterator, List, Optional, TYPE_CHECKING, Union +from collections.abc import Collection, Iterable, Iterator +from typing import TYPE_CHECKING from bs4 import BeautifulSoup from bs4.element import NavigableString, Tag +from bot.log import get_logger from bot.utils.helpers import find_nth_occurrence + from . import MAX_SIGNATURE_AMOUNT from ._html import get_dd_description, get_general_description, get_signatures from ._markdown import DocMarkdownConverter + if TYPE_CHECKING: from ._cog import DocItem -log = logging.getLogger(__name__) +log = get_logger(__name__) _WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") _PARAMETERS_RE = re.compile(r"\((.+)\)") @@ -34,7 +35,7 @@ # _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight _MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LINE_LENGTH + 8) * MAX_SIGNATURE_AMOUNT # Maximum embed description length - signatures on top -_MAX_DESCRIPTION_LENGTH = 2048 - _MAX_SIGNATURES_LENGTH +_MAX_DESCRIPTION_LENGTH = 4096 - _MAX_SIGNATURES_LENGTH _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace BracketPair = namedtuple("BracketPair", ["opening_bracket", "closing_bracket"]) @@ -54,7 +55,7 @@ def _split_parameters(parameters_string: str) -> Iterator[str]: """ last_split = 0 depth = 0 - current_search: Optional[BracketPair] = None + current_search: BracketPair | None = None enumerated_string = enumerate(parameters_string) for index, character in enumerated_string: @@ -90,7 +91,7 @@ def _split_parameters(parameters_string: str) -> Iterator[str]: yield parameters_string[last_split:] -def _truncate_signatures(signatures: Collection[str]) -> Union[List[str], Collection[str]]: +def _truncate_signatures(signatures: Collection[str]) -> list[str] | Collection[str]: """ Truncate passed signatures to not exceed `_MAX_SIGNATURES_LENGTH`. @@ -134,7 +135,7 @@ def _truncate_signatures(signatures: Collection[str]) -> Union[List[str], Collec def _get_truncated_description( - elements: Iterable[Union[Tag, NavigableString]], + elements: Iterable[Tag | NavigableString], markdown_converter: DocMarkdownConverter, max_length: int, max_lines: int, @@ -156,7 +157,7 @@ def _get_truncated_description( if rendered_length + element_length < max_length: if is_tag: - element_markdown = markdown_converter.process_tag(element, convert_as_inline=False) + element_markdown = markdown_converter.process_tag(element) else: element_markdown = markdown_converter.process_text(element) @@ -183,7 +184,7 @@ def _get_truncated_description( # Nothing needs to be truncated if the last element ends before the truncation index. if truncate_index >= markdown_element_ends[-1]: - return result + return result.strip(string.whitespace) # Determine the actual truncation index. possible_truncation_indices = [cut for cut in markdown_element_ends if cut < truncate_index] @@ -211,7 +212,7 @@ def _get_truncated_description( return truncated_result.strip(_TRUNCATE_STRIP_CHARACTERS) + "..." -def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag], url: str) -> str: +def _create_markdown(signatures: list[str] | None, description: Iterable[Tag], url: str) -> str: """ Create a Markdown string with the signatures at the top, and the converted html description below them. @@ -228,11 +229,10 @@ def _create_markdown(signatures: Optional[List[str]], description: Iterable[Tag] if signatures is not None: signature = "".join(f"```py\n{signature}```" for signature in _truncate_signatures(signatures)) return f"{signature}\n{description}" - else: - return description + return description -def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> Optional[str]: +def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> str | None: """ Return parsed Markdown of the passed item using the passed in soup, truncated to fit within a discord message. @@ -253,4 +253,10 @@ def get_symbol_markdown(soup: BeautifulSoup, symbol_data: DocItem) -> Optional[s else: signature = get_signatures(symbol_heading) description = get_dd_description(symbol_heading) - return _create_markdown(signature, description, symbol_data.url).replace("¶", "").strip() + + for description_element in description: + if isinstance(description_element, Tag): + for tag in description_element.find_all("a", class_="headerlink"): + tag.decompose() + + return _create_markdown(signature, description, symbol_data.url).strip() diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index ad764816f0..95003dab29 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -1,13 +1,23 @@ -from __future__ import annotations - import datetime -from typing import Optional, TYPE_CHECKING +import fnmatch +import time + +from async_rediscache.types.base import RedisObject + +from bot.log import get_logger +from bot.utils.lock import lock + +from ._doc_item import DocItem + +WEEK_SECONDS = int(datetime.timedelta(weeks=1).total_seconds()) -from async_rediscache.types.base import RedisObject, namespace_lock -if TYPE_CHECKING: - from ._cog import DocItem +log = get_logger(__name__) -WEEK_SECONDS = datetime.timedelta(weeks=1).total_seconds() + +def serialize_resource_id_from_doc_item(bound_args: dict) -> str: + """Return the redis_key of the DocItem `item` from the bound args of DocRedisCache.set.""" + item: DocItem = bound_args["item"] + return f"doc:{item_key(item)}" class DocRedisCache(RedisObject): @@ -15,56 +25,89 @@ class DocRedisCache(RedisObject): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._set_expires = set() + self._set_expires = dict[str, float]() - @namespace_lock + @lock("DocRedisCache.set", serialize_resource_id_from_doc_item, wait=True) async def set(self, item: DocItem, value: str) -> None: """ Set the Markdown `value` for the symbol `item`. All keys from a single page are stored together, expiring a week after the first set. """ - url_key = remove_suffix(item.relative_url_path, ".html") - redis_key = f"{self.namespace}:{item.package}:{url_key}" + redis_key = f"{self.namespace}:{item_key(item)}" needs_expire = False - with await self._get_pool_connection() as connection: - if redis_key not in self._set_expires: - # An expire is only set if the key didn't exist before. - # If this is the first time setting values for this key check if it exists and add it to - # `_set_expires` to prevent redundant checks for subsequent uses with items from the same page. - self._set_expires.add(redis_key) - needs_expire = not await connection.exists(redis_key) + set_expire = self._set_expires.get(redis_key) + if set_expire is None: + # An expire is only set if the key didn't exist before. + ttl = await self.redis_session.client.ttl(redis_key) + log.debug(f"Checked TTL for `{redis_key}`.") + + if ttl == -1: + log.warning(f"Key `{redis_key}` had no expire set.") + if ttl < 0: # not set or didn't exist + needs_expire = True + else: + log.debug(f"Key `{redis_key}` has a {ttl} TTL.") + self._set_expires[redis_key] = time.monotonic() + ttl - .1 # we need this to expire before redis + + elif time.monotonic() > set_expire: + # If we got here the key expired in redis and we can be sure it doesn't exist. + needs_expire = True + log.debug(f"Key `{redis_key}` expired in internal key cache.") - await connection.hset(redis_key, item.symbol_id, value) - if needs_expire: - await connection.expire(redis_key, WEEK_SECONDS) + await self.redis_session.client.hset(redis_key, item.symbol_id, value) + if needs_expire: + self._set_expires[redis_key] = time.monotonic() + WEEK_SECONDS + await self.redis_session.client.expire(redis_key, WEEK_SECONDS) + log.info(f"Set {redis_key} to expire in a week.") - @namespace_lock - async def get(self, item: DocItem) -> Optional[str]: + async def get(self, item: DocItem) -> str | None: """Return the Markdown content of the symbol `item` if it exists.""" - url_key = remove_suffix(item.relative_url_path, ".html") + return await self.redis_session.client.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id) - with await self._get_pool_connection() as connection: - return await connection.hget(f"{self.namespace}:{item.package}:{url_key}", item.symbol_id, encoding="utf8") + async def delete(self, package: str) -> bool: + """Remove all values for `package`; return True if at least one key was deleted, False otherwise.""" + pattern = f"{self.namespace}:{package}:*" + + package_keys = [ + package_key async for package_key in self.redis_session.client.scan_iter(match=pattern) + ] + if package_keys: + await self.redis_session.client.delete(*package_keys) + log.info(f"Deleted keys from redis: {package_keys}.") + self._set_expires = { + key: expire for key, expire in self._set_expires.items() if not fnmatch.fnmatchcase(key, pattern) + } + return True + return False + + +class StaleItemCounter(RedisObject): + """Manage increment counters for stale `DocItem`s.""" + + async def increment_for(self, item: DocItem) -> int: + """ + Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value. + + If the counter didn't exist, initialize it with 1. + """ + key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}" + await self.redis_session.client.expire(key, WEEK_SECONDS * 3) + return int(await self.redis_session.client.incr(key)) - @namespace_lock async def delete(self, package: str) -> bool: """Remove all values for `package`; return True if at least one key was deleted, False otherwise.""" - with await self._get_pool_connection() as connection: - package_keys = [ - package_key async for package_key in connection.iscan(match=f"{self.namespace}:{package}:*") - ] - if package_keys: - await connection.delete(*package_keys) - return True - return False - - -def remove_suffix(string: str, suffix: str) -> str: - """Remove `suffix` from end of `string`.""" - # TODO replace usages with str.removesuffix on 3.9 - if string.endswith(suffix): - return string[:-len(suffix)] - else: - return string + package_keys = [ + package_key + async for package_key in self.redis_session.client.scan_iter(match=f"{self.namespace}:{package}:*") + ] + if package_keys: + await self.redis_session.client.delete(*package_keys) + return True + return False + + +def item_key(item: DocItem) -> str: + """Get the redis redis key string from `item`.""" + return f"{item.package}:{item.relative_url_path.removesuffix('.html')}" diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 3a05b2c8a8..b5f46bdb1a 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,21 +1,21 @@ import itertools -import logging +import re from collections import namedtuple from contextlib import suppress -from typing import List, Union -from discord import Colour, Embed +from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process +from rapidfuzz import fuzz, process +from rapidfuzz.utils import default_process from bot import constants -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, STAFF_AND_COMMUNITY_ROLES from bot.decorators import redirect_output +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) COMMANDS_PER_PAGE = 8 PREFIX = constants.Bot.prefix @@ -25,7 +25,129 @@ Category = namedtuple("Category", ["name", "description", "cogs"]) -class HelpQueryNotFound(ValueError): +class SubcommandButton(ui.Button): + """ + A button shown in a group's help embed. + + The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.primary, + label: str | None = None, + disabled: bool = False, + custom_id: str | None = None, + url: str | None = None, + emoji: str | Emoji | PartialEmoji | None = None, + row: int | None = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the subcommand.""" + subcommand = self.command + if isinstance(subcommand, Group): + embed, subcommand_view = await self.help_command.format_group_help(subcommand) + else: + embed, subcommand_view = await self.help_command.command_formatting(subcommand) + + await interaction.response.edit_message(embed=embed, view=subcommand_view) + + +class GroupButton(ui.Button): + """ + A button shown in a subcommand's help embed. + + The button represents the parent command, and pressing it will edit the help embed to that of the parent. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.secondary, + label: str | None = None, + disabled: bool = False, + custom_id: str | None = None, + url: str | None = None, + emoji: str | Emoji | PartialEmoji | None = None, + row: int | None = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the parent.""" + embed, group_view = await self.help_command.format_group_help(self.command.parent) + await interaction.response.edit_message(embed=embed, view=group_view) + + +class CommandView(ui.View): + """ + The view added to any command's help embed. + + If the command has a parent, a button is added to the view to show that parent's help embed. + """ + + def __init__(self, help_command: CustomHelpCommand, command: Command, context: Context): + self.context = context + super().__init__() + + if command.parent: + self.add_item(GroupButton(help_command, command, emoji="↩️")) + + async def interaction_check(self, interaction: Interaction) -> bool: + """ + Ensures that the button only works for the user who spawned the help command. + + Also allows moderators to access buttons even when not the author of message. + """ + if interaction.user is not None: + if any(role.id in constants.MODERATION_ROLES for role in interaction.user.roles): + return True + + if interaction.user.id == self.context.author.id: + return True + + return False + + +class GroupView(CommandView): + """ + The view added to a group's help embed. + + The view generates a SubcommandButton for every subcommand the group has. + """ + + MAX_BUTTONS_IN_ROW = 5 + MAX_ROWS = 5 + + def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command], context: Context): + super().__init__(help_command, group, context) + # Don't add buttons if only a portion of the subcommands can be shown. + if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW: + log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.") + return + + for subcommand in subcommands: + self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name)) + + +class HelpQueryNotFoundError(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -35,7 +157,7 @@ class HelpQueryNotFound(ValueError): query, where keys are the possible matched command names and values are the likeness match scores. """ - def __init__(self, arg: str, possible_matches: dict = None): + def __init__(self, arg: str, possible_matches: dict | None = None): super().__init__(arg) self.possible_matches = possible_matches @@ -54,8 +176,8 @@ class CustomHelpCommand(HelpCommand): def __init__(self): super().__init__(command_attrs={"help": "Shows help for bot commands"}) - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) - async def command_callback(self, ctx: Context, *, command: str = None) -> None: + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_AND_COMMUNITY_ROLES) + async def command_callback(self, ctx: Context, *, command: str | None = None) -> None: """Attempts to match the provided query with a valid command or cog.""" # the only reason we need to tamper with this is because d.py does not support "categories", # so we need to deal with them ourselves. @@ -119,24 +241,22 @@ async def get_all_help_choices(self) -> set: choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category")) return choices - async def command_not_found(self, string: str) -> "HelpQueryNotFound": + async def command_not_found(self, query: str) -> HelpQueryNotFoundError: """ Handles when a query does not match a valid command, group, cog or category. Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ - choices = await self.get_all_help_choices() + choices = list(await self.get_all_help_choices()) + result = process.extract(default_process(query), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] + # Trim query to avoid embed limits when sending the error. + if len(query) >= 100: + query = query[:100] + "..." - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + return HelpQueryNotFoundError(f'Query "{query}" not found.', {choice[0]: choice[1] for choice in result}) - async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": + async def subcommand_not_found(self, command: Command, string: str) -> HelpQueryNotFoundError: """ Redirects the error to `command_not_found`. @@ -144,7 +264,7 @@ async def subcommand_not_found(self, command: Command, string: str) -> "HelpQuer """ return await self.command_not_found(f"{command.qualified_name} {string}") - async def send_error_message(self, error: HelpQueryNotFound) -> None: + async def send_error_message(self, error: HelpQueryNotFoundError) -> None: """Send the error message to the channel.""" embed = Embed(colour=Colour.red(), title=str(error)) @@ -154,7 +274,7 @@ async def send_error_message(self, error: HelpQueryNotFound) -> None: await self.context.send(embed=embed) - async def command_formatting(self, command: Command) -> Embed: + async def command_formatting(self, command: Command) -> tuple[Embed, CommandView | None]: """ Takes a command and turns it into an embed. @@ -186,19 +306,24 @@ async def command_formatting(self, command: Command) -> Embed: except CommandError: command_details += NOT_ALLOWED_TO_RUN_MESSAGE - command_details += f"*{command.help or 'No details provided.'}*\n" + # Remove line breaks from docstrings, if not used to separate paragraphs. + # Allow overriding this behaviour via putting \u2003 at the start of a line. + formatted_doc = re.sub("(? None: """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) + embed, view = await self.command_formatting(command) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) @staticmethod - def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: + def get_commands_brief_details(commands_: list[Command], return_as_list: bool = False) -> list[str] | str: """ Formats the prefix, command name and signature, and short doc for an iterable of commands. @@ -208,32 +333,37 @@ def get_commands_brief_details(commands_: List[Command], return_as_list: bool = for command in commands_: signature = f" {command.signature}" if command.signature else "" details.append( - f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*" + f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n{command.short_doc or 'No details provided'}" ) if return_as_list: return details - else: - return "".join(details) + return "".join(details) - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" + async def format_group_help(self, group: Group) -> tuple[Embed, CommandView | None]: + """Formats help for a group command.""" subcommands = group.commands if len(subcommands) == 0: # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return + return await self.command_formatting(group) # remove commands that the user can't run and are hidden, and sort by name commands_ = await self.filter_commands(subcommands, sort=True) - embed = await self.command_formatting(group) + embed, _ = await self.command_formatting(group) command_details = self.get_commands_brief_details(commands_) if command_details: embed.description += f"\n**Subcommands:**\n{command_details}" - message = await self.context.send(embed=embed) + # If the help is invoked in the context of an error, don't show subcommand navigation. + view = GroupView(self, group, commands_, self.context) if not self.context.command_failed else None + return embed, view + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + embed, view = await self.format_group_help(group) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) async def send_cog_help(self, cog: Cog) -> None: @@ -243,7 +373,7 @@ async def send_cog_help(self, cog: Cog) -> None: embed = Embed() embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark) - embed.description = f"**{cog.qualified_name}**\n*{cog.description}*" + embed.description = f"**{cog.qualified_name}**\n{cog.description}" command_details = self.get_commands_brief_details(commands_) if command_details: @@ -264,8 +394,7 @@ def _category_key(command: Command) -> str: if command.cog.category: return f"**{command.cog.category}**" return f"**{command.cog_name}**" - else: - return "**\u200bNo Category:**" + return "**\u200bNo Category:**" async def send_category_help(self, category: Category) -> None: """ @@ -283,7 +412,7 @@ async def send_category_help(self, category: Category) -> None: filtered_commands = await self.filter_commands(all_commands, sort=True) command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True) - description = f"**{category.name}**\n*{category.description}*" + description = f"**{category.name}**\n{category.description}" if command_detail_lines: description += "\n\n**Commands:**" @@ -353,12 +482,12 @@ def __init__(self, bot: Bot) -> None: bot.help_command = CustomHelpCommand() bot.help_command.cog = self - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Reset the help command when the cog is unloaded.""" self.bot.help_command = self.old_help_command -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Help cog.""" - bot.add_cog(Help(bot)) + await bot.add_cog(Help(bot)) log.info("Cog loaded: Help") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 834fee1b41..6d8b4f8758 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -1,25 +1,45 @@ import colorsys -import logging import pprint import textwrap from collections import defaultdict -from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union +from collections.abc import Mapping +from textwrap import shorten +from typing import Any, TYPE_CHECKING -import fuzzywuzzy +import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role +from discord.ext.commands import BucketType, Cog, Context, command, group, has_any_role +from discord.utils import escape_markdown +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel +from pydis_core.utils.members import get_or_fetch_member +from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service from bot import constants -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.converters import FetchedMember +from bot.constants import BaseURLs, Emojis +from bot.converters import MemberOrUser from bot.decorators import in_whitelist +from bot.errors import NonExistentRoleError +from bot.log import get_logger from bot.pagination import LinePaginator +from bot.utils import time from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.time import humanize_delta, time_since +from bot.utils.messages import send_denial -log = logging.getLogger(__name__) +log = get_logger(__name__) + +DEFAULT_RULES_DESCRIPTION = ( + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" + " all members of the community to have read and understood these." +) + +if TYPE_CHECKING: + from bot.exts.moderation.defcon import Defcon + from bot.exts.moderation.watchchannels.bigbrother import BigBrother + from bot.exts.recruitment.talentpool._cog import TalentPool class Information(Cog): @@ -29,7 +49,7 @@ def __init__(self, bot: Bot): self.bot = bot @staticmethod - def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]: + def get_channel_type_counts(guild: Guild) -> defaultdict[str, int]: """Return the total amounts of the various types of channels in `guild`.""" channel_counter = defaultdict(int) @@ -42,29 +62,47 @@ def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]: return channel_counter @staticmethod - def get_member_counts(guild: Guild) -> Dict[str, int]: + def join_role_stats(role_ids: list[int], guild: Guild, name: str | None = None) -> dict[str, int]: + """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" + member_count = 0 + for role_id in role_ids: + if (role := guild.get_role(role_id)) is not None: + member_count += len(role.members) + else: + raise NonExistentRoleError(role_id) + return {name or role.name.title(): member_count} + + @staticmethod + def get_member_counts(guild: Guild) -> dict[str, int]: """Return the total number of members for certain roles in `guild`.""" - roles = ( - guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, - constants.Roles.owners, constants.Roles.contributors, - ) + role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, + constants.Roles.owners, constants.Roles.contributors] + + role_stats = {} + for role_id in role_ids: + role_stats.update(Information.join_role_stats([role_id], guild)) + role_stats.update( + Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], guild, "Leads") ) - return {role.name.title(): len(role.members) for role in roles} + return role_stats - def get_extended_server_info(self, ctx: Context) -> str: + async def get_extended_server_info(self, ctx: Context) -> str: """Return additional server info only visible in moderation channels.""" talentpool_info = "" - if cog := self.bot.get_cog("Talentpool"): - talentpool_info = f"Nominated: {len(cog.watched_users)}\n" + talentpool_cog: TalentPool | None = self.bot.get_cog("Talentpool") + if talentpool_cog: + num_nominated = len(await talentpool_cog.api.get_nominations(active=True)) + talentpool_info = f"Nominated: {num_nominated}\n" bb_info = "" - if cog := self.bot.get_cog("Big Brother"): - bb_info = f"BB-watched: {len(cog.watched_users)}\n" + bb_cog: BigBrother | None = self.bot.get_cog("Big Brother") + if bb_cog: + bb_info = f"BB-watched: {len(bb_cog.watched_users)}\n" defcon_info = "" - if cog := self.bot.get_cog("Defcon"): - threshold = humanize_delta(cog.threshold) if cog.threshold else "-" + defcon_cog: Defcon | None = self.bot.get_cog("Defcon") + if defcon_cog: + threshold = time.humanize_delta(defcon_cog.threshold) if defcon_cog.threshold else "-" defcon_info = f"Defcon threshold: {threshold}\n" verification = f"Verification level: {ctx.guild.verification_level.name}\n" @@ -79,7 +117,7 @@ def get_extended_server_info(self, ctx: Context) -> str: {python_general.mention} cooldown: {python_general.slowmode_delay}s """) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_AND_COMMUNITY_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -94,14 +132,14 @@ async def roles_info(self, ctx: Context) -> None: # Build an embed embed = Embed( title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", - colour=Colour.blurple() + colour=Colour.og_blurple() ) await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_AND_COMMUNITY_ROLES) @command(name="role") - async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: + async def role_info(self, ctx: Context, *roles: Role | str) -> None: """ Return information on a role or list of roles. @@ -117,9 +155,9 @@ async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: parsed_roles.add(role_name) continue - match = fuzzywuzzy.process.extractOne( + match = rapidfuzz.process.extractOne( role_name, all_roles, score_cutoff=80, - scorer=fuzzywuzzy.fuzz.ratio + scorer=rapidfuzz.fuzz.ratio ) if not match: @@ -152,15 +190,15 @@ async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: @command(name="server", aliases=["server_info", "guild", "guild_info"]) async def server_info(self, ctx: Context) -> None: """Returns an embed full of server information.""" - embed = Embed(colour=Colour.blurple(), title="Server Information") + embed = Embed(colour=Colour.og_blurple(), title="Server Information") - created = time_since(ctx.guild.created_at, precision="days") - region = ctx.guild.region + created = time.format_relative(ctx.guild.created_at) num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone # Server Features are only useful in certain channels if ctx.channel.id in ( - *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib + *constants.MODERATION_CHANNELS, + constants.Channels.dev_core, ): features = f"\nFeatures: {', '.join(ctx.guild.features)}" else: @@ -171,21 +209,20 @@ async def server_info(self, ctx: Context) -> None: online_presences = py_invite.approximate_presence_count offline_presences = py_invite.approximate_member_count - online_presences member_status = ( - f"{constants.Emojis.status_online} {online_presences} " - f"{constants.Emojis.status_offline} {offline_presences}" + f"{constants.Emojis.status_online} {online_presences:,} " + f"{constants.Emojis.status_offline} {offline_presences:,}" ) - embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region}\ - {features} - Roles: {num_roles} - Member status: {member_status} - """) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.description = ( + f"Created: {created}" + f"{features}" + f"\nRoles: {num_roles}" + f"\nMember status: {member_status}" + ) + embed.set_thumbnail(url=ctx.guild.icon.url) # Members - total_members = ctx.guild.member_count + total_members = f"{ctx.guild.member_count:,}" member_counts = self.get_member_counts(ctx.guild) member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) embed.add_field(name=f"Members: {total_members}", value=member_info) @@ -200,13 +237,18 @@ async def server_info(self, ctx: Context) -> None: # Additional info if ran in moderation channels if is_mod_channel(ctx.channel): - embed.add_field(name="Moderation:", value=self.get_extended_server_info(ctx)) + embed.add_field(name="Moderation:", value=await self.get_extended_server_info(ctx)) await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info", "u"]) - async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: + async def user_info(self, ctx: Context, user_or_message: MemberOrUser | Message = None) -> None: """Returns info about a user.""" + if passed_as_message := isinstance(user_or_message, Message): + user = user_or_message.author + else: + user = user_or_message + if user is None: user = ctx.author @@ -216,19 +258,23 @@ async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: return # Will redirect to #bot-commands if it fails. - if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): - embed = await self.create_user_embed(ctx, user) + if in_whitelist_check(ctx, roles=constants.STAFF_AND_COMMUNITY_ROLES): + embed = await self.create_user_embed(ctx, user, passed_as_message) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: + async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_message: bool) -> Embed: """Creates an embed containing information on the `user`.""" - on_server = bool(ctx.guild.get_member(user.id)) + on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) - created = time_since(user.created_at, max_units=3) + created = time.format_relative(user.created_at) name = str(user) if on_server and user.nick: name = f"{user.nick} ({name})" + name = escape_markdown(name) + + if passed_as_message: + name += " - From Message" if user.public_flags.verified_bot: name += f" {constants.Emojis.verified_bot}" @@ -241,18 +287,21 @@ async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) - activity = await self.user_messages(user) - if on_server: - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) + if user.joined_at: + joined = time.format_relative(user.joined_at) + else: + joined = "Unable to get join date" + + # The 0 is for excluding the default @everyone role, + # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. + roles = ", ".join(role.mention for role in user.roles[:0:-1]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): membership.pop("Verified") membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: - roles = None membership = "The user is not a member of the server" fields = [ @@ -268,14 +317,14 @@ async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: "Member information", membership ), + await self.user_messages(user), ] # Show more verbose output in moderation channels for infractions and nominations if is_mod_channel(ctx.channel): - fields.append(activity) - fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) + fields.append(await self.user_alt_count(user)) else: fields.append(await self.basic_user_infraction_counts(user)) @@ -288,29 +337,43 @@ async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: for field_name, field_content in fields: embed.add_field(name=field_name, value=field_content, inline=False) - embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) - embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple() + embed.set_thumbnail(url=user.display_avatar.url) + embed.colour = user.colour if user.colour != Colour.default() else Colour.og_blurple() return embed - async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def user_alt_count(self, user: MemberOrUser) -> tuple[str, int | str]: + """Get the number of alts for the given member.""" + try: + resp = await self.bot.api_client.get(f"bot/users/{user.id}") + return ("Associated accounts", len(resp["alts"]) or "No associated accounts") + except ResponseCodeError as e: + # If user is not found, return a soft-error regarding this. + if e.response.status == 404: + return ("Associated accounts", "User not found in site database.") + + # If we have any other issue, re-raise the exception + raise e + + + async def basic_user_infraction_counts(self, user: MemberOrUser) -> tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'hidden': 'False', - 'user__id': str(user.id) + "hidden": "False", + "user__id": str(user.id) } ) total_infractions = len(infractions) - active_infractions = sum(infraction['active'] for infraction in infractions) + active_infractions = sum(infraction["active"] for infraction in infractions) infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: MemberOrUser) -> tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -318,9 +381,9 @@ async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[st in the output as well. """ infractions = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'user__id': str(user.id) + "user__id": str(user.id) } ) @@ -333,7 +396,7 @@ async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[st infraction_counter = defaultdict(int) for infraction in infractions: infraction_type = infraction["type"] - infraction_active = 'active' if infraction["active"] else 'inactive' + infraction_active = "active" if infraction["active"] else "inactive" infraction_types.add(infraction_type) infraction_counter[f"{infraction_active} {infraction_type}"] += 1 @@ -351,12 +414,12 @@ async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[st return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def user_nomination_counts(self, user: MemberOrUser) -> tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( - 'bot/nominations', + "bot/nominations", params={ - 'user__id': str(user.id) + "user__id": str(user.id) } ) @@ -376,7 +439,7 @@ async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: return "Nominations", "\n".join(output) - async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: + async def user_messages(self, user: MemberOrUser) -> tuple[str, str]: """ Gets the amount of messages for `member`. @@ -391,16 +454,23 @@ async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tu if e.status == 404: activity_output = "No activity" else: - activity_output.append(user_activity["total_messages"] or "No messages") - activity_output.append(user_activity["activity_blocks"] or "No activity") + total_message_text = ( + f"{user_activity['total_messages']:,}" if user_activity["total_messages"] else "No messages" + ) + activity_blocks_text = ( + f"{user_activity['activity_blocks']:,}" if user_activity["activity_blocks"] else "No activity" + ) + activity_output.append(total_message_text) + activity_output.append(activity_blocks_text) activity_output = "\n".join( - f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) + f"{name}: {metric}" + for name, metric in zip(["Messages", "Activity blocks"], activity_output, strict=True) ) - return ("Activity", activity_output) + return "Activity", activity_output - def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: + def format_fields(self, mapping: Mapping[str, Any], field_width: int | None = None) -> str: """Format a mapping to be readable to a human.""" # sorting is technically superfluous but nice if you want to look for a specific field fields = sorted(mapping.items(), key=lambda item: item[0]) @@ -408,39 +478,40 @@ def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = if field_width is None: field_width = len(max(mapping.keys(), key=len)) - out = '' + out = "" for key, val in fields: if isinstance(val, dict): # if we have dicts inside dicts we want to apply the same treatment to the inner dictionaries inner_width = int(field_width * 1.6) - val = '\n' + self.format_fields(val, field_width=inner_width) + val = "\n" + self.format_fields(val, field_width=inner_width) elif isinstance(val, str): # split up text since it might be long text = textwrap.fill(val, width=100, replace_whitespace=False) # indent it, I guess you could do this with `wrap` and `join` but this is nicer - val = textwrap.indent(text, ' ' * (field_width + len(': '))) + val = textwrap.indent(text, " " * (field_width + len(": "))) # the first line is already indented so we `str.lstrip` it val = val.lstrip() - if key == 'color': + if key == "color": # makes the base 10 representation of a hex number readable to humans val = hex(val) - out += '{0:>{width}}: {1}\n'.format(key, val, width=field_width) + out += "{0:>{width}}: {1}\n".format(key, val, width=field_width) # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" - if ctx.author not in message.channel.members: + async def send_raw_content(self, ctx: Context, message: Message, json: bool = False) -> None: + """ + Send information about the raw API response for a `discord.Message`. + + If `json` is True, send the information in a copy-pasteable Python format. + """ + if not message.channel.permissions_for(ctx.author).read_messages: await ctx.send(":x: You do not have permissions to see the channel this message is in.") return @@ -448,20 +519,19 @@ async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> No # doing this extra request is also much easier than trying to convert everything back into a dictionary again raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) - paginator = Paginator() + lines = [] def add_content(title: str, content: str) -> None: - paginator.add_line(f'== {title} ==\n') + lines.append(f"== {title} ==\n") # Replace backticks as it breaks out of code blocks. - # An invisible character seemed to be the most reasonable solution. We hope it's not close to 2000. - paginator.add_line(content.replace('`', '`\u200b')) - paginator.close_page() + # An invisible character seemed to be the most reasonable solution. + lines.append(content.replace("`", "`\u200b")) if message.content: - add_content('Raw message', message.content) + add_content("Raw message", message.content) transformer = pprint.pformat if json else self.format_fields - for field_name in ('embeds', 'attachments'): + for field_name in ("embeds", "attachments"): data = raw_data[field_name] if not data: @@ -469,18 +539,172 @@ def add_content(title: str, content: str) -> None: total = len(data) for current, item in enumerate(data, start=1): - title = f'Raw {field_name} ({current}/{total})' + title = f"Raw {field_name} ({current}/{total})" add_content(title, transformer(item)) - for page in paginator.pages: - await ctx.send(page, allowed_mentions=AllowedMentions.none()) + output = "\n".join(lines) + if len(output) < 2000-8: # To cover the backticks and newlines added below. + await ctx.send(f"```\n{output}\n```", allowed_mentions=AllowedMentions.none()) + return + + file = PasteFile(content=output, lexer="text") + try: + resp = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, + ) + message = f"Message was too long for Discord, posted the output to [our pastebin]({resp.link})." + except PasteTooLongError: + message = f"{Emojis.cross_mark} Too long to upload to paste service." + except PasteUploadError: + message = f"{Emojis.cross_mark} Failed to upload to paste service." + + await ctx.send(message) + + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_AND_COMMUNITY_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_AND_COMMUNITY_ROLES) + async def raw(self, ctx: Context, message: Message | None = None) -> None: + """Shows information about the raw API response.""" + if message is None: + if (reference := ctx.message.reference) and isinstance(reference.resolved, Message): + message = reference.resolved + else: + await send_denial( + ctx, "Missing message argument. Please provide a message ID/link or reply to a message." + ) + return + + await self.send_raw_content(ctx, message) @raw.command() - async def json(self, ctx: Context, message: Message) -> None: + async def json(self, ctx: Context, message: Message | None = None) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) + if message is None: + if (reference := ctx.message.reference) and isinstance(reference.resolved, Message): + message = reference.resolved + else: + await send_denial( + ctx, "Missing message argument. Please provide a message ID/link or reply to a message." + ) + return + + await self.send_raw_content(ctx, message, json=True) + + async def _set_rules_command_help(self) -> None: + help_string = f"{self.rules.help}\n\n" + help_string += "__Available keywords per rule__:\n\n" + + full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + + for index, (_, keywords) in enumerate(full_rules, start=1): + help_string += f"**Rule {index}**: {', '.join(keywords)}\n\r" + + self.rules.help = help_string + + async def _send_rules_alert(self, ctx: Context, requested_rules: list[int]) -> None: + """Send an alert to the Rule Alerts thread when a non-staff member uses the rules command.""" + rules_thread = await get_or_fetch_channel(self.bot, constants.Channels.rule_alerts) + + if rules_thread is None: + log.error("Failed to find the rules alert thread channel.") + return + + rule_desc = None + + if len(requested_rules) == 1: + rule_desc = f"Rule **{requested_rules[0]}** was requested by {ctx.author.mention} in {ctx.channel.mention}." + elif len(requested_rules) > 1: + rule_desc = ( + f"Rules **{', '.join(map(str, requested_rules))}** were requested " + f"by {ctx.author.mention} in {ctx.channel.mention}." + ) + + warning_embed = Embed( + title="Rules Command Alert", + description=rule_desc, + color=Colour.red(), + url=ctx.message.jump_url + ) + + warning_embed.set_footer(text="Warnings are sent for invocations from non-staff members.") + + warning_embed.set_author(name=f"{ctx.author} ({ctx.author.id})", icon_url=ctx.author.display_avatar.url) + + await rules_thread.send(embed=warning_embed) + + @command(aliases=("rule",)) + async def rules(self, ctx: Context, *, args: str | None) -> set[int] | None: + """ + Provides a link to all rules or, if specified, displays specific rule(s). + + It accepts either rule numbers or particular keywords that map to a particular rule. + Rule numbers and keywords can be sent in any order. + """ + rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") + keywords, rule_numbers = [], [] + + full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + keyword_to_rule_number = dict() + + for rule_number, (_, rule_keywords) in enumerate(full_rules, start=1): + for rule_keyword in rule_keywords: + keyword_to_rule_number[rule_keyword] = rule_number + + if args: + for word in args.split(maxsplit=100): + try: + rule_numbers.append(int(word)) + except ValueError: + # Stop on first invalid keyword/index to allow for normal messaging after + if (kw := word.lower()) not in keyword_to_rule_number: + break + keywords.append(kw) + + if not rule_numbers and not keywords: + # Neither rules nor keywords were submitted. Return the default description. + rules_embed.description = DEFAULT_RULES_DESCRIPTION + await ctx.send(embed=rules_embed) + return None + + # Remove duplicates and sort the rule indices + rule_numbers = sorted(set(rule_numbers)) + + invalid = ", ".join( + str(rule_number) for rule_number in rule_numbers + if rule_number < 1 or rule_number > len(full_rules)) + + if invalid: + await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) + return None + + final_rules = [] + final_rule_numbers = {keyword_to_rule_number[keyword] for keyword in keywords} + final_rule_numbers.update(rule_numbers) + + sorted_rules = sorted(final_rule_numbers) + + for rule_number in sorted_rules: + self.bot.stats.incr(f"rule_uses.{rule_number}") + final_rules.append(f"**{rule_number}.** {full_rules[rule_number - 1][0]}") + + if constants.Roles.helpers not in {role.id for role in ctx.author.roles}: + # If the user is not a helper, send an alert to the rules thread. + # + # Exclude the bot commands channel to avoid spamming it with alerts. + if ctx.channel.id != constants.Channels.bot_commands: + await self._send_rules_alert(ctx, sorted_rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + + return final_rule_numbers + + async def cog_load(self) -> None: + """Carry out cog asynchronous initialisation.""" + await self._set_rules_command_help() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Information cog.""" - bot.add_cog(Information(bot)) + await bot.add_cog(Information(bot)) diff --git a/bot/exts/info/patreon.py b/bot/exts/info/patreon.py new file mode 100644 index 0000000000..f14684ef27 --- /dev/null +++ b/bot/exts/info/patreon.py @@ -0,0 +1,129 @@ +import datetime + +import arrow +import discord +from discord.ext import commands, tasks +from pydis_core.utils.channel import get_or_fetch_channel + +from bot import constants +from bot.bot import Bot +from bot.constants import Channels, Guild, Roles, STAFF_AND_COMMUNITY_ROLES +from bot.decorators import in_whitelist +from bot.log import get_logger + +log = get_logger(__name__) + +PATREON_INFORMATION = ( + "Python Discord is a volunteer run non-profit organization, so we rely on Patreon donations to do what we do. " + "We use the money we get to offer excellent prizes for all of our events. These include t-shirts, " + "stickers, and sometimes even Raspberry Pis!\n\n" + "You can read more about how Patreon donations help us, and consider donating yourself, on our patreon page " + "[here](https://pydis.com/patreon)!" +) +NO_PATRONS_MESSAGE = "*There are currently no patrons at this tier.*" + +# List of tuples containing tier number and Discord role ID. +# Ordered from highest tier to lowest. +PATREON_TIERS: list[tuple[int, int]] = [ + (3, Roles.patreon_tier_3), + (2, Roles.patreon_tier_2), + (1, Roles.patreon_tier_1), +] + + +def get_patreon_tier(member: discord.Member) -> int: + """ + Get the patreon tier of `member`. + + A patreon tier of 0 indicates the user is not a patron. + """ + for tier, role_id in PATREON_TIERS: + if member.get_role(role_id): + return tier + return 0 + + +class Patreon(commands.Cog): + """Cog that shows patreon supporters.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + self.current_monthly_supporters.start() + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: + """Send a message when someone receives a patreon role.""" + old_patreon_tier = get_patreon_tier(before) + new_patreon_tier = get_patreon_tier(after) + + if new_patreon_tier <= old_patreon_tier: + return + + message = ( + f":tada: {after.mention} just became a **tier {new_patreon_tier}** patron!\n" + "Support us on Patreon: https://pydis.com/patreon" + ) + channel = await get_or_fetch_channel(self.bot, Channels.meta) + await channel.send(message) + + async def send_current_supporters(self, channel: discord.abc.Messageable, automatic: bool = False) -> None: + """Send the current list of patreon supporters, sorted by tier level.""" + guild = self.bot.get_guild(Guild.id) + + embed_list = [] + for tier, role_id in PATREON_TIERS: + role = guild.get_role(role_id) + + # Filter out any members where this is not their highest tier. + patrons = [member for member in role.members if get_patreon_tier(member) == tier] + patron_names = [f"- {patron}" for patron in patrons] + + embed = discord.Embed( + title=role.name, + description="\n".join(patron_names) if patron_names else NO_PATRONS_MESSAGE, + colour=role.colour + ) + embed_list.append(embed) + + main_embed = discord.Embed( + title="Patreon Supporters - Monthly Update" if automatic else "Patreon Supporters", + description=( + PATREON_INFORMATION + + "\n\nThank you to the users listed below who are already supporting us!" + ), + ) + + await channel.send(embeds=(main_embed, *embed_list)) + + @commands.group("patreon", aliases=("patron",), invoke_without_command=True) + async def patreon_info(self, ctx: commands.Context) -> None: + """Send information about how Python Discord uses Patreon.""" + embed = discord.Embed( + title="Patreon", + description=( + PATREON_INFORMATION + + "\n\nTo see our current supporters, run " + + f"`{constants.Bot.prefix}patreon supporters` in <#{Channels.bot_commands}>" + ) + ) + await ctx.send(embed=embed) + + @patreon_info.command("supporters", aliases=("patrons",)) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_AND_COMMUNITY_ROLES) + async def patreon_supporters(self, ctx: commands.Context) -> None: + """Sends the current list of patreon supporters, sorted by tier level.""" + await self.send_current_supporters(ctx.channel) + + @tasks.loop(time=datetime.time(hour=17)) + async def current_monthly_supporters(self) -> None: + """A loop running daily to see if it's the first of the month. If so call `self.send_current_supporters()`.""" + now = arrow.utcnow() + if now.day == 1: + meta_channel = await get_or_fetch_channel(self.bot, Channels.meta) + await self.send_current_supporters(meta_channel, automatic=True) + + +async def setup(bot: Bot) -> None: + """Load the Patreon cog.""" + await bot.add_cog(Patreon(bot)) diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index 8ac96bbdb9..4655b21ff0 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -1,27 +1,31 @@ -import logging -from datetime import datetime, timedelta -from email.parser import HeaderParser -from io import StringIO -from typing import Dict, Optional, Tuple +from datetime import UTC, datetime, timedelta +from typing import TypedDict from discord import Colour, Embed from discord.ext.commands import Cog, Context, command from bot.bot import Bot -from bot.constants import Keys -from bot.utils.cache import AsyncCache +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" -PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" +PEP_API_URL = "https://peps.python.org/api/peps.json" -pep_cache = AsyncCache() +class PEPInfo(TypedDict): + """ + Useful subset of the PEP API response. -GITHUB_API_HEADERS = {} -if Keys.github: - GITHUB_API_HEADERS["Authorization"] = f"token {Keys.github}" + Full structure documented at https://peps.python.org/api/ + """ + + number: int + title: str + url: str + status: str + python_version: str | None + created: str + type: str class PythonEnhancementProposals(Cog): @@ -29,136 +33,69 @@ class PythonEnhancementProposals(Cog): def __init__(self, bot: Bot): self.bot = bot - self.peps: Dict[int, str] = {} - # To avoid situations where we don't have last datetime, set this to now. - self.last_refreshed_peps: datetime = datetime.now() - self.bot.loop.create_task(self.refresh_peps_urls()) - - async def refresh_peps_urls(self) -> None: - """Refresh PEP URLs listing in every 3 hours.""" - # Wait until HTTP client is available - await self.bot.wait_until_ready() - log.trace("Started refreshing PEP URLs.") - self.last_refreshed_peps = datetime.now() - - async with self.bot.http_session.get( - PEPS_LISTING_API_URL, - headers=GITHUB_API_HEADERS - ) as resp: + self.peps: dict[int, PEPInfo] = {} + self.last_refreshed_peps: datetime | None = None + + async def refresh_pep_data(self) -> None: + """Refresh PEP data.""" + # Putting this first should prevent any race conditions + self.last_refreshed_peps = datetime.now(tz=UTC) + + log.trace("Started refreshing PEP data.") + async with self.bot.http_session.get(PEP_API_URL) as resp: if resp.status != 200: - log.warning(f"Fetching PEP URLs from GitHub API failed with code {resp.status}") + log.warning( + f"Fetching PEP data from PEP API failed with code {resp.status}" + ) return - listing = await resp.json() - log.trace("Got PEP URLs listing from GitHub API") + for pep_num, pep_info in listing.items(): + self.peps[int(pep_num)] = pep_info - for file in listing: - name = file["name"] - if name.startswith("pep-") and name.endswith((".rst", ".txt")): - pep_number = name.replace("pep-", "").split(".")[0] - self.peps[int(pep_number)] = file["download_url"] + log.info("Successfully refreshed PEP data.") - log.info("Successfully refreshed PEP URLs listing.") - - @staticmethod - def get_pep_zero_embed() -> Embed: - """Get information embed about PEP 0.""" - pep_embed = Embed( - title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - url="https://www.python.org/dev/peps/" + def generate_pep_embed(self, pep: PEPInfo) -> Embed: + """Generate PEP embed.""" + embed = Embed( + title=f"**PEP {pep['number']} - {pep['title']}**", + url=pep["url"], ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") + embed.set_thumbnail(url=ICON_URL) - return pep_embed + fields_to_check = ("status", "python_version", "created", "type") + for field_name in fields_to_check: + if field_value := pep.get(field_name): + field_name = field_name.replace("_", " ").title() + embed.add_field(name=field_name, value=field_value) - async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: - """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" - if ( - pep_nr not in self.peps - and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() - and len(str(pep_nr)) < 5 - ): - await self.refresh_peps_urls() + return embed - if pep_nr not in self.peps: - log.trace(f"PEP {pep_nr} was not found") - return Embed( - title="PEP not found", - description=f"PEP {pep_nr} does not exist.", - colour=Colour.red() + @command(name="pep", aliases=("get_pep", "p")) + async def pep_command(self, ctx: Context, pep_number: int) -> None: + """Fetches information about a PEP and sends it to the channel.""" + # Refresh the PEP data up to every hour, as e.g. the PEP status might have changed. + if ( + self.last_refreshed_peps is None or ( + (self.last_refreshed_peps + timedelta(hours=1)) <= datetime.now(tz=UTC) + and len(str(pep_number)) < 5 ) + ): + await self.refresh_pep_data() - return None - - def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: - """Generate PEP embed based on PEP headers data.""" - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - return pep_embed - - @pep_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: - """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" - response = await self.bot.http_session.get(self.peps[pep_nr]) - - if response.status == 200: - log.trace(f"PEP {pep_nr} found") - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - return self.generate_pep_embed(pep_header, pep_nr), True + if pep := self.peps.get(pep_number): + embed = self.generate_pep_embed(pep) else: - log.trace( - f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." + log.trace(f"PEP {pep_number} was not found") + embed = Embed( + title="PEP not found", + description=f"PEP {pep_number} does not exist.", + colour=Colour.red(), ) - return Embed( - title="Unexpected error", - description="Unexpected HTTP error during PEP search. Please let us know.", - colour=Colour.red() - ), False - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: int) -> None: - """Fetches information about a PEP and sends it to the channel.""" - # Trigger typing in chat to show users that bot is responding - await ctx.trigger_typing() - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - pep_embed = self.get_pep_zero_embed() - success = True - else: - success = False - if not (pep_embed := await self.validate_pep_number(pep_number)): - pep_embed, success = await self.get_pep_embed(pep_number) - - await ctx.send(embed=pep_embed) - if success: - log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") - self.bot.stats.incr(f"pep_fetches.{pep_number}") - else: - log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") + await ctx.send(embed=embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the PEP cog.""" - bot.add_cog(PythonEnhancementProposals(bot)) + await bot.add_cog(PythonEnhancementProposals(bot)) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 2e42e7d6b4..a00f30753e 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -1,14 +1,19 @@ import itertools -import logging import random import re +import typing +from contextlib import suppress +from datetime import datetime -from discord import Embed +from discord import Embed, NotFound from discord.ext.commands import Cog, Context, command from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput +from bot.log import get_logger +from bot.utils import time +from bot.utils.messages import wait_for_deletion URL = "https://pypi.org/pypi/{package}/json" PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png" @@ -18,16 +23,26 @@ ILLEGAL_CHARACTERS = re.compile(r"[^-_.a-zA-Z0-9]+") INVALID_INPUT_DELETE_DELAY = RedirectOutput.delete_delay -log = logging.getLogger(__name__) +log = get_logger(__name__) +def _get_latest_distribution_timestamp(data: dict[str, typing.Any]) -> datetime | None: + """Get upload date of last distribution, or `None` if no distributions were found.""" + if not data["urls"]: + return None -class PyPi(Cog): - """Cog for getting information about PyPi packages.""" + try: + return time.discord_timestamp(data["urls"][-1]["upload_time_iso_8601"], time.TimestampFormats.DATE) + except KeyError: + log.trace("KeyError trying to fetch upload time: data['urls'][-1]['upload_time_iso_8601']") + return None + +class PyPI(Cog): + """Cog for getting information about PyPI packages.""" def __init__(self, bot: Bot): self.bot = bot - @command(name="pypi", aliases=("package", "pack")) + @command(name="pypi", aliases=("package", "pack", "pip")) async def get_package_info(self, ctx: Context, package: str) -> None: """Provide information about a specific package from PyPI.""" embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red) @@ -52,27 +67,39 @@ async def get_package_info(self, ctx: Context, package: str) -> None: embed.url = info["package_url"] embed.colour = next(PYPI_COLOURS) - summary = escape_markdown(info["summary"]) + # Summary can be None if not provided by the package + summary: str | None = info["summary"] # Summary could be completely empty, or just whitespace. if summary and not summary.isspace(): - embed.description = summary + embed.description = escape_markdown(summary) else: embed.description = "No summary provided." + upload_date = _get_latest_distribution_timestamp(response_json) + if upload_date: + embed.description += f"\n\nReleased on {upload_date}." + error = False else: - embed.description = "There was an error when fetching your PyPi package." - log.trace(f"Error when fetching PyPi package: {response.status}.") + embed.description = "There was an error when fetching your PyPI package." + log.trace(f"Error when fetching PyPI package: {response.status}.") if error: - await ctx.send(embed=embed, delete_after=INVALID_INPUT_DELETE_DELAY) - await ctx.message.delete(delay=INVALID_INPUT_DELETE_DELAY) + error_message = await ctx.send(embed=embed) + await wait_for_deletion(error_message, (ctx.author.id,), timeout=INVALID_INPUT_DELETE_DELAY) + + # Make sure that we won't cause a ghost-ping by deleting the message + if not (ctx.message.mentions or ctx.message.role_mentions): + with suppress(NotFound): + await ctx.message.delete() + await error_message.delete() + else: await ctx.send(embed=embed) -def setup(bot: Bot) -> None: - """Load the PyPi cog.""" - bot.add_cog(PyPi(bot)) +async def setup(bot: Bot) -> None: + """Load the PyPI cog.""" + await bot.add_cog(PyPI(bot)) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0ab5738a49..c786a9d192 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,18 +1,21 @@ -import logging +import re import typing as t -from datetime import date, datetime +from datetime import UTC, datetime, timedelta import discord import feedparser +import sentry_sdk from bs4 import BeautifulSoup from discord.ext.commands import Cog from discord.ext.tasks import loop +from pydis_core.site_api import ResponseCodeError from bot import constants from bot.bot import Bot +from bot.log import get_logger from bot.utils.webhooks import send_webhook -PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" +PEPS_RSS_URL = "https://peps.python.org/peps.rss" RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads" THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/" @@ -21,7 +24,15 @@ AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -log = logging.getLogger(__name__) +# By first matching everything within a codeblock, +# when matching markdown it won't be within a codeblock +MARKDOWN_REGEX = re.compile( + r"(?P`.*?`)" # matches everything within a codeblock + r"|(?P(? None: + """Load all existing seen items from db and create any missing mailing lists.""" + with sentry_sdk.start_span(description="Fetch mailing lists from site"): + response = await self.bot.api_client.get("bot/mailing-lists") - async def start_tasks(self) -> None: - """Start the tasks for fetching new PEPs and mailing list messages.""" - self.fetch_new_media.start() + for mailing_list in response: + self.seen_items[mailing_list["name"]] = set(mailing_list["seen_items"]) - @loop(minutes=20) - async def fetch_new_media(self) -> None: - """Fetch new mailing list messages and then new PEPs.""" - await self.post_maillist_news() - await self.post_pep_news() - - async def sync_maillists(self) -> None: - """Sync currently in-use maillists with API.""" - # Wait until guild is available to avoid running before everything is ready - await self.bot.wait_until_guild_available() + with sentry_sdk.start_span(description="Update site with new mailing lists"): + for mailing_list in ("pep", *constants.PythonNews.mail_lists): + if mailing_list not in self.seen_items: + await self.bot.api_client.post("bot/mailing-lists", json={"name": mailing_list}) + self.seen_items[mailing_list] = set() - response = await self.bot.api_client.get("bot/bot-settings/news") - for mail in constants.PythonNews.mail_lists: - if mail not in response["data"]: - response["data"][mail] = [] - - # Because we are handling PEPs differently, we don't include it to mail lists - if "pep" not in response["data"]: - response["data"]["pep"] = [] + self.fetch_new_media.start() - await self.bot.api_client.put("bot/bot-settings/news", json=response) + async def cog_unload(self) -> None: + """Stop news posting tasks on cog unload.""" + self.fetch_new_media.cancel() - async def get_webhook_names(self) -> None: + async def get_webhooks(self) -> None: """Get webhook author names from maillist API.""" - await self.bot.wait_until_guild_available() - async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp: lists = await resp.json() for mail in lists: - if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: - self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + mailing_list_name = mail["name"].split("@")[0] + if mailing_list_name in constants.PythonNews.mail_lists: + self.webhook_names[mailing_list_name] = mail["display_name"] + self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - async def post_pep_news(self) -> None: - """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" - # Wait until everything is ready and http_session available + @loop(minutes=20) + async def fetch_new_media(self) -> None: + """Fetch new mailing list messages and then new PEPs.""" await self.bot.wait_until_guild_available() - await self.sync_maillists() + if not self.webhook: + await self.get_webhooks() + + await self.post_maillist_news() + await self.post_pep_news() + + @staticmethod + def escape_markdown(content: str) -> str: + """Escape the markdown underlines and spoilers that aren't in codeblocks.""" + return MARKDOWN_REGEX.sub( + lambda match: match.group("codeblock") or "\\" + match.group("markdown"), + content + ) + async def post_pep_news(self) -> None: + """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" async with self.bot.http_session.get(PEPS_RSS_URL) as resp: data = feedparser.parse(await resp.text("utf-8")) - news_listing = await self.bot.api_client.get("bot/bot-settings/news") - payload = news_listing.copy() - pep_numbers = news_listing["data"]["pep"] + pep_numbers = self.seen_items["pep"] # Reverse entries to send oldest first data["entries"].reverse() for new in data["entries"]: try: - new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z") + # %Z doesn't actually set the tzinfo of the datetime object, manually set this to UTC + pep_creation = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z").replace(tzinfo=UTC) except ValueError: log.warning(f"Wrong datetime format passed in PEP new: {new['published']}") continue pep_nr = new["title"].split(":")[0].split()[1] if ( pep_nr in pep_numbers - or new_datetime.date() < date.today() + # A PEP is assigned a creation date before it is reviewed and published to the RSS feed. + # The time between creation date and it appearing in the RSS feed is usually not long, + # but we allow up to 6 weeks to be safe. + or pep_creation < datetime.now(tz=UTC) - timedelta(weeks=6) ): continue # Build an embed and send a webhook embed = discord.Embed( - title=new["title"], - description=new["summary"], - timestamp=new_datetime, + title=self.escape_markdown(new["title"]), + description=self.escape_markdown(new["summary"]), + timestamp=pep_creation, url=new["link"], colour=constants.Colours.soft_green ) @@ -116,26 +135,19 @@ async def post_pep_news(self) -> None: avatar_url=AVATAR_URL, wait=True, ) - payload["data"]["pep"].append(pep_nr) - - # Increase overall PEP new stat - self.bot.stats.incr("python_news.posted.pep") - - if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") + pep_successfully_added = await self.add_item_to_mail_list("pep", pep_nr) + if pep_successfully_added and msg.channel.is_news(): + log.trace("Publishing PEP announcement because it was in a news channel") await msg.publish() - # Apply new sent news to DB to avoid duplicate sending - await self.bot.api_client.put("bot/bot-settings/news", json=payload) - async def post_maillist_news(self) -> None: """Send new maillist threads to #python-news that is listed in configuration.""" - await self.bot.wait_until_guild_available() - await self.sync_maillists() - existing_news = await self.bot.api_client.get("bot/bot-settings/news") - payload = existing_news.copy() - for maillist in constants.PythonNews.mail_lists: + if maillist not in self.seen_items: + # If for some reason we have a mailing list that isn't tracked. + log.warning("Mailing list %s doesn't exist in the database", maillist) + continue + async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp: recents = BeautifulSoup(await resp.text(), features="lxml") @@ -160,20 +172,21 @@ async def post_maillist_news(self) -> None: log.warning(f"Invalid datetime from Thread email: {email_information['date']}") continue + thread_id = thread_information["thread_id"] if ( - thread_information["thread_id"] in existing_news["data"][maillist] - or 'Re: ' in thread_information["subject"] - or new_date.date() < date.today() + thread_id in self.seen_items[maillist] + or "Re: " in thread_information["subject"] + or new_date.date() < datetime.now(tz=UTC).date() ): continue - content = email_information["content"] + content = self.escape_markdown(email_information["content"]) link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) # Build an embed and send a message to the webhook embed = discord.Embed( - title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + title=self.escape_markdown(thread_information["subject"]), + description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content, timestamp=new_date, url=link, colour=constants.Colours.soft_green @@ -193,18 +206,32 @@ async def post_maillist_news(self) -> None: avatar_url=AVATAR_URL, wait=True, ) - payload["data"][maillist].append(thread_information["thread_id"]) - - # Increase this specific maillist counter in stats - self.bot.stats.incr(f"python_news.posted.{maillist.replace('-', '_')}") - - if msg.channel.is_news(): + thread_successfully_added = await self.add_item_to_mail_list(maillist, thread_id) + if thread_successfully_added and msg.channel.is_news(): log.trace("Publishing mailing list message because it was in a news channel") await msg.publish() - await self.bot.api_client.put("bot/bot-settings/news", json=payload) + async def add_item_to_mail_list(self, mail_list: str, item_identifier: str) -> bool: + """Adds a new item to a particular mailing_list.""" + try: + await self.bot.api_client.post(f"bot/mailing-lists/{mail_list}/seen-items", json=item_identifier) + self.seen_items[mail_list].add(item_identifier) + # Increase this specific mailing list counter in stats + self.bot.stats.incr(f"python_news.posted.{mail_list.replace('-', '_')}") + return True + + except ResponseCodeError as e: + non_field_errors = e.response_json.get("non_field_errors", []) + if non_field_errors != ["Seen item already known."]: + raise e + log.trace( + "Item %s has already been seen in the following mailing list: %s", + item_identifier, + mail_list + ) + return False - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: + async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> tuple[t.Any, t.Any]: """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" async with self.bot.http_session.get( THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier) @@ -215,18 +242,7 @@ async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) email_information = await resp.json() return thread_information, email_information - async def get_webhook_and_channel(self) -> None: - """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`.""" - await self.bot.wait_until_guild_available() - self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook) - - await self.start_tasks() - - def cog_unload(self) -> None: - """Stop news posting tasks on cog unload.""" - self.fetch_new_media.cancel() - -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Add `News` cog.""" - bot.add_cog(PythonNews(bot)) + await bot.add_cog(PythonNews(bot)) diff --git a/bot/exts/info/resources.py b/bot/exts/info/resources.py new file mode 100644 index 0000000000..99caa2ae0d --- /dev/null +++ b/bot/exts/info/resources.py @@ -0,0 +1,69 @@ +import re +from urllib.parse import quote + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot + +REGEX_CONSECUTIVE_NON_LETTERS = r"[^A-Za-z0-9]+" +RESOURCE_URL = "https://www.pythondiscord.com/resources/" + + +def to_kebabcase(resource_topic: str) -> str: + """ + Convert any string to kebab-case. + + For example, convert + "__Favorite FROOT¤#/$?is----LeMON???" to + "favorite-froot-is-lemon" + + Code adopted from: + https://github.com/python-discord/site/blob/main/pydis_site/apps/resources/templatetags/to_kebabcase.py + """ + # First, make it lowercase, and just remove any apostrophes. + # We remove the apostrophes because "wasnt" is better than "wasn-t" + resource_topic = resource_topic.casefold() + resource_topic = resource_topic.replace("'", "") + + # Now, replace any non-alphanumerics that remains with a dash. + # If there are multiple consecutive non-letters, just replace them with a single dash. + # my-favorite-class is better than my-favorite------class + resource_topic = re.sub( + REGEX_CONSECUTIVE_NON_LETTERS, + "-", + resource_topic, + ) + + # Now we use strip to get rid of any leading or trailing dashes. + resource_topic = resource_topic.strip("-") + return resource_topic + + +class Resources(commands.Cog): + """Display information about the Python Discord website Resource page.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="resources", aliases=("res",)) + async def resources_command(self, ctx: commands.Context, *, resource_topic: str | None) -> None: + """Display information and a link to the Python Discord website Resources page.""" + url = RESOURCE_URL + + if resource_topic: + # Capture everything prior to new line allowing users to add messages below the command then prep for url + url = f"{url}?topics={quote(to_kebabcase(resource_topic.splitlines()[0]))}" + + embed = Embed( + title="Resources", + description=f"The [Resources page]({url}) on our website contains a list " + f"of hand-selected learning resources that we " + f"regularly recommend to both beginners and experts." + ) + await ctx.send(embed=embed) + + +async def setup(bot: Bot) -> None: + """Load the Resources cog.""" + await bot.add_cog(Resources(bot)) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py deleted file mode 100644 index fb5b99086d..0000000000 --- a/bot/exts/info/site.py +++ /dev/null @@ -1,143 +0,0 @@ -import logging - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, Greedy, group - -from bot.bot import Bot -from bot.constants import URLs -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" - - -class Site(Cog): - """Commands for linking to different parts of the site.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="site", aliases=("s",), invoke_without_command=True) - async def site_group(self, ctx: Context) -> None: - """Commands for getting info about our website.""" - await ctx.send_help(ctx.command) - - @site_group.command(name="home", aliases=("about",), root_aliases=("home",)) - async def site_main(self, ctx: Context) -> None: - """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" - - embed = Embed(title="Python Discord website") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - f"[Our official website]({url}) is an open-source community project " - "created with Python and Django. It contains information about the server " - "itself, lets you sign up for upcoming events, has its own wiki, contains " - "a list of valuable learning resources, and much more." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="resources", root_aliases=("resources", "resource")) - async def site_resources(self, ctx: Context) -> None: - """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" - - embed = Embed(title="Resources") - embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected learning resources that we regularly recommend " - f"to both beginners and experts." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="tools", root_aliases=("tools",)) - async def site_tools(self, ctx: Context) -> None: - """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" - - embed = Embed(title="Tools") - embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.blurple() - embed.description = ( - f"The [Tools page]({tools_url}) on our website contains a " - f"couple of the most popular tools for programming in Python." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="help") - async def site_help(self, ctx: Context) -> None: - """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" - - embed = Embed(title="Asking Good Questions") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "Asking the right question about something that's new to you can sometimes be tricky. " - f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " - "It contains everything you need to get the very best help from our community." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="faq", root_aliases=("faq",)) - async def site_faq(self, ctx: Context) -> None: - """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" - - embed = Embed(title="FAQ") - embed.set_footer(text=url) - embed.colour = Colour.blurple() - embed.description = ( - "As the largest Python community on Discord, we get hundreds of questions every day. " - "Many of these questions have been asked before. We've compiled a list of the most " - "frequently asked questions along with their answers, which can be found on " - f"our [FAQ page]({url})." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) - async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules') - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" - " all members of the community to have read and understood these." - ) - - await ctx.send(embed=rules_embed) - return - - full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - - # Remove duplicates and sort the rule indices - rules = sorted(set(rules)) - invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) - - if invalid: - await ctx.send(f":x: Invalid rule indices: {invalid}") - return - - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") - - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) - - await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) - - -def setup(bot: Bot) -> None: - """Load the Site cog.""" - bot.add_cog(Site(bot)) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index ef07c77a1c..9dd5c39bf5 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -1,80 +1,95 @@ +import enum import inspect from pathlib import Path -from typing import Optional, Tuple, Union -from discord import Embed, utils +from discord import Embed from discord.ext import commands +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import URLs +from bot.exts.info.tags import TagIdentifier -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] +SourceObject = commands.HelpCommand | commands.Command | commands.Cog | TagIdentifier | commands.ExtensionNotLoaded +class SourceType(enum.StrEnum): + """The types of source objects recognized by the source command.""" -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" + help_command = enum.auto() + command = enum.auto() + cog = enum.auto() + tag = enum.auto() + extension_not_loaded = enum.auto() + + +class BotSource(commands.Cog): + """Displays information about the bot's source code.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(name="source", aliases=("src",)) + async def source_command( + self, + ctx: commands.Context, + *, + source_item: str | None = None, + ) -> None: + """Display information and a GitHub link to the source code of a command, tag, or cog.""" + if not source_item: + embed = Embed(title="Bot's GitHub Repository") + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") + await ctx.send(embed=embed) + return + + obj, source_type = await self.get_source_object(ctx, source_item) + embed = await self.build_embed(obj, source_type) + await ctx.send(embed=embed) @staticmethod - async def convert(ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" + async def get_source_object(ctx: commands.Context, argument: str) -> tuple[SourceObject, SourceType]: + """Convert argument into the source object and source type.""" if argument.lower() == "help": - return ctx.bot.help_command + return ctx.bot.help_command, SourceType.help_command cog = ctx.bot.get_cog(argument) if cog: - return cog + return cog, SourceType.cog cmd = ctx.bot.get_command(argument) if cmd: - return cmd + return cmd, SourceType.command tags_cog = ctx.bot.get_cog("Tags") show_tag = True if not tags_cog: show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() + else: + identifier = TagIdentifier.from_string(argument.lower()) + if identifier in tags_cog.tags: + return identifier, SourceType.tag - escaped_arg = utils.escape_markdown(argument) + escaped_arg = escape_markdown(argument) raise commands.BadArgument( f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." ) - -class BotSource(commands.Cog): - """Displays information about the bot's source code.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command(name="source", aliases=("src",)) - async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command, tag, or cog.""" - if not source_item: - embed = Embed(title="Bot's GitHub Repository") - embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") - embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") - await ctx.send(embed=embed) - return - - embed = await self.build_embed(source_item) - await ctx.send(embed=embed) - - def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: + def get_source_link(self, source_item: SourceObject, source_type: SourceType) -> tuple[str, str, int | None]: """ Build GitHub link of source item, return this link, file location and first line number. Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). """ - if isinstance(source_item, commands.Command): + if source_type == SourceType.command: source_item = inspect.unwrap(source_item.callback) src = source_item.__code__ filename = src.co_filename - elif isinstance(source_item, str): + elif source_type == SourceType.tag: tags_cog = self.bot.get_cog("Tags") - filename = tags_cog._cache[source_item]["location"] + filename = tags_cog.tags[source_item].file_path else: src = type(source_item) try: @@ -82,7 +97,7 @@ def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[i except TypeError: raise commands.BadArgument("Cannot get source for a dynamically-created object.") - if not isinstance(source_item, str): + if source_type != SourceType.tag: try: lines, first_line_no = inspect.getsourcelines(src) except OSError: @@ -95,7 +110,7 @@ def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[i # Handle tag file location differently than others to avoid errors in some cases if not first_line_no: - file_location = Path(filename).relative_to("/bot/") + file_location = Path(filename) else: file_location = Path(filename).relative_to(Path.cwd()).as_posix() @@ -103,17 +118,17 @@ def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[i return url, file_location, first_line_no or None - async def build_embed(self, source_object: SourceType) -> Optional[Embed]: + async def build_embed(self, source_object: SourceObject, source_type: SourceType) -> Embed | None: """Build embed based on source object.""" - url, location, first_line = self.get_source_link(source_object) + url, location, first_line = self.get_source_link(source_object, source_type) - if isinstance(source_object, commands.HelpCommand): + if source_type == SourceType.help_command: title = "Help Command" description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, commands.Command): + elif source_type == SourceType.command: description = source_object.short_doc title = f"Command: {source_object.qualified_name}" - elif isinstance(source_object, str): + elif source_type == SourceType.tag: title = f"Tag: {source_object}" description = "" else: @@ -128,6 +143,6 @@ async def build_embed(self, source_object: SourceType) -> Optional[Embed]: return embed -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the BotSource cog.""" - bot.add_cog(BotSource(bot)) + await bot.add_cog(BotSource(bot)) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index 4d8bb645e5..af7663c282 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -41,12 +41,11 @@ async def on_message(self, message: Message) -> None: # of them for interesting statistics to be drawn out of this. return - reformatted_name = message.channel.name.replace('-', '_') - - if CHANNEL_NAME_OVERRIDES.get(message.channel.id): - reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id) - - reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS) + channel = message.channel + if hasattr(channel, "parent") and channel.parent: + channel = channel.parent + reformatted_name = CHANNEL_NAME_OVERRIDES.get(channel.id, channel.name) + reformatted_name = "".join(char if char in ALLOWED_CHARS else "_" for char in reformatted_name) stat_name = f"channels.{reformatted_name}" self.bot.stats.incr(stat_name) @@ -85,11 +84,11 @@ async def update_guild_boost(self) -> None: self.bot.stats.gauge("boost.amount", g.premium_subscription_count) self.bot.stats.gauge("boost.tier", g.premium_tier) - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Stop the boost statistic task on unload of the Cog.""" self.update_guild_boost.stop() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the stats cog.""" - bot.add_cog(Stats(bot)) + await bot.add_cog(Stats(bot)) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py new file mode 100644 index 0000000000..6e8f089bfc --- /dev/null +++ b/bot/exts/info/subscribe.py @@ -0,0 +1,246 @@ +import operator +from dataclasses import dataclass + +import discord +import sentry_sdk +from discord.ext import commands +from discord.interactions import Interaction +from pydis_core.utils import members +from pydis_core.utils.channel import get_or_fetch_channel + +from bot import constants +from bot.bot import Bot +from bot.decorators import redirect_output +from bot.log import get_logger + + +@dataclass(frozen=True) +class AssignableRole: + """A role that can be assigned to a user.""" + + role_id: int + name: str | None = None # This gets populated within Subscribe.cog_load() + + +ASSIGNABLE_ROLES = ( + AssignableRole(constants.Roles.announcements), + AssignableRole(constants.Roles.pyweek_announcements), + AssignableRole(constants.Roles.archived_channels_access), + AssignableRole(constants.Roles.lovefest), + AssignableRole(constants.Roles.advent_of_code), + AssignableRole(constants.Roles.revival_of_code), + AssignableRole(constants.Roles.aspiring_contributors), +) + +ITEMS_PER_ROW = 3 +DELETE_MESSAGE_AFTER = 300 # Seconds + +log = get_logger(__name__) + + +class RoleButtonView(discord.ui.View): + """A view that holds the list of SingleRoleButtons to show to the member.""" + + def __init__(self, member: discord.Member, assignable_roles: list[AssignableRole]): + super().__init__(timeout=DELETE_MESSAGE_AFTER) + self.interaction_owner = member + author_roles = [role.id for role in member.roles] + + for index, role in enumerate(assignable_roles): + row = index // ITEMS_PER_ROW + self.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) + + async def interaction_check(self, interaction: Interaction) -> bool: + """Ensure that the user clicking the button is the member who invoked the command.""" + if interaction.user != self.interaction_owner: + await interaction.response.send_message( + ":x: This is not your command to react to!", + ephemeral=True + ) + return False + return True + + +class SingleRoleButton(discord.ui.Button): + """A button that adds or removes a role from the member depending on its current state.""" + + ADD_STYLE = discord.ButtonStyle.success + REMOVE_STYLE = discord.ButtonStyle.red + UNAVAILABLE_STYLE = discord.ButtonStyle.secondary + LABEL_FORMAT = "{action} role {role_name}" + + def __init__(self, role: AssignableRole, assigned: bool, row: int): + style = self.REMOVE_STYLE if assigned else self.ADD_STYLE + label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) + + super().__init__( + style=style, + label=label, + row=row, + ) + self.role = role + self.assigned = assigned + + async def callback(self, interaction: Interaction) -> None: + """Update the member's role and change button text to reflect current text.""" + if isinstance(interaction.user, discord.User): + log.trace("User %s is not a member", interaction.user) + await interaction.message.delete() + self.view.stop() + return + + await members.handle_role_change( + interaction.user, + interaction.user.remove_roles if self.assigned else interaction.user.add_roles, + discord.Object(self.role.role_id), + ) + + self.assigned = not self.assigned + try: + await self.update_view(interaction) + send_function = interaction.followup.send + except discord.NotFound: + log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user) + self.view.stop() + send_function = interaction.response.send_message + + await send_function( + self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name), + ephemeral=True, + ) + + async def update_view(self, interaction: Interaction) -> None: + """Updates the original interaction message with a new view object with the updated buttons.""" + self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE + self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name) + await interaction.response.edit_message(view=self.view) + + +class AllSelfAssignableRolesView(discord.ui.View): + """A persistent view that'll hold one button allowing interactors to toggle all available self-assignable roles.""" + + def __init__(self, assignable_roles: list[AssignableRole]): + super().__init__(timeout=None) + self.assignable_roles = assignable_roles + + @discord.ui.button( + style=discord.ButtonStyle.success, + label="Show all self assignable roles", + custom_id="toggle-available-roles-button", + row=1 + ) + async def show_all_self_assignable_roles(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Sends the original subscription view containing the available self assignable roles.""" + view = RoleButtonView(interaction.user, self.assignable_roles) + await interaction.response.send_message( + view=view, + ephemeral=True + ) + + +class Subscribe(commands.Cog): + """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES.""" + + GREETING_EMOJI = ":wave:" + + SELF_ASSIGNABLE_ROLES_MESSAGE = ( + f"Hi there {GREETING_EMOJI}," + "\nWe have self-assignable roles for server updates and events!" + "\nClick the button below to toggle them:" + ) + + def __init__(self, bot: Bot): + self.bot = bot + self.assignable_roles: list[AssignableRole] = [] + self.guild: discord.Guild = None + + async def cog_load(self) -> None: + """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" + await self.bot.wait_until_guild_available() + self.guild = self.bot.get_guild(constants.Guild.id) + + with sentry_sdk.start_span(description="Prepare assignable roles"): + for role in ASSIGNABLE_ROLES: + discord_role = self.guild.get_role(role.role_id) + if discord_role is None: + log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id) + continue + self.assignable_roles.append( + AssignableRole( + role_id=role.role_id, + name=discord_role.name, + ) + ) + # Sort by role name + self.assignable_roles.sort(key=operator.attrgetter("name")) + + with sentry_sdk.start_span(description="Fetch/create the self assignable roles message"): + placeholder_message_view_tuple = await self._fetch_or_create_self_assignable_roles_message() + self_assignable_roles_message, self_assignable_roles_view = placeholder_message_view_tuple + + with sentry_sdk.start_span(description="Attach self assignable role persistent view"): + self._attach_persistent_roles_view(self_assignable_roles_message, self_assignable_roles_view) + + @commands.cooldown(1, 10, commands.BucketType.member) + @commands.command(name="subscribe", aliases=("unsubscribe",)) + @redirect_output( + destination_channel=constants.Channels.bot_commands, + bypass_roles=constants.STAFF_AND_COMMUNITY_ROLES, + ) + async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args + """Display the member's current state for each role, and allow them to add/remove the roles.""" + view = RoleButtonView(ctx.author, self.assignable_roles) + await ctx.send( + "Click the buttons below to add or remove your roles!", + view=view, + delete_after=DELETE_MESSAGE_AFTER + ) + + async def _fetch_or_create_self_assignable_roles_message(self) -> tuple[discord.Message, discord.ui.View | None]: + """ + Fetches the message that holds the self assignable roles view. + + If the initial message isn't found, a new one will be created. + This message will always be needed to attach the persistent view to it + """ + roles_channel: discord.TextChannel = await get_or_fetch_channel(self.bot, constants.Channels.roles) + + async for message in roles_channel.history(limit=30): + if message.content == self.SELF_ASSIGNABLE_ROLES_MESSAGE: + log.debug(f"Found self assignable roles view message: {message.id}") + return message, None + + log.debug("Self assignable roles view message hasn't been found, creating a new one.") + view = AllSelfAssignableRolesView(self.assignable_roles) + placeholder_message = await roles_channel.send(self.SELF_ASSIGNABLE_ROLES_MESSAGE, view=view) + return placeholder_message, view + + def _attach_persistent_roles_view( + self, + placeholder_message: discord.Message, + persistent_roles_view: discord.ui.View | None = None + ) -> None: + """ + Attaches the persistent view that toggles self assignable roles to its placeholder message. + + The message is searched for/created upon loading the Cog. + + Parameters + __________ + :param placeholder_message: The message that will hold the persistent view allowing + users to toggle the RoleButtonView + :param persistent_roles_view: The view attached to the placeholder_message + If none, a new view will be created + """ + if not persistent_roles_view: + persistent_roles_view = AllSelfAssignableRolesView(self.assignable_roles) + + self.bot.add_view(persistent_roles_view, message_id=placeholder_message.id) + + +async def setup(bot: Bot) -> None: + """Load the 'Subscribe' cog.""" + if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW * 5: # Discord limits views to 5 rows of buttons. + log.error("Too many roles for 5 rows, not loading the Subscribe cog.") + else: + await bot.add_cog(Subscribe(bot)) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bb91a85637..65b0c5c650 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -1,19 +1,21 @@ -import logging +import enum import re import time from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional +from typing import Literal, NamedTuple -from discord import Colour, Embed, Member -from discord.ext.commands import Cog, Context, group +import discord +import frontmatter +from discord import Embed, Interaction, Member, app_commands +from discord.ext.commands import Cog, Context from bot import constants from bot.bot import Bot -from bot.converters import TagNameConverter +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) TEST_CHANNELS = ( constants.Channels.bot_commands, @@ -21,276 +23,363 @@ ) REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) -FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." +FOOTER_TEXT = "To show a tag, use /tag ." + + +class COOLDOWN(enum.Enum): + """Sentinel value to signal that a tag is on cooldown.""" + + obj = object() + + +class TagIdentifier(NamedTuple): + """Stores the group and name used as an identifier for a tag.""" + + group: str | None + name: str + + def get_fuzzy_score(self, fuzz_tag_identifier: TagIdentifier) -> float: + """Get fuzzy score, using `fuzz_tag_identifier` as the identifier to fuzzy match with.""" + if (self.group is None) != (fuzz_tag_identifier.group is None): + # Ignore tags without groups if the identifier has a group and vice versa + return .0 + if self.group == fuzz_tag_identifier.group: + # Completely identical, or both None + group_score = 1 + else: + group_score = _fuzzy_search(fuzz_tag_identifier.group, self.group) + + fuzzy_score = group_score * _fuzzy_search(fuzz_tag_identifier.name, self.name) * 100 + if fuzzy_score: + log.trace(f"Fuzzy score {fuzzy_score:=06.2f} for tag {self!r} with fuzz {fuzz_tag_identifier!r}") + return fuzzy_score + + def __str__(self) -> str: + if self.group is not None: + return f"{self.group} {self.name}" + return self.name + + @classmethod + def from_string(cls, string: str) -> TagIdentifier: + """Create a `TagIdentifier` instance from the beginning of `string`.""" + split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2) + if len(split_string) == 1: + return cls(None, split_string[0]) + return cls(split_string[0], split_string[1]) + + +class Tag: + """Provide an interface to a tag from resources with `file_content`.""" + + def __init__(self, content_path: Path): + post = frontmatter.loads(content_path.read_text("utf8")) + self.file_path = content_path + self.content = post.content + self.metadata = post.metadata + self._restricted_to: set[int] = set(self.metadata.get("restricted_to", ())) + self._cooldowns: dict[discord.TextChannel, float] = {} + self.aliases: list[str] = self.metadata.get("aliases", []) + + @property + def embed(self) -> Embed: + """Create an embed for the tag.""" + embed = Embed.from_dict(self.metadata.get("embed", {})) + embed.description = self.content + return embed + + def accessible_by(self, member: Member) -> bool: + """Check whether `member` can access the tag.""" + return bool( + not self._restricted_to + or self._restricted_to & {role.id for role in member.roles} + ) + + def on_cooldown_in(self, channel: discord.TextChannel) -> bool: + """Check whether the tag is on cooldown in `channel`.""" + return self._cooldowns.get(channel, float("-inf")) > time.time() + + def set_cooldown_for(self, channel: discord.TextChannel) -> None: + """Set the tag to be on cooldown in `channel` for `constants.Cooldowns.tags` seconds.""" + self._cooldowns[channel] = time.time() + constants.Cooldowns.tags + + +def _fuzzy_search(search: str, target: str) -> float: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + _search = REGEX_NON_ALPHABET.sub("", search.lower()) + if not _search: + return 0 + + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + + current = 0 + for _target in _targets: + index = 0 + try: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + except IndexError: + # Exit when _search runs out + break + + return current / len(_search) class Tags(Cog): - """Save new tags and fetch existing tags.""" + """Fetch tags by name or content.""" + + PAGINATOR_DEFAULTS = dict(max_lines=15, empty=False, footer_text=FOOTER_TEXT) def __init__(self, bot: Bot): self.bot = bot - self.tag_cooldowns = {} - self._cache = self.get_tags() - - @staticmethod - def get_tags() -> dict: - """Get all tags.""" - cache = {} + self.tags: dict[TagIdentifier, Tag] = {} + self.initialize_tags() + def initialize_tags(self) -> None: + """Load all tags from resources into `self.tags`.""" base_path = Path("bot", "resources", "tags") + for file in base_path.glob("**/*"): if file.is_file(): - tag_title = file.stem - tag = { - "title": tag_title, - "embed": { - "description": file.read_text(encoding="utf8"), - }, - "restricted_to": None, - "location": f"/bot/{file}" - } - - # Convert to a list to allow negative indexing. - parents = list(file.relative_to(base_path).parents) - if len(parents) > 1: - # -1 would be '.' hence -2 is used as the index. - tag["restricted_to"] = parents[-2].name - - cache[tag_title] = tag - - return cache - - @staticmethod - def check_accessibility(user: Member, tag: dict) -> bool: - """Check if user can access a tag.""" - return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] - - @staticmethod - def _fuzzy_search(search: str, target: str) -> float: - """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass - return current / len(_search) * 100 - - def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: - """Return a list of suggested tags.""" - scores: Dict[str, int] = { - tag_title: Tags._fuzzy_search(tag_name, tag['title']) - for tag_title, tag in self._cache.items() - } - - thresholds = thresholds or [100, 90, 80, 70, 60] - - for threshold in thresholds: + parent_dir = file.relative_to(base_path).parent + tag_name = file.stem + # Files directly under `base_path` have an empty string as the parent directory name + tag_group = parent_dir.name or None + + tag = Tag(file) + self.tags[TagIdentifier(tag_group, tag_name)] = tag + + for alias in tag.aliases: + self.tags[TagIdentifier(tag_group, alias)] = tag + + def _get_suggestions(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: + """Return a list of suggested tags for `tag_identifier`.""" + for threshold in [100, 90, 80, 70, 60]: suggestions = [ - self._cache[tag_title] - for tag_title, matching_score in scores.items() - if matching_score >= threshold + (identifier, tag) + for identifier, tag in self.tags.items() + if identifier.get_fuzzy_score(tag_identifier) >= threshold ] if suggestions: return suggestions return [] - def _get_tag(self, tag_name: str) -> list: - """Get a specific tag.""" - found = [self._cache.get(tag_name.lower(), None)] - if not found[0]: - return self._get_suggestions(tag_name) - return found - - def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: - """ - Search for tags via contents. - - `predicate` will be the built-in any, all, or a custom callable. Must return a bool. - """ - keywords_processed: List[str] = [] - for keyword in keywords.split(','): - keyword_sanitized = keyword.strip().casefold() - if not keyword_sanitized: - # this happens when there are leading / trailing / consecutive comma. - continue - keywords_processed.append(keyword_sanitized) - - if not keywords_processed: - # after sanitizing, we can end up with an empty list, for example when keywords is ',' - # in that case, we simply want to search for such keywords directly instead. - keywords_processed = [keywords] - - matching_tags = [] - for tag in self._cache.values(): - matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) - if self.check_accessibility(user, tag) and check(matches): - matching_tags.append(tag) - - return matching_tags - - async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: - """Send the result of matching tags to user.""" - if not matching_tags: - pass - elif len(matching_tags) == 1: - await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) - else: - is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 - embed = Embed( - title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in matching_tags[:10]) - ) - await LinePaginator.paginate( - sorted(f"**»** {tag['title']}" for tag in matching_tags), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) - async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: - """Show all known tags, a single tag, or run a subcommand.""" - await self.get_command(ctx, tag_name=tag_name) + def get_fuzzy_matches(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: + """Get tags with identifiers similar to `tag_identifier`.""" + suggestions = [] - @tags_group.group(name='search', invoke_without_command=True) - async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + if tag_identifier.group is not None and len(tag_identifier.group) >= 2: + # Try fuzzy matching with only a name first + suggestions += self._get_suggestions(TagIdentifier(None, tag_identifier.group)) - Only search for tags that has ALL the keywords. - """ - matching_tags = self._get_tags_via_content(all, keywords, ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) + if len(tag_identifier.name) >= 2: + suggestions += self._get_suggestions(tag_identifier) - @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: - """ - Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. + return suggestions - Search for tags that has ANY of the keywords. + async def get_tag_embed( + self, + member: Member, + channel: discord.abc.Messageable, + tag_identifier: TagIdentifier, + ) -> Embed | Literal[COOLDOWN.obj] | None: """ - matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) - await self._send_matching_tags(ctx, keywords, matching_tags) - - async def display_tag(self, ctx: Context, tag_name: str = None) -> bool: + Generate an embed of the requested tag or of suggestions if the tag doesn't exist + or isn't accessible by the member. + + If the requested tag is on cooldown return `COOLDOWN.obj`, otherwise if no suggestions were found return None. + """ # noqa: D205 + filtered_tags = [ + (ident, tag) for ident, tag in + self.get_fuzzy_matches(tag_identifier)[:10] + if tag.accessible_by(member) + ] + + # Try exact match, includes checking through alt names + tag = self.tags.get(tag_identifier) + + if tag is None and tag_identifier.group is not None: + # Try exact match with only the name + name_only_identifier = TagIdentifier(None, tag_identifier.group) + tag = self.tags.get(name_only_identifier) + if tag: + # Ensure the correct tag information is sent to statsd + tag_identifier = name_only_identifier + + if tag is None and len(filtered_tags) == 1: + tag_identifier = filtered_tags[0][0] + tag = filtered_tags[0][1] + + if tag is not None: + if tag.on_cooldown_in(channel): + log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") + return COOLDOWN.obj + tag.set_cooldown_for(channel) + + self.bot.stats.incr( + f"tags.usages" + f"{'.' + tag_identifier.group.replace('-', '_') if tag_identifier.group else ''}" + f".{tag_identifier.name.replace('-', '_')}" + ) + return tag.embed + + if not filtered_tags: + return None + suggested_tags_text = "\n".join( + f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" + for identifier, tag in filtered_tags + if not tag.on_cooldown_in(channel) + ) + return Embed( + title="Did you mean...", + description=suggested_tags_text + ) + + def accessible_tags(self, member: Member) -> list[str]: + """Return a formatted list of tags that are accessible by `member`; groups first, and alphabetically sorted.""" + def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str: + group, name = tag_item[0] + if group is None: + # Max codepoint character to force tags without a group to the end + group = chr(0x10ffff) + + return group + name + + result_lines = [] + current_group = "" + group_accessible = True + + for identifier, tag in sorted(self.tags.items(), key=tag_sort_key): + + if identifier.group != current_group: + if not group_accessible: + # Remove group separator line if no tags in the previous group were accessible by the member. + result_lines.pop() + # A new group began, add a separator with the group name. + current_group = identifier.group + if current_group is not None: + group_accessible = False + result_lines.append(f"\n\N{BULLET} **{current_group}**") + else: + result_lines.append("\n") + + if tag.accessible_by(member): + result_lines.append(f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}") + group_accessible = True + + return result_lines + + def accessible_tags_in_group(self, group: str, member: Member) -> list[str]: + """Return a formatted list of tags in `group`, that are accessible by `member`.""" + return sorted( + f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" + for identifier, tag in self.tags.items() + if identifier.group == group and tag.accessible_by(member) + ) + + async def get_command_ctx( + self, + ctx: Context, + name: str + ) -> bool: """ - If a tag is not found, display similar tag names as suggestions. - - If a tag is not specified, display a paginated embed of all tags. + Made specifically for `ErrorHandler().try_get_tag` to handle sending tags through ctx. - Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display - nothing and return True. + See `get_command` for more info, but here name is not optional unlike `get_command`. """ - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. - - The cooldown duration is set in constants.py. - """ - now = time.time() - - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) + identifier = TagIdentifier.from_string(name) - if cooldown_conditions: + if identifier.group is None: + # Try to find accessible tags from a group matching the identifier's name. + if group_tags := self.accessible_tags_in_group(identifier.name, ctx.author): + await LinePaginator.paginate( + group_tags, ctx, Embed(title=f"Tags under *{identifier.name}*"), **self.PAGINATOR_DEFAULTS + ) return True - return False - - if _command_on_cooldown(tag_name): - time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] - time_left = constants.Cooldowns.tags - time_elapsed - log.info( - f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds." - ) - return True - if tag_name is not None: - temp_founds = self._get_tag(tag_name) + embed = await self.get_tag_embed(ctx.author, ctx.channel, identifier) + if embed is None: + return False - founds = [] + if embed is not COOLDOWN.obj: - for found_tag in temp_founds: - if self.check_accessibility(ctx.author, found_tag): - founds.append(found_tag) + await wait_for_deletion( + await ctx.send(embed=embed), + (ctx.author.id,) + ) + # A valid tag was found and was either sent, or is on cooldown + return True - if len(founds) == 1: - tag = founds[0] - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { - "time": time.time(), - "channel": ctx.channel.id - } + @app_commands.command(name="tag") + @app_commands.guild_only() + async def get_command(self, interaction: Interaction, *, name: str | None) -> bool: + """ + If a single argument matching a group name is given, list all accessible tags from that group + Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name. - self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") + With no arguments, list all accessible tags. - await wait_for_deletion( - await ctx.send(embed=Embed.from_dict(tag['embed'])), - [ctx.author.id], - ) - return True - elif founds and len(tag_name) >= 3: - await wait_for_deletion( - await ctx.send( - embed=Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - ) - ), - [ctx.author.id], + Returns True if a message was sent, or if the tag is on cooldown. + Returns False if no message was sent. + """ # noqa: D205 + if not name: + if self.tags: + await LinePaginator.paginate( + self.accessible_tags(interaction.user), + interaction, Embed(title="Available tags"), + **self.PAGINATOR_DEFAULTS, ) - return True - - else: - tags = self._cache.values() - if not tags: - await ctx.send(embed=Embed( - description="**There are no tags in the database!**", - colour=Colour.red() - )) - return True else: - embed: Embed = Embed(title="**Current tags**") + await interaction.response.send_message(embed=Embed(description="**There are no tags!**")) + return True + + identifier = TagIdentifier.from_string(name) + + if identifier.group is None: + # Try to find accessible tags from a group matching the identifier's name. + if group_tags := self.accessible_tags_in_group(identifier.name, interaction.user): await LinePaginator.paginate( - sorted( - f"**»** {tag['title']}" for tag in tags - if self.check_accessibility(ctx.author, tag) - ), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 + group_tags, interaction, Embed(title=f"Tags under *{identifier.name}*"), **self.PAGINATOR_DEFAULTS ) return True - return False - - @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> bool: - """ - Get a specified tag, or a list of all tags if no tag is specified. - - Returns True if something can be sent, or if the tag is on cooldown. - Returns False if no matches are found. - """ - return await self.display_tag(ctx, tag_name) - + embed = await self.get_tag_embed(interaction.user, interaction.channel, identifier) + ephemeral = False + if embed is None: + description = f"**There are no tags matching the name {name!r}!**" + embed = Embed(description=description) + ephemeral = True + elif embed is COOLDOWN.obj: + description = f"Tag {name!r} is on cooldown." + embed = Embed(description=description) + ephemeral = True + + await interaction.response.send_message(embed=embed, ephemeral=ephemeral) + if not ephemeral: + await wait_for_deletion( + await interaction.original_response(), + (interaction.user.id,) + ) -def setup(bot: Bot) -> None: + # A valid tag was found and was either sent, or is on cooldown + return True + + @get_command.autocomplete("name") + async def name_autocomplete( + self, + interaction: Interaction, + current: str + ) -> list[app_commands.Choice[str]]: + """Autocompleter for `/tag get` command.""" + names = [tag.name for tag in self.tags] + choices = [ + app_commands.Choice(name=tag, value=tag) + for tag in names if current.lower() in tag + ] + return choices[:25] if len(choices) > 25 else choices + + +async def setup(bot: Bot) -> None: """Load the Tags cog.""" - bot.add_cog(Tags(bot)) + await bot.add_cog(Tags(bot)) diff --git a/bot/exts/moderation/alts.py b/bot/exts/moderation/alts.py new file mode 100644 index 0000000000..c3fec608d6 --- /dev/null +++ b/bot/exts/moderation/alts.py @@ -0,0 +1,175 @@ +import gettext + +import discord +from discord.ext import commands +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.members import get_or_fetch_member + +from bot import constants +from bot.bot import Bot +from bot.converters import UnambiguousMemberOrUser +from bot.log import get_logger +from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel +from bot.utils.time import discord_timestamp + +log = get_logger(__name__) + + +class AlternateAccounts(commands.Cog): + """A cog used to track a user's alternative accounts across Discord.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def error_text_from_error(error: ResponseCodeError) -> str: + """Format the error into a user-facing message.""" + if resp_json := error.response_json: + errors = ", ".join( + resp_json.get("non_field_errors", []) + + resp_json.get("source", []) + + resp_json.get("target", []) + + resp_json.get("detail", []) + ) + if errors: + return gettext.ngettext("Error from site: ", "Errors from site: ", len(errors)) + errors + return str(error.response_json) + return error.response_text + + async def alts_to_string(self, alts: list[dict]) -> list[str]: + """Convert a list of alts to a list of string representations.""" + lines = [] + guild = self.bot.get_guild(self.bot.guild_id) + for idx, alt in enumerate(alts): + alt_obj = await get_or_fetch_member(guild, alt["target"]) + alt_name = str(alt_obj) if alt_obj else alt["target"] + created_at = discord_timestamp(alt["created_at"]) + updated_at = discord_timestamp(alt["updated_at"]) + + edited = f" edited on {updated_at}\n" if "edited" in alt else "\n" + num_alts = len(alt["alts"]) + lines.append( + f"**Association #{idx} - {alt_name}**\n" + f"<@{alt['target']}> - {alt['target']}\n" + f"Issued by: <@{alt['actor']}> ({alt['actor']}) on {created_at}{edited}" + f"Context: {alt['context']}\n" + f"<@{alt['target']}> has {num_alts} associated {gettext.ngettext('account', 'accounts', num_alts)}" + ) + return lines + + @commands.group(name="association", aliases=("alt", "assoc"), invoke_without_command=True) + async def association_group( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + *, + context: str, + ) -> None: + """ + Alternate accounts commands. + + When called directly marks the two users given as alt accounts. + The context as to why they are believed to be alt accounts must be given. + """ + if user_1.bot or user_2.bot: + await ctx.send(":x: Cannot mark bots as alts") + return + + try: + await self.bot.api_client.post( + f"bot/users/{user_1.id}/alts", + json={"target": user_2.id, "actor": ctx.author.id, "context": context}, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ {user_1.mention} and {user_2.mention} successfully marked as alts.") + + @association_group.command(name="edit", aliases=("e",)) + async def edit_association_command( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + *, + context: str, + ) -> None: + """Edit the context of an association between two users.""" + try: + await self.bot.api_client.patch( + f"bot/users/{user_1.id}/alts", + json={"target": user_2.id, "context": context}, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ Context for association between {user_1.mention} and {user_2.mention} updated.") + + @association_group.command(name="remove", aliases=("r",)) + async def alt_remove_command( + self, + ctx: commands.Context, + user_1: UnambiguousMemberOrUser, + user_2: UnambiguousMemberOrUser, + ) -> None: + """Remove the alt association between the two users.""" + try: + await self.bot.api_client.delete( + f"bot/users/{user_1.id}/alts", + json=user_2.id, + ) + except ResponseCodeError as e: + error = self.error_text_from_error(e) + await ctx.send(f":x: {error}") + return + await ctx.send(f"✅ {user_1.mention} and {user_2.mention} are no longer marked as alts.") + + @association_group.command(name="info", root_aliases=("alts",)) + async def alt_info_command( + self, + ctx: commands.Context, + user: UnambiguousMemberOrUser, + ) -> None: + """Output a list of known alts of this user, and the reasons as to why they are believed to be alts.""" + try: + resp = await self.bot.api_client.get(f"bot/users/{user.id}") + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: {user.mention} not found in site database") + return + raise + alts = resp["alts"] + if not alts: + await ctx.send(f":x: No known alts for {user}") + return + + embed = discord.Embed( + title=f"Associated accounts for {user} ({len(alts)} total)", + colour=discord.Colour.orange(), + ) + lines = await self.alts_to_string(alts) + await LinePaginator.paginate( + lines, + ctx=ctx, + embed=embed, + empty=True, + max_lines=3, + max_size=1000, + allowed_roles=constants.MODERATION_ROLES, + ) + + async def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators inside moderator channels to invoke the commands in this cog.""" + checks = [ + await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), + is_mod_channel(ctx.channel) + ] + return all(checks) + +async def setup(bot: Bot) -> None: + """Load the AlternateAccounts cog.""" + await bot.add_cog(AlternateAccounts(bot)) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py new file mode 100644 index 0000000000..1f0a2dce4d --- /dev/null +++ b/bot/exts/moderation/clean.py @@ -0,0 +1,669 @@ +import contextlib +import itertools +import re +import time +from collections import defaultdict +from collections.abc import Callable, Iterable +from contextlib import suppress +from datetime import datetime +from itertools import takewhile +from typing import Literal, TYPE_CHECKING + +from discord import Colour, Message, NotFound, TextChannel, Thread, User, errors +from discord.ext.commands import Cog, Context, Converter, Greedy, command, group, has_any_role +from discord.ext.commands.converter import TextChannelConverter +from discord.ext.commands.errors import BadArgument + +from bot.bot import Bot +from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES +from bot.converters import Age, ISODateTime +from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger +from bot.utils.channel import is_mod_channel +from bot.utils.messages import upload_log +from bot.utils.modlog import send_log_message + +log = get_logger(__name__) + +# Number of seconds before command invocations and responses are deleted in non-moderation channels. +MESSAGE_DELETE_DELAY = 5 + +# Type alias for checks for whether a message should be deleted. +Predicate = Callable[[Message], bool] +# Type alias for message lookup ranges. +CleanLimit = Message | Age | ISODateTime + + +class CleanChannels(Converter): + """A converter to turn the string into a list of channels to clean, or the literal `*` for all public channels.""" + + _channel_converter = TextChannelConverter() + + async def convert(self, ctx: Context, argument: str) -> Literal["*"] | list[TextChannel]: + """Converts a string to a list of channels to clean, or the literal `*` for all public channels.""" + if argument == "*": + return "*" + return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()] + + +class Regex(Converter): + """A converter that takes a string in the form `.+` and returns the contents of the inline code compiled.""" + + async def convert(self, ctx: Context, argument: str) -> re.Pattern: + """Strips the backticks from the string and compiles it to a regex pattern.""" + match = re.fullmatch(r"`(.+?)`", argument) + if not match: + raise BadArgument("Regex pattern missing wrapping backticks") + try: + return re.compile(match.group(1), re.IGNORECASE + re.DOTALL) + except re.error as e: + raise BadArgument(f"Regex error: {e.msg}") + + +if TYPE_CHECKING: # Used to allow method resolution in IDEs like in converters.py. + CleanChannels = Literal["*"] | list[TextChannel] + Regex = re.Pattern + + +class Clean(Cog): + """ + A cog that allows messages to be deleted in bulk while applying various filters. + + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. + + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + # region: Helper functions + + @staticmethod + def _validate_input( + channels: CleanChannels | None, + bots_only: bool, + users: list[User] | None, + first_limit: CleanLimit | None, + second_limit: CleanLimit | None, + ) -> None: + """Raise errors if an argument value or a combination of values is invalid.""" + if first_limit is None: + # This is an optional argument for the sake of the master command, but it's actually required. + raise BadArgument("Missing cleaning limit.") + + if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels: + raise BadArgument("Both a message limit and channels specified.") + + if isinstance(first_limit, Message) and isinstance(second_limit, Message): + # Messages are not in same channel. + if first_limit.channel != second_limit.channel: + raise BadArgument("Message limits are in different channels.") + + if users and bots_only: + raise BadArgument("Marked as bots only, but users were specified.") + + @staticmethod + async def _send_expiring_message(ctx: Context, content: str) -> None: + """Send `content` to the context channel. Automatically delete if it's not a mod channel.""" + delete_after = None if is_mod_channel(ctx.channel) else MESSAGE_DELETE_DELAY + await ctx.send(content, delete_after=delete_after) + + @staticmethod + def _channels_set( + channels: CleanChannels, ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit + ) -> set[TextChannel]: + """Standardize the input `channels` argument to a usable set of text channels.""" + # Default to using the invoking context's channel or the channel of the message limit(s). + if not channels: + # Input was validated - if first_limit is a message, second_limit won't point at a different channel. + if isinstance(first_limit, Message): + channels = {first_limit.channel} + elif isinstance(second_limit, Message): + channels = {second_limit.channel} + else: + channels = {ctx.channel} + else: + if channels == "*": + channels = { + channel for channel in itertools.chain(ctx.guild.channels, ctx.guild.threads) + if isinstance(channel, TextChannel | Thread) + # Assume that non-public channels are not needed to optimize for speed. + and channel.permissions_for(ctx.guild.default_role).view_channel + } + else: + channels = set(channels) + + return channels + + @staticmethod + def _build_predicate( + first_limit: datetime, + second_limit: datetime | None = None, + bots_only: bool = False, + users: list[User] | None = None, + regex: re.Pattern | None = None, + ) -> Predicate: + """Return the predicate that decides whether to delete a given message.""" + def predicate_bots_only(message: Message) -> bool: + """Return True if the message was sent by a bot.""" + return message.author.bot + + def predicate_specific_users(message: Message) -> bool: + """Return True if the message was sent by the user provided in the _clean_messages call.""" + return message.author in users + + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = "\n".join(attr for attr in content if attr) + + # Now let's see if there's a regex match + return bool(regex.search(content)) + + def predicate_range(message: Message) -> bool: + """Check if the message age is between the two limits.""" + return first_limit < message.created_at < second_limit + + def predicate_after(message: Message) -> bool: + """Check if the message is younger than the first limit.""" + return message.created_at > first_limit + + predicates = [] + # Set up the correct predicate + if second_limit: + predicates.append(predicate_range) # Delete messages in the specified age range + else: + predicates.append(predicate_after) # Delete messages older than the specified age + + if bots_only: + predicates.append(predicate_bots_only) # Delete messages from bots + if users: + predicates.append(predicate_specific_users) # Delete messages from specific user + if regex: + predicates.append(predicate_regex) # Delete messages that match regex + + if len(predicates) == 1: + return predicates[0] + return lambda m: all(pred(m) for pred in predicates) + + async def _delete_invocation(self, ctx: Context) -> None: + """Delete the command invocation if it's not in a mod channel.""" + if not is_mod_channel(ctx.channel): + self.mod_log.ignore(Event.message_delete, ctx.message.id) + try: + await ctx.message.delete() + except errors.NotFound: + # Invocation message has already been deleted + log.info("Tried to delete invocation message, but it was already deleted.") + + def _use_cache(self, limit: datetime) -> bool: + """Tell whether all messages to be cleaned can be found in the cache.""" + return self.bot.cached_messages[0].created_at <= limit + + def _get_messages_from_cache( + self, + channels: set[TextChannel], + to_delete: Predicate, + lower_limit: datetime + ) -> tuple[defaultdict[TextChannel, list], list[int]]: + """Helper function for getting messages from the cache.""" + message_mappings = defaultdict(list) + message_ids = [] + for message in takewhile(lambda m: m.created_at > lower_limit, reversed(self.bot.cached_messages)): + if not self.cleaning: + # Cleaning was canceled + return message_mappings, message_ids + + if message.channel in channels and to_delete(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + return message_mappings, message_ids + + async def _get_messages_from_channels( + self, + channels: Iterable[TextChannel], + to_delete: Predicate, + after: datetime, + before: datetime | None = None + ) -> tuple[defaultdict[TextChannel, list], list]: + """ + Collect the messages for deletion by iterating over the histories of the appropriate channels. + + The clean cog enforces an upper limit on message age through `_validate_input`. + """ + message_mappings = defaultdict(list) + message_ids = [] + + for channel in channels: + async for message in channel.history(limit=CleanMessages.message_limit, before=before, after=after): + + if not self.cleaning: + # Cleaning was canceled, return empty containers. + return defaultdict(list), [] + + if to_delete(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + return message_mappings, message_ids + + @staticmethod + def is_older_than_14d(message: Message) -> bool: + """ + Precisely checks if message is older than 14 days, bulk deletion limit. + + Inspired by how purge works internally. + Comparison on message age could possibly be less accurate which in turn would resort in problems + with message deletion if said messages are very close to the 14d mark. + """ + two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + return message.id < two_weeks_old_snowflake + + async def _delete_messages_individually(self, messages: list[Message]) -> list[Message]: + """Delete each message in the list unless cleaning is cancelled. Return the deleted messages.""" + deleted = [] + for message in messages: + # Ensure that deletion was not canceled + if not self.cleaning: + return deleted + with contextlib.suppress(NotFound): # Message doesn't exist or was already deleted + await message.delete() + deleted.append(message) + return deleted + + async def _delete_found(self, message_mappings: dict[TextChannel, list[Message]]) -> list[Message]: + """ + Delete the detected messages. + + Deletion is made in bulk per channel for messages less than 14d old. + The function returns the deleted messages. + If cleaning was cancelled in the middle, return messages already deleted. + """ + deleted = [] + for channel, messages in message_mappings.items(): + to_delete = [] + + delete_old = False + for current_index, message in enumerate(messages): # noqa: B007 + if not self.cleaning: + # Means that the cleaning was canceled + return deleted + + if self.is_older_than_14d(message): + # Further messages are too old to be deleted in bulk + delete_old = True + break + + to_delete.append(message) + + if len(to_delete) == 100: + # Only up to 100 messages can be deleted in a bulk + await channel.delete_messages(to_delete) + deleted.extend(to_delete) + to_delete.clear() + + if not self.cleaning: + return deleted + if len(to_delete) > 0: + # Deleting any leftover messages if there are any + with suppress(NotFound): + await channel.delete_messages(to_delete) + deleted.extend(to_delete) + + if not self.cleaning: + return deleted + if delete_old: + old_deleted = await self._delete_messages_individually(messages[current_index:]) + deleted.extend(old_deleted) + + return deleted + + async def _modlog_cleaned_messages( + self, + messages: list[Message], + channels: CleanChannels, + ctx: Context + ) -> str | None: + """Log the deleted messages to the modlog, returning the log url if logging was successful.""" + if not messages: + # Can't build an embed, nothing to clean! + await self._send_expiring_message(ctx, ":x: No matching messages could be found.") + return None + + # Reverse the list to have reverse chronological order + log_messages = reversed(messages) + log_url = await upload_log(log_messages, ctx.author.id) + + # Build the embed and send it + if channels == "*": + target_channels = "all public channels" + else: + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(messages)}** messages deleted in {target_channels} by " + f"{ctx.author.mention}\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + for channel_id in [Channels.mod_log, Channels.message_log]: + await send_log_message( + self.bot, + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=channel_id, + ) + + return log_url + + # endregion + + async def _clean_messages( + self, + ctx: Context, + channels: CleanChannels | None, + bots_only: bool = False, + users: list[User] | None = None, + regex: re.Pattern | None = None, + first_limit: CleanLimit | None = None, + second_limit: CleanLimit | None = None, + attempt_delete_invocation: bool = True, + ) -> str | None: + """A helper function that does the actual message cleaning, returns the log url if logging was successful.""" + self._validate_input(channels, bots_only, users, first_limit, second_limit) + + # Are we already performing a clean? + if self.cleaning: + await self._send_expiring_message( + ctx, ":x: Please wait for the currently ongoing clean operation to complete." + ) + return None + self.cleaning = True + + deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit) + + if isinstance(first_limit, Message): + first_limit = first_limit.created_at + if isinstance(second_limit, Message): + second_limit = second_limit.created_at + if first_limit and second_limit: + first_limit, second_limit = sorted([first_limit, second_limit]) + + # Needs to be called after standardizing the input. + predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex) + + if attempt_delete_invocation: + # Delete the invocation first + await self._delete_invocation(ctx) + + if self._use_cache(first_limit): + log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.") + message_mappings, message_ids = self._get_messages_from_cache( + channels=deletion_channels, to_delete=predicate, lower_limit=first_limit + ) + else: + log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.") + message_mappings, message_ids = await self._get_messages_from_channels( + channels=deletion_channels, + to_delete=predicate, + after=first_limit, # Remember first is the earlier datetime (the "older" time). + before=second_limit + ) + + if not self.cleaning: + # Means that the cleaning was canceled + return None + + # Now let's delete the actual messages with purge. + self.mod_log.ignore(Event.message_delete, *message_ids) + deleted_messages = await self._delete_found(message_mappings) + self.cleaning = False + + if not channels: + channels = deletion_channels + log_url = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) + + success_message = ( + f"{Emojis.ok_hand} Deleted {len(deleted_messages)} messages. " + f"A log of the deleted messages can be found here {log_url}." + ) + if log_url and is_mod_channel(ctx.channel): + try: + await ctx.reply(success_message) + except errors.HTTPException: + await ctx.send(success_message) + elif log_url: + if mods := self.bot.get_channel(Channels.mods): + await mods.send(f"{ctx.author.mention} {success_message}") + return log_url + + # region: Commands + + @group(invoke_without_command=True, name="clean", aliases=("clear",)) + async def clean_group( + self, + ctx: Context, + users: Greedy[User] = None, + first_limit: CleanLimit | None = None, + second_limit: CleanLimit | None = None, + regex: Regex | None = None, + bots_only: bool | None = False, + *, + channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input. + ) -> None: + """ + Commands for cleaning messages in channels. + + If arguments are provided, will act as a master command from which all subcommands can be derived. + + \u2003• `users`: A series of user mentions, ID's, or names. + \u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. + At least one limit is required. The limits are *exclusive*. + If a message is provided, cleaning will happen in that channel, and channels cannot be provided. + If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. + \u2003• `regex`: A regex pattern the message must contain to be deleted. + The pattern must be provided enclosed in backticks. + If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. + \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified. + \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all public channels. + """ + if not any([users, first_limit, second_limit, regex, channels]): + await ctx.send_help(ctx.command) + return + + await self._clean_messages(ctx, channels, bots_only, users, regex, first_limit, second_limit) + + @clean_group.command(name="users", aliases=["user"]) + async def clean_users( + self, + ctx: Context, + users: Greedy[User], + message_or_time: CleanLimit, + *, + channels: CleanChannels = None + ) -> None: + """ + Delete messages posted by the provided users, stop cleaning after reaching `message_or_time`. + + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + + If a message is specified the cleanup will be limited to the channel the message is in. + + If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels. + An asterisk can also be used to designate cleanup across all channels. + """ + await self._clean_messages(ctx, users=users, channels=channels, first_limit=message_or_time) + + @clean_group.command(name="bots", aliases=["bot"]) + async def clean_bots( + self, + ctx: Context, + message_or_time: CleanLimit, + *, + channels: CleanChannels = None, + ) -> None: + """ + Delete all messages posted by a bot, stop cleaning after reaching `message_or_time`. + + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + + If a message is specified the cleanup will be limited to the channel the message is in. + + If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels. + An asterisk can also be used to designate cleanup across all channels. + """ + await self._clean_messages(ctx, bots_only=True, channels=channels, first_limit=message_or_time) + + @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) + async def clean_regex( + self, + ctx: Context, + regex: Regex, + message_or_time: CleanLimit, + *, + channels: CleanChannels = None + ) -> None: + """ + Delete all messages that match a certain regex, stop cleaning after reaching `message_or_time`. + + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + + If a message is specified the cleanup will be limited to the channel the message is in. + + If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels. + An asterisk can also be used to designate cleanup across all channels. + + The `regex` pattern must be provided enclosed in backticks. + + For example: \\`[0-9]\\`. + + If the `regex` pattern contains spaces, it still needs to be enclosed in double quotes on top of that. + + For example: "\\`[0-9]\\`". + """ + await self._clean_messages(ctx, regex=regex, channels=channels, first_limit=message_or_time) + + @clean_group.command(name="until") + async def clean_until( + self, + ctx: Context, + until: CleanLimit, + channel: TextChannel = None + ) -> None: + """ + Delete all messages until a certain limit. + + A limit can be either a message, and ISO date-time string, or a time delta. + + The limit is *exclusive*. + + If a message is specified the cleanup will be limited to the channel the message is in. + + If a timedelta or an ISO datetime is specified, `channels` can be specified to clean across multiple channels. + An asterisk can also be used to designate cleanup across all channels. + """ + await self._clean_messages( + ctx, + channels=[channel] if channel else None, + first_limit=until, + ) + + @clean_group.command(name="between", aliases=["after-until", "from-to"]) + async def clean_between( + self, + ctx: Context, + first_limit: CleanLimit, + second_limit: CleanLimit, + channel: TextChannel = None + ) -> None: + """ + Delete all messages within range. + + The range is specified through two limits. + A limit can be either a message, and ISO date-time string, or a time delta. + + The limits are *exclusive*. + + If two messages are specified, they both must be in the same channel. + The cleanup will be limited to the channel the messages are in. + + If two timedeltas or ISO datetimes are specified, `channels` can be specified to clean across multiple channels. + An asterisk can also be used to designate cleanup across all channels. + """ + await self._clean_messages( + ctx, + channels=[channel] if channel else None, + first_limit=first_limit, + second_limit=second_limit, + ) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" + if not self.cleaning: + message = ":question: There's no cleaning going on." + else: + self.cleaning = False + message = f"{Emojis.check_mark} Clean interrupted." + + await self._send_expiring_message(ctx, message) + await self._delete_invocation(ctx) + + @command() + async def purge(self, ctx: Context, users: Greedy[User], age: Age | ISODateTime | None = None) -> None: + """ + Clean messages of `users` from all public channels up to a certain message `age` (10 minutes by default). + + Requires 1 or more users to be specified. For channel-based cleaning, use `clean` instead. + + `age` can be a duration or an ISO 8601 timestamp. + """ + if not users: + raise BadArgument("At least one user must be specified.") + + if age is None: + age = await Age().convert(ctx, "10M") + + await self._clean_messages(ctx, channels="*", users=users, first_limit=age) + + # endregion + + async def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return await has_any_role(*MODERATION_ROLES).predicate(ctx) + + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Safely end the cleaning operation on unexpected errors.""" + self.cleaning = False + + +async def setup(bot: Bot) -> None: + """Load the Clean cog.""" + await bot.add_cog(Clean(bot)) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index dfb1afd19d..3196c4a1a8 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,27 +1,29 @@ import asyncio -import logging import traceback from collections import namedtuple from datetime import datetime from enum import Enum -from typing import Optional, Union -from aioredis import RedisError +import arrow from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Member, User +from discord import Colour, Embed, Forbidden, Member, TextChannel, User from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role +from pydis_core.utils import scheduling +from pydis_core.utils.scheduling import Scheduler +from redis import RedisError from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger +from bot.utils import time from bot.utils.messages import format_user -from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta +from bot.utils.modlog import send_log_message -log = logging.getLogger(__name__) +log = get_logger(__name__) REJECTION_MESSAGE = """ Hi, {user} - Thanks for your interest in our server! @@ -42,12 +44,12 @@ class Action(Enum): """Defcon Action.""" - ActionInfo = namedtuple('LogInfoDetails', ['icon', 'emoji', 'color', 'template']) + ActionInfo = namedtuple("LogInfoDetails", ["icon", "emoji", "color", "template"]) SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "") SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "") DURATION_UPDATE = ActionInfo( - Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n" + Icons.defcon_update, Emojis.defcon_update, Colour.og_blurple(), "**Threshold:** {threshold}\n\n" ) @@ -67,25 +69,27 @@ def __init__(self, bot: Bot): self.scheduler = Scheduler(self.__class__.__name__) - self.bot.loop.create_task(self._sync_settings()) + scheduling.create_task(self._sync_settings(), event_loop=self.bot.loop) - @property - def mod_log(self) -> ModLog: + async def get_mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") + while not (cog := self.bot.get_cog("ModLog")): + await asyncio.sleep(1) + return cog @defcon_settings.atomic_transaction async def _sync_settings(self) -> None: """On cog load, try to synchronize DEFCON settings to the API.""" log.trace("Waiting for the guild to become available before syncing.") await self.bot.wait_until_guild_available() + self.channel = await self.bot.fetch_channel(Channels.defcon) log.trace("Syncing settings.") try: settings = await self.defcon_settings.to_dict() - self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None + self.threshold = time.parse_duration_string(settings["threshold"]) if settings.get("threshold") else None self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None except RedisError: log.exception("Unable to get DEFCON settings!") @@ -99,27 +103,29 @@ async def _sync_settings(self) -> None: self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold()) self._update_notifier() - log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}") + log.info(f"DEFCON synchronized: {time.humanize_delta(self.threshold) if self.threshold else '-'}") - self._update_channel_topic() + await self._update_channel_topic() @Cog.listener() async def on_member_join(self, member: Member) -> None: """Check newly joining users to see if they meet the account age threshold.""" if self.threshold: - now = datetime.utcnow() + now = arrow.utcnow() - if now - member.created_at < relativedelta_to_timedelta(self.threshold): + if now - member.created_at < time.relativedelta_to_timedelta(self.threshold): log.info(f"Rejecting user {member}: Account is too new") message_sent = False try: await member.send(REJECTION_MESSAGE.format(user=member.mention)) - message_sent = True + except Forbidden: + log.debug(f"Cannot send DEFCON rejection DM to {member}: DMs disabled") except Exception: - log.exception(f"Unable to send rejection message to user: {member}") + # Broadly catch exceptions because DM isn't critical, but it's imperative to kick them. + log.exception(f"Error sending DEFCON rejection message to {member}") await member.kick(reason="DEFCON active, user is too new") self.bot.stats.incr("defcon.leaves") @@ -131,36 +137,41 @@ async def on_member_join(self, member: Member) -> None: if not message_sent: message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled." - await self.mod_log.send_log_message( - Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar_url_as(static_format="png") + await send_log_message( + self.bot, + Icons.defcon_denied, + Colours.soft_red, + "Entry denied", + message, + thumbnail=member.display_avatar.url ) - @group(name='defcon', aliases=('dc',), invoke_without_command=True) + @group(name="defcon", aliases=("dc",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) - @defcon_group.command(aliases=('s',)) + @defcon_group.command(aliases=("s",)) @has_any_role(*MODERATION_ROLES) async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" + expiry = time.format_relative(self.expiry) if self.expiry else "-" embed = Embed( - colour=Colour.blurple(), title="DEFCON Status", + colour=Colour.og_blurple(), title="DEFCON Status", description=f""" - **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} - **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} + **Threshold:** {time.humanize_delta(self.threshold) if self.threshold else "-"} + **Expires:** {expiry} **Verification level:** {ctx.guild.verification_level.name} """ ) await ctx.send(embed=embed) - @defcon_group.command(name="threshold", aliases=('t', 'd')) + @defcon_group.command(name="threshold", aliases=("t", "d")) @has_any_role(*MODERATION_ROLES) async def threshold_command( - self, ctx: Context, threshold: Union[DurationDelta, int], expiry: Optional[Expiry] = None + self, ctx: Context, threshold: DurationDelta | int, expiry: Expiry | None = None ) -> None: """ Set how old an account must be to join the server. @@ -172,7 +183,7 @@ async def threshold_command( """ if isinstance(threshold, int): threshold = relativedelta(days=threshold) - await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry) + await self._update_threshold(ctx.author, ctx.channel, threshold, expiry) @defcon_group.command() @has_any_role(Roles.admins) @@ -181,7 +192,12 @@ async def shutdown(self, ctx: Context) -> None: role = ctx.guild.default_role permissions = role.permissions - permissions.update(send_messages=False, add_reactions=False, connect=False) + permissions.update( + send_messages=False, + add_reactions=False, + send_messages_in_threads=False, + connect=False + ) await role.edit(reason="DEFCON shutdown", permissions=permissions) await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @@ -192,19 +208,31 @@ async def unshutdown(self, ctx: Context) -> None: role = ctx.guild.default_role permissions = role.permissions - permissions.update(send_messages=True, add_reactions=True, connect=True) + permissions.update( + send_messages=True, + add_reactions=True, + send_messages_in_threads=True, + connect=True + ) await role.edit(reason="DEFCON unshutdown", permissions=permissions) await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") - def _update_channel_topic(self) -> None: + async def _update_channel_topic(self) -> None: """Update the #defcon channel topic with the current DEFCON status.""" - new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})" + threshold = time.humanize_delta(self.threshold) if self.threshold else "-" + new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {threshold})" - self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - asyncio.create_task(self.channel.edit(topic=new_topic)) + (await self.get_mod_log()).ignore(Event.guild_channel_update, Channels.defcon) + scheduling.create_task(self.channel.edit(topic=new_topic)) @defcon_settings.atomic_transaction - async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: + async def _update_threshold( + self, + author: User, + channel: TextChannel, + threshold: relativedelta, + expiry: Expiry | None = None + ) -> None: """Update the new threshold in the cog, cache, defcon channel, and logs, and additionally schedule expiry.""" self.threshold = threshold if threshold == relativedelta(days=0): # If the threshold is 0, we don't need to schedule anything @@ -223,8 +251,8 @@ async def _update_threshold(self, author: User, threshold: relativedelta, expiry try: await self.defcon_settings.update( { - 'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", - 'expiry': expiry.isoformat() if expiry else 0 + "threshold": Defcon._stringify_relativedelta(self.threshold) if self.threshold else "", + "expiry": expiry.isoformat() if expiry else 0 } ) except RedisError: @@ -234,27 +262,32 @@ async def _update_threshold(self, author: User, threshold: relativedelta, expiry expiry_message = "" if expiry: - expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}" + formatted_expiry = time.humanize_delta(expiry, max_units=2) + expiry_message = f" for the next {formatted_expiry}" if self.threshold: channel_message = ( - f"updated; accounts must be {humanize_delta(self.threshold)} " + f"updated; accounts must be {time.humanize_delta(self.threshold)} " f"old to join the server{expiry_message}" ) else: channel_message = "removed" - await self.channel.send( - f"{action.value.emoji} DEFCON threshold {channel_message}{error}." - ) + message = f"{action.value.emoji} DEFCON threshold {channel_message}{error}." + await self.channel.send(message) + + # If invoked outside of #defcon send to `ctx.channel` too + if channel != self.channel: + await channel.send(message) + await self._send_defcon_log(action, author) - self._update_channel_topic() + await self._update_channel_topic() self._log_threshold_stat(threshold) async def _remove_threshold(self) -> None: """Resets the threshold back to 0.""" - await self._update_threshold(self.bot.user, relativedelta(days=0)) + await self._update_threshold(self.bot.user, self.channel, relativedelta(days=0)) @staticmethod def _stringify_relativedelta(delta: relativedelta) -> str: @@ -264,7 +297,7 @@ def _stringify_relativedelta(delta: relativedelta) -> str: def _log_threshold_stat(self, threshold: relativedelta) -> None: """Adds the threshold to the bot stats in days.""" - threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY + threshold_days = time.relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY self.bot.stats.gauge("defcon.threshold", threshold_days) async def _send_defcon_log(self, action: Action, actor: User) -> None: @@ -272,11 +305,11 @@ async def _send_defcon_log(self, action: Action, actor: User) -> None: info = action.value log_msg: str = ( f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n" - f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}" + f"{info.template.format(threshold=(time.humanize_delta(self.threshold) if self.threshold else '-'))}" ) status_msg = f"DEFCON {action.name.lower()}" - await self.mod_log.send_log_message(info.icon, info.color, status_msg, log_msg) + await send_log_message(self.bot, info.icon, info.color, status_msg, log_msg) def _update_notifier(self) -> None: """Start or stop the notifier according to the DEFCON status.""" @@ -291,15 +324,15 @@ def _update_notifier(self) -> None: @tasks.loop(hours=1) async def defcon_notifier(self) -> None: """Routinely notify moderators that DEFCON is active.""" - await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.") + await self.channel.send(f"Defcon is on and is set to {time.humanize_delta(self.threshold)}.") - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Cancel the notifer and threshold removal tasks when the cog unloads.""" log.trace("Cog unload: canceling defcon notifier task.") self.defcon_notifier.cancel() self.scheduler.cancel_all() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Defcon cog.""" - bot.add_cog(Defcon(bot)) + await bot.add_cog(Defcon(bot)) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 1d2206e275..03b18e46a8 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -1,13 +1,13 @@ -import logging - import discord from discord.ext.commands import Cog, Context, command, has_any_role +from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES -from bot.utils.services import send_to_paste_service +from bot.constants import BaseURLs, Emojis, MODERATION_ROLES +from bot.log import get_logger +from bot.utils.channel import is_mod_channel -log = logging.getLogger(__name__) +log = get_logger(__name__) class DMRelay(Cog): @@ -53,20 +53,27 @@ async def dmrelay(self, ctx: Context, user: discord.User, limit: int = 100) -> N f"User: {user} ({user.id})\n" f"Channel ID: {user.dm_channel.id}\n\n" ) - - paste_link = await send_to_paste_service(metadata + output, extension="txt") - - if paste_link is None: - await ctx.send(f"{Emojis.cross_mark} Failed to upload output to hastebin.") - return - - await ctx.send(paste_link) + file = PasteFile(content=metadata + output, lexer="text") + try: + resp = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, + ) + message = resp.link + except PasteTooLongError: + message = f"{Emojis.cross_mark} Too long to upload to paste service." + except PasteUploadError: + message = f"{Emojis.cross_mark} Failed to upload to paste service." + + await ctx.send(message) async def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return await has_any_role(*MODERATION_ROLES).predicate(ctx) + """Only allow moderators to invoke the commands in this cog in mod channels.""" + return (await has_any_role(*MODERATION_ROLES).predicate(ctx) + and is_mod_channel(ctx.channel)) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the DMRelay cog.""" - bot.add_cog(DMRelay(bot)) + await bot.add_cog(DMRelay(bot)) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0e479d33f5..9c11ad96c3 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,17 +1,20 @@ import asyncio -import logging -import typing as t -from datetime import datetime +import re +from datetime import UTC, datetime from enum import Enum import discord -from discord.ext.commands import Cog +from async_rediscache import RedisCache +from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound +from pydis_core.utils import scheduling from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Webhooks -from bot.utils.messages import sub_clyde +from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks +from bot.log import get_logger +from bot.utils.messages import format_user, sub_clyde +from bot.utils.time import TimestampFormats, discord_timestamp -log = logging.getLogger(__name__) +log = get_logger(__name__) # Amount of messages for `crawl_task` to process at most on start-up - limited to 50 # as in practice, there should never be this many messages, and if there are, @@ -21,6 +24,12 @@ # Seconds for `crawl_task` to sleep after adding reactions to a message CRAWL_SLEEP = 2 +DISCORD_MESSAGE_LINK_RE = re.compile( + r"(https?://(?:(ptb|canary|www)\.)?discord(?:app)?\.com/channels/" + r"[0-9]{15,20}" + r"/[0-9]{15,20}/[0-9]{15,20})" +) + class Signal(Enum): """ @@ -36,17 +45,17 @@ class Signal(Enum): # Reactions from non-mod roles will be removed -ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) +ALLOWED_ROLES: set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check -ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +ALL_SIGNALS: set[str] = {signal.value for signal in Signal} # An embed coupled with an optional file to be dispatched # If the file is not None, the embed attempts to show it in its body -FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] +FileEmbed = tuple[discord.Embed, discord.File | None] -async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: +async def download_file(attachment: discord.Attachment) -> discord.File | None: """ Download & return `attachment` file. @@ -88,12 +97,22 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di colour = Colours.soft_red footer = f"Rejected by {actioned_by}" + reported_timestamp = discord_timestamp(incident.created_at) + relative_timestamp = discord_timestamp(incident.created_at, TimestampFormats.RELATIVE) + reported_on_msg = f"*Reported {reported_timestamp} ({relative_timestamp}).*" + + # If the description will be too long (>4096 total characters), truncate the incident content + if len(incident.content) > (allowed_content_chars := 4096-len(reported_on_msg)-2): # -2 for the newlines + description = incident.content[:allowed_content_chars-3] + f"...\n\n{reported_on_msg}" + else: + description = incident.content + f"\n\n{reported_on_msg}" + embed = discord.Embed( - description=incident.content, - timestamp=datetime.utcnow(), + description=description, colour=colour, + timestamp=datetime.now(UTC) ) - embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url) if incident.attachments: attachment = incident.attachments[0] # User-sent messages can only contain one attachment @@ -104,7 +123,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di else: embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file else: - file = None + file = discord.utils.MISSING return embed, file @@ -115,12 +134,13 @@ def is_incident(message: discord.Message) -> bool: message.channel.id == Channels.incidents, # Message sent in #incidents not message.author.bot, # Not by a bot not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.pinned, # Isn't header + not message.reference, # And is not a reply ) return all(conditions) -def own_reactions(message: discord.Message) -> t.Set[str]: +def own_reactions(message: discord.Message) -> set[str]: """Get the set of reactions placed on `message` by the bot itself.""" return {str(reaction.emoji) for reaction in message.reactions if reaction.me} @@ -130,6 +150,109 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) +def shorten_text(text: str) -> str: + """ + Truncate the text if there are over 3 lines or 300 characters, or if it is a single word. + + The maximum length of the string would be 303 characters across 3 lines at maximum. + """ + original_length = len(text) + # Truncate text to a maximum of 300 characters + if len(text) > 300: + text = text[:300] + + # Limit to a maximum of three lines + text = "\n".join(text.split("\n", maxsplit=3)[:3]) + + # If it is a single word, then truncate it to 50 characters + if text.find(" ") == -1: + text = text[:50] + + # Remove extra whitespaces from the `text` + text = text.strip() + + # Add placeholder if the text was shortened + if len(text) < original_length: + text = f"{text}..." + + return text + + +async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed | None: + """ + Create an embedded representation of the discord message link contained in the incident report. + + The Embed would contain the following information --> + Author: @Jason Terror ♦ (736234578745884682) + Channel: Special/#bot-commands (814190307980607493) + Content: This is a very important message! + """ + embed = None + + try: + message: discord.Message = await MessageConverter().convert(ctx, message_link) + except MessageNotFound: + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + + last_100_logs: list[discord.Message] = [message async for message in mod_logs_channel.history(limit=100)] + + for log_entry in last_100_logs: + if not log_entry.embeds: + continue + + log_embed: discord.Embed = log_entry.embeds[0] + if ( + log_embed.author.name == "Message deleted" + and f"[Jump to message]({message_link})" in log_embed.description + ): + embed = discord.Embed( + colour=discord.Colour.dark_gold(), + title="Deleted Message Link", + description=( + f"Found <#{Channels.mod_log}> entry for deleted message: " + f"[Jump to message]({log_entry.jump_url})." + ) + ) + if not embed: + embed = discord.Embed( + colour=discord.Colour.red(), + title="Bad Message Link", + description=f"Message {message_link} not found." + ) + except discord.DiscordException as e: + log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") + else: + channel = message.channel + if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel: + log.info( + f"Helpers don't have read permissions in #{channel.name}," + f" not sending message link embed for {message_link}" + ) + return None + + embed = discord.Embed( + colour=discord.Colour.gold(), + description=( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** {channel.mention} ({channel.category}" + f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}" + f"{channel.name})\n" + ), + timestamp=message.created_at + ) + embed.set_author(name=message.author, icon_url=message.author.display_avatar.url) + embed.add_field( + name="Content", + value=shorten_text(message.content) if message.content else "[No Message Content]" + ) + embed.set_footer(text=f"Message ID: {message.id}") + + if message.attachments: + embed.set_image(url=message.attachments[0].url) + + return embed + + async def add_signals(incident: discord.Message) -> None: """ Add `Signal` member emoji to `incident` as reactions. @@ -143,7 +266,14 @@ async def add_signals(incident: discord.Message) -> None: log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) + try: + await incident.add_reaction(signal_emoji.value) + except discord.NotFound as e: + if e.code != 10008: + raise + + log.trace(f"Couldn't react with signal because message {incident.id} was deleted; skipping incident") + return class Incidents(Cog): @@ -160,6 +290,7 @@ class Incidents(Cog): * See: `crawl_incidents` On message: + * Run message through `extract_message_links` and send them into the channel * Add `Signal` member emoji if message qualifies as an incident * Ignore messages starting with # * Use this if verbal communication is necessary @@ -173,17 +304,34 @@ class Incidents(Cog): * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message + * Delete quotation message if cached * See: `on_raw_reaction_add` Please refer to function docstrings for implementation details. """ + # This dictionary maps an incident report message to the message link embed's ID + # RedisCache[discord.Message.id, discord.Message.id] + message_link_embeds_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot + self.incidents_webhook = None + + scheduling.create_task(self.fetch_webhook()) self.event_lock = asyncio.Lock() - self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + self.crawl_task = scheduling.create_task(self.crawl_incidents()) + + async def fetch_webhook(self) -> None: + """Fetch the incidents webhook object, so we can post message link embeds to it.""" + await self.bot.wait_until_guild_available() + + try: + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents.id) + except discord.HTTPException: + log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents.id}`.") async def crawl_incidents(self) -> None: """ @@ -241,11 +389,11 @@ async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: - webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive.id) await webhook.send( embed=embed, - username=sub_clyde(incident.author.name), - avatar_url=incident.author.avatar_url, + username=sub_clyde(incident.author.display_name), + avatar_url=incident.author.display_avatar.url, file=attachment_file, ) except Exception: @@ -267,8 +415,8 @@ def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id - coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) - return self.bot.loop.create_task(coroutine) + coroutine = self.bot.wait_for("raw_message_delete", check=check, timeout=timeout) + return scheduling.create_task(coroutine) async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ @@ -284,18 +432,27 @@ async def process_event(self, reaction: str, incident: discord.Message, member: This ensures that if there is a racing event awaiting the lock, it will fail to find the message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock forever should something go wrong. + + Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the + webhook message for that particular link from the channel. """ - members_roles: t.Set[int] = {role.id for role in member.roles} + members_roles: set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return try: signal = Signal(reaction) except ValueError: log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return log.trace(f"Received signal: {signal}") @@ -313,17 +470,24 @@ async def process_event(self, reaction: str, incident: discord.Message, member: confirmation_task = self.make_confirmation_task(incident, timeout) log.trace("Deleting original message") - await incident.delete() + try: + await incident.delete() + except discord.NotFound: + log.trace("Couldn't delete message because it was already deleted") log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") try: await confirmation_task - except asyncio.TimeoutError: + except TimeoutError: log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!") else: log.trace("Deletion was confirmed") - async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + if self.incidents_webhook: + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embed(incident.id) + + async def resolve_message(self, message_id: int) -> discord.Message | None: """ Get `discord.Message` for `message_id` from cache, or API. @@ -338,7 +502,7 @@ async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) + message: discord.Message | None = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") @@ -402,11 +566,109 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" - if is_incident(message): - await add_signals(message) + """ + Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. + + If `message` is an incident report, then run it through `extract_message_links` to get all + the message link embeds (embeds which contain information about that particular link). + These message link embeds are then sent into the channel. + + Also passes the message into `add_signals` if the message is an incident. + """ + if not is_incident(message): + return + + await add_signals(message) + + # Only use this feature if incidents webhook embed is found + if self.incidents_webhook: + if embed_list := await self.extract_message_links(message): + await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) + + @Cog.listener() + async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: + """ + Delete message link embeds for `payload.message_id`. + + Search through the cache for message, if found delete it from cache and channel. + """ + if self.incidents_webhook: + await self.delete_msg_link_embed(payload.message_id) + + async def extract_message_links(self, message: discord.Message) -> list[discord.Embed] | None: + """ + Check if there's any message links in the text content. + + Then pass the message_link into `make_message_link_embed` to format an + embed for it containing information about the link. + + As Discord only allows a max of 10 embeds in a single webhook, just send the + first 10 embeds and don't care about the rest. + + If no links are found for the message, just log a trace statement. + """ + message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + if not message_links: + log.trace( + f"No message links detected on incident message with id {message.id}." + ) + return None + + embeds = [] + for message_link in message_links[:10]: + ctx = await self.bot.get_context(message) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) + + return embeds + + async def send_message_link_embeds( + self, + webhook_embed_list: list, + message: discord.Message, + webhook: discord.Webhook, + ) -> int | None: + """ + Send message link embeds to #incidents channel. + + Using the `webhook` passed in as a parameter to send + the embeds in the `webhook_embed_list` parameter. + + After sending each embed it maps the `message.id` + to the `webhook_msg_ids` IDs in the async redis-cache. + """ + try: + webhook_msg = await webhook.send( + embeds=[embed for embed in webhook_embed_list if embed], + username=sub_clyde(message.author.name), + avatar_url=message.author.display_avatar.url, + wait=True, + ) + except discord.DiscordException: + log.exception( + f"Failed to send message link embed {message.id} to #incidents." + ) + else: + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + log.trace("Message link embeds sent successfully to #incidents!") + return webhook_msg.id + + async def delete_msg_link_embed(self, message_id: int) -> None: + """Delete the Discord message link message found in cache for `message_id`.""" + log.trace("Deleting Discord message link's webhook message.") + webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id)) + + if webhook_msg_id: + try: + await self.incidents_webhook.delete_message(webhook_msg_id) + except discord.errors.NotFound: + log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.") + + await self.message_link_embeds_cache.delete(message_id) + log.trace("Successfully deleted discord links webhook message.") -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Incidents cog.""" - bot.add_cog(Incidents(bot)) + await bot.add_cog(Incidents(bot)) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8286d36350..2adcdb485e 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,48 +1,63 @@ -import logging import textwrap import typing as t from abc import abstractmethod -from datetime import datetime +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta from gettext import ngettext +import arrow import dateutil.parser import discord +from async_rediscache import RedisCache from discord.ext.commands import Context +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling from bot import constants -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours +from bot.constants import Colours, Roles +from bot.converters import MemberOrUser from bot.exts.moderation.infraction import _utils -from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog -from bot.utils import messages, scheduling, time +from bot.log import get_logger +from bot.utils import messages, time from bot.utils.channel import is_mod_channel +from bot.utils.modlog import send_log_message -log = logging.getLogger(__name__) +log = get_logger(__name__) +AUTOMATED_TIDY_UP_HOURS = 1 + +# Error when trying to delete a message in an archived thread. +ARCHIVED_THREAD_ERROR = 50083 class InfractionScheduler: """Handles the application, pardoning, and expiration of infractions.""" + messages_to_tidy: RedisCache = RedisCache() + def __init__(self, bot: Bot, supported_infractions: t.Container[str]): self.bot = bot self.scheduler = scheduling.Scheduler(self.__class__.__name__) + self.tidy_up_scheduler = scheduling.Scheduler( + f"{self.__class__.__name__}TidyUp" + ) + self.supported_infractions = supported_infractions - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) - - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Cancel scheduled tasks.""" self.scheduler.cancel_all() + self.tidy_up_scheduler.cancel_all() @property def mod_log(self) -> ModLog: """Get the currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None: + async def cog_load(self) -> None: """Schedule expiration for previous infractions.""" await self.bot.wait_until_guild_available() + supported_infractions = self.supported_infractions log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") @@ -67,26 +82,80 @@ async def reschedule_infractions(self, supported_infractions: t.Container[str]) # We make sure to fire this if to_schedule: next_reschedule_point = max( - dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule + dateutil.parser.isoparse(infr["expires_at"]) for infr in to_schedule ) log.trace("Will reschedule remaining infractions at %s", next_reschedule_point) - self.scheduler.schedule_at(next_reschedule_point, -1, self.reschedule_infractions(supported_infractions)) + self.scheduler.schedule_at(next_reschedule_point, -1, self.cog_load()) + + log.trace("Done rescheduling expirations, scheduling tidy up tasks.") + + for key, expire_at in await self.messages_to_tidy.items(): + channel_id, message_id = map(int, key.split(":")) + expire_at = dateutil.parser.isoparse(expire_at) + + log.trace( + "Scheduling tidy up for message %s in channel %s at %s", + message_id, channel_id, expire_at + ) + + self.tidy_up_scheduler.schedule_at( + expire_at, + message_id, + self._delete_infraction_message(channel_id, message_id) + ) - log.trace("Done rescheduling") + async def _delete_infraction_message( + self, + channel_id: int, + message_id: int + ) -> None: + """ + Delete a message in the given channel. + + This is used to delete infraction messages after a certain period of time. + """ + try: + partial_channel = self.bot.get_partial_messageable(channel_id) + partial_message = partial_channel.get_partial_message(message_id) + await partial_message.delete() + log.trace(f"Deleted infraction message {message_id} in channel {channel_id}.") + except discord.NotFound: + log.info(f"Channel or message {message_id} not found in channel {channel_id}.") + except discord.Forbidden: + log.info(f"Bot lacks permissions to delete message {message_id} in channel {channel_id}.") + except discord.HTTPException as e: + if e.code == ARCHIVED_THREAD_ERROR: + log.info( + f"Cannot delete message {message_id} in channel {channel_id} because the thread is archived." + ) + else: + log.exception(f"Issue during scheduled deletion of message {message_id} in channel {channel_id}.") + return # Keep the task in Redis on HTTP errors + + await self.messages_to_tidy.delete(f"{channel_id}:{message_id}") async def reapply_infraction( self, infraction: _utils.Infraction, - apply_coro: t.Optional[t.Awaitable] + action: Callable[[], Awaitable[None]] | None ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() + """ + Reapply an infraction if it's still active or deactivate it if less than 60 sec left. + + Note: The `action` provided is an async function rather than a coroutine + to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). + """ + if infraction["expires_at"] is not None: + # Calculate the time remaining, in seconds, for the infraction. + expiry = dateutil.parser.isoparse(infraction["expires_at"]) + delta = (expiry - arrow.utcnow()).total_seconds() + else: + # If the infraction is permanent, it is not possible to get the time remaining. + delta = None - # Mark as inactive if less than a minute remains. - if delta < 60: + # Mark as inactive if the infraction is not permanent and less than a minute remains. + if delta is not None and delta < 60: log.info( "Infraction will be deactivated instead of re-applied " "because less than 1 minute remains." @@ -96,7 +165,7 @@ async def reapply_infraction( # Allowing mod log since this is a passive action that should be logged. try: - await apply_coro + await action() except discord.HTTPException as e: # When user joined and then right after this left again before action completed, this can't apply roles if e.code == 10007 or e.status == 404: @@ -106,7 +175,7 @@ async def reapply_infraction( else: log.exception( f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" - f"when awaiting {infraction['type']} coroutine for {infraction['user']}." + f"when running {infraction['type']} action for {infraction['user']}." ) else: log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") @@ -115,25 +184,32 @@ async def apply_infraction( self, ctx: Context, infraction: _utils.Infraction, - user: UserSnowflake, - action_coro: t.Optional[t.Awaitable] = None, - user_reason: t.Optional[str] = None, + user: MemberOrUser, + action: Callable[[], Awaitable[None]] | None = None, + user_reason: str | None = None, additional_info: str = "", ) -> bool: """ Apply an infraction to the user, log the infraction, and optionally notify the user. - `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. + `action`, if not provided, will result in the infraction not getting scheduled for deletion. `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. + Note: The `action` provided is an async function rather than just a coroutine + to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). + Returns whether or not the infraction succeeded. """ infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_infraction_with_duration(infraction["expires_at"]) - id_ = infraction['id'] + id_ = infraction["id"] + jump_url = infraction["jump_url"] + expiry = time.format_with_duration( + infraction["expires_at"], + infraction["last_applied"] + ) if user_reason is None: user_reason = reason @@ -143,8 +219,8 @@ async def apply_infraction( # Default values for the confirmation message and mod log. confirm_msg = ":ok_hand: applied" - # Specifying an expiry for a note or warning makes no sense. - if infr_type in ("note", "warning"): + # Specifying an expiry for a kick, note, or warning makes no sense. + if infr_type in ("kick", "note", "warning"): expiry_msg = "" else: expiry_msg = f" until {expiry}" if expiry else " permanently" @@ -161,30 +237,16 @@ async def apply_infraction( # send DMs to user that it doesn't share a guild with. If we were to # apply kick/ban infractions first, this would mean that we'd make it # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") + if not infraction["hidden"] and infr_type in {"ban", "kick"}: + if await _utils.notify_infraction(infraction, user, user_reason): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" else: - # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif is_mod_channel(ctx.channel): + if is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( @@ -193,14 +255,23 @@ async def apply_infraction( ) total = len(infractions) end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" + elif infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = ( + f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})." + f"\n\nThe <@&{Roles.moderators}> have been alerted for review" + ) purge = infraction.get("purge", "") # Execute the necessary actions to apply the infraction on Discord. - if action_coro: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + if action: + log.trace(f"Running the infraction #{id_} application action.") try: - await action_coro + await action() if expiry: # Schedule the expiration of the infraction. self.schedule_expiration(infraction) @@ -223,8 +294,27 @@ async def apply_infraction( log.exception(log_msg) failed = True + + if not failed: + infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" + + # If we need to DM and haven't already tried to + if not infraction["hidden"] and infr_type not in {"ban", "kick"}: + if await _utils.notify_infraction(infraction, user, user_reason): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" + else: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + if infr_type == "warning" and not ctx.channel.permissions_for(user).view_channel: + failed = True + log_title = "failed to apply" + additional_info += "\n*Failed to show the warning to the user*" + confirm_msg = (f":x: Failed to apply **warning** to {user.mention} " + "because DMing the user was unsuccessful") + if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + log.trace(f"Trying to delete infraction {id_} from database because applying infraction failed.") try: await self.bot.api_client.delete(f"bot/infractions/{id_}") except ResponseCodeError as e: @@ -232,57 +322,90 @@ async def apply_infraction( log_title += " and failed to delete" log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" - else: - infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") - await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") + mentions = discord.AllowedMentions(users=[user], roles=False) + sent_msg = await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.", allowed_mentions=mentions) + + # Only tidy up bot issued infractions in non-mod channels. + if infraction["actor"] == self.bot.user.id and not is_mod_channel(ctx.channel): + expire_message_time = datetime.now(UTC) + timedelta(hours=AUTOMATED_TIDY_UP_HOURS) + + log.trace(f"Scheduling message tidy for infraction #{id_} in {AUTOMATED_TIDY_UP_HOURS} hours.") + + # Schedule the message to be deleted after a certain period of time. + self.tidy_up_scheduler.schedule_at( + expire_message_time, + sent_msg.id, + self._delete_infraction_message(ctx.channel.id, sent_msg.id) + ) + + # Persist to Redis to handle for bot restarts. + await self.messages_to_tidy.set( + f"{ctx.channel.id}:{sent_msg.id}", + expire_message_time.isoformat(), + ) + + if jump_url is None: + jump_url = "(Infraction issued in a ModMail channel.)" + else: + jump_url = f"[Click here.]({jump_url})" # Send a log message to the mod log. # Don't use ctx.message.author for the actor; antispam only patches ctx.author. log.trace(f"Sending apply mod log for infraction #{id_}.") - await self.mod_log.send_log_message( + await send_log_message( + self.bot, icon_url=icon, colour=Colours.soft_red, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar_url_as(static_format="png"), + thumbnail=user.display_avatar.url, text=textwrap.dedent(f""" Member: {messages.format_user(user)} Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text} Reason: {reason} + Jump URL: {jump_url} {additional_info} """), content=log_content, - footer=f"ID {infraction['id']}" + footer=f"ID: {id_}" ) - log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") + log.info(f"{'Failed to apply' if failed else 'Applied'} {purge}{infr_type} infraction #{id_} to {user}.") return not failed async def pardon_infraction( - self, - ctx: Context, - infr_type: str, - user: UserSnowflake, - send_msg: bool = True + self, + ctx: Context, + infr_type: str, + user: MemberOrUser, + pardon_reason: str | None = None, + *, + send_msg: bool = True, + notify: bool = True ) -> None: """ Prematurely end an infraction for a user and log the action in the mod log. + If `pardon_reason` is None, then the database will not receive + appended text explaining why the infraction was pardoned. + If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. + the context channel. Otherwise, no such message will be sent. + + If `notify` is True, notify the user of the pardon via DM where applicable. """ log.trace(f"Pardoning {infr_type} infraction for {user}.") # Check the current active infraction log.trace(f"Fetching active {infr_type} infractions for {user}.") response = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'active': 'true', - 'type': infr_type, - 'user__id': user.id + "active": "true", + "type": infr_type, + "user__id": user.id } ) @@ -292,12 +415,12 @@ async def pardon_infraction( return # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) + log_text = await self.deactivate_infraction(response[0], pardon_reason, send_log=False, notify=notify) log_text["Member"] = messages.format_user(user) log_text["Actor"] = ctx.author.mention log_content = None - id_ = response[0]['id'] + id_ = response[0]["id"] footer = f"ID: {id_}" # Accordingly display whether the user was successfully notified via DM. @@ -332,11 +455,12 @@ async def pardon_infraction( log_text["Reason"] = log_text.pop("Reason") # Send a log message to the mod log. - await self.mod_log.send_log_message( + await send_log_message( + self.bot, icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar_url_as(static_format="png"), + thumbnail=user.display_avatar.url, text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, content=log_content, @@ -345,14 +469,23 @@ async def pardon_infraction( async def deactivate_infraction( self, infraction: _utils.Infraction, - send_log: bool = True - ) -> t.Dict[str, str]: + pardon_reason: str | None = None, + *, + send_log: bool = True, + notify: bool = True + ) -> dict[str, str]: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. + expiration task cancelled. + + If `pardon_reason` is None, then the database will not receive + appended text explaining why the infraction was pardoned. + + If `send_log` is True, a mod log is sent for the deactivation of the infraction. + + If `notify` is True, notify the user of the pardon via DM where applicable. Infractions of unsupported types will raise a ValueError. """ @@ -362,25 +495,20 @@ async def deactivate_infraction( actor = infraction["actor"] type_ = infraction["type"] id_ = infraction["id"] - inserted_at = infraction["inserted_at"] - expiry = infraction["expires_at"] log.info(f"Marking infraction #{id_} as inactive (expired).") - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None - created = time.format_infraction_with_duration(inserted_at, expiry) - log_content = None log_text = { "Member": f"<@{user_id}>", "Actor": f"<@{actor}>", "Reason": infraction["reason"], - "Created": created, + "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), } try: log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) + returned_log = await self._pardon_action(infraction, notify) if returned_log is not None: log_text = {**log_text, **returned_log} # Merge the logs together @@ -425,9 +553,20 @@ async def deactivate_infraction( try: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") + + data = {"active": False} + + if pardon_reason is not None: + data["reason"] = "" + # Append pardon reason to infraction in database. + if (punish_reason := infraction["reason"]) is not None: + data["reason"] = punish_reason + " | " + + data["reason"] += f"Pardoned: {pardon_reason}" + await self.bot.api_client.patch( f"bot/infractions/{id_}", - json={"active": False} + json=data ) except ResponseCodeError as e: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") @@ -449,13 +588,14 @@ async def deactivate_infraction( log_title = "expiration failed" if "Failure" in log_text else "expired" user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None + avatar = user.display_avatar.url if user else None # Move reason to end so when reason is too long, this is not gonna cut out required items. log_text["Reason"] = log_text.pop("Reason") log.trace(f"Sending deactivation mod log for infraction #{id_}.") - await self.mod_log.send_log_message( + await send_log_message( + self.bot, icon_url=_utils.INFRACTION_ICONS[type_][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {type_}", @@ -468,10 +608,15 @@ async def deactivate_infraction( return log_text @abstractmethod - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action( + self, + infraction: _utils.Infraction, + notify: bool + ) -> dict[str, str] | None: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ raise NotImplementedError @@ -483,5 +628,5 @@ def schedule_expiration(self, infraction: _utils.Infraction) -> None: At the time of expiration, the infraction is marked as inactive on the website and the expiration task is cancelled. """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + expiry = dateutil.parser.isoparse(infraction["expires_at"]) self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a98b4828b4..752f3ad38a 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,51 +1,78 @@ -import logging -import typing as t -from datetime import datetime -import discord -from discord.ext.commands import Context - -from bot.api import ResponseCodeError -from bot.constants import Colours, Icons -from bot.errors import InvalidInfractedUser +import datetime -log = logging.getLogger(__name__) +import arrow +import discord +from dateutil.relativedelta import relativedelta +from discord import Member +from discord.ext.commands import Bot, Context +from pydis_core.site_api import ResponseCodeError + +import bot +from bot.constants import Categories, Channels, Colours, Icons, MODERATION_ROLES, STAFF_AND_COMMUNITY_ROLES +from bot.converters import DurationOrExpiry, MemberOrUser +from bot.errors import InvalidInfractedUserError +from bot.exts.moderation.infraction._views import InfractionConfirmationView +from bot.log import get_logger +from bot.utils import time +from bot.utils.channel import is_in_category, is_mod_channel +from bot.utils.time import unpack_duration + +log = get_logger(__name__) # apply icon, pardon icon INFRACTION_ICONS = { "ban": (Icons.user_ban, Icons.user_unban), "kick": (Icons.sign_out, None), - "mute": (Icons.user_mute, Icons.user_unmute), + "timeout": (Icons.user_timeout, Icons.user_untimeout), "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), - "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), + "voice_mute": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" # Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] -Infraction = t.Dict[str, t.Union[str, int, bool]] +Infraction = dict[str, str | int | bool] -APPEAL_EMAIL = "appeals@pythondiscord.com" +APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm" +MODMAIL_ACCOUNT_ID = "683001325440860340" INFRACTION_TITLE = "Please review our rules" -INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." INFRACTION_APPEAL_MODMAIL_FOOTER = ( - 'If you would like to discuss or appeal this infraction, ' - 'send a message to the ModMail bot' + "\nIf you would like to discuss or appeal this infraction, " + f"send a message to the ModMail bot (<@{MODMAIL_ACCOUNT_ID}>)." +) +INFRACTION_MODMAIL_FOOTER = ( + "\nIf you would like to discuss this infraction, " + f"send a message to the ModMail bot (<@{MODMAIL_ACCOUNT_ID}>)." ) INFRACTION_AUTHOR_NAME = "Infraction information" -INFRACTION_DESCRIPTION_TEMPLATE = ( +LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL_MODMAIL_FOOTER)) + +INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE = ( "**Type:** {type}\n" + "**Duration:** {duration}\n" "**Expires:** {expires}\n" "**Reason:** {reason}\n" ) +INFRACTION_DESCRIPTION_WARNING_TEMPLATE = ( + "**Type:** Warning\n" + "**Reason:** {reason}\n" +) + -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: +MAXIMUM_TIMEOUT_DAYS = datetime.timedelta(days=28) +TIMEOUT_CAP_MESSAGE = ( + f"The timeout for {{0}} can't be longer than {MAXIMUM_TIMEOUT_DAYS.days} days." + " I'll pretend that's what you meant." +) + + +async def post_user(ctx: Context, user: MemberOrUser) -> dict | None: """ Create a new user in the database. @@ -53,19 +80,16 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ log.trace(f"Attempting to add user {user.id} to the database.") - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), - 'id': user.id, - 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), - 'roles': [] + "discriminator": int(user.discriminator), + "id": user.id, + "in_guild": False, + "name": user.name, + "roles": [] } try: - response = await ctx.bot.api_client.post('bot/users', json=payload) + response = await ctx.bot.api_client.post("bot/users", json=payload) log.info(f"User {user.id} added to the DB.") return response except ResponseCodeError as e: @@ -74,54 +98,72 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: async def post_infraction( - ctx: Context, - user: UserSnowflake, - infr_type: str, - reason: str, - expires_at: datetime = None, - hidden: bool = False, - active: bool = True -) -> t.Optional[dict]: + ctx: Context, + user: MemberOrUser, + infr_type: str, + reason: str, + duration_or_expiry: DurationOrExpiry | None = None, + hidden: bool = False, + active: bool = True, + dm_sent: bool = False, +) -> dict | None: """Posts an infraction to the API.""" - if isinstance(user, (discord.Member, discord.User)) and user.bot: + if isinstance(user, discord.Member | discord.User) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") - raise InvalidInfractedUser(user) + raise InvalidInfractedUserError(user) log.trace(f"Posting {infr_type} infraction for {user} to the API.") + current_time = arrow.utcnow() + + if any( + is_in_category(ctx.channel, category) + for category in (Categories.modmail, Categories.appeals, Categories.appeals_2) + ) or ctx.message is None: + jump_url = None + else: + jump_url = ctx.message.jump_url + payload = { "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, "reason": reason, "type": infr_type, "user": user.id, - "active": active + "active": active, + "dm_sent": dm_sent, + "jump_url": jump_url, + "inserted_at": current_time.isoformat(), + "last_applied": current_time.isoformat(), } - if expires_at: - payload['expires_at'] = expires_at.isoformat() + + if duration_or_expiry is not None: + _, expiry = unpack_duration(duration_or_expiry, current_time) + payload["expires_at"] = expiry.isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. for should_post_user in (True, False): try: - response = await ctx.bot.api_client.post('bot/infractions', json=payload) + response = await ctx.bot.api_client.post("bot/infractions", json=payload) return response except ResponseCodeError as e: - if e.status == 400 and 'user' in e.response_json: + if e.status == 400 and "user" in e.response_json: # Only one attempt to add the user to the database, not two: if not should_post_user or await post_user(ctx, user) is None: - return + return None else: log.exception(f"Unexpected error while adding an infraction for {user}:") await ctx.send(f":x: There was an error adding the infraction: status {e.status}.") - return + return None + return None async def get_active_infraction( ctx: Context, - user: UserSnowflake, + user: MemberOrUser, infr_type: str, send_msg: bool = True -) -> t.Optional[dict]: +) -> dict | None: """ Retrieves an active infraction of the given type for the user. @@ -132,45 +174,87 @@ async def get_active_infraction( log.trace(f"Checking if {user} has active infractions of type {infr_type}.") active_infractions = await ctx.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'active': 'true', - 'type': infr_type, - 'user__id': str(user.id) + "active": "true", + "type": infr_type, + "user__id": str(user.id) } ) if active_infractions: # Checks to see if the moderator should be told there is an active infraction if send_msg: log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) + await send_active_infraction_message(ctx, active_infractions[0]) return active_infractions[0] - else: - log.trace(f"{user} does not have active infractions of type {infr_type}.") + log.trace(f"{user} does not have active infractions of type {infr_type}.") + return None + + +async def send_active_infraction_message(ctx: Context, infraction: Infraction) -> None: + """Send a message stating that the given infraction is active.""" + await ctx.send( + f":x: According to my records, this user already has a {infraction['type']} infraction. " + f"See infraction **#{infraction['id']}**." + ) async def notify_infraction( - user: UserObject, - infr_type: str, - expires_at: t.Optional[str] = None, - reason: t.Optional[str] = None, - icon_url: str = Icons.token_removed + infraction: Infraction, + user: MemberOrUser, + reason: str | None = None ) -> bool: - """DM a user about their new infraction and return True if the DM is successful.""" + """ + DM a user about their new infraction and return True if the DM is successful. + + `reason` can be used to override what is in `infraction`. Otherwise, this data will + be retrieved from `infraction`. + """ + infr_id = infraction["id"] + infr_type = infraction["type"].replace("_", " ").title() + icon_url = INFRACTION_ICONS[infraction["type"]][0] + + if infraction["expires_at"] is None: + expires_at = "Never" + duration = "Permanent" + else: + origin = arrow.get(infraction["last_applied"]) + expiry = arrow.get(infraction["expires_at"]) + expires_at = time.format_relative(expiry) + duration = time.humanize_delta(origin, expiry, max_units=2) + + if not infraction["active"]: + expires_at += " (Inactive)" + + if infraction["inserted_at"] != infraction["last_applied"]: + duration += " (Edited)" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") - text = INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.title(), - expires=expires_at or "N/A", - reason=reason or "No reason provided." - ) + if reason is None: + reason = infraction["reason"] + + if infraction["type"] == "warning": + text = INFRACTION_DESCRIPTION_WARNING_TEMPLATE.format( + reason=reason or "No reason provided." + ) + else: + text = INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( + type=infr_type.title(), + expires=expires_at, + duration=duration, + reason=reason or "No reason provided." + ) # For case when other fields than reason is too long and this reach limit, then force-shorten string - if len(text) > 2048: - text = f"{text[:2045]}..." + if len(text) > 4096 - LONGEST_EXTRAS: + text = f"{text[:4093-LONGEST_EXTRAS]}..." + + text += ( + INFRACTION_APPEAL_SERVER_FOOTER if infraction["type"] == "ban" + else INFRACTION_MODMAIL_FOOTER if infraction["type"] == "warning" + else INFRACTION_APPEAL_MODMAIL_FOOTER + ) embed = discord.Embed( description=text, @@ -181,15 +265,19 @@ async def notify_infraction( embed.title = INFRACTION_TITLE embed.url = RULES_URL - embed.set_footer( - text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER - ) + dm_sent = await send_private_embed(user, embed) + if dm_sent: + await bot.instance.api_client.patch( + f"bot/infractions/{infr_id}", + json={"dm_sent": True} + ) + log.debug(f"Update infraction #{infr_id} dm_sent field to true.") - return await send_private_embed(user, embed) + return dm_sent async def notify_pardon( - user: UserObject, + user: MemberOrUser, title: str, content: str, icon_url: str = Icons.user_verified @@ -207,7 +295,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: +async def send_private_embed(user: MemberOrUser, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. @@ -222,3 +310,63 @@ async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: "The user either could not be retrieved or probably disabled their DMs." ) return False + + +def cap_timeout_duration(duration: datetime.datetime | relativedelta) -> tuple[bool, datetime.datetime]: + """Cap the duration of a duration to Discord's limit.""" + now = arrow.utcnow() + capped = False + if isinstance(duration, relativedelta): + duration += now + + if duration > now + MAXIMUM_TIMEOUT_DAYS: + duration = now + MAXIMUM_TIMEOUT_DAYS - datetime.timedelta(minutes=1) # Duration cap is exclusive. + capped = True + elif duration > now + MAXIMUM_TIMEOUT_DAYS - datetime.timedelta(minutes=1): + # Duration cap is exclusive. This is to still allow specifying "28d". + duration -= datetime.timedelta(minutes=1) + return capped, duration + + +async def confirm_elevated_user_infraction(ctx: Context, user: MemberOrUser) -> bool: + """ + If user has an elevated role, require confirmation before issuing infraction. + + A member with the staff or community roles are considered elevated. + + Returns a boolean indicating whether the infraction should proceed. + """ + if not isinstance(user, Member) or not any(role.id in STAFF_AND_COMMUNITY_ROLES for role in user.roles): + return True + + confirmation_view = InfractionConfirmationView( + allowed_users=(ctx.author.id,), + allowed_roles=MODERATION_ROLES, + timeout=10, + ) + confirmation_view.message = await ctx.send( + f"{user.mention} has an elevated role. Are you sure you want to infract them?", + view=confirmation_view, + allowed_mentions=discord.AllowedMentions.none(), + ) + + timed_out = await confirmation_view.wait() + if timed_out: + log.trace(f"Attempted infraction of user {user} by moderator {ctx.author} cancelled due to timeout.") + return False + + if confirmation_view.confirmed is False: + log.trace(f"Attempted infraction of user {user} by moderator {ctx.author} cancelled due to manual cancel.") + return False + + return True + + +async def notify_timeout_cap(bot: Bot, ctx: Context, user: discord.Member) -> None: + """Notify moderators about a timeout duration being capped.""" + cap_message_for_user = TIMEOUT_CAP_MESSAGE.format(user.mention) + if is_mod_channel(ctx.channel): + await ctx.reply(f":warning: {cap_message_for_user}") + else: + await bot.get_channel(Channels.mods).send( + f":warning: {ctx.author.mention} {cap_message_for_user}") diff --git a/bot/exts/moderation/infraction/_views.py b/bot/exts/moderation/infraction/_views.py new file mode 100644 index 0000000000..f539fa768c --- /dev/null +++ b/bot/exts/moderation/infraction/_views.py @@ -0,0 +1,31 @@ +from typing import Any + +import discord +from discord import ButtonStyle, Interaction +from discord.ui import Button +from pydis_core.utils import interactions + + +class InfractionConfirmationView(interactions.ViewWithUserAndRoleCheck): + """A confirmation view to be sent before issuing potentially suspect infractions.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.confirmed = False + + @discord.ui.button(label="Infract", style=ButtonStyle.red) + async def confirm(self, interaction: Interaction, button: Button) -> None: + """Callback coroutine that is called when the "Infract" button is pressed.""" + self.confirmed = True + await interaction.response.defer() + self.stop() + + @discord.ui.button(label="Cancel", style=ButtonStyle.gray) + async def cancel(self, interaction: Interaction, button: Button) -> None: + """Callback coroutine that is called when the "cancel" button is pressed.""" + await interaction.response.send_message("Cancelled infraction.") + self.stop() + + async def on_timeout(self) -> None: + await super().on_timeout() + await self.message.reply("Cancelled infraction due to timeout.") diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f19323c7c2..7ce574a074 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,23 +1,50 @@ -import logging import textwrap import typing as t +from datetime import UTC, timedelta +import arrow import discord +from dateutil.relativedelta import relativedelta from discord import Member from discord.ext import commands from discord.ext.commands import Context, command +from pydis_core.utils.members import get_or_fetch_member from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Duration, Expiry, FetchedMember -from bot.decorators import respect_role_hierarchy +from bot.converters import Age, Duration, DurationOrExpiry, MemberOrUser, UnambiguousMemberOrUser +from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler -from bot.exts.moderation.infraction._utils import UserSnowflake +from bot.log import get_logger from bot.utils.messages import format_user -log = logging.getLogger(__name__) +log = get_logger(__name__) + +if t.TYPE_CHECKING: + from bot.exts.moderation.clean import Clean + from bot.exts.moderation.infraction.management import ModManagement + from bot.exts.moderation.watchchannels.bigbrother import BigBrother + + +# Comp ban +DISCORD_ARTICLE_URL = "https://support.discord.com/hc/en-us/articles" +LINK_PASSWORD = DISCORD_ARTICLE_URL + "/218410947-I-forgot-my-Password-Where-can-I-set-a-new-one" +LINK_2FA = DISCORD_ARTICLE_URL + "/219576828-Setting-up-Two-Factor-Authentication" +COMP_BAN_REASON = ( + "Your account has been used to send links to a phishing website. You have been automatically banned. " + "If you are not aware of sending them, that means your account has been compromised.\n\n" + + f"Here is a guide from Discord on [how to change your password]({LINK_PASSWORD}).\n\n" + + f"We also highly recommend that you [enable 2 factor authentication on your account]({LINK_2FA}), " + "for heightened security.\n\n" + + "Once you have changed your password, feel free to follow the instructions at the bottom of " + "this message to appeal your ban." +) +COMP_BAN_DURATION = timedelta(days=4) class Infractions(InfractionScheduler, commands.Cog): @@ -27,34 +54,15 @@ class Infractions(InfractionScheduler, commands.Cog): category_description = "Server moderation tools." def __init__(self, bot: Bot): - super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"}) + super().__init__(bot, supported_infractions={"ban", "kick", "timeout", "note", "warning", "voice_mute"}) self.category = "Moderation" - self._muted_role = discord.Object(constants.Roles.muted) self._voice_verified_role = discord.Object(constants.Roles.voice_verified) - @commands.Cog.listener() - async def on_member_join(self, member: Member) -> None: - """Reapply active mute infractions for returning members.""" - active_mutes = await self.bot.api_client.get( - "bot/infractions", - params={ - "active": "true", - "type": "mute", - "user__id": member.id - } - ) - - if active_mutes: - reason = f"Re-applying active mute: {active_mutes[0]['id']}" - action = member.add_roles(self._muted_role, reason=reason) - - await self.reapply_infraction(active_mutes[0], action) - # region: Permanent infractions - @command() - async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + @command(aliases=("warning",)) + async def warn(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: str | None = None) -> None: """Warn a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -67,7 +75,7 @@ async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[st await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: str | None = None) -> None: """Kick a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -76,66 +84,123 @@ async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[st await self.apply_kick(ctx, user, reason) @command() + @ensure_future_timestamp(timestamp_arg=3) async def ban( self, ctx: Context, - user: FetchedMember, - duration: t.Optional[Expiry] = None, + user: UnambiguousMemberOrUser, + duration_or_expiry: DurationOrExpiry | None = None, *, - reason: t.Optional[str] = None + reason: str | None = None ) -> None: """ - Permanently ban a user for the given reason and stop watching them with Big Brother. + Permanently ban a `user` for the given `reason` and stop watching them with Big Brother. - If duration is specified, it temporarily bans that user for the given duration. + If a duration is specified, it temporarily bans the `user` for the given duration. + Alternatively, an ISO 8601 timestamp representing the expiry time can be provided + for `duration_or_expiry`. """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) - @command(aliases=('pban',)) - async def purgeban( + @command(aliases=("clban", "purgeban", "pban")) + @ensure_future_timestamp(timestamp_arg=3) + async def cleanban( self, ctx: Context, - user: FetchedMember, - duration: t.Optional[Expiry] = None, + user: UnambiguousMemberOrUser, + duration: DurationOrExpiry | None = None, *, - reason: t.Optional[str] = None + reason: str | None = None ) -> None: """ - Same as ban but removes all their messages of the last 24 hours. + Same as ban, but also cleans all their messages from the last hour. If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, 1, expires_at=duration) + clean_cog: Clean | None = self.bot.get_cog("Clean") + if clean_cog is None: + # If we can't get the clean cog, fall back to native purgeban. + await self.apply_ban(ctx, user, reason, purge_days=1, duration_or_expiry=duration) + return + + infraction = await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) + if not infraction or not infraction.get("id"): + # Ban was unsuccessful, quit early. + return + + # Calling commands directly skips discord.py's convertors, so we need to convert args manually. + clean_time = await Age().convert(ctx, "1h") + + log_url = await clean_cog._clean_messages( + ctx, + users=[user], + channels="*", + first_limit=clean_time, + attempt_delete_invocation=False, + ) + if not log_url: + # Cleaning failed, or there were no messages to clean, exit early. + return + + infr_manage_cog: ModManagement | None = self.bot.get_cog("ModManagement") + if infr_manage_cog is None: + # If we can't get the mod management cog, don't bother appending the log. + return + + # Overwrite the context's send function so infraction append + # doesn't output the update infraction confirmation message. + async def send(*args, **kwargs) -> None: + pass + ctx.send = send + await infr_manage_cog.infraction_append(ctx, infraction, None, reason=f"[Clean log]({log_url})") + + @command(aliases=("cpban",)) + async def compban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + """Same as cleanban, but specifically with the ban reason and duration used for compromised accounts.""" + await self.cleanban(ctx, user, duration=(arrow.utcnow() + COMP_BAN_DURATION).datetime, reason=COMP_BAN_REASON) + + @command(aliases=("vban",)) + async def voiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. - @command(aliases=('vban',)) - async def voiceban( + Permanently ban a user from joining voice channels. + + If duration is specified, it temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `voicemute`?") + + @command(aliases=("vmute",)) + @ensure_future_timestamp(timestamp_arg=3) + async def voicemute( self, ctx: Context, - user: FetchedMember, - duration: t.Optional[Expiry] = None, + user: UnambiguousMemberOrUser, + duration: DurationOrExpiry | None = None, *, - reason: t.Optional[str] + reason: str | None ) -> None: """ - Permanently ban user from using voice channels. + Permanently mute user in voice channels. - If duration is specified, it temporarily voice bans that user for the given duration. + If duration is specified, it temporarily voice mutes that user for the given duration. """ - await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Temporary infractions - @command(aliases=["mute"]) - async def tempmute( + @command(aliases=["mute", "tempmute"]) + @ensure_future_timestamp(timestamp_arg=3) + async def timeout( self, ctx: Context, - user: FetchedMember, - duration: t.Optional[Expiry] = None, + user: UnambiguousMemberOrUser, + duration: DurationOrExpiry | None = None, *, - reason: t.Optional[str] = None + reason: str | None = None ) -> None: """ - Temporarily mute a user for the given reason and duration. + Timeout a user for the given reason and duration. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -149,24 +214,30 @@ async def tempmute( Alternatively, an ISO 8601 timestamp can be provided for the duration. - If no duration is given, a one hour duration is used by default. - """ + If no duration is given, a one-hour duration is used by default. + """ # noqa: RUF002 if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") return if duration is None: duration = await Duration().convert(ctx, "1h") - await self.apply_mute(ctx, user, reason, expires_at=duration) + else: + capped, duration = _utils.cap_timeout_duration(duration) + if capped: + await _utils.notify_timeout_cap(self.bot, ctx, user) + + await self.apply_timeout(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tban",)) + @ensure_future_timestamp(timestamp_arg=3) async def tempban( self, ctx: Context, - user: FetchedMember, - duration: Expiry, + user: UnambiguousMemberOrUser, + duration_or_expiry: DurationOrExpiry, *, - reason: t.Optional[str] = None + reason: str | None = None ) -> None: """ Temporarily ban a user for the given reason and duration. @@ -182,20 +253,30 @@ async def tempban( \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + """ # noqa: RUF002 + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) @command(aliases=("tempvban", "tvban")) - async def tempvoiceban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] + async def tempvoiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. + + Temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `tempvoicemute`?") + + @command(aliases=("tempvmute", "tvmute")) + @ensure_future_timestamp(timestamp_arg=3) + async def tempvoicemute( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + duration: DurationOrExpiry, + *, + reason: str | None ) -> None: """ - Temporarily voice ban a user for the given reason and duration. + Temporarily voice mute a user for the given reason and duration. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -208,14 +289,14 @@ async def tempvoiceban( \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + """ # noqa: RUF002 + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def note(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: str | None = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -223,8 +304,8 @@ async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[st await self.apply_infraction(ctx, infraction, user) - @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + @command(hidden=True, aliases=["shadowban", "sban"]) + async def shadow_ban(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: str | None = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -232,13 +313,14 @@ async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optio # region: Temporary shadow infractions @command(hidden=True, aliases=["shadowtempban", "stempban", "stban"]) + @ensure_future_timestamp(timestamp_arg=3) async def shadow_tempban( self, ctx: Context, - user: FetchedMember, - duration: Expiry, + user: UnambiguousMemberOrUser, + duration: DurationOrExpiry, *, - reason: t.Optional[str] = None + reason: str | None = None ) -> None: """ Temporarily ban a user for the given reason and duration without notifying the user. @@ -254,36 +336,73 @@ async def shadow_tempban( \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + """ # noqa: RUF002 + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration, hidden=True) # endregion # region: Remove infractions (un- commands) - @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user) + @command(aliases=("unmute",)) + async def untimeout( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + *, + pardon_reason: str | None = None + ) -> None: + """Prematurely end the active timeout infraction for the user.""" + await self.pardon_infraction(ctx, "timeout", user, pardon_reason) @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: + async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: str) -> None: """Prematurely end the active ban infraction for the user.""" - await self.pardon_infraction(ctx, "ban", user) + await self.pardon_infraction(ctx, "ban", user, pardon_reason) @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: - """Prematurely end the active voice ban infraction for the user.""" - await self.pardon_infraction(ctx, "voice_ban", user) + async def unvoiceban(self, ctx: Context) -> None: + """ + NOT IMPLEMENTED. + + Temporarily voice bans that user for the given duration. + """ + await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?") + + @command(aliases=("uvmute",)) + async def unvoicemute( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + *, + pardon_reason: str | None = None + ) -> None: + """Prematurely end the active voice mute infraction for the user.""" + await self.pardon_infraction(ctx, "voice_mute", user, pardon_reason) # endregion # region: Base apply functions - async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: - """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "mute"): + @respect_role_hierarchy(member_arg=2) + async def apply_timeout(self, ctx: Context, user: Member, reason: str | None, **kwargs) -> None: + """Apply a timeout infraction with kwargs passed to `post_infraction`.""" + if isinstance(user, Member) and user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't timeout users above or equal to me in the role hierarchy.") return - infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) + if active := await _utils.get_active_infraction(ctx, user, "timeout", send_msg=False): + if active["actor"] != self.bot.user.id: + await _utils.send_active_infraction_message(ctx, active) + return + + # Allow the current timeout attempt to override an automatically triggered timeout. + log_text = await self.deactivate_infraction(active, notify=False) + if "Failure" in log_text: + await ctx.send( + f":x: can't override infraction **timeout** for {user.mention}: " + f"failed to deactivate. {log_text['Failure']}" + ) + return + + infraction = await _utils.post_infraction(ctx, user, "timeout", reason, active=True, **kwargs) if infraction is None: return @@ -293,17 +412,24 @@ async def action() -> None: # Skip members that left the server if not isinstance(user, Member): return + duration_or_expiry = kwargs["duration_or_expiry"] + if isinstance(duration_or_expiry, relativedelta): + duration_or_expiry += arrow.utcnow() - await user.add_roles(self._muted_role, reason=reason) - - log.trace(f"Attempting to kick {user} from voice because they've been muted.") - await user.move_to(None, reason=reason) + await user.edit(timed_out_until=duration_or_expiry, reason=reason) - await self.apply_infraction(ctx, infraction, user, action()) + await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) - async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: + async def apply_kick(self, ctx: Context, user: Member, reason: str | None, **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" + if user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't kick users above or equal to me in the role hierarchy.") + return + + if not await _utils.confirm_elevated_user_infraction(ctx, user): + return + infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) if infraction is None: return @@ -313,75 +439,85 @@ async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = user.kick(reason=reason) + async def action() -> None: + await user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) async def apply_ban( self, ctx: Context, - user: UserSnowflake, - reason: t.Optional[str], - purge_days: t.Optional[int] = 0, + user: MemberOrUser, + reason: str | None, + purge_days: int | None = 0, **kwargs - ) -> None: + ) -> dict | None: """ Apply a ban infraction with kwargs passed to `post_infraction`. Will also remove the banned user from the Big Brother watch list if applicable. """ + if isinstance(user, Member) and user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.") + return None + + if not await _utils.confirm_elevated_user_infraction(ctx, user): + return None + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None + is_temporary = kwargs.get("duration_or_expiry") is not None active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: if is_temporary: log.trace("Tempban ignored as it cannot overwrite an active ban.") - return + return None - if active_infraction.get('expires_at') is None: + if active_infraction.get("expires_at") is None: log.trace("Permaban already exists, notify.") await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") - return + else: + log.trace("Tempban exists, notify.") + await ctx.send( + f":x: Can't permanently ban user with existing temporary ban (#{active_infraction['id']}). " + ) - log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) + return None infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: - return + return None infraction["purge"] = "purge " if purge_days else "" - self.mod_log.ignore(Event.member_remove, user.id) - - if reason: - reason = textwrap.shorten(reason, width=512, placeholder="...") + async def action() -> None: + # Discord only supports ban reasons up to 512 characters in length. + discord_reason = textwrap.shorten(reason or "", width=512, placeholder="...") + await ctx.guild.ban(user, reason=discord_reason, delete_message_days=purge_days) - action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) + self.mod_log.ignore(Event.member_remove, user.id) await self.apply_infraction(ctx, infraction, user, action) - if infraction.get('expires_at') is not None: + bb_cog: BigBrother | None = self.bot.get_cog("Big Brother") + if infraction.get("expires_at") is not None: log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") - return - - bb_cog = self.bot.get_cog("Big Brother") - if not bb_cog: + elif not bb_cog: log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") - return - - log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + else: + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) - bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + return infraction @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: - """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "voice_ban"): + async def apply_voice_mute(self, ctx: Context, user: MemberOrUser, reason: str | None, **kwargs) -> None: + """Apply a voice mute infraction with kwargs passed to `post_infraction`.""" + if await _utils.get_active_infraction(ctx, user, "voice_mute"): return - infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + infraction = await _utils.post_infraction(ctx, user, "voice_mute", reason, active=True, **kwargs) if infraction is None: return @@ -395,41 +531,51 @@ async def action() -> None: if not isinstance(user, Member): return - await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + await user.move_to(None, reason="Disconnected from voice to apply voice mute.") await user.remove_roles(self._voice_verified_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action()) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Base pardon functions - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) + async def pardon_timeout( + self, + user_id: int, + guild: discord.Guild, + reason: str | None, + *, + notify: bool = True + ) -> dict[str, str]: + """Remove a user's timeout, optionally DM them a notification, and return a log dict.""" + user = await get_or_fetch_member(guild, user_id) log_text = {} if user: - # Remove the muted role. + # Remove the timeout. self.mod_log.ignore(Event.member_update, user.id) - await user.remove_roles(self._muted_role, reason=reason) - - # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=_utils.INFRACTION_ICONS["mute"][1] - ) + if user.is_timed_out(): # Handle pardons via the command and any other obscure weirdness. + log.trace(f"Manually pardoning timeout for user {user.id}") + await user.edit(timed_out_until=None, reason=reason) + + if notify: + # DM the user about the expiration. + notified = await _utils.notify_pardon( + user=user, + title="Your timeout has ended", + content="You may now send messages in the server.", + icon_url=_utils.INFRACTION_ICONS["timeout"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: - log.info(f"Failed to unmute user {user_id}: user not found") + log.info(f"Failed to remove timeout from user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." return log_text - async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str | None) -> dict[str, str]: """Remove a user's ban on the Discord guild and return a log dict.""" user = discord.Object(user_id) log_text = {} @@ -444,43 +590,52 @@ async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optiona return log_text - async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) + async def pardon_voice_mute( + self, + user_id: int, + guild: discord.Guild, + *, + notify: bool = True + ) -> dict[str, str]: + """Optionally DM the user a pardon notification and return a log dict.""" + user = await get_or_fetch_member(guild, user_id) log_text = {} if user: - # DM user about infraction expiration - notified = await _utils.notify_pardon( - user=user, - title="Voice ban ended", - content="You have been unbanned and can verify yourself again in the server.", - icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] - ) + if notify: + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Voice mute ended", + content="You have been unmuted and can verify yourself again in the server.", + icon_url=_utils.INFRACTION_ICONS["voice_mute"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: log_text["Info"] = "User was not found in the guild." return log_text - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> dict[str, str] | None: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ guild = self.bot.get_guild(constants.Guild.id) user_id = infraction["user"] reason = f"Infraction #{infraction['id']} expired or was pardoned." - if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) - elif infraction["type"] == "ban": + if infraction["type"] == "timeout": + return await self.pardon_timeout(user_id, guild, reason, notify=notify) + if infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) - elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild, reason) + if infraction["type"] == "voice_mute": + return await self.pardon_voice_mute(user_id, guild, notify=notify) + return None # endregion @@ -493,11 +648,36 @@ async def cog_check(self, ctx: Context) -> bool: async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Send a notification to the invoking context on a Union failure.""" if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters or discord.Member in error.converters: + if discord.User in error.converters or Member in error.converters: await ctx.send(str(error.errors[0])) error.handled = True + @commands.Cog.listener() + async def on_member_join(self, member: Member) -> None: + """ + Apply active timeout infractions for returning members. + + This is needed for users who might have had their infraction edited in our database but not in Discord itself. + """ + active_timeouts = await self.bot.api_client.get( + endpoint="bot/infractions", + params={"active": "true", "type": "timeout", "user__id": member.id} + ) + + if active_timeouts: + timeout_infraction = active_timeouts[0] + expiry = arrow.get(timeout_infraction["expires_at"], tzinfo=UTC).datetime.replace(second=0, microsecond=0) + + if member.is_timed_out() and expiry == member.timed_out_until.replace(second=0, microsecond=0): + return + + reason = f"Applying active timeout for returning member: {timeout_infraction['id']}" + + async def action() -> None: + await member.edit(timed_out_until=expiry, reason=reason) + await self.reapply_infraction(timeout_infraction, action) + -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Infractions cog.""" - bot.add_cog(Infractions(bot)) + await bot.add_cog(Infractions(bot)) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b3783cd606..d2d194696c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,23 +1,43 @@ -import logging +import gettext +import re import textwrap import typing as t -from datetime import datetime import discord from discord.ext import commands from discord.ext.commands import Context from discord.utils import escape_markdown +from pydis_core.utils.members import get_or_fetch_member from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user +from bot.constants import Categories +from bot.converters import DurationOrExpiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser +from bot.decorators import ensure_future_timestamp +from bot.errors import InvalidInfractionError +from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions -from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import messages, time -from bot.utils.channel import is_mod_channel +from bot.utils.channel import is_in_category, is_mod_channel +from bot.utils.modlog import send_log_message +from bot.utils.time import unpack_duration -log = logging.getLogger(__name__) +log = get_logger(__name__) + +NO_DURATION_INFRACTIONS = ("note", "warning", "kick") + +FAILED_DM_SYMBOL = constants.Emojis.failmail +HIDDEN_INFRACTION_SYMBOL = "🕵️" +EDITED_DURATION_SYMBOL = "✏️" + +SYMBOLS_GUIDE = f""" +Symbols guide: +\u2003{FAILED_DM_SYMBOL} - The infraction DM failed to deliver. +\u2003{HIDDEN_INFRACTION_SYMBOL} - The infraction is hidden. +\u2003{EDITED_DURATION_SYMBOL}- The duration was edited. +""" class ModManagement(commands.Cog): @@ -28,31 +48,71 @@ class ModManagement(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") + # Add the symbols guide to the help embeds of the appropriate commands. + for command in ( + self.infraction_group, + self.infraction_search_group, + self.search_reason, + self.search_user, + self.search_by_actor + ): + command.help += f"\n{SYMBOLS_GUIDE}" @property def infractions_cog(self) -> Infractions: """Get currently loaded Infractions cog instance.""" return self.bot.get_cog("Infractions") - # region: Edit infraction commands + @commands.group(name="infraction", aliases=("infr", "infractions", "inf", "i"), invoke_without_command=True) + async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None: + """ + Infraction management commands. + + If `infraction` is passed then this command fetches that infraction. The `Infraction` converter + supports 'l', 'last' and 'recent' to get the most recent infraction made by `ctx.author`. + """ + if infraction is None: + await ctx.send_help(ctx.command) + return + + embed = discord.Embed( + title=f"{self.format_infraction_title(infraction)}", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, [infraction], ignore_fields=("id",)) + + @infraction_group.command(name="resend", aliases=("send", "rs", "dm")) + async def infraction_resend(self, ctx: Context, infraction: Infraction) -> None: + """Resend a DM to a user about a given infraction of theirs.""" + if infraction["hidden"]: + await ctx.send(f"{constants.Emojis.failmail} You may not resend hidden infractions.") + return + + member_id = infraction["user"]["id"] + member = await get_or_fetch_member(ctx.guild, member_id) + if not member: + await ctx.send(f"{constants.Emojis.failmail} Cannot find member `{member_id}` in the guild.") + return - @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) + id_ = infraction["id"] + reason = infraction["reason"] or "No reason provided." + reason += "\n\n**This is a re-sent message for a previously applied infraction which may have been edited.**" + + if await _utils.notify_infraction(infraction, member, reason): + await ctx.send(f":incoming_envelope: Resent DM for infraction `{id_}`.") + else: + await ctx.send(f"{constants.Emojis.failmail} Failed to resend DM for infraction `{id_}`.") + + # region: Edit infraction commands @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + duration: DurationOrExpiry | t.Literal["p", "permanent"] | None, *, - reason: str = None + reason: str = None # noqa: RUF013 ) -> None: """ Append text and/or edit the duration of an infraction. @@ -75,23 +135,24 @@ async def infraction_append( If a previous infraction reason does not end with an ending punctuation mark, this automatically adds a period before the amended reason. - """ + """ # noqa: RUF002 old_reason = infraction["reason"] - if old_reason is not None: + if old_reason is not None and reason is not None: add_period = not old_reason.endswith((".", "!", "?")) reason = old_reason + (". " if add_period else " ") + reason await self.infraction_edit(ctx, infraction, duration, reason=reason) - @infraction_group.command(name='edit', aliases=('e',)) + @infraction_group.command(name="edit", aliases=("e",)) + @ensure_future_timestamp(timestamp_arg=3) async def infraction_edit( self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821 + duration: DurationOrExpiry | t.Literal["p", "permanent"] | None, *, - reason: str = None + reason: str = None # noqa: RUF013 ) -> None: """ Edit the duration and/or the reason of an infraction. @@ -111,7 +172,7 @@ async def infraction_edit( Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ + """ # noqa: RUF002 if duration is None and reason is None: # Unlike UserInputError, the error handler will show a specified message for BadArgument raise commands.BadArgument("Neither a new expiry nor a new reason was specified.") @@ -122,23 +183,28 @@ async def infraction_edit( confirm_messages = [] log_text = "" - if duration is not None and not infraction['active']: - if reason is None: + if duration is not None and not infraction["active"]: + if (infr_type := infraction["type"]) in ("note", "warning"): + await ctx.send(f":x: Cannot edit the expiration of a {infr_type}.") + else: await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return - confirm_messages.append("expiry unchanged (infraction already expired)") - elif isinstance(duration, str): - request_data['expires_at'] = None + return + + if isinstance(duration, str): + request_data["expires_at"] = None confirm_messages.append("marked as permanent") elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_infraction_with_duration(request_data['expires_at']) - confirm_messages.append(f"set to expire on {expiry}") + origin, expiry = unpack_duration(duration) + # Update `last_applied` if expiry changes. + request_data["last_applied"] = origin.isoformat() + request_data["expires_at"] = expiry.isoformat() + formatted_expiry = time.format_with_duration(expiry, origin) + confirm_messages.append(f"set to expire on {formatted_expiry}") else: confirm_messages.append("expiry unchanged") if reason: - request_data['reason'] = reason + request_data["reason"] = reason confirm_messages.append("set a new reason") log_text += f""" Previous reason: {infraction['reason']} @@ -149,166 +215,282 @@ async def infraction_edit( # Update the infraction new_infraction = await self.bot.api_client.patch( - f'bot/infractions/{infraction_id}', + f"bot/infractions/{infraction_id}", json=request_data, ) + # Get information about the infraction's user + user_id = new_infraction["user"] + user = await get_or_fetch_member(ctx.guild, user_id) + # Re-schedule infraction if the expiration has been updated - if 'expires_at' in request_data: + if "expires_at" in request_data: # A scheduled task should only exist if the old infraction wasn't permanent - if infraction['expires_at']: - self.infractions_cog.scheduler.cancel(new_infraction['id']) + if infraction["expires_at"]: + self.infractions_cog.scheduler.cancel(infraction_id) # If the infraction was not marked as permanent, schedule a new expiration task - if request_data['expires_at']: + if request_data["expires_at"]: self.infractions_cog.schedule_expiration(new_infraction) + # Timeouts are handled by Discord itself, so we need to edit the expiry in Discord as well + if user and infraction["type"] == "timeout": + capped, duration = _utils.cap_timeout_duration(expiry) + if capped: + await _utils.notify_timeout_cap(self.bot, ctx, user) + await user.edit(reason=reason, timed_out_until=duration) log_text += f""" - Previous expiry: {infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} + Previous expiry: {time.until_expiration(infraction['expires_at'])} + New expiry: {time.until_expiration(new_infraction['expires_at'])} """.rstrip() - changes = ' & '.join(confirm_messages) + changes = " & ".join(confirm_messages) await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}") - # Get information about the infraction's user - user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) - if user: user_text = messages.format_user(user) - thumbnail = user.avatar_url_as(static_format="png") + thumbnail = user.display_avatar.url else: user_text = f"<@{user_id}>" thumbnail = None - await self.mod_log.send_log_message( + if any( + is_in_category(ctx.channel, category) + for category in (Categories.modmail, Categories.appeals, Categories.appeals_2) + ): + jump_url = "(Infraction edited in a ModMail channel.)" + else: + jump_url = f"[Click here.]({ctx.message.jump_url})" + + await send_log_message( + self.bot, icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), + colour=discord.Colour.og_blurple(), title="Infraction edited", thumbnail=thumbnail, text=textwrap.dedent(f""" Member: {user_text} Actor: <@{new_infraction['actor']}> Edited by: {ctx.message.author.mention}{log_text} - """) + Jump URL: {jump_url} + """), + footer=f"ID: {infraction_id}" ) # endregion # region: Search infractions - @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: + @infraction_group.group(name="search", aliases=("s",), invoke_without_command=True) + async def infraction_search_group(self, ctx: Context, query: UnambiguousUser | Snowflake | str) -> None: """Searches for infractions in the database.""" if isinstance(query, int): await self.search_user(ctx, discord.Object(query)) - else: + elif isinstance(query, str): await self.search_reason(ctx, query) + else: + await self.search_user(ctx, query) - @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: + @infraction_search_group.command(name="user", aliases=("member", "userid")) + async def search_user(self, ctx: Context, user: MemberOrUser | discord.Object) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( - 'bot/infractions/expanded', - params={'user__id': str(user.id)} + "bot/infractions/expanded", + params={"user__id": str(user.id)} ) - user = self.bot.get_user(user.id) - if not user and infraction_list: - # Use the user data retrieved from the DB for the username. - user = infraction_list[0]["user"] - user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" + if isinstance(user, discord.Member | discord.User): + user_str = escape_markdown(str(user)) + else: + if infraction_list: + user_data = infraction_list[0]["user"] + user_str = escape_markdown(user_data["name"]) + f"#{user_data['discriminator']:04}" + else: + user_str = str(user.id) + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", + title=f"Infractions for {user_str} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) + # Manually form mention from ID as discord.Object doesn't have a `.mention` attr + prefix = f"<@{user.id}> - {user.id}" + # If the user has alts show in the prefix + if infraction_list and (alts := infraction_list[0]["user"]["alts"]): + prefix += f" ({len(alts)} associated {gettext.ngettext('account', 'accounts', len(alts))})" + + await self.send_infraction_list(ctx, embed, infraction_list, prefix, ("user",)) @infraction_search_group.command(name="reason", aliases=("match", "regex", "re")) async def search_reason(self, ctx: Context, reason: str) -> None: """Search for infractions by their reason. Use Re2 for matching.""" + try: + re.compile(reason) + except re.error as e: + raise commands.BadArgument(f"Invalid regular expression in `reason`: {e}") + + infraction_list = await self.bot.api_client.get( + "bot/infractions/expanded", + params={"search": reason} + ) + + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) + embed = discord.Embed( + title=f"Infractions with matching context ({formatted_infraction_count} total)", + colour=discord.Colour.orange() + ) + if len(reason) > 500: + reason = reason[:500] + "..." + await self.send_infraction_list(ctx, embed, infraction_list, reason) + + # endregion + # region: Search for infractions by given actor + + @infraction_group.command(name="by", aliases=("b",)) + async def search_by_actor( + self, + ctx: Context, + actor: t.Literal["m", "me"] | UnambiguousUser, + oldest_first: bool = False + ) -> None: + """ + Search for infractions made by `actor`. + + Use "m" or "me" as the `actor` to get infractions by author. + + Use "1" for `oldest_first` to send oldest infractions first. + """ + if isinstance(actor, str): + actor = ctx.author + + if oldest_first: + ordering = "inserted_at" # oldest infractions first + else: + ordering = "-inserted_at" # newest infractions first + infraction_list = await self.bot.api_client.get( - 'bot/infractions/expanded', - params={'search': reason} + "bot/infractions/expanded", + params={ + "actor__id": str(actor.id), + "ordering": ordering + } ) + + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + title=f"Infractions by {actor} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) + + prefix = f"{actor.mention} - {actor.id}" + await self.send_infraction_list(ctx, embed, infraction_list, prefix, ("actor",)) # endregion # region: Utility functions + @staticmethod + def format_infraction_count(infraction_count: int) -> str: + """ + Returns a string-formatted infraction count. + + API limits returned infractions to a maximum of 100, so if `infraction_count` + is 100 then we return `"100+"`. Otherwise, return `str(infraction_count)`. + """ + if infraction_count == 100: + return "100+" + return str(infraction_count) + async def send_infraction_list( self, ctx: Context, embed: discord.Embed, - infractions: t.Iterable[t.Dict[str, t.Any]] + infractions: t.Iterable[dict[str, t.Any]], + prefix: str = "", + ignore_fields: tuple[str, ...] = () ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: await ctx.send(":warning: No infractions could be found for that query.") return - lines = tuple( - self.infraction_to_string(infraction) - for infraction in infractions - ) + lines = [self.infraction_to_string(infraction, ignore_fields) for infraction in infractions] await LinePaginator.paginate( lines, ctx=ctx, embed=embed, + prefix=f"{prefix}\n", empty=True, max_lines=3, - max_size=1000 + max_size=1000, + allowed_roles=constants.MODERATION_ROLES, ) - def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str: + def infraction_to_string(self, infraction: dict[str, t.Any], ignore_fields: tuple[str, ...]) -> str: """Convert the infraction object to a string representation.""" - active = infraction["active"] - user = infraction["user"] expires_at = infraction["expires_at"] - created = time.format_infraction(infraction["inserted_at"]) + inserted_at = infraction["inserted_at"] + last_applied = infraction["last_applied"] + jump_url = infraction["jump_url"] + + title = "" + if "id" not in ignore_fields: + title = f"**{self.format_infraction_title(infraction)}**" + + symbols = [] + if not infraction["hidden"] and infraction["dm_sent"] is False: + symbols.append(FAILED_DM_SYMBOL) + if infraction["hidden"]: + symbols.append(HIDDEN_INFRACTION_SYMBOL) + if inserted_at != infraction["last_applied"]: + symbols.append(EDITED_DURATION_SYMBOL) + symbols = " ".join(symbols) + + user_str = "" + if "user" not in ignore_fields: + user_str = "For " + self.format_user_from_record(infraction["user"]) + + actor_str = "" + if "actor" not in ignore_fields: + actor_str = f"By <@{infraction['actor']['id']}>" + + issued = "Issued " + time.discord_timestamp(inserted_at) + + duration = "" + if infraction["type"] not in NO_DURATION_INFRACTIONS: + if expires_at is None: + duration = "*Permanent*" + else: + duration = time.humanize_delta(last_applied, expires_at) + if infraction["active"]: + duration = f"{duration} (Expires {time.format_relative(expires_at)})" + duration = f"Duration: {duration}" + + if jump_url is None: + # Infraction was issued prior to jump urls being stored in the database + # or infraction was issued in ModMail category. + context = f"**Context**: {infraction['reason'] or '*None*'}" + else: + context = f"**[Context]({jump_url})**: {infraction['reason'] or '*None*'}" + + return "\n".join(part for part in (title, symbols, user_str, actor_str, issued, duration, context) if part) - # Format the user string. + def format_user_from_record(self, user: dict) -> str: + """Create a formatted user string from its DB record.""" if user_obj := self.bot.get_user(user["id"]): # The user is in the cache. - user_str = messages.format_user(user_obj) - else: - # Use the user data retrieved from the DB. - name = escape_markdown(user['name']) - user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" + return messages.format_user(user_obj) - if active: - remaining = time.until_expiration(expires_at) or "Expired" - else: - remaining = "Inactive" + # Use the user data retrieved from the DB. + name = escape_markdown(user["name"]) + return f"<@{user['id']}> ({name}#{user['discriminator']:04})" - if expires_at is None: - expires = "*Permanent*" - else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(expires_at, date_from) - - lines = textwrap.dedent(f""" - {"**===============**" if active else "==============="} - Status: {"__**Active**__" if active else "Inactive"} - User: {user_str} - Type: **{infraction["type"]}** - Shadow: {infraction["hidden"]} - Created: {created} - Expires: {expires} - Remaining: {remaining} - Actor: <@{infraction["actor"]["id"]}> - ID: `{infraction["id"]}` - Reason: {infraction["reason"] or "*None*"} - {"**===============**" if active else "==============="} - """) - - return lines.strip() + @staticmethod + def format_infraction_title(infraction: Infraction) -> str: + """Format the infraction title.""" + title = infraction["type"].replace("_", " ").title() + if infraction["active"]: + title = f"__Active__ {title}" + return f"{title} #{infraction['id']}" # endregion @@ -322,14 +504,21 @@ async def cog_check(self, ctx: Context) -> bool: return all(checks) # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" + async def cog_command_error(self, ctx: Context, error: commands.CommandError) -> None: + """Handles errors for commands within this cog.""" if isinstance(error, commands.BadUnionArgument): if discord.User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True + elif isinstance(error, InvalidInfractionError): + if error.infraction_arg.isdigit(): + await ctx.send(f":x: Could not find an infraction with id `{error.infraction_arg}`.") + else: + await ctx.send(f":x: `{error.infraction_arg}` is not a valid integer infraction id.") + error.handled = True + -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the ModManagement cog.""" - bot.add_cog(ModManagement(bot)) + await bot.add_cog(ModManagement(bot)) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 07e79b9fe7..006334755d 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -1,23 +1,24 @@ import json -import logging import random import textwrap -import typing as t from pathlib import Path from discord import Embed, Member from discord.ext.commands import Cog, Context, command, has_any_role from discord.utils import escape_markdown +from pydis_core.utils.members import get_or_fetch_member from bot import constants from bot.bot import Bot -from bot.converters import Duration, Expiry +from bot.converters import Duration, DurationOrExpiry +from bot.decorators import ensure_future_timestamp from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.log import get_logger +from bot.utils import time from bot.utils.messages import format_user -from bot.utils.time import format_infraction -log = logging.getLogger(__name__) +log = get_logger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" SUPERSTARIFY_DEFAULT_DURATION = "1h" @@ -56,32 +57,28 @@ async def on_member_update(self, before: Member, after: Member) -> None: return infraction = active_superstarifies[0] - forced_nick = self.get_nick(infraction["id"], before.id) + infr_id = infraction["id"] + + forced_nick = self.get_nick(infr_id, before.id) if after.display_name == forced_nick: return # Nick change was triggered by this event. Ignore. + reason = ( + "You have tried to change your nickname on the **Python Discord** server " + f"from **{before.display_name}** to **{after.display_name}**, but as you " + "are currently in superstar-prison, you do not have permission to do so." + ) + log.info( f"{after.display_name} ({after.id}) tried to escape superstar prison. " f"Changing the nick back to {before.display_name}." ) await after.edit( nick=forced_nick, - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + reason=f"Superstarified member tried to escape the prison: {infr_id}" ) - notified = await _utils.notify_infraction( - user=after, - infr_type="Superstarify", - expires_at=format_infraction(infraction["expires_at"]), - reason=( - "You have tried to change your nickname on the **Python Discord** server " - f"from **{before.display_name}** to **{after.display_name}**, but as you " - "are currently in superstar-prison, you do not have permission to do so." - ), - icon_url=_utils.INFRACTION_ICONS["superstar"][0] - ) - - if not notified: + if not await _utils.notify_infraction(infraction, after, reason): log.info("Failed to DM user about why they cannot change their nickname.") @Cog.listener() @@ -98,21 +95,23 @@ async def on_member_join(self, member: Member) -> None: if active_superstarifies: infraction = active_superstarifies[0] - action = member.edit( - nick=self.get_nick(infraction["id"], member.id), - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) + async def action() -> None: + await member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) await self.reapply_infraction(infraction, action) @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar")) + @ensure_future_timestamp(timestamp_arg=3) async def superstarify( self, ctx: Context, member: Member, - duration: t.Optional[Expiry], + duration: DurationOrExpiry | None, *, - reason: str = '', + reason: str = "", ) -> None: """ Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. @@ -131,7 +130,11 @@ async def superstarify( An optional reason can be provided, which would be added to a message stating their old nickname and linking to the nickname policy. - """ + """ # noqa: RUF002 + if member.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't starify users above or equal to me in the role hierarchy.") + return + if await _utils.get_active_infraction(ctx, member, "superstar"): return @@ -140,12 +143,12 @@ async def superstarify( # Post the infraction to the API old_nick = member.display_name - infraction_reason = f'Old nickname: {old_nick}. {reason}' + infraction_reason = f"Old nickname: {old_nick}. {reason}" infraction = await _utils.post_infraction(ctx, member, "superstar", infraction_reason, duration, active=True) id_ = infraction["id"] forced_nick = self.get_nick(id_, member.id) - expiry_str = format_infraction(infraction["expires_at"]) + expiry_str = time.discord_timestamp(infraction["expires_at"]) # Apply the infraction async def action() -> None: @@ -172,8 +175,8 @@ async def action() -> None: ).format successful = await self.apply_infraction( - ctx, infraction, member, action(), - user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''), + ctx, infraction, member, action, + user_reason=user_message(reason=f"**Additional details:** {reason}\n\n" if reason else ""), additional_info=nickname_info ) @@ -183,7 +186,7 @@ async def action() -> None: embed = Embed( title="Superstarified!", colour=constants.Colours.soft_orange, - description=user_message(reason='') + description=user_message(reason="") ) await ctx.send(embed=embed) @@ -192,13 +195,13 @@ async def unsuperstarify(self, ctx: Context, member: Member) -> None: """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> dict[str, str] | None: + """Pardon a superstar infraction, optionally notify the user via DM, and return a log dict.""" if infraction["type"] != "superstar": - return + return None guild = self.bot.get_guild(constants.Guild.id) - user = guild.get_member(infraction["user"]) + user = await get_or_fetch_member(guild, infraction["user"]) # Don't bother sending a notification if the user left the guild. if not user: @@ -208,18 +211,19 @@ async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Di ) return {} + log_text = {"Member": format_user(user)} + # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=_utils.INFRACTION_ICONS["superstar"][1] - ) + if notify: + notified = await _utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=_utils.INFRACTION_ICONS["superstar"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" - return { - "Member": format_user(user), - "DM": "Sent" if notified else "**Failed**" - } + return log_text @staticmethod def get_nick(infraction_id: int, member_id: int) -> str: @@ -235,6 +239,6 @@ async def cog_check(self, ctx: Context) -> bool: return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Superstarify cog.""" - bot.add_cog(Superstarify(bot)) + await bot.add_cog(Superstarify(bot)) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index db5f04d83b..82c32c8643 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -1,24 +1,23 @@ import csv import json -import logging from datetime import timedelta from io import StringIO -from typing import Dict, List, Optional +from typing import Literal import arrow from aiohttp.client_exceptions import ClientResponseError from arrow import Arrow from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, group, has_any_role +from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service +from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot -from bot.constants import Metabase as MetabaseConfig, Roles -from bot.converters import allowed_strings -from bot.utils import send_to_paste_service +from bot.constants import BaseURLs, Metabase as MetabaseConfig, Roles +from bot.log import get_logger from bot.utils.channel import is_mod_channel -from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__) BASE_HEADERS = { "Content-Type": "application/json" @@ -34,15 +33,32 @@ def __init__(self, bot: Bot) -> None: self.bot = bot self._session_scheduler = Scheduler(self.__class__.__name__) - self.session_token: Optional[str] = None # session_info["session_token"]: str - self.session_expiry: Optional[float] = None # session_info["session_expiry"]: UtcPosixTimestamp + self.session_token: str | None = None # session_info["session_token"]: str + self.session_expiry: float | None = None # session_info["session_expiry"]: UtcPosixTimestamp self.headers = BASE_HEADERS - self.exports: Dict[int, List[Dict]] = {} # Saves the output of each question, so internal eval can access it + self.exports: dict[int, list[dict]] = {} # Saves the output of each question, so internal eval can access it - self.init_task = self.bot.loop.create_task(self.init_cog()) + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle ClientResponseError errors locally to invalidate token if needed.""" + if not hasattr(error, "original") or not isinstance(error.original, ClientResponseError): + return - async def init_cog(self) -> None: + if error.original.status == 403: + # User doesn't have access to the given question + log.warning(f"Failed to auth with Metabase for {error.original.url}.") + await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") + elif error.original.status == 404: + await ctx.send(f":x: {ctx.author.mention} That question could not be found.") + else: + # User credentials are invalid, or the refresh failed. + # Delete the expiry time, to force a refresh on next startup. + await self.session_info.delete("session_expiry") + log.exception("Session token is invalid or refresh failed.") + await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") + error.handled = True + + async def cog_load(self) -> None: """Initialise the metabase session.""" expiry_time = await self.session_info.get("session_expiry") if expiry_time: @@ -65,7 +81,8 @@ async def refresh_session(self) -> None: "username": MetabaseConfig.username, "password": MetabaseConfig.password } - async with self.bot.http_session.post(f"{MetabaseConfig.url}/session", json=data) as resp: + async with self.bot.http_session.post(f"{MetabaseConfig.base_url}/api/session", json=data) as resp: + resp.raise_for_status() json_data = await resp.json() self.session_token = json_data.get("id") @@ -86,12 +103,12 @@ async def metabase_group(self, ctx: Context) -> None: """A group of commands for interacting with metabase.""" await ctx.send_help(ctx.command) - @metabase_group.command(name="extract") + @metabase_group.command(name="extract", aliases=("export",)) async def metabase_extract( self, ctx: Context, question_id: int, - extension: allowed_strings("csv", "json") = "csv" + extension: Literal["csv", "json"] = "csv" ) -> None: """ Extract data from a metabase question. @@ -106,48 +123,55 @@ async def metabase_extract( Valid extensions are: csv and json. """ - async with ctx.typing(): - - # Make sure we have a session token before running anything - await self.init_task - - url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}" - try: - async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: - if extension == "csv": - out = await resp.text() - # Save the output for use with int e - self.exports[question_id] = list(csv.DictReader(StringIO(out))) - - elif extension == "json": - out = await resp.json() - # Save the output for use with int e - self.exports[question_id] = out - - # Format it nicely for human eyes - out = json.dumps(out, indent=4, sort_keys=True) - except ClientResponseError as e: - if e.status == 403: - # User doesn't have access to the given question - log.warning(f"Failed to auth with Metabase for question {question_id}.") - await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") - else: - # User credentials are invalid, or the refresh failed. - # Delete the expiry time, to force a refresh on next startup. - await self.session_info.delete("session_expiry") - log.exception("Session token is invalid or refresh failed.") - await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") - return - - paste_link = await send_to_paste_service(out, extension=extension) - if paste_link: - message = f":+1: {ctx.author.mention} Here's your link: {paste_link}" - else: - message = f":x: {ctx.author.mention} Link service is unavailible." - await ctx.send( - f"{message}\nYou can also access this data within internal eval by doing: " - f"`bot.get_cog('Metabase').exports[{question_id}]`" + await ctx.typing() + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + if extension == "csv": + extension = "text" # paste site doesn't support csv as a lexer + out = await resp.text(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = list(csv.DictReader(StringIO(out))) + + elif extension == "json": + out = await resp.json(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = out + + # Format it nicely for human eyes + out = json.dumps(out, indent=4, sort_keys=True) + + file = PasteFile(content=out, lexer=extension) + try: + resp = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, ) + except PasteTooLongError: + message = f":x: {ctx.author.mention} Too long to upload to paste service." + except PasteUploadError: + message = f":x: {ctx.author.mention} Failed to upload to paste service." + else: + message = f":+1: {ctx.author.mention} Here's your link: {resp.link}" + + await ctx.send( + f"{message}\nYou can also access this data within internal eval by doing: " + f"`bot.get_cog('Metabase').exports[{question_id}]`" + ) + + @metabase_group.command(name="publish", aliases=("share",)) + async def metabase_publish(self, ctx: Context, question_id: int) -> None: + """Publically shares the given question and posts the link.""" + await ctx.typing() + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/public_link" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + response_json = await resp.json(encoding="utf-8") + sharing_url = f"{MetabaseConfig.public_url}/public/question/{response_json['uuid']}" + await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}") # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: @@ -158,22 +182,14 @@ async def cog_check(self, ctx: Context) -> bool: ] return all(checks) - def cog_unload(self) -> None: - """ - Cancel the init task and scheduled tasks. - - It's important to wait for init_task to be cancelled before cancelling scheduled - tasks. Otherwise, it's possible for _session_scheduler to schedule another task - after cancel_all has finished, despite _init_task.cancel being called first. - This is cause cancel() on its own doesn't block until the task is cancelled. - """ - self.init_task.cancel() - self.init_task.add_done_callback(lambda _: self._session_scheduler.cancel_all()) + async def cog_unload(self) -> None: + """Cancel all scheduled tasks.""" + self._session_scheduler.cancel_all() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Metabase cog.""" if not all((MetabaseConfig.username, MetabaseConfig.password)): log.error("Credentials not provided, cog not loaded.") return - bot.add_cog(Metabase(bot)) + await bot.add_cog(Metabase(bot)) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be65ade6e2..ebd156d4be 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -1,27 +1,27 @@ import asyncio import difflib import itertools -import logging -import typing as t -from datetime import datetime -from itertools import zip_longest +from datetime import UTC, datetime import discord from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff -from discord import Colour +from discord import Colour, Message, Thread from discord.abc import GuildChannel -from discord.ext.commands import Cog, Context -from discord.utils import escape_markdown +from discord.ext.commands import Cog +from discord.utils import escape_markdown, format_dt, snowflake_time +from pydis_core.utils.channel import get_or_fetch_channel from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs -from bot.utils.messages import format_user -from bot.utils.time import humanize_delta +from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles +from bot.log import get_logger +from bot.utils import time +from bot.utils.messages import format_user, upload_log +from bot.utils.modlog import send_log_message -log = logging.getLogger(__name__) +log = get_logger(__name__) -GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] +GUILD_CHANNEL = discord.CategoryChannel | discord.TextChannel | discord.VoiceChannel CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") @@ -41,102 +41,14 @@ def __init__(self, bot: Bot): self.bot = bot self._ignored = {event: [] for event in Event} - self._cached_deletes = [] self._cached_edits = [] - async def upload_log( - self, - messages: t.Iterable[discord.Message], - actor_id: int, - attachments: t.Iterable[t.List[str]] = None - ) -> str: - """Upload message logs to the database and return a URL to a page for viewing the logs.""" - if attachments is None: - attachments = [] - - response = await self.bot.api_client.post( - 'bot/deleted-messages', - json={ - 'actor': actor_id, - 'creation': datetime.utcnow().isoformat(), - 'deletedmessage_set': [ - { - 'id': message.id, - 'author': message.author.id, - 'channel_id': message.channel.id, - 'content': message.content.replace("\0", ""), # Null chars cause 400. - 'embeds': [embed.to_dict() for embed in message.embeds], - 'attachments': attachment, - } - for message, attachment in zip_longest(messages, attachments, fillvalue=[]) - ] - } - ) - - return f"{URLs.site_logs_view}/{response['id']}" - def ignore(self, event: Event, *items: int) -> None: """Add event to ignored events to suppress log emission.""" for item in items: if item not in self._ignored[event]: self._ignored[event].append(item) - async def send_log_message( - self, - icon_url: t.Optional[str], - colour: t.Union[discord.Colour, int], - title: t.Optional[str], - text: str, - thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, - channel_id: int = Channels.mod_log, - ping_everyone: bool = False, - files: t.Optional[t.List[discord.File]] = None, - content: t.Optional[str] = None, - additional_embeds: t.Optional[t.List[discord.Embed]] = None, - timestamp_override: t.Optional[datetime] = None, - footer: t.Optional[str] = None, - ) -> Context: - """Generate log embed and send to logging channel.""" - # Truncate string directly here to avoid removing newlines - embed = discord.Embed( - description=text[:2045] + "..." if len(text) > 2048 else text - ) - - if title and icon_url: - embed.set_author(name=title, icon_url=icon_url) - - embed.colour = colour - embed.timestamp = timestamp_override or datetime.utcnow() - - if footer: - embed.set_footer(text=footer) - - if thumbnail: - embed.set_thumbnail(url=thumbnail) - - if ping_everyone: - if content: - content = f"<@&{Roles.moderators}>\n{content}" - else: - content = f"<@&{Roles.moderators}>" - - # Truncate content to 2000 characters and append an ellipsis. - if content and len(content) > 2000: - content = content[:2000 - 3] + "..." - - channel = self.bot.get_channel(channel_id) - log_message = await channel.send( - content=content, - embed=embed, - files=files - ) - - if additional_embeds: - for additional_embed in additional_embeds: - await channel.send(embed=additional_embed) - - return await self.bot.get_context(log_message) # Optionally return for use with antispam - @Cog.listener() async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: """Log channel create event to mod log.""" @@ -161,7 +73,7 @@ async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None: else: message = f"{channel.name} (`{channel.id}`)" - await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message) + await send_log_message(self.bot, Icons.hash_green, Colours.soft_green, title, message) @Cog.listener() async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: @@ -181,9 +93,12 @@ async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None: else: message = f"{channel.name} (`{channel.id}`)" - await self.send_log_message( - Icons.hash_red, Colours.soft_red, - title, message + await send_log_message( + self.bot, + Icons.hash_red, + Colours.soft_red, + title, + message ) @Cog.listener() @@ -196,12 +111,6 @@ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChann self._ignored[Event.guild_channel_update].remove(before.id) return - # Two channel updates are sent for a single edit: 1 for topic and 1 for category change. - # TODO: remove once support is added for ignoring multiple occurrences for the same channel. - help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use) - if after.category and after.category.id in help_categories: - return - diff = DeepDiff(before, after) changes = [] done = [] @@ -250,9 +159,12 @@ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChann else: message = f"**#{after.name}** (`{after.id}`)\n{message}" - await self.send_log_message( - Icons.hash_blurple, Colour.blurple(), - "Channel updated", message + await send_log_message( + self.bot, + Icons.hash_blurple, + Colour.og_blurple(), + "Channel updated", + message ) @Cog.listener() @@ -261,9 +173,12 @@ async def on_guild_role_create(self, role: discord.Role) -> None: if role.guild.id != GuildConstant.id: return - await self.send_log_message( - Icons.crown_green, Colours.soft_green, - "Role created", f"`{role.id}`" + await send_log_message( + self.bot, + Icons.crown_green, + Colours.soft_green, + "Role created", + f"`{role.id}`" ) @Cog.listener() @@ -272,9 +187,12 @@ async def on_guild_role_delete(self, role: discord.Role) -> None: if role.guild.id != GuildConstant.id: return - await self.send_log_message( - Icons.crown_red, Colours.soft_red, - "Role removed", f"{role.name} (`{role.id}`)" + await send_log_message( + self.bot, + Icons.crown_red, + Colours.soft_red, + "Role removed", + f"{role.name} (`{role.id}`)" ) @Cog.listener() @@ -325,9 +243,12 @@ async def on_guild_role_update(self, before: discord.Role, after: discord.Role) message = f"**{after.name}** (`{after.id}`)\n{message}" - await self.send_log_message( - Icons.crown_blurple, Colour.blurple(), - "Role updated", message + await send_log_message( + self.bot, + Icons.crown_blurple, + Colour.og_blurple(), + "Role updated", + message ) @Cog.listener() @@ -375,10 +296,13 @@ async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> message = f"**{after.name}** (`{after.id}`)\n{message}" - await self.send_log_message( - Icons.guild_update, Colour.blurple(), - "Guild updated", message, - thumbnail=after.icon_url_as(format="png") + await send_log_message( + self.bot, + Icons.guild_update, + Colour.og_blurple(), + "Guild updated", + message, + thumbnail=after.icon.with_static_format("png") ) @Cog.listener() @@ -391,10 +315,13 @@ async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> N self._ignored[Event.member_ban].remove(member.id) return - await self.send_log_message( - Icons.user_ban, Colours.soft_red, - "User banned", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + await send_log_message( + self.bot, + Icons.user_ban, + Colours.soft_red, + "User banned", + format_user(member), + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -404,18 +331,21 @@ async def on_member_join(self, member: discord.Member) -> None: if member.guild.id != GuildConstant.id: return - now = datetime.utcnow() + now = datetime.now(tz=UTC) difference = abs(relativedelta(now, member.created_at)) - message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) + message = format_user(member) + "\n\n**Account age:** " + time.humanize_delta(difference) if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account! message = f"{Emojis.new} {message}" - await self.send_log_message( - Icons.sign_in, Colours.soft_green, - "User joined", message, - thumbnail=member.avatar_url_as(static_format="png"), + await send_log_message( + self.bot, + Icons.sign_in, + Colours.soft_green, + "User joined", + message, + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -429,10 +359,13 @@ async def on_member_remove(self, member: discord.Member) -> None: self._ignored[Event.member_remove].remove(member.id) return - await self.send_log_message( - Icons.sign_out, Colours.soft_red, - "User left", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + await send_log_message( + self.bot, + Icons.sign_out, + Colours.soft_red, + "User left", + format_user(member), + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -446,15 +379,18 @@ async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> N self._ignored[Event.member_unban].remove(member.id) return - await self.send_log_message( - Icons.user_unban, Colour.blurple(), - "User unbanned", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + await send_log_message( + self.bot, + Icons.user_unban, + Colour.og_blurple(), + "User unbanned", + format_user(member), + thumbnail=member.display_avatar.url, channel_id=Channels.mod_log ) @staticmethod - def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]: + def get_role_diff(before: list[discord.Role], after: list[discord.Role]) -> list[str]: """Return a list of strings describing the roles added and removed.""" changes = [] before_roles = set(before) @@ -510,88 +446,142 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) message = f"{format_user(after)}\n{message}" - await self.send_log_message( + await send_log_message( + self.bot, icon_url=Icons.user_update, - colour=Colour.blurple(), + colour=Colour.og_blurple(), title="Member updated", text=message, - thumbnail=after.avatar_url_as(static_format="png"), + thumbnail=after.display_avatar.url, channel_id=Channels.user_log ) - @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: - """Log message delete event to message change log.""" + def is_message_blacklisted(self, message: Message) -> bool: + """Return true if the message is in a blacklisted thread or channel.""" + # Ignore bots or DMs + if message.author.bot or not message.guild: + return True + + return self.is_channel_ignored(message.channel.id) + + def is_channel_ignored(self, channel: int | GuildChannel | Thread) -> bool: + """ + Return true if the channel, or parent channel in the case of threads, passed should be ignored by modlog. + + Currently ignored channels are: + 1. Channels not in the guild we care about (constants.Guild.id). + 2. Channels that mods do not have view permissions to + 3. Channels in constants.Guild.modlog_blacklist + """ + if isinstance(channel, int): + channel = self.bot.get_channel(channel) + + # Ignore not found channels, DMs, and messages outside of the main guild. + if not channel or channel.guild is None or channel.guild.id != GuildConstant.id: + return True + + # Look at the parent channel of a thread. + if isinstance(channel, Thread): + channel = channel.parent + + # Mod team doesn't have view permission to the channel. + if not channel.permissions_for(channel.guild.get_role(Roles.mod_team)).view_channel: + return True + + return channel.id in GuildConstant.modlog_blacklist + + async def log_cached_deleted_message(self, message: discord.Message) -> None: + """ + Log the message's details to message change log. + + This is called when a cached message is deleted. + """ channel = message.channel author = message.author - # Ignore DMs. - if not message.guild: + if self.is_message_blacklisted(message): return - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: - return - - self._cached_deletes.append(message.id) - if message.id in self._ignored[Event.message_delete]: self._ignored[Event.message_delete].remove(message.id) return - if author.bot: - return - if channel.category: response = ( f"**Author:** {format_user(author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"**Sent at:** {format_dt(message.created_at)}\n" f"[Jump to message]({message.jump_url})\n" - "\n" ) else: response = ( f"**Author:** {format_user(author)}\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" + f"**Sent at:** {format_dt(message.created_at)}\n" f"[Jump to message]({message.jump_url})\n" - "\n" ) + # If the message is a reply, add the reference to the response + if message.reference is not None and message.reference.resolved is not None: + resolved_message = message.reference.resolved + + if isinstance(resolved_message, discord.DeletedReferencedMessage): + # Reference is a deleted message + reference_line = f"**In reply to:** `{resolved_message.id}`(Deleted Message)\n" + response = reference_line + response + + elif isinstance(resolved_message, discord.Message): + jump_url = resolved_message.jump_url + author = resolved_message.author.mention + + reference_line = ( + f"**In reply to:** {author} [Jump to referenced message]({jump_url})\n" + ) + response = reference_line + response + + elif message.reference is not None and message.reference.resolved is None: + reference_line = ( + "**In reply to:** (Message could not be resolved)\n" + ) + response = reference_line + response + if message.attachments: # Prepend the message metadata with the number of attachments response = f"**Attachments:** {len(message.attachments)}\n" + response # Shorten the message content if necessary + response += "\n**Deleted Message:**:\n" content = message.clean_content - remaining_chars = 2040 - len(response) + remaining_chars = 4090 - len(response) if len(content) > remaining_chars: - botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) + botlog_url = await upload_log(messages=[message], actor_id=message.author.id) ending = f"\n\nMessage truncated, [full message here]({botlog_url})." truncation_point = remaining_chars - len(ending) content = f"{content[:truncation_point]}...{ending}" response += f"{content}" - await self.send_log_message( - Icons.message_delete, Colours.soft_red, + await send_log_message( + self.bot, + Icons.message_delete, + Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log ) - @Cog.listener() - async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: - """Log raw message delete event to message change log.""" - if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired + async def log_uncached_deleted_message(self, event: discord.RawMessageDeleteEvent) -> None: + """ + Log the message's details to message change log. - if event.message_id in self._cached_deletes: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_deletes.remove(event.message_id) + This is called when a message absent from the cache is deleted. + Hence, the message contents aren't logged. + """ + await self.bot.wait_until_guild_available() + if self.is_channel_ignored(event.channel_id): return if event.message_id in self._ignored[Event.message_delete]: @@ -604,6 +594,7 @@ async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> N response = ( f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{event.message_id}`\n" + f"**Sent at:** {format_dt(snowflake_time(event.message_id))}\n" "\n" "This message was not cached, so the message content cannot be displayed." ) @@ -611,26 +602,32 @@ async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> N response = ( f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{event.message_id}`\n" + f"**Sent at:** {format_dt(snowflake_time(event.message_id))}\n" "\n" "This message was not cached, so the message content cannot be displayed." ) - await self.send_log_message( - Icons.message_delete, Colours.soft_red, + await send_log_message( + self.bot, + Icons.message_delete, + Colours.soft_red, "Message deleted", response, channel_id=Channels.message_log ) + @Cog.listener() + async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: + """Log message deletions to message change log.""" + if event.cached_message is not None: + await self.log_cached_deleted_message(event.cached_message) + else: + await self.log_uncached_deleted_message(event) + @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" - if ( - not msg_before.guild - or msg_before.guild.id != GuildConstant.id - or msg_before.channel.id in GuildConstant.modlog_blacklist - or msg_before.author.bot - ): + if self.is_message_blacklisted(msg_before): return self._cached_edits.append(msg_before.id) @@ -650,16 +647,16 @@ async def on_message_edit(self, msg_before: discord.Message, msg_after: discord. for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0]) ) - content_before: t.List[str] = [] - content_after: t.List[str] = [] + content_before: list[str] = [] + content_after: list[str] = [] for index, (diff_type, words) in enumerate(diff_groups): - sub = ' '.join(words) - if diff_type == '-': + sub = " ".join(words) + if diff_type == "-": content_before.append(f"[{sub}](http://o.hi)") - elif diff_type == '+': + elif diff_type == "+": content_after.append(f"[{sub}](http://o.hi)") - elif diff_type == ' ': + elif diff_type == " ": if len(words) > 2: sub = ( f"{words[0] if index > 0 else ''}" @@ -685,7 +682,7 @@ async def on_message_edit(self, msg_before: discord.Message, msg_after: discord. # datetime as the baseline and create a human-readable delta between this edit event # and the last time the message was edited timestamp = msg_before.edited_at - delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at)) + delta = time.humanize_delta(msg_after.edited_at, msg_before.edited_at) footer = f"Last edited {delta} ago" else: # Message was not previously edited, use the created_at datetime as the baseline, no @@ -693,26 +690,31 @@ async def on_message_edit(self, msg_before: discord.Message, msg_after: discord. timestamp = msg_before.created_at footer = None - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited", response, - channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer + await send_log_message( + self.bot, + Icons.message_edit, + Colour.og_blurple(), + "Message edited", + response, + channel_id=Channels.message_log, + timestamp_override=timestamp, + footer=footer ) @Cog.listener() async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" + if event.guild_id is None: + return # ignore DM edits + + await self.bot.wait_until_guild_available() try: - channel = self.bot.get_channel(int(event.data["channel_id"])) + channel = await get_or_fetch_channel(self.bot, int(event.data["channel_id"])) message = await channel.fetch_message(event.message_id) - except discord.NotFound: # Was deleted before we got the event + except discord.NotFound: # Channel/message was deleted before we got the event return - if ( - not message.guild - or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.modlog_blacklist - or message.author.bot - ): + if self.is_message_blacklisted(message): return await asyncio.sleep(1) # Wait here in case the normal event was fired @@ -741,14 +743,84 @@ async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> Non f"{message.clean_content}" ) - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", - before_response, channel_id=Channels.message_log + await send_log_message( + self.bot, + Icons.message_edit, + Colour.og_blurple(), + "Message edited (Before)", + before_response, + channel_id=Channels.message_log + ) + + await send_log_message( + self.bot, + Icons.message_edit, + Colour.og_blurple(), + "Message edited (After)", + after_response, + channel_id=Channels.message_log + ) + + @Cog.listener() + async def on_thread_update(self, before: Thread, after: Thread) -> None: + """Log thread archiving, un-archiving and name edits.""" + if self.is_channel_ignored(after.id): + log.trace("Ignoring update of thread %s (%d)", after.mention, after.id) + return + + if before.name != after.name: + await send_log_message( + self.bot, + Icons.hash_blurple, + Colour.og_blurple(), + "Thread name edited", + ( + f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): " + f"`{before.name}` -> `{after.name}`" + ) + ) + return + + if not before.archived and after.archived: + colour = Colours.soft_red + action = "archived" + icon = Icons.hash_red + elif before.archived and not after.archived: + colour = Colours.soft_green + action = "un-archived" + icon = Icons.hash_green + else: + return + + await send_log_message( + self.bot, + icon, + colour, + f"Thread {action}", + ( + f"Thread {after.mention} ({after.name}, `{after.id}`) from {after.parent.mention} " + f"(`{after.parent.id}`) was {action}" + ), + channel_id=Channels.message_log, ) - await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", - after_response, channel_id=Channels.message_log + @Cog.listener() + async def on_thread_delete(self, thread: Thread) -> None: + """Log thread deletion.""" + if self.is_channel_ignored(thread): + log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id) + return + + await send_log_message( + self.bot, + Icons.hash_red, + Colours.soft_red, + "Thread deleted", + ( + f"Thread {thread.mention} ({thread.name}, `{thread.id}`) from {thread.parent.mention} " + f"(`{thread.parent.id}`) deleted" + ), + channel_id=Channels.message_log, ) @Cog.listener() @@ -761,7 +833,8 @@ async def on_voice_state_update( """Log member voice state changes to the voice log channel.""" if ( member.guild.id != GuildConstant.id - or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) + or (before.channel and self.is_channel_ignored(before.channel.id)) + or (after.channel and self.is_channel_ignored(after.channel.id)) ): return @@ -783,7 +856,7 @@ async def on_voice_state_update( diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} icon = Icons.voice_state_blue - colour = Colour.blurple() + colour = Colour.og_blurple() changes = [] for attr, values in diff_values.items(): @@ -815,16 +888,17 @@ async def on_voice_state_update( message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) message = f"{format_user(member)}\n{message}" - await self.send_log_message( + await send_log_message( + self.bot, icon_url=icon, colour=colour, title="Voice state updated", text=message, - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.display_avatar.url, channel_id=Channels.voice_log ) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the ModLog cog.""" - bot.add_cog(ModLog(bot)) + await bot.add_cog(ModLog(bot)) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 1ad5005de8..002bc4cfe6 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,17 +1,23 @@ +import asyncio import datetime -import logging +import arrow from async_rediscache import RedisCache -from dateutil.parser import isoparse -from discord import Embed, Member +from dateutil.parser import isoparse, parse as dateutil_parse +from discord import Member from discord.ext.commands import Cog, Context, group, has_any_role +from pydis_core.utils.members import get_or_fetch_member +from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles +from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import Expiry -from bot.utils.scheduling import Scheduler +from bot.log import get_logger +from bot.utils.time import TimestampFormats, discord_timestamp -log = logging.getLogger(__name__) +log = get_logger(__name__) + +MAXIMUM_WORK_LIMIT = 16 class ModPings(Cog): @@ -22,14 +28,23 @@ class ModPings(Cog): # The cache's values are the times when the role should be re-applied to them, stored in ISO format. pings_off_mods = RedisCache() + # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds'] + # The cache's keys are mod's ID + # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off + modpings_schedule = RedisCache() + def __init__(self, bot: Bot): self.bot = bot - self._role_scheduler = Scheduler(self.__class__.__name__) + self._role_scheduler = Scheduler("ModPingsOnOff") + self._modpings_scheduler = Scheduler("ModPingsSchedule") self.guild = None self.moderators_role = None - self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") + async def cog_load(self) -> None: + """Schedule both when to reapply role and all mod ping schedules.""" + # await self.reschedule_modpings_schedule() + await self.reschedule_roles() async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" @@ -43,30 +58,89 @@ async def reschedule_roles(self) -> None: log.trace("Applying the moderators role to the mod team where necessary.") for mod in mod_team.members: - if mod in pings_on: # Make sure that on-duty mods aren't in the cache. - if mod in pings_off: + if mod in pings_on: # Make sure that on-duty mods aren't in the redis cache. + if mod.id in pings_off: await self.pings_off_mods.delete(mod.id) continue - # Keep the role off only for those in the cache. + # Keep the role off only for those in the redis cache. if mod.id not in pings_off: await self.reapply_role(mod) else: - expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) + expiry = isoparse(pings_off[mod.id]) self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + # At this stage every entry in `pings_off` is expected to have a scheduled task, but that might not be the case + # if the discord.py cache is missing members, or if the ID belongs to a former moderator. + for mod_id, expiry_iso in pings_off.items(): + if mod_id not in self._role_scheduler: + mod = await get_or_fetch_member(self.guild, mod_id) + # Make sure the member is still a moderator and doesn't have the pingable role. + if mod is None or mod.get_role(Roles.mod_team) is None or mod.get_role(Roles.moderators) is not None: + await self.pings_off_mods.delete(mod_id) + else: + self._role_scheduler.schedule_at(isoparse(expiry_iso), mod_id, self.reapply_role(mod)) + + async def reschedule_modpings_schedule(self) -> None: + """Reschedule moderators schedule ping.""" + await self.bot.wait_until_guild_available() + schedule_cache = await self.modpings_schedule.to_dict() + + log.info("Scheduling modpings schedule for applicable moderators found in cache.") + for mod_id, schedule in schedule_cache.items(): + start_timestamp, work_time = schedule.split("|") + start = datetime.datetime.fromtimestamp(float(start_timestamp), tz=datetime.UTC) + + mod = await self.bot.fetch_user(mod_id) + self._modpings_scheduler.schedule_at( + start, + mod_id, + self.add_role_schedule(mod, work_time, start) + ) + + async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: + """Removes the moderator's role to the given moderator.""" + log.trace(f"Removing moderator role from mod with ID {mod.id}") + await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") + + # Remove the task before scheduling it again + self._modpings_scheduler.cancel(mod.id) + + # Add the task again + log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") + schedule_start += datetime.timedelta(days=1) + self._modpings_scheduler.schedule_at( + schedule_start, + mod.id, + self.add_role_schedule(mod, work_time, schedule_start) + ) + + async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: + """Adds the moderator's role to the given moderator.""" + # If the moderator has pings off, then skip adding role + if mod.id in await self.pings_off_mods.to_dict(): + log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.") + else: + log.trace(f"Applying moderator role to mod with ID {mod.id}") + await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!") + + log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}") + await asyncio.sleep(work_time) + await self.remove_role_schedule(mod, work_time, schedule_start) + async def reapply_role(self, mod: Member) -> None: """Reapply the moderator's role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + await self.pings_off_mods.delete(mod.id) - @group(name='modpings', aliases=('modping',), invoke_without_command=True) + @group(name="modpings", aliases=("modping",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def modpings_group(self, ctx: Context) -> None: """Allow the removal and re-addition of the pingable moderators role.""" await ctx.send_help(ctx.command) - @modpings_group.command(name='off') + @modpings_group.command(name="off") @has_any_role(*MODERATION_ROLES) async def off_command(self, ctx: Context, duration: Expiry) -> None: """ @@ -85,9 +159,8 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: Alternatively, an ISO 8601 timestamp can be provided for the duration. The duration cannot be longer than 30 days. - """ - duration: datetime.datetime - delta = duration - datetime.datetime.utcnow() + """ # noqa: RUF002 + delta = duration - arrow.utcnow() if delta > datetime.timedelta(days=30): await ctx.send(":x: Cannot remove the role for longer than 30 days.") return @@ -104,11 +177,12 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: self._role_scheduler.cancel(mod.id) self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) - embed = Embed(timestamp=duration, colour=Colours.bright_green) - embed.set_footer(text="Moderators role has been removed until", icon_url=Icons.green_checkmark) - await ctx.send(embed=embed) + await ctx.send( + f"{Emojis.check_mark} Moderators role has been removed " + f"until {discord_timestamp(duration, format=TimestampFormats.DAY_TIME)}." + ) - @modpings_group.command(name='on') + @modpings_group.command(name="on") @has_any_role(*MODERATION_ROLES) async def on_command(self, ctx: Context) -> None: """Re-apply the pingable moderators role.""" @@ -126,13 +200,64 @@ async def on_command(self, ctx: Context) -> None: await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") - def cog_unload(self) -> None: + @modpings_group.group( + name="schedule", + aliases=("s",), + invoke_without_command=True + ) + @has_any_role(*MODERATION_ROLES) + async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: + """Schedule modpings role to be added at and removed at everyday at UTC time!""" + start, end = dateutil_parse(start), dateutil_parse(end) + + if end < start: + end += datetime.timedelta(days=1) + + if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT): + await ctx.send( + f":x: {ctx.author.mention} You can't have the modpings role for" + f" more than {MAXIMUM_WORK_LIMIT} hours!" + ) + return + + if start < datetime.datetime.now(datetime.UTC): + # The datetime has already gone for the day, so make it tomorrow + # otherwise the scheduler would schedule it immediately + start += datetime.timedelta(days=1) + + work_time = (end - start).total_seconds() + + await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") + + if ctx.author.id in self._modpings_scheduler: + self._modpings_scheduler.cancel(ctx.author.id) + + self._modpings_scheduler.schedule_at( + start, + ctx.author.id, + self.add_role_schedule(ctx.author, work_time, start) + ) + + await ctx.send( + f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " + f"{discord_timestamp(start, TimestampFormats.TIME)} to " + f"{discord_timestamp(end, TimestampFormats.TIME)}!" + ) + + @schedule_modpings.command(name="delete", aliases=("del", "d")) + async def modpings_schedule_delete(self, ctx: Context) -> None: + """Delete your modpings schedule.""" + self._modpings_scheduler.cancel(ctx.author.id) + await self.modpings_schedule.delete(ctx.author.id) + await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!") + + async def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" - log.trace("Cog unload: canceling role tasks.") - self.reschedule_task.cancel() + log.trace("Cog unload: cancelling all scheduled tasks.") self._role_scheduler.cancel_all() + self._modpings_scheduler.cancel_all() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the ModPings cog.""" - bot.add_cog(ModPings(bot)) + await bot.add_cog(ModPings(bot)) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2a7ca932e1..a299d7eedd 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,47 +1,66 @@ import json -import logging +from collections import OrderedDict from contextlib import suppress -from datetime import datetime, timedelta, timezone -from operator import attrgetter -from typing import Optional +from datetime import UTC, datetime, timedelta from async_rediscache import RedisCache -from discord import TextChannel +from discord import Guild, PermissionOverwrite, TextChannel, Thread, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context +from discord.utils import MISSING +from pydis_core.utils.scheduling import Scheduler +from bot import constants from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter -from bot.utils.lock import LockedResourceError, lock_arg -from bot.utils.scheduling import Scheduler +from bot.log import get_logger +from bot.utils.lock import LockedResourceError, lock, lock_arg -log = logging.getLogger(__name__) +log = get_logger(__name__) LOCK_NAMESPACE = "silence" -MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." -MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." +MSG_SILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} is already silenced." +MSG_SILENCE_PERMANENT = f"{constants.Emojis.check_mark} silenced {{channel}} indefinitely." +MSG_SILENCE_SUCCESS = f"{constants.Emojis.check_mark} silenced {{{{channel}}}} for {{duration}} minute(s)." -MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"{constants.Emojis.cross_mark} {{channel}} was not unsilenced because the current overwrites were " f"set manually or the cache was prematurely cleared. " f"Please edit the overwrites manually to unsilence." ) -MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." +MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced {{channel}}." + +TextOrVoiceChannel = TextChannel | VoiceChannel + +VOICE_CHANNELS = { + constants.Channels.code_help_voice_0: constants.Channels.code_help_chat_0, + constants.Channels.code_help_voice_1: constants.Channels.code_help_chat_1, + constants.Channels.general_voice_0: constants.Channels.voice_chat_0, + constants.Channels.general_voice_1: constants.Channels.voice_chat_1, + constants.Channels.staff_voice: constants.Channels.staff_voice_chat, +} class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" def __init__(self, alert_channel: TextChannel): - super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) + super().__init__( + self._notifier, + seconds=1, + minutes=0, + hours=0, + count=None, + reconnect=True, + time=MISSING, + name=None + ) self._silenced_channels = {} self._alert_channel = alert_channel - def add_channel(self, channel: TextChannel) -> None: + def add_channel(self, channel: TextOrVoiceChannel) -> None: """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() @@ -64,11 +83,19 @@ async def _notifier(self) -> None: f"Sending notice with channels: " f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}." ) - channels_text = ', '.join( + channels_text = ", ".join( f"{channel.mention} for {(self._current_loop-start)//60} min" for channel, start in self._silenced_channels.items() ) - await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + await self._alert_channel.send( + f"<@&{constants.Roles.moderators}> currently silenced channels: {channels_text}" + ) + + +async def _select_lock_channel(args: OrderedDict[str, any]) -> TextOrVoiceChannel: + """Passes the channel to be silenced to the resource lock.""" + channel, _ = Silence.parse_silence_args(args["ctx"], args["duration_or_channel"], args["duration"]) + return channel class Silence(commands.Cog): @@ -86,94 +113,204 @@ def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._init_task = self.bot.loop.create_task(self._async_init()) - - async def _async_init(self) -> None: + async def cog_load(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() - guild = self.bot.get_guild(Guild.id) + guild = self.bot.get_guild(constants.Guild.id) + self._everyone_role = guild.default_role - self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) - self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) + self._verified_voice_role = guild.get_role(constants.Roles.voice_verified) + + self._mod_alerts_channel = self.bot.get_channel(constants.Channels.mod_alerts) + + self.notifier = SilenceNotifier(self.bot.get_channel(constants.Channels.mod_log)) await self._reschedule() + async def send_message( + self, + message: str, + source_channel: TextChannel, + target_channel: TextOrVoiceChannel, + *, + alert_target: bool = False + ) -> None: + """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" + # Reply to invocation channel + source_reply = message + if source_channel != target_channel: + source_reply = source_reply.format(channel=target_channel.mention) + else: + source_reply = source_reply.format(channel="current channel") + await source_channel.send(source_reply) + + # Reply to target channel + if alert_target: + if isinstance(target_channel, VoiceChannel): + voice_chat = self.bot.get_channel(VOICE_CHANNELS.get(target_channel.id)) + if voice_chat and source_channel != voice_chat: + await voice_chat.send(message.format(channel=target_channel.mention)) + + elif source_channel != target_channel: + await target_channel.send(message.format(channel="current channel")) + @commands.command(aliases=("hush",)) - @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) - async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: + @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) + async def silence( + self, + ctx: Context, + duration_or_channel: TextOrVoiceChannel | HushDurationConverter = None, + duration: HushDurationConverter = 10, + *, + kick: bool = False + ) -> None: """ Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. + + Passing a voice channel will attempt to move members out of the channel and back to force sync permissions. + If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin. """ - await self._init_task + channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration) - channel_info = f"#{ctx.channel} ({ctx.channel.id})" + channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._set_silence_overwrites(ctx.channel): + # Since threads don't have specific overrides, we cannot silence them individually. + # The parent channel has to be muted or the thread should be archived. + if isinstance(channel, Thread): + await ctx.send(":x: Threads cannot be silenced.") + return + + if not await self._set_silence_overwrites(channel, kick=kick): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") - await ctx.send(MSG_SILENCE_FAIL) + await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False) return - await self._schedule_unsilence(ctx, duration) + if isinstance(channel, VoiceChannel): + if kick: + await self._kick_voice_members(channel) + else: + await self._force_voice_sync(channel) + + await self._schedule_unsilence(ctx, channel, duration) if duration is None: - self.notifier.add_channel(ctx.channel) + self.notifier.add_channel(channel) log.info(f"Silenced {channel_info} indefinitely.") - await ctx.send(MSG_SILENCE_PERMANENT) + await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, alert_target=True) + else: log.info(f"Silenced {channel_info} for {duration} minute(s).") - await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) - - @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context) -> None: - """ - Unsilence the current channel. - - If the channel was silenced indefinitely, notifications for the channel will stop. - """ - await self._init_task - log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") - await self._unsilence_wrapper(ctx.channel) - - @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) - async def _unsilence_wrapper(self, channel: TextChannel) -> None: - """Unsilence `channel` and send a success/failure message.""" - if not await self._unsilence(channel): - overwrite = channel.overwrites_for(self._everyone_role) - if overwrite.send_messages is False or overwrite.add_reactions is False: - await channel.send(MSG_UNSILENCE_MANUAL) + formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration) + await self.send_message(formatted_message, ctx.channel, channel, alert_target=True) + + @staticmethod + def parse_silence_args( + ctx: Context, + duration_or_channel: TextOrVoiceChannel | int, + duration: HushDurationConverter + ) -> tuple[TextOrVoiceChannel, int | None]: + """Helper method to parse the arguments of the silence command.""" + if duration_or_channel: + if isinstance(duration_or_channel, TextChannel | VoiceChannel): + channel = duration_or_channel else: - await channel.send(MSG_UNSILENCE_FAIL) + channel = ctx.channel + duration = duration_or_channel else: - await channel.send(MSG_UNSILENCE_SUCCESS) + channel = ctx.channel + + if duration == -1: + duration = None - async def _set_silence_overwrites(self, channel: TextChannel) -> bool: + return channel, duration + + async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" - overwrite = channel.overwrites_for(self._everyone_role) - prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + # Get the original channel overwrites + if isinstance(channel, TextChannel): + role = self._everyone_role + overwrite = channel.overwrites_for(role) + prev_overwrites = dict( + send_messages=overwrite.send_messages, + add_reactions=overwrite.add_reactions, + create_private_threads=overwrite.create_private_threads, + create_public_threads=overwrite.create_public_threads, + send_messages_in_threads=overwrite.send_messages_in_threads + ) + else: + role = self._verified_voice_role + overwrite = channel.overwrites_for(role) + prev_overwrites = dict(speak=overwrite.speak) + if kick: + prev_overwrites.update(connect=overwrite.connect) + + # Stop if channel was already silenced if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False - overwrite.update(send_messages=False, add_reactions=False) - await channel.set_permissions(self._everyone_role, overwrite=overwrite) + # Set new permissions, store + overwrite.update(**dict.fromkeys(prev_overwrites, False)) + await channel.set_permissions(role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True - async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: + async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: int | None) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: - await self.unsilence_timestamps.set(ctx.channel.id, -1) + await self.unsilence_timestamps.set(channel.id, -1) + else: + self.scheduler.schedule_later(duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel)) + unsilence_time = datetime.now(tz=UTC) + timedelta(minutes=duration) + await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) + + @commands.command(aliases=("unhush",)) + async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: + """ + Unsilence the given channel if given, else the current one. + + If the channel was silenced indefinitely, notifications for the channel will stop. + """ + if channel is None: + channel = ctx.channel + log.debug(f"Unsilencing channel #{channel} from {ctx.author}'s command.") + await self._unsilence_wrapper(channel, ctx) + + @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) + async def _unsilence_wrapper(self, channel: TextOrVoiceChannel, ctx: Context | None = None) -> None: + """ + Unsilence `channel` and send a success/failure message to ctx.channel. + + If ctx is None or not passed, `channel` is used in its place. + If `channel` and ctx.channel are the same, only one message is sent. + """ + msg_channel = channel + if ctx is not None: + msg_channel = ctx.channel + + if not await self._unsilence(channel): + if isinstance(channel, VoiceChannel): + overwrite = channel.overwrites_for(self._verified_voice_role) + has_channel_overwrites = overwrite.speak is False + else: + overwrite = channel.overwrites_for(self._everyone_role) + has_channel_overwrites = overwrite.send_messages is False or overwrite.add_reactions is False + + # Send fail message to muted channel or voice chat channel, and invocation channel + if has_channel_overwrites: + await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel, alert_target=False) + else: + await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel, alert_target=False) + else: - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) - await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) - async def _unsilence(self, channel: TextChannel) -> bool: + async def _unsilence(self, channel: TextOrVoiceChannel) -> bool: """ Unsilence `channel`. @@ -183,19 +320,42 @@ async def _unsilence(self, channel: TextChannel) -> bool: Return `True` if channel permissions were changed, `False` otherwise. """ + # Get stored overwrites, and return if channel is unsilenced prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - overwrite = channel.overwrites_for(self._everyone_role) + # Select the role based on channel type, and get current overwrites + if isinstance(channel, TextChannel): + role = self._everyone_role + overwrite = channel.overwrites_for(role) + permissions = "`Send Messages` and `Add Reactions`" + else: + role = self._verified_voice_role + overwrite = channel.overwrites_for(role) + permissions = "`Speak` and `Connect`" + + # Check if old overwrites were not stored if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") - overwrite.update(send_messages=None, add_reactions=None) + overwrite.update( + send_messages=None, + add_reactions=None, + create_private_threads=None, + create_public_threads=None, + send_messages_in_threads=None, + speak=None, + connect=None + ) else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._everyone_role, overwrite=overwrite) + # Update Permissions + await channel.set_permissions(role, overwrite=overwrite) + if isinstance(channel, VoiceChannel): + await self._force_voice_sync(channel) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) @@ -203,15 +363,81 @@ async def _unsilence(self, channel: TextChannel) -> bool: await self.previous_overwrites.delete(channel.id) await self.unsilence_timestamps.delete(channel.id) + # Alert Admin team if old overwrites were not available if prev_overwrites is None: await self._mod_alerts_channel.send( - f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " - f"overwrites for {self._everyone_role.mention} are at their desired values." + f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the {permissions} " + f"overwrites for {role.mention} are at their desired values." ) return True + @staticmethod + async def _get_afk_channel(guild: Guild) -> VoiceChannel: + """Get a guild's AFK channel, or create one if it does not exist.""" + afk_channel = guild.afk_channel + + if afk_channel is None: + overwrites = { + guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + afk_channel = await guild.create_voice_channel("mute-temp", overwrites=overwrites) + log.info(f"Failed to get afk-channel, created #{afk_channel} ({afk_channel.id})") + + return afk_channel + + @staticmethod + async def _kick_voice_members(channel: VoiceChannel) -> None: + """Remove all non-staff members from a voice channel.""" + log.debug(f"Removing all non staff members from #{channel.name} ({channel.id}).") + + for member in channel.members: + # Skip staff + if any(role.id in constants.MODERATION_ROLES for role in member.roles): + continue + + try: + await member.move_to(None, reason="Kicking member from voice channel.") + log.trace(f"Kicked {member.name} from voice channel.") + except Exception as e: + log.debug(f"Failed to move {member.name}. Reason: {e}") + continue + + log.debug("Removed all members.") + + async def _force_voice_sync(self, channel: VoiceChannel) -> None: + """ + Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. + + Permission modification has to happen before this function. + """ + # Obtain temporary channel + delete_channel = channel.guild.afk_channel is None + afk_channel = await self._get_afk_channel(channel.guild) + + try: + # Move all members to temporary channel and back + for member in channel.members: + # Skip staff + if any(role.id in constants.MODERATION_ROLES for role in member.roles): + continue + + try: + await member.move_to(afk_channel, reason="Muting VC member.") + log.trace(f"Moved {member.name} to afk channel.") + + await member.move_to(channel, reason="Muting VC member.") + log.trace(f"Moved {member.name} to original voice channel.") + except Exception as e: + log.debug(f"Failed to move {member.name}. Reason: {e}") + continue + + finally: + # Delete VC channel if it was created. + if delete_channel: + await afk_channel.delete(reason="Deleting temporary mute channel.") + async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" for channel_id, timestamp in await self.unsilence_timestamps.items(): @@ -225,8 +451,8 @@ async def _reschedule(self) -> None: self.notifier.add_channel(channel) continue - dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) - delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() + dt = datetime.fromtimestamp(timestamp, tz=UTC) + delta = (dt - datetime.now(tz=UTC)).total_seconds() if delta <= 0: # Suppress the error since it's not being invoked by a user via the command. with suppress(LockedResourceError): @@ -235,21 +461,16 @@ async def _reschedule(self) -> None: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) - def cog_unload(self) -> None: - """Cancel the init task and scheduled tasks.""" - # It's important to wait for _init_task (specifically for _reschedule) to be cancelled - # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule - # more tasks after cancel_all has finished, despite _init_task.cancel being called first. - # This is cause cancel() on its own doesn't block until the task is cancelled. - self._init_task.cancel() - self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all()) - # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx) + return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx) + + async def cog_unload(self) -> None: + """Cancel all scheduled tasks.""" + self.scheduler.cancel_all() -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Silence cog.""" - bot.add_cog(Silence(bot)) + await bot.add_cog(Silence(bot)) diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index d8baff76a6..a83692fa46 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -1,87 +1,182 @@ -import logging -from typing import Optional +from datetime import datetime +from typing import Literal +from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import TextChannel +from discord import TextChannel, Thread from discord.ext.commands import Cog, Context, group, has_any_role +from pydis_core.utils.channel import get_or_fetch_channel +from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot from bot.constants import Channels, Emojis, MODERATION_ROLES -from bot.converters import DurationDelta +from bot.converters import Duration, DurationDelta +from bot.log import get_logger from bot.utils import time -log = logging.getLogger(__name__) +log = get_logger(__name__) SLOWMODE_MAX_DELAY = 21600 # seconds COMMONLY_SLOWMODED_CHANNELS = { Channels.python_general: "python_general", - Channels.discord_py: "discordpy", + Channels.discord_bots: "discord_bots", Channels.off_topic_0: "ot0", } +MessageHolder = TextChannel | Thread | None + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" + # RedisCache[discord.channel.id : f"{delay}, {expiry}"] + # `delay` is the slowmode delay assigned to the text channel. + # `expiry` is a naïve ISO 8601 string which describes when the slowmode should be removed. + slowmode_cache = RedisCache() + def __init__(self, bot: Bot) -> None: self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) - @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + @group(name="slowmode", aliases=["sm"], invoke_without_command=True) async def slowmode_group(self, ctx: Context) -> None: """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" await ctx.send_help(ctx.command) - @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + @slowmode_group.command(name="get", aliases=["g"]) + async def get_slowmode(self, ctx: Context, channel: MessageHolder) -> None: """Get the slowmode delay for a text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel - delay = relativedelta(seconds=channel.slowmode_delay) - humanized_delay = time.humanize_delta(delay) - - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') - - @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a text channel.""" + humanized_delay = time.humanize_delta(seconds=channel.slowmode_delay) + original_delay, humanized_original_delay, expiration_timestamp = await self._fetch_sm_cache(channel.id) + if original_delay is not None: + await ctx.send( + f"The slowmode delay for {channel.mention} is {humanized_delay}" + f" and will revert to {humanized_original_delay} {expiration_timestamp}." + ) + else: + await ctx.send(f"The slowmode delay for {channel.mention} is {humanized_delay}.") + + @slowmode_group.command(name="set", aliases=["s"]) + async def set_slowmode( + self, + ctx: Context, + channel: MessageHolder, + delay: DurationDelta | Literal["0s", "0seconds"], + expiry: Duration | None = None + ) -> None: + """ + Set the slowmode delay for a text channel. + + Supports temporary slowmodes with the `expiry` argument that automatically + revert to the original delay after expiration. + """ # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` # Must do this to get the delta in a particular unit of time - slowmode_delay = time.relativedelta_to_timedelta(delay).total_seconds() + if isinstance(delay, str): + delay = relativedelta(seconds=0) + slowmode_delay = int(time.relativedelta_to_timedelta(delay).total_seconds()) humanized_delay = time.humanize_delta(delay) # Ensure the delay is within discord's limits - if slowmode_delay <= SLOWMODE_MAX_DELAY: - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - - await channel.edit(slowmode_delay=slowmode_delay) - if channel.id in COMMONLY_SLOWMODED_CHANNELS: - log.info(f'Recording slowmode change in stats for {channel.name}.') - self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) + if slowmode_delay > SLOWMODE_MAX_DELAY: + log.info( + f"{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, " + "which is not between 0 and 6 hours." + ) await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' + f"{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours." ) + return - else: + if expiry is not None: + expiration_timestamp = time.format_relative(expiry) + + original_delay, humanized_original_delay, _ = await self._fetch_sm_cache(channel.id) + # Cache the channel's current delay if it has no expiry, otherwise use the cached original delay. + if original_delay is None: + original_delay = channel.slowmode_delay + humanized_original_delay = time.humanize_delta(seconds=original_delay) + else: + self.scheduler.cancel(channel.id) + await self.slowmode_cache.set(channel.id, f"{original_delay}, {expiry}") + + self.scheduler.schedule_at(expiry, channel.id, self._revert_slowmode(channel.id)) log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' - 'which is not between 0 and 6 hours.' + f"{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}" + f" which will revert to {humanized_original_delay} in {time.humanize_delta(expiry)}." ) - + await channel.edit(slowmode_delay=slowmode_delay) await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + f"{Emojis.check_mark} The slowmode delay for {channel.mention}" + f" is now {humanized_delay} and will revert to {humanized_original_delay} {expiration_timestamp}." ) + else: + if await self.slowmode_cache.contains(channel.id): + await self.slowmode_cache.delete(channel.id) + self.scheduler.cancel(channel.id) - @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + log.info(f"{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.") + await channel.edit(slowmode_delay=slowmode_delay) + await ctx.send( + f"{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}." + ) + if channel.id in COMMONLY_SLOWMODED_CHANNELS: + log.info(f"Recording slowmode change in stats for {channel.name}.") + self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay) + + async def _reschedule(self) -> None: + log.trace("Rescheduling the expiration of temporary slowmodes from cache.") + for channel_id, cached_data in await self.slowmode_cache.items(): + expiration = cached_data.split(", ")[1] + expiration_datetime = datetime.fromisoformat(expiration) + channel = self.bot.get_channel(channel_id) + log.info(f"Rescheduling slowmode expiration for #{channel} ({channel_id}).") + self.scheduler.schedule_at(expiration_datetime, channel_id, self._revert_slowmode(channel_id)) + + async def _fetch_sm_cache(self, channel_id: int) -> tuple[int | None, str, str]: + """ + Fetch the channel's info from the cache and decode it. + + If no cache for the channel, the returned slowmode is None. + """ + cached_data = await self.slowmode_cache.get(channel_id, None) + if not cached_data: + return None, "", "" + + original_delay, expiration_time = cached_data.split(", ") + original_delay = int(original_delay) + humanized_original_delay = time.humanize_delta(seconds=original_delay) + expiration_timestamp = time.format_relative(expiration_time) + + return original_delay, humanized_original_delay, expiration_timestamp + + async def _revert_slowmode(self, channel_id: int) -> None: + original_delay, humanized_original_delay, _ = await self._fetch_sm_cache(channel_id) + channel = await get_or_fetch_channel(self.bot, channel_id) + mod_channel = await get_or_fetch_channel(self.bot, Channels.mods) + log.info( + f"Slowmode in #{channel.name} ({channel.id}) has expired and has reverted to {humanized_original_delay}." + ) + await channel.edit(slowmode_delay=original_delay) + await mod_channel.send( + f"{Emojis.check_mark} A previously applied slowmode in {channel.jump_url} ({channel.id})" + f" has expired and has been reverted to {humanized_original_delay}." + ) + await self.slowmode_cache.delete(channel.id) + + @slowmode_group.command(name="reset", aliases=["r"]) + async def reset_slowmode(self, ctx: Context, channel: MessageHolder) -> None: """Reset the slowmode delay for a text channel to 0 seconds.""" await self.set_slowmode(ctx, channel, relativedelta(seconds=0)) @@ -89,7 +184,16 @@ async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return await has_any_role(*MODERATION_ROLES).predicate(ctx) + async def cog_load(self) -> None: + """Wait for guild to become available and reschedule slowmodes which should expire.""" + await self.bot.wait_until_guild_available() + await self._reschedule() + + async def cog_unload(self) -> None: + """Cancel all scheduled tasks.""" + self.scheduler.cancel_all() + -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Slowmode cog.""" - bot.add_cog(Slowmode(bot)) + await bot.add_cog(Slowmode(bot)) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index fd856a7f41..1efe478200 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -1,5 +1,4 @@ -import logging -from datetime import timedelta, timezone +from datetime import UTC, timedelta from operator import itemgetter import arrow @@ -7,15 +6,25 @@ from arrow import Arrow from async_rediscache import RedisCache from discord.ext import commands +from pydis_core.utils import scheduling +from pydis_core.utils.members import get_or_fetch_member from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission +from bot.constants import ( + Colours, + Emojis, + Guild, + MODERATION_ROLES, + Roles, + STAFF_AND_COMMUNITY_ROLES, + VideoPermission, +) from bot.converters import Expiry +from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils.scheduling import Scheduler -from bot.utils.time import format_infraction_with_duration +from bot.utils import time -log = logging.getLogger(__name__) +log = get_logger(__name__) class Stream(commands.Cog): @@ -27,40 +36,28 @@ class Stream(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.reload_task = self.bot.loop.create_task(self._reload_tasks_from_redis()) - - def cog_unload(self) -> None: - """Cancel all scheduled tasks.""" - self.reload_task.cancel() - self.reload_task.add_done_callback(lambda _: self.scheduler.cancel_all()) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) async def _revoke_streaming_permission(self, member: discord.Member) -> None: """Remove the streaming permission from the given Member.""" await self.task_cache.delete(member.id) await member.remove_roles(discord.Object(Roles.video), reason="Streaming access revoked") - async def _reload_tasks_from_redis(self) -> None: + async def cog_load(self) -> None: """Reload outstanding tasks from redis on startup, delete the task if the member has since left the server.""" await self.bot.wait_until_guild_available() items = await self.task_cache.items() + guild = self.bot.get_guild(Guild.id) for key, value in items: - member = self.bot.get_guild(Guild.id).get_member(key) + member = await get_or_fetch_member(guild, key) if not member: - # Member isn't found in the cache - try: - member = await self.bot.get_guild(Guild.id).fetch_member(key) - except discord.errors.NotFound: - log.debug( - f"Member {key} left the guild before we could schedule " - "the revoking of their streaming permissions." - ) - await self.task_cache.delete(key) - continue - except discord.HTTPException: - log.exception(f"Exception while trying to retrieve member {key} from Discord.") - continue + log.debug( + "User with ID %d left the guild before their streaming permissions could be revoked.", + key + ) + await self.task_cache.delete(key) + continue revoke_time = Arrow.utcfromtimestamp(value) log.debug(f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}") @@ -94,7 +91,12 @@ async def _suspend_stream(self, ctx: commands.Context, member: discord.Member) - @commands.command(aliases=("streaming",)) @commands.has_any_role(*MODERATION_ROLES) - async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None: + async def stream( + self, + ctx: commands.Context, + member: discord.Member, + duration: Expiry = None, + ) -> None: """ Temporarily grant streaming permissions to a member for a given duration. @@ -109,7 +111,7 @@ async def stream(self, ctx: commands.Context, member: discord.Member, duration: \u2003`s` - seconds Alternatively, an ISO 8601 timestamp can be provided for the duration. - """ + """ # noqa: RUF002 log.trace(f"Attempting to give temporary streaming permission to {member} ({member.id}).") if duration is None: @@ -119,7 +121,7 @@ async def stream(self, ctx: commands.Context, member: discord.Member, duration: elif duration.tzinfo is None: # Make duration tz-aware. # ISODateTime could already include tzinfo, this check is so it isn't overwritten. - duration.replace(tzinfo=timezone.utc) + duration.replace(tzinfo=UTC) # Check if the member already has streaming permission already_allowed = any(Roles.video == role.id for role in member.roles) @@ -134,20 +136,15 @@ async def stream(self, ctx: commands.Context, member: discord.Member, duration: await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") - # Use embed as embed timestamps do timezone conversions. - embed = discord.Embed( - description=f"{Emojis.check_mark} {member.mention} can now stream.", - colour=Colours.soft_green - ) - embed.set_footer(text=f"Streaming permission has been given to {member} until") - embed.timestamp = duration - - # Mention in content as mentions in embeds don't ping - await ctx.send(content=member.mention, embed=embed) + await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {time.discord_timestamp(duration)}.") # Convert here for nicer logging - revoke_time = format_infraction_with_duration(str(duration)) - log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") + humanized_duration = time.humanize_delta(duration, arrow.utcnow(), max_units=2) + end_time = duration.strftime("%Y-%m-%d %H:%M:%S") + log.debug( + f"Successfully gave {member} ({member.id}) permission " + f"to stream for {humanized_duration} (until {end_time})." + ) @commands.command(aliases=("pstream",)) @commands.has_any_role(*MODERATION_ROLES) @@ -199,20 +196,20 @@ async def revokestream(self, ctx: commands.Context, member: discord.Member) -> N await self._suspend_stream(ctx, member) - @commands.command(aliases=('lstream',)) + @commands.command(aliases=("lstream",)) @commands.has_any_role(*MODERATION_ROLES) async def liststream(self, ctx: commands.Context) -> None: - """Lists all non-staff users who have permission to stream.""" - non_staff_members_with_stream = [ + """Lists all users who aren't staff or members of the python community and have stream permissions.""" + non_staff_or_community_members_with_stream = [ member for member in ctx.guild.get_role(Roles.video).members - if not any(role.id in STAFF_ROLES for role in member.roles) + if not any(role.id in STAFF_AND_COMMUNITY_ROLES for role in member.roles) ] # List of tuples (UtcPosixTimestamp, str) # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator. streamer_info = [] - for member in non_staff_members_with_stream: + for member in non_staff_or_community_members_with_stream: if revoke_time := await self.task_cache.get(member.id): # Member only has temporary streaming perms revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize() @@ -239,7 +236,11 @@ async def liststream(self, ctx: commands.Context) -> None: else: await ctx.send("No members with stream permissions found.") + async def cog_unload(self) -> None: + """Cancel all scheduled tasks.""" + self.scheduler.cancel_all() + -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Loads the Stream cog.""" - bot.add_cog(Stream(bot)) + await bot.add_cog(Stream(bot)) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index bfe9b74b45..d9308b177c 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,4 +1,3 @@ -import logging import typing as t import discord @@ -6,10 +5,9 @@ from bot import constants from bot.bot import Bot -from bot.decorators import in_whitelist -from bot.utils.checks import InWhitelistCheckFailure +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = """ @@ -29,11 +27,11 @@ Additionally, if you'd like to receive notifications for the announcements \ we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \ to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. +If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \ +<#{constants.Channels.bot_commands}> and click the role again!. To introduce you to our community, we've made the following video: https://youtu.be/ZH26PuX3re0 @@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None: class Verification(Cog): """ - User verification and role management. + User verification. Statistics are collected in the 'verification.' namespace. - - Additionally, this cog offers the !subscribe and !unsubscribe commands, """ def __init__(self, bot: Bot) -> None: @@ -107,89 +103,30 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - # endregion - # region: subscribe commands - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles( - discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" - ) - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - # endregion # region: miscellaneous - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - - @command(name='verify') + @command(name="verify") @has_any_role(*constants.MODERATION_ROLES) async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: """Command for moderators to verify any user.""" - log.trace(f'verify command called by {ctx.author} for {user.id}.') + log.trace(f"verify command called by {ctx.author} for {user.id}.") if not user.pending: - log.trace(f'{user.id} is already verified, aborting.') - await ctx.send(f'{constants.Emojis.cross_mark} {user.mention} is already verified.') + log.trace(f"{user.id} is already verified, aborting.") + await ctx.send(f"{constants.Emojis.cross_mark} {user.mention} is already verified.") return # Adding a role automatically verifies the user, so we add and remove the Announcements role. temporary_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.announcements) await user.add_roles(temporary_role) await user.remove_roles(temporary_role) - log.trace(f'{user.id} manually verified.') - await ctx.send(f'{constants.Emojis.check_mark} {user.mention} is now verified.') + log.trace(f"{user.id} manually verified.") + await ctx.send(f"{constants.Emojis.check_mark} {user.mention} is now verified.") # endregion -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Verification cog.""" - bot.add_cog(Verification(bot)) + await bot.add_cog(Verification(bot)) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 94b23a3443..f959d5d11f 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,22 +1,21 @@ -import asyncio -import logging -from contextlib import suppress -from datetime import datetime, timedelta +from datetime import timedelta +import arrow import discord from async_rediscache import RedisCache -from discord import Colour, Member, VoiceState -from discord.ext.commands import Cog, Context, command +from discord import Colour, Member, TextChannel, VoiceState +from discord.ext.commands import Cog, Context, command, has_any_role +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel - -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf -from bot.decorators import has_no_roles, in_whitelist -from bot.exts.moderation.modlog import ModLog +from bot.constants import Channels, Icons, MODERATION_ROLES, Roles, VoiceGate as GateConf +from bot.log import get_logger from bot.utils.checks import InWhitelistCheckFailure +from bot.utils.messages import format_user +from bot.utils.modlog import send_log_message -log = logging.getLogger(__name__) +log = get_logger(__name__) # Flag written to the cog's RedisCache as a value when the Member's (key) notification # was already removed ~ this signals both that no further notifications should be sent, @@ -29,247 +28,218 @@ ) MESSAGE_FIELD_MAP = { - "joined_at": f"have been on the server for less than {GateConf.minimum_days_member} days", - "voice_banned": "have an active voice ban infraction", - "total_messages": f"have sent less than {GateConf.minimum_messages} messages", - "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks", + "joined_at": f"been on the server for fewer than {GateConf.minimum_days_member} days", + "voice_gate_blocked": "an active voice infraction", + "activity_blocks": "not been active enough on the server yet", } VOICE_PING = ( "Wondering why you can't talk in the voice channels? " - "Use the `!voiceverify` command in here to verify. " - "If you don't yet qualify, you'll be told why!" -) - -VOICE_PING_DM = ( - "Wondering why you can't talk in the voice channels? " - "Use the `!voiceverify` command in {channel_mention} to verify. " + "Click the Voice Verify button above to verify. " "If you don't yet qualify, you'll be told why!" ) -class VoiceGate(Cog): - """Voice channels verification management.""" - - # RedisCache[t.Union[discord.User.id, discord.Member.id], t.Union[discord.Message.id, int]] - # The cache's keys are the IDs of members who are verified or have joined a voice channel - # The cache's values are either the message ID of the ping message or 0 (NO_MSG) if no message is present - redis_cache = RedisCache() +class VoiceVerificationView(discord.ui.View): + """Persistent view to add a Voice Verify button.""" def __init__(self, bot: Bot) -> None: + super().__init__(timeout=None) self.bot = bot - @property - def mod_log(self) -> ModLog: - """Get the currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @redis_cache.atomic_transaction # Fully process each call until starting the next - async def _delete_ping(self, member_id: int) -> None: - """ - If `redis_cache` holds a message ID for `member_id`, delete the message. - - If the message was deleted, the value under the `member_id` key is then set to `NO_MSG`. - When `member_id` is not in the cache, or has a value of `NO_MSG` already, this function - does nothing. - """ - if message_id := await self.redis_cache.get(member_id): - log.trace(f"Removing voice gate reminder message for user: {member_id}") - with suppress(discord.NotFound): - await self.bot.http.delete_message(Channels.voice_gate, message_id) - await self.redis_cache.set(member_id, NO_MSG) - else: - log.trace(f"Voice gate reminder message for user {member_id} was already removed") - - @redis_cache.atomic_transaction - async def _ping_newcomer(self, member: discord.Member) -> tuple: - """ - See if `member` should be sent a voice verification notification, and send it if so. - - Returns (False, None) if the notification was not sent. This happens when: - * The `member` has already received the notification - * The `member` is already voice-verified - - Otherwise, the notification message ID is stored in `redis_cache` and return (True, channel). - channel is either [discord.TextChannel, discord.DMChannel]. - """ - if await self.redis_cache.contains(member.id): - log.trace("User already in cache. Ignore.") - return False, None - - log.trace("User not in cache and is in a voice channel.") - verified = any(Roles.voice_verified == role.id for role in member.roles) - if verified: - log.trace("User is verified, add to the cache and ignore.") - await self.redis_cache.set(member.id, NO_MSG) - return False, None - - log.trace("User is unverified. Send ping.") - - await self.bot.wait_until_guild_available() - voice_verification_channel = self.bot.get_channel(Channels.voice_gate) - - try: - message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention)) - except discord.Forbidden: - log.trace("DM failed for Voice ping message. Sending in channel.") - message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}") - - await self.redis_cache.set(member.id, message.id) - return True, message.channel - - @command(aliases=('voiceverify',)) - @has_no_roles(Roles.voice_verified) - @in_whitelist(channels=(Channels.voice_gate,), redirect=None) - async def voice_verify(self, ctx: Context, *_) -> None: - """ - Apply to be able to use voice within the Discord server. - - In order to use voice you must meet all three of the following criteria: - - You must have over a certain number of messages within the Discord server - - You must have accepted our rules over a certain number of days ago - - You must not be actively banned from using our voice channels - - You must have been active for over a certain number of 10-minute blocks - """ - await self._delete_ping(ctx.author.id) # If user has received a ping in voice_verification, delete the message + @discord.ui.button(label="Voice Verify", style=discord.ButtonStyle.primary, custom_id="voice_verify_button",) + async def voice_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """A button that checks to see if the user qualifies for voice verification and verifies them if they do.""" + if interaction.user.get_role(Roles.voice_verified): + await interaction.response.send_message(( + "You have already verified! " + "If you have received this message in error, " + "please send a message to the ModMail bot."), + ephemeral=True, + delete_after=GateConf.delete_after_delay, + ) + return try: - data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") - except ResponseCodeError as e: - if e.status == 404: - embed = discord.Embed( - title="Not found", - description=( - "We were unable to find user data for you. " - "Please try again shortly, " - "if this problem persists please contact the server staff through Modmail." - ), - color=Colour.red() + data = await self.bot.api_client.get( + f"bot/users/{interaction.user.id}/metricity_data", + raise_for_status=True + ) + except ResponseCodeError as err: + if err.response.status == 404: + await interaction.response.send_message(( + "We were unable to find user data for you. " + "Please try again shortly. " + "If this problem persists, please contact the server staff through ModMail."), + ephemeral=True, + delete_after=GateConf.delete_after_delay, ) - log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") + log.info("Unable to find Metricity data about %s (%s)", interaction.user, interaction.user.id) else: - embed = discord.Embed( - title="Unexpected response", - description=( - "We encountered an error while attempting to find data for your user. " - "Please try again and let us know if the problem persists." - ), - color=Colour.red() + await interaction.response.send_message(( + "We encountered an error while attempting to find data for your user. " + "Please try again and let us know if the problem persists."), + ephemeral=True, + delete_after=GateConf.delete_after_delay, + ) + log.warning( + "Got response code %s while trying to get %s Metricity data.", + err.status, + interaction.user.id ) - log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") - try: - await ctx.author.send(embed=embed) - except discord.Forbidden: - log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.") - await ctx.send(embed=embed) - return checks = { - "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member), - "total_messages": data["total_messages"] < GateConf.minimum_messages, - "voice_banned": data["voice_banned"], - "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks + "joined_at": ( + interaction.user.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member) + ), + "voice_gate_blocked": data["voice_gate_blocked"], + "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks, } + failed = any(checks.values()) - failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] - [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: + failed_reasons = [] + for key, value in checks.items(): + if value is True: + failed_reasons.append(MESSAGE_FIELD_MAP[key]) + self.bot.stats.incr(f"voice_gate.failed.{key}") + embed = discord.Embed( title="Voice Gate failed", - description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), + description=FAILED_MESSAGE.format( + reasons="\n".join(f"- You have {reason}." for reason in failed_reasons) + ), color=Colour.red() ) - try: - await ctx.author.send(embed=embed) - await ctx.send(f"{ctx.author}, please check your DMs.") - except discord.Forbidden: - await ctx.channel.send(ctx.author.mention, embed=embed) + + await interaction.response.send_message( + embed=embed, + ephemeral=True, + delete_after=GateConf.delete_after_delay, + ) + + log_reasons = "\n".join(f"- Has {reason}." for reason in failed_reasons) + + await send_log_message( + self.bot, + icon_url=Icons.defcon_denied, + colour=Colour.red(), + title="Voice gate failed", + text=f"{format_user(interaction.user)} failed the voice gate.\n\n{log_reasons}", + thumbnail=interaction.user.avatar, + channel_id=Channels.voice_log, + ) + return - self.mod_log.ignore(Event.member_update, ctx.author.id) embed = discord.Embed( title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", - color=Colour.green() + color=Colour.green(), ) - if ctx.author.voice: + # interaction.user.voice will return None if the user is not in a voice channel + if interaction.user.voice: embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." - try: - await ctx.author.send(embed=embed) - await ctx.send(f"{ctx.author}, please check your DMs.") - except discord.Forbidden: - await ctx.channel.send(ctx.author.mention, embed=embed) + await interaction.response.send_message( + embed=embed, + ephemeral=True, + delete_after=GateConf.delete_after_delay, + ) + await interaction.user.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") - # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. - await asyncio.sleep(3) - await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + await send_log_message( + self.bot, + icon_url=Icons.defcon_unshutdown, + colour=Colour.green(), + title="Voice gate passed", + text=f"{format_user(interaction.user)} passed the voice gate.", + thumbnail=interaction.user.avatar, + channel_id=Channels.voice_log, + ) self.bot.stats.incr("voice_gate.passed") - @Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" - # Check is channel voice gate - if message.channel.id != Channels.voice_gate: - return - ctx = await self.bot.get_context(message) - is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" - - # When it's a bot sent message, delete it after some time - if message.author.bot: - # Comparing the message with the voice ping constant - if message.content.endswith(VOICE_PING): - log.trace("Message is the voice verification ping. Ignore.") - return - with suppress(discord.NotFound): - await message.delete(delay=GateConf.bot_message_delete_delay) - return - - # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: - log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") +class VoiceGate(Cog): + """Voice channels verification management.""" + + # RedisCache[discord.User.id | discord.Member.id, discord.Message.id | int] + # The cache's keys are the IDs of members who are verified or have joined a voice channel + # The cache's values are set to 0, as we only need to track which users have connected before + redis_cache = RedisCache() + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def cog_load(self) -> None: + """Adds verify button to be monitored by the bot.""" + self.bot.add_view(VoiceVerificationView(self.bot)) + + @redis_cache.atomic_transaction + async def _ping_newcomer(self, member: discord.Member) -> None: + """See if `member` should be sent a voice verification notification, and send it if so.""" + log.trace("User is not verified. Checking cache.") + if await self.redis_cache.contains(member.id): + log.trace("User %s already in cache. Ignore.", member.id) return - # Ignore deleted voice verification messages - if ctx.command is not None and ctx.command.name == "voice_verify": - self.mod_log.ignore(Event.message_delete, message.id) + log.trace("User %s is unverified and has not been pinged before. Sending ping.", member.id) + await self.bot.wait_until_guild_available() + voice_verification_channel = await get_or_fetch_channel(self.bot, Channels.voice_gate) + + await voice_verification_channel.send( + f"Hello, {member.mention}! {VOICE_PING}", + delete_after=GateConf.delete_after_delay, + ) - with suppress(discord.NotFound): - await message.delete() + await self.redis_cache.set(member.id, NO_MSG) + log.trace("User %s added to cache to not be pinged again.", member.id) @Cog.listener() async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState) -> None: """Pings a user if they've never joined the voice chat before and aren't voice verified.""" if member.bot: - log.trace("User is a bot. Ignore.") + log.trace("User %s is a bot. Ignore.", member.id) + return + + if member.get_role(Roles.voice_verified): + log.trace("User %s already verified. Ignore", member.id) return # member.voice will return None if the user is not in a voice channel if member.voice is None: - log.trace("User not in a voice channel. Ignore.") + log.trace("User %s not in a voice channel. Ignore.", member.id) + return + + if isinstance(after.channel, discord.StageChannel): + log.trace("User %s joined a stage channel. Ignore.", member.id) return # To avoid race conditions, checking if the user should receive a notification # and sending it if appropriate is delegated to an atomic helper - notification_sent, message_channel = await self._ping_newcomer(member) - - # Schedule the channel ping notification to be deleted after the configured delay, which is - # again delegated to an atomic helper - if notification_sent and isinstance(message_channel, discord.TextChannel): - await asyncio.sleep(GateConf.voice_ping_delete_delay) - await self._delete_ping(member.id) + await self._ping_newcomer(member) async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Check for & ignore any InWhitelistCheckFailure.""" if isinstance(error, InWhitelistCheckFailure): error.handled = True + @command(name="prepare_voice") + @has_any_role(*MODERATION_ROLES) + async def prepare_voice_button(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None: + """Sends a message that includes the Voice Verify button. Should only need to be run once.""" + if channel is None: + await ctx.send(text, view=VoiceVerificationView(self.bot)) + elif not channel.permissions_for(ctx.author).send_messages: + await ctx.send("You don't have permission to send messages to that channel.") + else: + await channel.send(text, view=VoiceVerificationView(self.bot)) + -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" - bot.add_cog(VoiceGate(bot)) + await bot.add_cog(VoiceGate(bot)) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 9f26c34f2d..44c0be2a7e 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -1,27 +1,30 @@ import asyncio -import logging import re import textwrap from abc import abstractmethod from collections import defaultdict, deque from dataclasses import dataclass -from typing import Any, Dict, Optional +from typing import Any import discord from discord import Color, DMChannel, Embed, HTTPException, Message, errors from discord.ext.commands import Cog, Context +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling +from pydis_core.utils.channel import get_or_fetch_channel +from pydis_core.utils.logging import CustomLogger +from pydis_core.utils.members import get_or_fetch_member -from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons -from bot.exts.filters.token_remover import TokenRemover -from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE -from bot.exts.moderation.modlog import ModLog +from bot.exts.filtering._filters.unique.discord_token import DiscordTokenFilter +from bot.exts.filtering._filters.unique.webhook import WEBHOOK_URL_RE +from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages -from bot.utils.time import get_time_delta +from bot.utils import CogABCMeta, messages, time +from bot.utils.modlog import send_log_message -log = logging.getLogger(__name__) +log = get_logger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") @@ -30,8 +33,8 @@ class MessageHistory: """Represents a watch channel's message history.""" - last_author: Optional[int] = None - last_channel: Optional[int] = None + last_author: int | None = None + last_channel: int | None = None message_count: int = 0 @@ -46,20 +49,20 @@ def __init__( webhook_id: int, api_endpoint: str, api_default_params: dict, - logger: logging.Logger, + logger: CustomLogger, *, disable_header: bool = False ) -> None: self.bot = bot - self.destination = destination # E.g., Channels.big_brother_logs + self.destination = destination # E.g., Channels.big_brother self.webhook_id = webhook_id # E.g., Webhooks.big_brother self.api_endpoint = api_endpoint # E.g., 'bot/infractions' self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'} self.log = logger # Logger of the child cog for a correct name in the logs self._consume_task = None - self.watched_users = defaultdict(dict) + self.watched_users = {} self.message_queue = defaultdict(lambda: defaultdict(deque)) self.consumption_queue = {} self.retries = 5 @@ -69,13 +72,6 @@ def __init__( self.message_history = MessageHistory() self.disable_header = disable_header - self._start = self.bot.loop.create_task(self.start_watchchannel()) - - @property - def modlog(self) -> ModLog: - """Provides access to the ModLog cog for alert purposes.""" - return self.bot.get_cog("ModLog") - @property def consuming_messages(self) -> bool: """Checks if a consumption task is currently running.""" @@ -93,12 +89,12 @@ def consuming_messages(self) -> bool: return True - async def start_watchchannel(self) -> None: + async def cog_load(self) -> None: """Starts the watch channel by getting the channel, webhook, and user cache ready.""" await self.bot.wait_until_guild_available() try: - self.channel = await self.bot.fetch_channel(self.destination) + self.channel = await get_or_fetch_channel(self.bot, self.destination) except HTTPException: self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`") @@ -121,7 +117,8 @@ async def start_watchchannel(self) -> None: """ ) - await self.modlog.send_log_message( + await send_log_message( + self.bot, title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, ping_everyone=True, @@ -129,11 +126,12 @@ async def start_watchchannel(self) -> None: colour=Color.red() ) - self.bot.remove_cog(self.__class__.__name__) + await self.bot.remove_cog(self.__class__.__name__) return if not await self.fetch_user_cache(): - await self.modlog.send_log_message( + await send_log_message( + self.bot, title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text=( "Could not retrieve the list of watched users from the API. " @@ -156,10 +154,10 @@ async def fetch_user_cache(self) -> bool: self.log.exception("Failed to fetch the watched users from the API", exc_info=err) return False - self.watched_users = defaultdict(dict) + self.watched_users.clear() for entry in data: - user_id = entry.pop('user') + user_id = entry.pop("user") self.watched_users[user_id] = entry return True @@ -169,7 +167,7 @@ async def on_message(self, msg: Message) -> None: """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) + self._consume_task = scheduling.create_task(self.consume_messages()) self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") self.message_queue[msg.author.id][msg.channel.id].append(msg) @@ -187,28 +185,31 @@ async def consume_messages(self, delay_consumption: bool = True) -> None: self.consumption_queue = self.message_queue.copy() self.message_queue.clear() - for user_channel_queues in self.consumption_queue.values(): - for channel_queue in user_channel_queues.values(): + for user_id, channel_queues in self.consumption_queue.items(): + for channel_queue in channel_queues.values(): while channel_queue: msg = channel_queue.popleft() - self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") - await self.relay_message(msg) + if watch_info := self.watched_users.get(user_id, None): + self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)") + await self.relay_message(msg, watch_info) + else: + self.log.trace(f"Not consuming message {msg.id} as user {user_id} is no longer watched.") self.consumption_queue.clear() if self.message_queue: self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + self._consume_task = scheduling.create_task(self.consume_messages(delay_consumption=False)) else: self.log.trace("Done consuming messages.") async def webhook_send( self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, + content: str | None = None, + username: str | None = None, + avatar_url: str | None = None, + embed: Embed | None = None, ) -> None: """Sends a message to the webhook with the specified kwargs.""" username = messages.sub_clyde(username) @@ -220,7 +221,7 @@ async def webhook_send( exc_info=exc ) - async def relay_message(self, msg: Message) -> None: + async def relay_message(self, msg: Message, watch_info: dict) -> None: """Relays the message to the relevant watch channel.""" limit = BigBrotherConfig.header_message_limit @@ -231,9 +232,9 @@ async def relay_message(self, msg: Message) -> None: ): self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id) - await self.send_header(msg) + await self.send_header(msg, watch_info) - if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content): + if DiscordTokenFilter.find_token_in_message(msg.content) or WEBHOOK_URL_RE.search(msg.content): cleaned_content = "Content is censored because it contains a bot or webhook token." elif cleaned_content := msg.clean_content: # Put all non-media URLs in a code block to prevent embeds @@ -246,7 +247,7 @@ async def relay_message(self, msg: Message) -> None: await self.webhook_send( cleaned_content, username=msg.author.display_name, - avatar_url=msg.author.avatar_url + avatar_url=msg.author.display_avatar.url ) if msg.attachments: @@ -260,7 +261,7 @@ async def relay_message(self, msg: Message) -> None: await self.webhook_send( embed=e, username=msg.author.display_name, - avatar_url=msg.author.avatar_url + avatar_url=msg.author.display_avatar.url ) except discord.HTTPException as exc: self.log.exception( @@ -270,21 +271,19 @@ async def relay_message(self, msg: Message) -> None: self.message_history.message_count += 1 - async def send_header(self, msg: Message) -> None: + async def send_header(self, msg: Message, watch_info: dict) -> None: """Sends a header embed with information about the relayed messages to the watch channel.""" if self.disable_header: return - user_id = msg.author.id - guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) - actor = actor.display_name if actor else self.watched_users[user_id]['actor'] + actor = await get_or_fetch_member(guild, watch_info["actor"]) + actor = actor.display_name if actor else watch_info["actor"] - inserted_at = self.watched_users[user_id]['inserted_at'] - time_delta = get_time_delta(inserted_at) + inserted_at = watch_info["inserted_at"] + time_delta = time.format_relative(inserted_at) - reason = self.watched_users[user_id]['reason'] + reason = watch_info["reason"] if isinstance(msg.channel, DMChannel): # If a watched user DMs the bot there won't be a channel name or jump URL @@ -294,10 +293,9 @@ async def send_header(self, msg: Message) -> None: message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + embed = Embed(description=f"{msg.author.mention} {message_jump}\n\n{footer}") - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.display_avatar.url) async def list_watched_users( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -325,7 +323,7 @@ async def list_watched_users( async def prepare_watched_users_data( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Prepare overview information of watched users to list. @@ -346,18 +344,19 @@ async def prepare_watched_users_data( update_cache = False list_data["updated"] = update_cache - watched_iter = self.watched_users.items() + # Copy into list to prevent issues if it is modified elsewhere while it's being iterated over. + watched_list = list(self.watched_users.items()) if oldest_first: - watched_iter = reversed(watched_iter) + watched_list.reverse() list_data["info"] = {} - for user_id, user_data in watched_iter: - member = ctx.guild.get_member(user_id) - line = f"• `{user_id}`" + for user_id, user_data in watched_list: + member = await get_or_fetch_member(ctx.guild, user_id) + line = f"- `{user_id}`" if member: line += f" ({member.name}#{member.discriminator})" - inserted_at = user_data['inserted_at'] - line += f", added {get_time_delta(inserted_at)}" + inserted_at = user_data["inserted_at"] + line += f", added {time.format_relative(inserted_at)}" if not member: # Cross off users who left the server. line = f"~~{line}~~" list_data["info"][user_id] = line @@ -369,10 +368,8 @@ async def prepare_watched_users_data( def _remove_user(self, user_id: int) -> None: """Removes a user from a watch channel.""" self.watched_users.pop(user_id, None) - self.message_queue.pop(user_id, None) - self.consumption_queue.pop(user_id, None) - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Takes care of unloading the cog and canceling the consumption task.""" self.log.trace("Unloading the cog") if self._consume_task and not self._consume_task.done(): diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 3b44056d35..7af8c7152b 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -1,4 +1,3 @@ -import logging import textwrap from collections import ChainMap @@ -6,11 +5,12 @@ from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.infraction._utils import post_infraction from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class BigBrother(WatchChannel, Cog, name="Big Brother"): @@ -19,20 +19,20 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): def __init__(self, bot: Bot) -> None: super().__init__( bot, - destination=Channels.big_brother_logs, - webhook_id=Webhooks.big_brother, - api_endpoint='bot/infractions', - api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'}, + destination=Channels.big_brother, + webhook_id=Webhooks.big_brother.id, + api_endpoint="bot/infractions", + api_default_params={"active": "true", "type": "watch", "ordering": "-inserted_at", "limit": 10_000}, logger=log ) - @group(name='bigbrother', aliases=('bb',), invoke_without_command=True) + @group(name="bigbrother", aliases=("bb",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" await ctx.send_help(ctx.command) - @bigbrother_group.command(name='watched', aliases=('all', 'list')) + @bigbrother_group.command(name="watched", aliases=("all", "list")) @has_any_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -47,7 +47,7 @@ async def watched_command( """ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - @bigbrother_group.command(name='oldest') + @bigbrother_group.command(name="oldest") @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ @@ -58,9 +58,9 @@ async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) + @bigbrother_group.command(name="watch", aliases=("w",), root_aliases=("watch",)) @has_any_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -69,13 +69,13 @@ async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) """ await self.apply_watch(ctx, user, reason) - @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) + @bigbrother_group.command(name="unwatch", aliases=("uw",), root_aliases=("unwatch",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" await self.apply_unwatch(ctx, user, reason) - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """ Add `user` to watched users and apply a watch infraction with `reason`. @@ -87,26 +87,31 @@ async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> N return if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + await ctx.send(f":x: Updating the user cache failed, can't watch user {user.mention}") return if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched.") + await ctx.send(f":x: {user.mention} is already being watched.") return - response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) + # discord.User instances don't have a roles attribute + if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles): + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.") + return + + response = await post_infraction(ctx, user, "watch", reason, hidden=True, active=True) if response is not None: self.watched_users[user.id] = response - msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + msg = f":white_check_mark: Messages sent by {user.mention} will now be relayed to Big Brother." history = await self.bot.api_client.get( self.api_endpoint, params={ "user__id": str(user.id), "active": "false", - 'type': 'watch', - 'ordering': '-inserted_at' + "type": "watch", + "ordering": "-inserted_at" } ) @@ -120,7 +125,7 @@ async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> N await ctx.send(msg) - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, send_message: bool = True) -> None: """ Remove `user` from watched users and mark their infraction as inactive with `reason`. @@ -140,10 +145,10 @@ async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, se await self.bot.api_client.patch( f"{self.api_endpoint}/{infraction['id']}", - json={'active': False} + json={"active": False} ) - await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) + await post_infraction(ctx, user, "watch", f"Unwatched: {reason}", hidden=True, active=False) self._remove_user(user.id) @@ -151,7 +156,7 @@ async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, se log.debug(f"Perma-banned user {user} was unwatched.") return log.trace("User is not banned. Sending message to channel") - message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + message = f":white_check_mark: Messages sent by {user.mention} will no longer be relayed." else: log.trace("No active watches found for user.") @@ -164,6 +169,6 @@ async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, se await ctx.send(message) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the BigBrother cog.""" - bot.add_cog(BigBrother(bot)) + await bot.add_cog(BigBrother(bot)) diff --git a/bot/exts/recruitment/talentpool/__init__.py b/bot/exts/recruitment/talentpool/__init__.py index 52d27eb99f..aa09a1ee2a 100644 --- a/bot/exts/recruitment/talentpool/__init__.py +++ b/bot/exts/recruitment/talentpool/__init__.py @@ -1,8 +1,8 @@ from bot.bot import Bot -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the TalentPool cog.""" from bot.exts.recruitment.talentpool._cog import TalentPool - bot.add_cog(TalentPool(bot)) + await bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/recruitment/talentpool/_api.py b/bot/exts/recruitment/talentpool/_api.py new file mode 100644 index 0000000000..476ba6b800 --- /dev/null +++ b/bot/exts/recruitment/talentpool/_api.py @@ -0,0 +1,158 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, TypeAdapter +from pydis_core.site_api import APIClient + + +class NominationEntry(BaseModel): + """Pydantic model representing a nomination entry.""" + + actor_id: int = Field(alias="actor") + reason: str + inserted_at: datetime + + +class Nomination(BaseModel): + """Pydantic model representing a nomination.""" + + id: int + active: bool + user_id: int = Field(alias="user") + inserted_at: datetime + end_reason: str + ended_at: datetime | None + entries: list[NominationEntry] + reviewed: bool + thread_id: int | None + + +class NominationAPI: + """Abstraction of site API interaction for talentpool.""" + + def __init__(self, site_api: APIClient): + self.site_api = site_api + + async def get_nominations( + self, + user_id: int | None = None, + active: bool | None = None, + reviewed: bool | None = None, + ordering: str = "-inserted_at" + ) -> list[Nomination]: + """ + Fetch a list of nominations. + + Passing a value of `None` indicates it shouldn't filtered by. + """ + params = {"ordering": ordering} + if active is not None: + params["active"] = str(active) + if reviewed is not None: + params["reviewed"] = str(reviewed) + if user_id is not None: + params["user__id"] = str(user_id) + + data = await self.site_api.get("bot/nominations", params=params) + nominations = TypeAdapter(list[Nomination]).validate_python(data) + return nominations + + async def get_nomination(self, nomination_id: int) -> Nomination: + """Fetch a nomination by ID.""" + data = await self.site_api.get(f"bot/nominations/{nomination_id}") + nomination = Nomination.model_validate(data) + return nomination + + async def get_active_nomination(self, user_id: int) -> Nomination | None: + """Search for an active nomination for a user and return it.""" + nominations = await self.get_nominations(user_id=user_id, active=True) + + if len(nominations) >= 1: + return nominations[0] + + return None + + async def get_nomination_reason(self, user_id: int, actor_id: int) -> tuple[Nomination, str] | None: + """Search for a nomination & reason for a specific actor on a specific user.""" + nominations = await self.get_nominations(user_id, True) + + for nomination in nominations: + for entry in nomination.entries: + if entry.actor_id == actor_id: + return nomination, entry.reason + + return None + + async def edit_nomination( + self, + nomination_id: int, + *, + end_reason: str | None = None, + active: bool | None = None, + reviewed: bool | None = None, + thread_id: int | None = None, + ) -> Nomination: + """ + Edit a nomination. + + Passing a value of `None` indicates it shouldn't be updated. + """ + data = {} + if end_reason is not None: + data["end_reason"] = end_reason + if active is not None: + data["active"] = active + if reviewed is not None: + data["reviewed"] = reviewed + if thread_id is not None: + data["thread_id"] = thread_id + + result = await self.site_api.patch(f"bot/nominations/{nomination_id}", json=data) + return Nomination.model_validate(result) + + async def edit_nomination_entry( + self, + nomination_id: int, + *, + actor_id: int, + reason: str, + ) -> Nomination: + """Edit a nomination entry.""" + data = {"actor": actor_id, "reason": reason} + result = await self.site_api.patch(f"bot/nominations/{nomination_id}", json=data) + return Nomination.model_validate(result) + + async def post_nomination( + self, + user_id: int, + actor_id: int, + reason: str, + ) -> Nomination: + """Post a nomination to site.""" + data = { + "actor": actor_id, + "reason": reason, + "user": user_id, + } + result = await self.site_api.post("bot/nominations", json=data) + return Nomination.model_validate(result) + + async def get_activity( + self, + user_ids: list[int], + *, + days: int, + ) -> dict[int, int]: + """ + Get the number of messages sent in the past `days` days by users with the given IDs. + + Returns a dictionary mapping user ID to message count. + """ + if not user_ids: + return {} + + result = await self.site_api.post( + "bot/users/metricity_activity_data", + json=user_ids, + params={"days": str(days)} + ) + return {int(user_id): message_count for user_id, message_count in result.items()} diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 03326cab25..1e55b2d796 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,133 +1,419 @@ -import logging +import asyncio import textwrap -from collections import ChainMap +from datetime import UTC, datetime from io import StringIO -from typing import Union import discord -from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User -from discord.ext.commands import Cog, Context, group, has_any_role +from async_rediscache import RedisCache +from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User, app_commands +from discord.ext import commands, tasks +from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel +from pydis_core.utils.members import get_or_fetch_member +from sentry_sdk import new_scope -from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.constants import Bot as BotConfig, Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES +from bot.converters import MemberOrUser, UnambiguousMemberOrUser +from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI from bot.exts.recruitment.talentpool._review import Reviewer +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time +AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 -log = logging.getLogger(__name__) +# The number of days that a user can have no activity (no messages sent) +# until they should be removed from the talentpool. +DAYS_UNTIL_INACTIVE = 45 +log = get_logger(__name__) -class TalentPool(WatchChannel, Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" +class NominationContextModal(discord.ui.Modal, title="New Nomination"): + nomination = discord.ui.TextInput( + label="Additional nomination context:", + style=discord.TextStyle.long, + placeholder="Additional context for nomination...", + required=False, + # Take some length off for the URL that is going to be appended + max_length=REASON_MAX_CHARS - 110 + ) + + def __init__(self, cog: TalentPool, message: discord.Message, noms_channel: discord.TextChannel): + self.message = message + self.api = cog.api + self.noms_channel = noms_channel + self.bot = cog.bot + self.cog = cog + + super().__init__() + + async def on_submit(self, interaction: discord.Interaction) -> None: + reason = "" + + if self.nomination.value and self.nomination.value != "": + reason += f"{self.nomination.value}\n\n" + + reason += f"Nominated from: {self.message.jump_url}" + + try: + await self.api.post_nomination(self.message.author.id, interaction.user.id, reason) + except ResponseCodeError as e: + match (e.status, e.response_json): + case (400, {"user": _}): + await interaction.response.send_message( + f":x: {self.target.mention} can't be found in the database tables.", + ephemeral=True + ) + log.warning(f"Could not find {self.target.author} in the site database tables, sync may be broken") + return + + raise e + + await interaction.response.send_message( + f":white_check_mark: The nomination for {self.message.author.mention}" + " has been added to the talent pool", + ephemeral=True + ) + + noms_channel_message = ( + f":new: {interaction.user.mention} has nominated " + f"{self.message.author.mention} from {self.message.jump_url}" + ) + + if self.nomination.value and self.nomination.value != "": + noms_channel_message += "\n```\n" + noms_channel_message += self.nomination.value + noms_channel_message += "```" + + await self.noms_channel.send(noms_channel_message) + + await self.cog.maybe_relay_update(self.message.author.id, noms_channel_message) + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + """Handle any exceptions in the processing of the modal.""" + await self.cog._nominate_context_error(interaction, error) + +class TalentPool(Cog, name="Talentpool"): + """Used to nominate potential helper candidates.""" + + # RedisCache[str, bool] + # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled. + talentpool_settings = RedisCache() def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - disable_header=True, + self.bot = bot + self.api = NominationAPI(bot.api_client) + self.reviewer = Reviewer(bot, self.api) + # This lock lets us avoid cancelling the reviewer loop while the review code is running. + self.autoreview_lock = asyncio.Lock() + + # Setup the context menu command for message-based nomination + self.nominate_user_context_menu = app_commands.ContextMenu( + name="Nominate User", + callback=self._nominate_context_callback, ) + self.nominate_user_context_menu.default_permissions = discord.Permissions.none() + self.nominate_user_context_menu.error(self._nominate_context_error) + self.bot.tree.add_command(self.nominate_user_context_menu, guild=discord.Object(Guild.id)) - self.reviewer = Reviewer(self.__class__.__name__, bot, self) - self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + async def cog_load(self) -> None: + """Start autoreview loop if enabled.""" + if await self.autoreview_enabled(): + self.autoreview_loop.start() - @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) + self.prune_talentpool.start() + + async def autoreview_enabled(self) -> bool: + """Return whether automatic posting of nomination reviews is enabled.""" + return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) + + @group(name="talentpool", aliases=("tp", "talent", "nomination", "n"), invoke_without_command=True) + @has_any_role(*STAFF_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) - @has_any_role(*MODERATION_ROLES) - async def watched_command( + @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True) + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def nomination_autoreview_group(self, ctx: Context) -> None: + """Commands for enabling or disabling autoreview.""" + await ctx.send_help(ctx.command) + + @nomination_autoreview_group.command(name="enable", aliases=("on",)) + @has_any_role(Roles.admins, Roles.founders) + @commands.max_concurrency(1) + async def autoreview_enable(self, ctx: Context) -> None: + """ + Enable automatic posting of reviews. + + A review will be posted when the current number of active reviews is below the limit + and long enough has passed since the last review. + + Users will be considered for review if they have been in the talent pool past a + threshold time. + + The next user to review is chosen based on the number of nominations a user has, + using the age of the first nomination as a tie-breaker (oldest first). + """ + if await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already enabled.") + return + + self.autoreview_loop.start() + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) + await ctx.send(":white_check_mark: Autoreview enabled.") + + @nomination_autoreview_group.command(name="disable", aliases=("off",)) + @has_any_role(Roles.admins, Roles.founders) + @commands.max_concurrency(1) + async def autoreview_disable(self, ctx: Context) -> None: + """Disable automatic posting of reviews.""" + if not await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already disabled.") + return + + # Only cancel the loop task when the autoreview code is not running + async with self.autoreview_lock: + self.autoreview_loop.cancel() + + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) + await ctx.send(":white_check_mark: Autoreview disabled.") + + @nomination_autoreview_group.command(name="status") + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def autoreview_status(self, ctx: Context) -> None: + """Show whether automatic posting of reviews is enabled or disabled.""" + if await self.autoreview_enabled(): + await ctx.send("Autoreview is currently enabled.") + else: + await ctx.send("Autoreview is currently disabled.") + + @tasks.loop(hours=1) + async def autoreview_loop(self) -> None: + """Send request to `reviewer` to send a nomination if ready.""" + if not await self.autoreview_enabled(): + return + + async with self.autoreview_lock: + log.info("Running check for users to nominate.") + await self.reviewer.maybe_review_user() + + @tasks.loop(hours=24) + async def prune_talentpool(self) -> None: + """ + Prune any inactive users from the talentpool. + + A user is considered inactive if they have sent no messages on the server + in the past `DAYS_UNTIL_INACTIVE` days. + """ + log.info("Running task to prune users from talent pool") + nominations = await self.api.get_nominations(active=True) + + if not nominations: + return + + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=DAYS_UNTIL_INACTIVE + ) + + nomination_discussion = await get_or_fetch_channel(self.bot, Channels.nomination_discussion) + for nomination in nominations: + if messages_per_user[nomination.user_id] > 0: + continue + + if nomination.reviewed: + continue + + log.info("Removing %s from the talent pool due to inactivity", nomination.user_id) + + await nomination_discussion.send( + f":warning: <@{nomination.user_id}> ({nomination.user_id})" + " was removed from the talentpool as they have sent no messages" + f" in the past {DAYS_UNTIL_INACTIVE} days." + ) + await self.api.edit_nomination( + nomination.id, + active=False, + end_reason=f"Automatic removal: User was inactive for more than {DAYS_UNTIL_INACTIVE}" + ) + + @nomination_group.group( + name="list", + aliases=("nominated", "nominees"), + invoke_without_command=True + ) + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def list_group( self, ctx: Context, - oldest_first: bool = False, - update_cache: bool = True ) -> None: """ - Shows the users that are currently being monitored in the talent pool. + Shows the users that are currently in the talent pool. - The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + The "Recent Nominations" sections shows users nominated in the past 7 days, + so will not be considered for autoreview. - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. + In the "Autoreview Priority" section a :zzz: emoji will be shown next to + users that have not been active recently enough to be considered for autoreview. + Note that the order in this section will change over time so should not be relied upon. """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.show_nominations_list(ctx, grouped_view=True) - async def list_watched_users( + @list_group.command(name="oldest") + async def list_oldest(self, ctx: Context) -> None: + """Shows the users that are currently in the talent pool, ordered by oldest nomination.""" + await self.show_nominations_list(ctx, oldest_first=True) + + @list_group.command(name="newest") + async def list_newest(self, ctx: Context) -> None: + """Shows the users that are currently in the talent pool, ordered by newest nomination.""" + await self.show_nominations_list(ctx, oldest_first=False) + + async def show_nominations_list( self, ctx: Context, + *, oldest_first: bool = False, - update_cache: bool = True + grouped_view: bool = False, ) -> None: """ - Gives an overview of the nominated users list. - - It specifies the users' mention, name, how long ago they were nominated, and whether their - review was scheduled or already posted. + Lists the currently nominated users. - The optional kwarg `oldest_first` orders the list by oldest entry. + If `grouped_view` is passed, nominations will be displayed in the groups + being reviewed, recent nominations, and others by autoreview priority. - The optional kwarg `update_cache` specifies whether the cache should - be refreshed by polling the API. + Otherwise, nominations will be sorted by age + (ordered based on the value of `oldest_first`). """ - # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding - # the list_watched_users function. - watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) - - if update_cache and not watched_data["updated"]: - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + now = datetime.now(tz=UTC) + nominations = await self.api.get_nominations(active=True) + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=DAYS_UNTIL_INACTIVE + ) - lines = [] - for user_id, line in watched_data["info"].items(): - if self.watched_users[user_id]['reviewed']: - line += " *(reviewed)*" - elif user_id in self.reviewer: - line += " *(scheduled)*" - lines.append(line) + if grouped_view: + reviewed_nominations = [] + recent_nominations = [] + other_nominations = [] + for nomination in nominations: + if nomination.reviewed: + reviewed_nominations.append(nomination) + elif not self.reviewer.is_nomination_old_enough(nomination, now): + recent_nominations.append(nomination) + else: + other_nominations.append(nomination) + + other_nominations = await self.reviewer.sort_nominations_to_review(other_nominations, now) + + lines = [ + "**Being Reviewed:**", + *await self.list_nominations(ctx, reviewed_nominations, messages_per_user), + "**Recent Nominations:**", + *await self.list_nominations(ctx, recent_nominations, messages_per_user), + "**Other Nominations by Autoreview Priority:**", + *await self.list_nominations(ctx, other_nominations, messages_per_user, show_inactive=True) + ] + else: + if oldest_first: + nominations.reverse() + lines = await self.list_nominations(ctx, nominations, messages_per_user, show_reviewed=True) - if not lines: - lines = ("There's nothing here yet.",) + if not lines: + lines = ["There are no active nominations"] embed = Embed( - title=watched_data["title"], + title="Talent Pool active nominations", color=Color.blue() ) - await LinePaginator.paginate(lines, ctx, embed, empty=False) + await LinePaginator.paginate( + lines, + ctx, + embed, + empty=False, + allowed_roles=MODERATION_ROLES, + ) - @nomination_group.command(name='oldest') - @has_any_role(*MODERATION_ROLES) - async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + async def list_nominations( + self, + ctx: Context, + nominations: list[Nomination], + messages_per_user: dict[int, int], + *, + show_reviewed: bool = False, + show_inactive: bool = False, + ) -> list[str]: """ - Shows talent pool monitored users ordered by oldest nomination. + Formats the given nominations into a list. - The optional kwarg `update_cache` can be used to update the user - cache using the API before listing the users. + Pass `show_reviewed` to indicate reviewed nominations, and `show_inactive` to + indicate if the user doesn't have recent enough activity to be autoreviewed. """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + lines: list[str] = [] + + if not nominations: + return ["*None*"] + + for nomination in nominations: + line = f"- `{nomination.user_id}`" + + member = await get_or_fetch_member(ctx.guild, nomination.user_id) + if member: + line += f" ({member.name}#{member.discriminator})" + else: + line += " (not on server)" - @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) + line += f", added {time.format_relative(nomination.inserted_at)}" + + if show_reviewed and nomination.reviewed: + line += " *(reviewed)*" + + is_active = self.reviewer.is_user_active_enough(messages_per_user[nomination.user_id]) + if show_inactive and not is_active: + line += " :zzz:" + + lines.append(line) + return lines + + async def maybe_relay_update(self, nominee_id: int, update: str) -> None: + """ + Checks for an active nomination thread for the user, if one is found relay the given update there. + + This is used to relay new nominations and nomination updates to active vote channels. + """ + nomination = await self.api.get_active_nomination(nominee_id) + + if nomination and nomination.thread_id: + log.debug(f"Found thread ID for {nominee_id} nomination, relaying new context.") + thread = await get_or_fetch_channel(self.bot, nomination.thread_id) + + await thread.send(update) + + @nomination_group.command( + name="forcenominate", + aliases=("fw", "forceadd", "fa", "fn", "forcewatch"), + root_aliases=("forcenominate",) + ) @has_any_role(*MODERATION_ROLES) - async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = "") -> None: """ Adds the given `user` to the talent pool, from any channel. A `reason` for adding the user to the talent pool is optional. """ - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @nomination_group.command( + name="nominate", + aliases=("nom", "n", "watch", "w", "add", "a"), + root_aliases=("nominate", "nom") + ) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = "") -> None: """ Adds the given `user` to the talent pool. @@ -138,326 +424,527 @@ async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str if any(role.id in MODERATION_ROLES for role in ctx.author.roles): await ctx.send( f":x: Nominations should be run in the <#{Channels.nominations}> channel. " - "Use `!tp forcewatch` to override this check." + f"Use `{BotConfig.prefix}tp forcenominate` to override this check." ) else: - await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel") + await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel.") return - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) + + @app_commands.checks.has_any_role(*STAFF_ROLES) + async def _nominate_context_callback(self, interaction: discord.Interaction, message: discord.Message) -> None: + """Create or update a nomination for the author of the selected message.""" + if message.author.bot: + await interaction.response.send_message( + ":x: I'm afraid I can't do that. Only humans can be nominated.", + ephemeral=True + ) + return + + if isinstance(message.author, Member) and any(role.id in STAFF_ROLES for role in message.author.roles): + await interaction.response.send_message( + ":x: Nominating staff members, eh? Here's a cookie :cookie:", + ephemeral=True + ) + return + + maybe_nom = await self.api.get_nomination_reason(message.author.id, interaction.user.id) + + if maybe_nom: + nomination, reason = maybe_nom + + reason += f"\n\n{message.jump_url}" + + if len(reason) > REASON_MAX_CHARS: + await interaction.response.send_message( + ":x: Cannot add additional context due to nomination reason character limit.", + ephemeral=True + ) + return + + await self.api.edit_nomination_entry( + nomination.id, + actor_id=interaction.user.id, + reason=reason + ) + + await interaction.response.send_message( + ":white_check_mark: Existing nomination updated", + ephemeral=True + ) + + await self.maybe_relay_update( + message.author.id, + f":new: {interaction.user.mention} has attached {message.jump_url} to their nomination" + ) + + return + + nominations_channel = self.bot.get_channel(Channels.nominations) + + await interaction.response.send_modal(NominationContextModal(self, message, nominations_channel)) + + async def _nominate_context_error( + self, + interaction: discord.Interaction, + error: app_commands.AppCommandError + ) -> None: + """Handle any errors that occur with the nomination context command.""" + if isinstance(error, app_commands.errors.MissingAnyRole): + await interaction.response.send_message( + ":x: You do not have permission to use this command", ephemeral=True + ) + return + + message = ( + f":x: An unexpected error occured, Please let us know!\n\n" + f"```{error.__class__.__name__}: {error}```" + ) + + if not interaction.response.is_done(): + await interaction.response.send_message(message, ephemeral=True) + else: + await interaction.followup.send(message, ephemeral=True) - async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None: + with new_scope() as scope: + scope.user = { + "id": interaction.user.id, + "username": str(interaction.user) + } + + scope.set_tag("command", interaction.command.name) + scope.set_extra("interaction_data", interaction.data) + + log.error( + f"Error executing application command '{interaction.command.name}' invoked by {interaction.user}", + exc_info=error + ) + + async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """Adds the given user to the talent pool.""" if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.") return if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") return - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") - return - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return - # Manual request with `raise_for_status` as False because we want the actual response - session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) - kwargs = { - 'json': { - 'actor': ctx.author.id, - 'reason': reason, - 'user': user.id - }, - 'raise_for_status': False, - } - async with session.post(url, **kwargs) as resp: - response_data = await resp.json() - - if resp.status == 400: - if response_data.get('user', False): - await ctx.send(":x: The specified user can't be found in the database tables") - elif response_data.get('actor', False): - await ctx.send(":x: You have already nominated this user") - - return - else: - resp.raise_for_status() - - self.watched_users[user.id] = response_data + try: + await self.api.post_nomination(user.id, ctx.author.id, reason) + except ResponseCodeError as e: + match (e.status, e.response_json): + case (400, {"user": _}): + await ctx.send(f":x: {user.mention} can't be found in the database tables.") + return + case (400, {"actor": _}): + await ctx.send(f":x: You have already nominated {user.mention}.") + return + raise - if user.id not in self.reviewer: - self.reviewer.schedule_review(user.id) + await ctx.send(f"✅ The nomination for {user.mention} has been added to the talent pool.") - history = await self.bot.api_client.get( - self.api_endpoint, - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) + thread_update = f":new: **{ctx.author.mention} has nominated {user.mention}" - msg = f"✅ The nomination for {user} has been added to the talent pool" - if history: - msg += f"\n\n({len(history)} previous nominations in total)" + if reason: + thread_update += f" with reason:** {reason}" + else: + thread_update += "**" - await ctx.send(msg) + await self.maybe_relay_update(user.id, thread_update) - @nomination_group.command(name='history', aliases=('info', 'search')) - @has_any_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: + @nomination_group.command(name="history", aliases=("info", "search")) + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def history_command(self, ctx: Context, user: MemberOrUser) -> None: """Shows the specified user's nomination history.""" - result = await self.bot.api_client.get( - self.api_endpoint, - params={ - 'user__id': str(user.id), - 'ordering': "-active,-inserted_at" - } - ) + result = await self.api.get_nominations(user.id, ordering="-active,-inserted_at") + if not result: - await ctx.send(":warning: This user has never been nominated") + await ctx.send(f":warning: {user.mention} has never been nominated.") return embed = Embed( title=f"Nominations for {user.display_name} `({user.id})`", color=Color.blue() ) - lines = [self._nomination_to_string(nomination) for nomination in result] + lines = [await self._nomination_to_string(nomination) for nomination in result] await LinePaginator.paginate( lines, ctx=ctx, embed=embed, empty=True, max_lines=3, - max_size=1000 + max_size=1000, + allowed_roles=MODERATION_ROLES, ) - @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) - @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",)) + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. Providing a `reason` is required. """ if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return - if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + if await self.end_nomination(user.id, reason): + await ctx.send(f":white_check_mark: Successfully un-nominated {user.mention}.") else: - await ctx.send(":x: The specified user does not have an active nomination") + await ctx.send(f":x: {user.mention} doesn't have an active nomination.") - @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) - async def nomination_edit_group(self, ctx: Context) -> None: - """Commands to edit nominations.""" + @nomination_group.group(name="append", aliases=("amend",), invoke_without_command=True) + @has_any_role(*STAFF_ROLES) + async def nomination_append_group(self, ctx: Context) -> None: + """Commands to append additional text to nominations.""" await ctx.send_help(ctx.command) - @nomination_edit_group.command(name='reason') - @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: - """Edits the reason of a specific nominator in a specific active nomination.""" - if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") - return + @nomination_append_group.command(name="reason") + @has_any_role(*STAFF_ROLES) + async def append_reason_command( + self, + ctx: Context, + nominee_or_nomination_id: UnambiguousMemberOrUser | int, + nominator: UnambiguousMemberOrUser | None = None, + *, + reason: str + ) -> None: + """ + Append additional text to the nomination reason of a specific nominator for a given nomination. - try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") - except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") + If nominee_or_nomination_id resolves to a member or user, + append the text to the currently active nomination for that person. + Otherwise, if it's an int, look up that nomination ID to edit. + + If no nominator is specified, assume the invoker is editing their own nomination reason. + Otherwise, edit the reason from that specific nominator. + + Raise a permission error if a non-mod staff member invokes this command on a + specific nomination ID, or with an nominator other than themselves. + """ + # If not specified, assume the invoker is editing their own nomination reason. + nominator = nominator or ctx.author + + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + if ctx.channel.id != Channels.nominations: + await ctx.send(f":x: Nomination edits must be run in the <#{Channels.nominations}> channel.") return - else: + + if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): + raise BadArgument( + "Only moderators can edit specific nomination IDs, " + "or the reason of a nominator other than themselves." + ) + + if not reason: + await ctx.send(":x: You must include an additional reason when appending to an existing nomination.") + + if isinstance(nominee_or_nomination_id, int): + nomination_id = nominee_or_nomination_id + try: + original_nomination = await self.api.get_nomination(nomination_id=nomination_id) + except ResponseCodeError as e: + match (e.status, e.response_json): + case (400, {"actor": _}): + await ctx.send(f":x: {nominator.mention} doesn't have an entry in this nomination.") + return + case (404, _): + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`.") + return raise - if not nomination["active"]: - await ctx.send(":x: Can't edit the reason of an inactive nomination.") - return + nomination_entries = original_nomination.entries + else: + active_nominations = await self.api.get_nominations(user_id=nominee_or_nomination_id.id, active=True) + if active_nominations: + nomination_entries = active_nominations[0].entries + else: + await ctx.send(f":x: {nominee_or_nomination_id.mention} doesn't have an active nomination.") + return - if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") - return + old_reason = next((e.reason for e in nomination_entries if e.actor_id == nominator.id), None) - self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + if old_reason: + add_period = not old_reason.endswith((".", "!", "?")) + reason = old_reason + (". " if add_period else " ") + reason - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"actor": actor.id, "reason": reason} + await self._edit_nomination_reason( + ctx, + target=nominee_or_nomination_id, + actor=nominator, + reason=reason ) - await self.fetch_user_cache() # Update cache - await ctx.send(":white_check_mark: Successfully updated nomination reason.") - @nomination_edit_group.command(name='end_reason') - @has_any_role(*MODERATION_ROLES) - async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: - """Edits the unnominate reason for the nomination with the given `id`.""" + @nomination_group.group(name="edit", aliases=("e",), invoke_without_command=True) + @has_any_role(*STAFF_ROLES) + async def nomination_edit_group(self, ctx: Context) -> None: + """Commands to edit nominations.""" + await ctx.send_help(ctx.command) + + @nomination_edit_group.command(name="reason") + @has_any_role(*STAFF_ROLES) + async def edit_reason_command( + self, + ctx: Context, + nominee_or_nomination_id: UnambiguousMemberOrUser | int, + nominator: UnambiguousMemberOrUser | None = None, + *, + reason: str + ) -> None: + """ + Edit the nomination reason of a specific nominator for a given nomination. + + If nominee_or_nomination_id resolves to a member or user, edit the currently active nomination for that person. + Otherwise, if it's an int, look up that nomination ID to edit. + + If no nominator is specified, assume the invoker is editing their own nomination reason. + Otherwise, edit the reason from that specific nominator. + + Raise a permission error if a non-mod staff member invokes this command on a + specific nomination ID, or with an nominator other than themselves. + """ + # If not specified, assume the invoker is editing their own nomination reason. + nominator = nominator or ctx.author + + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + if ctx.channel.id != Channels.nominations: + await ctx.send(f":x: Nomination edits must be run in the <#{Channels.nominations}> channel.") + return + + if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): + # Invoker has specified another nominator, or a specific nomination id + raise BadArgument( + "Only moderators can edit specific nomination IDs, " + "or the reason of a nominator other than themselves." + ) + + await self._edit_nomination_reason( + ctx, + target=nominee_or_nomination_id, + actor=nominator, + reason=reason + ) + + async def _edit_nomination_reason( + self, + ctx: Context, + *, + target: int | Member | User, + actor: MemberOrUser, + reason: str, + ) -> None: + """Edit a nomination reason in the database after validating the input.""" if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return + if isinstance(target, int): + nomination_id = target + else: + active_nominations = await self.api.get_nominations(user_id=target.id, active=True) + if active_nominations: + nomination_id = active_nominations[0].id + else: + await ctx.send(f":x: {target.mention} doesn't have an active nomination.") + return + + log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {reason!r}") + try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + nomination = await self.api.edit_nomination_entry(nomination_id, actor_id=actor.id, reason=reason) except ResponseCodeError as e: - if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") - await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") - return - else: - raise + match (e.status, e.response_json): + case (400, {"actor": _}): + await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.") + return + case (404, _): + await ctx.send(f":x: Can't find a nomination with id `{target}`.") + return + raise - if nomination["active"]: - await ctx.send(":x: Can't edit the end reason of an active nomination.") - return + await ctx.send(f":white_check_mark: Updated the nomination reason for <@{nomination.user_id}>.") - self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") + thread_update = f":pencil: **{actor.mention} has edited their nomination:** {reason}" - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", - json={"end_reason": reason} - ) - await self.fetch_user_cache() # Update cache. - await ctx.send(":white_check_mark: Updated the end reason of the nomination!") + await self.maybe_relay_update(nomination.user_id, thread_update) - @nomination_group.command(aliases=('mr',)) - @has_any_role(*MODERATION_ROLES) - async def mark_reviewed(self, ctx: Context, user_id: int) -> None: - """Mark a user's nomination as reviewed and cancel the review task.""" - if not await self.reviewer.mark_reviewed(ctx, user_id): + @nomination_edit_group.command(name="end_reason") + @has_any_role(*MODERATION_ROLES, Roles.founders) + async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None: + """Edits the unnominate reason for the nomination with the given `id`.""" + if len(reason) > REASON_MAX_CHARS: + await ctx.send(f":x: The reason's length must not exceed {REASON_MAX_CHARS} characters.") return - await ctx.send(f"{Emojis.check_mark} The user with ID `{user_id}` was marked as reviewed.") - @nomination_group.command(aliases=('gr',)) - @has_any_role(*MODERATION_ROLES) + log.trace(f"Changing end reason for nomination with id {nomination_id} to {reason!r}") + try: + nomination = await self.api.edit_nomination(nomination_id, end_reason=reason) + except ResponseCodeError as e: + match (e.status, e.response_json): + case (400, {"end_reason": _}): + await ctx.send(f":x: Can't edit nomination with id `{nomination_id}` because it's still active.") + return + case (404, _): + await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`.") + return + raise + + await ctx.send(f":white_check_mark: Updated the nomination end reason for <@{nomination.user_id}>.") + + @nomination_group.command(aliases=("gr",)) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" - review = (await self.reviewer.make_review(user_id))[0] - if review: - file = discord.File(StringIO(review), f"{user_id}_review.md") - await ctx.send(file=file) - else: - await ctx.send(f"There doesn't appear to be an active nomination for {user_id}") + nominations = await self.api.get_nominations(user_id, active=True) + if not nominations: + await ctx.send(f":x: There doesn't appear to be an active nomination for {user_id}") + return - @nomination_group.command(aliases=('review',)) - @has_any_role(*MODERATION_ROLES) + review, _, _, nominations = await self.reviewer.make_review(nominations[0]) + + review_file = discord.File(StringIO(review), f"{user_id}_review.md") + nominations_file = discord.File(StringIO("\n\n".join(nominations)), f"{user_id}_nominations.md") + + await ctx.send(files=[review_file, nominations_file]) + + @nomination_group.command(aliases=("review",)) + @has_any_role(*MODERATION_ROLES, Roles.founders) async def post_review(self, ctx: Context, user_id: int) -> None: """Post the automatic review for the user ahead of time.""" - if not await self.reviewer.mark_reviewed(ctx, user_id): + nominations = await self.api.get_nominations(user_id, active=True) + if not nominations: + await ctx.send(f":x: There doesn't appear to be an active nomination for {user_id}") + return + + nomination = nominations[0] + if nomination.reviewed: + await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:") return - await self.reviewer.post_review(user_id, update_database=False) + await self.reviewer.post_review(nomination) await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None: """Remove `user` from the talent pool after they are banned.""" - await self.unwatch(user.id, "User was banned.") + if await self.end_nomination(user.id, "Automatic removal: User was banned"): + nomination_discussion = await get_or_fetch_channel(self.bot, Channels.nomination_discussion) + await nomination_discussion.send( + f":warning: <@{user.id}> ({user.id})" + " was removed from the talentpool due to being banned." + ) @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: """ Watch for reactions in the #nomination-voting channel to automate it. - Adding a ticket emoji will unpin the message. Adding an incident reaction will archive the message. """ if payload.channel_id != Channels.nomination_voting: return + if payload.user_id == self.bot.user.id: + return + message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id) emoji = str(payload.emoji) - if emoji == "\N{TICKET}": - await message.unpin(reason="Admin task created.") - elif emoji in {Emojis.incident_actioned, Emojis.incident_unactioned}: + if emoji in {Emojis.incident_actioned, Emojis.incident_unactioned}: log.info(f"Archiving nomination {message.id}") await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned) - async def unwatch(self, user_id: int, reason: str) -> bool: + async def end_nomination(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - {"user__id": str(user_id)}, - self.api_default_params, - ) - ) + active_nominations = await self.api.get_nominations(user_id, active=True) - if not active_nomination: - log.debug(f"No active nominate exists for {user_id=}") + if not active_nominations: + log.debug(f"No active nomination exists for {user_id=}") return False log.info(f"Ending nomination: {user_id=} {reason=}") - nomination = active_nomination[0] - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - self._remove_user(user_id) - - self.reviewer.cancel(user_id) - + nomination = active_nominations[0] + await self.api.edit_nomination(nomination.id, end_reason=reason, active=False) return True - def _nomination_to_string(self, nomination_object: dict) -> str: + async def _nomination_to_string(self, nomination: Nomination) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) entries = [] - for site_entry in nomination_object["entries"]: - actor_id = site_entry["actor"] - actor = guild.get_member(actor_id) + for entry in nomination.entries: + actor = await get_or_fetch_member(guild, entry.actor_id) - reason = site_entry["reason"] or "*None*" - created = time.format_infraction(site_entry["inserted_at"]) + reason = entry.reason or "*None*" + created = time.discord_timestamp(entry.inserted_at) entries.append( - f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}" + f"Actor: {actor.mention if actor else entry.actor_id}\nCreated: {created}\nReason: {reason}" ) entries_string = "\n\n".join(entries) - active = nomination_object["active"] + start_date = time.discord_timestamp(nomination.inserted_at) + + thread_jump_url = "*Not created*" + + if nomination.thread_id: + try: + thread = await get_or_fetch_channel(self.bot, nomination.thread_id) + except discord.HTTPException: + thread_jump_url = "*Not found*" + else: + thread_jump_url = f"[Jump to thread!]({thread.jump_url})" - start_date = time.format_infraction(nomination_object["inserted_at"]) - if active: + if nomination.active: lines = textwrap.dedent( f""" =============== Status: **Active** Date: {start_date} - Nomination ID: `{nomination_object["id"]}` + Nomination ID: `{nomination.id}` + Nomination vote thread: {thread_jump_url} {entries_string} =============== """ ) else: - end_date = time.format_infraction(nomination_object["ended_at"]) + end_date = time.discord_timestamp(nomination.ended_at) lines = textwrap.dedent( f""" =============== Status: Inactive Date: {start_date} - Nomination ID: `{nomination_object["id"]}` + Nomination ID: `{nomination.id}` + Nomination vote thread: {thread_jump_url} {entries_string} End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} + Unnomination reason: {nomination.end_reason} =============== """ ) return lines.strip() - def cog_unload(self) -> None: - """Cancels all review tasks on cog unload.""" - super().cog_unload() - self.reviewer.cancel_all() + async def cog_unload(self) -> None: + """Cancels the autoreview loop on cog unload.""" + # Only cancel the loop task when the autoreview code is not running + async with self.autoreview_lock: + self.autoreview_loop.cancel() + + self.prune_talentpool.cancel() + + self.bot.tree.remove_command( + self.nominate_user_context_menu.name, + guild=discord.Object(Guild.id), + type=self.nominate_user_context_menu.type + ) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b9ff619865..1535aaa45a 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -1,194 +1,368 @@ -import asyncio import contextlib -import logging import random import re -import textwrap import typing from collections import Counter -from datetime import datetime, timedelta -from typing import List, Optional, Union +from datetime import UTC, datetime, timedelta -from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta -from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel -from discord.ext.commands import Context +import discord +from async_rediscache import RedisCache +from discord import Embed, Emoji, Member, NotFound, PartialMessage +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel +from pydis_core.utils.members import get_or_fetch_member -from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Roles -from bot.utils.messages import count_unique_users_reaction, pin_no_system_message -from bot.utils.scheduling import Scheduler -from bot.utils.time import get_time_delta, humanize_delta, time_since +from bot.exts.recruitment.talentpool._api import Nomination, NominationAPI, NominationEntry +from bot.log import get_logger +from bot.utils import time +from bot.utils.messages import count_unique_users_reaction if typing.TYPE_CHECKING: - from bot.exts.recruitment.talentpool._cog import TalentPool + from bot.exts.utils.thread_bumper import ThreadBumper -log = logging.getLogger(__name__) - -# Maximum amount of days before an automatic review is posted. -MAX_DAYS_IN_POOL = 30 +log = get_logger(__name__) # Maximum amount of characters allowed in a message MAX_MESSAGE_SIZE = 2000 - -# Regex finding the user ID of a user mention -MENTION_RE = re.compile(r"<@!?(\d+?)>") -# Regex matching role pings -ROLE_MENTION_RE = re.compile(r"<@&\d+>") +# Maximum amount of characters allowed in an embed +MAX_EMBED_SIZE = 4000 + +# Maximum number of active reviews +MAX_ONGOING_REVIEWS = 3 +# Maximum number of total reviews +MAX_TOTAL_REVIEWS = 10 +# Minimum time between reviews +MIN_REVIEW_INTERVAL = timedelta(days=1) +# Minimum time between nomination and sending a review +MIN_NOMINATION_TIME = timedelta(days=7) +# Number of days ago that the user must have activity since +RECENT_ACTIVITY_DAYS = 7 + +# A constant for weighting number of nomination entries against nomination age when selecting a user to review. +# The higher this is, the lower the effect of review age. At 1, age and number of entries are weighted equally. +REVIEW_SCORE_WEIGHT = 1.5 + +# Regex for finding a nomination, and extracting the nominee. +NOMINATION_MESSAGE_REGEX = re.compile( + r"<@!?(\d+)> \(.+(#\d{4})?\) for Helper!\n\n", + re.MULTILINE +) class Reviewer: - """Schedules, formats, and publishes reviews of helper nominees.""" + """Manages, formats, and publishes reviews of helper nominees.""" - def __init__(self, name: str, bot: Bot, pool: 'TalentPool'): + # RedisCache[ + # "last_vote_date": float | POSIX UTC timestamp. + # ] + status_cache = RedisCache() + + def __init__(self, bot: Bot, nomination_api: NominationAPI): self.bot = bot - self._pool = pool - self._review_scheduler = Scheduler(name) + self.api = nomination_api - def __contains__(self, user_id: int) -> bool: - """Return True if the user with ID user_id is scheduled for review, False otherwise.""" - return user_id in self._review_scheduler + async def maybe_review_user(self) -> bool: + """ + Checks if a new vote should be triggered, and triggers one if ready. - async def reschedule_reviews(self) -> None: - """Reschedule all active nominations to be reviewed at the appropriate time.""" - log.trace("Rescheduling reviews") - await self.bot.wait_until_guild_available() - # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. - await self._pool.fetch_user_cache() + Returns a boolean representing whether a new vote was sent or not. + """ + if not await self.is_ready_for_review(): + return False - for user_id, user_data in self._pool.watched_users.items(): - if not user_data["reviewed"]: - self.schedule_review(user_id) + nomination = await self.get_nomination_to_review() + if not nomination: + return False + + await self.post_review(nomination) + return True - def schedule_review(self, user_id: int) -> None: - """Schedules a single user for review.""" - log.trace(f"Scheduling review of user with ID {user_id}") + async def is_ready_for_review(self) -> bool: + """ + Returns a boolean representing whether a new vote should be triggered. + + The criteria for this are: + - The current number of reviews is lower than `MAX_ONGOING_REVIEWS`. + - The most recent review was sent less than `MIN_REVIEW_INTERVAL` ago. + """ + voting_channel = self.bot.get_channel(Channels.nomination_voting) - user_data = self._pool.watched_users.get(user_id) - inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) - review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) + last_vote_timestamp = await self.status_cache.get("last_vote_date") + if last_vote_timestamp: + last_vote_date = datetime.fromtimestamp(last_vote_timestamp, tz=UTC) + time_since_last_vote = datetime.now(UTC) - last_vote_date - # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed. - if datetime.utcnow() - review_at < timedelta(days=1): - self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) + if time_since_last_vote < MIN_REVIEW_INTERVAL: + log.debug("Most recent review was less than %s ago, cancelling check", MIN_REVIEW_INTERVAL) + return False + else: + log.info("Date of last vote not found in cache, a vote may be sent early") + + ongoing_count = 0 + total_count = 0 + + async for msg in voting_channel.history(): + # Try and filter out any non-review messages. We also only want to count + # one message from reviews split over multiple messages. We use fixed text + # from the start as any later text could be split over messages. + if not msg.author.bot or "for Helper!" not in msg.content: + continue + + total_count += 1 + + is_ticketed = False + for reaction in msg.reactions: + if reaction.emoji == "\N{TICKET}": + is_ticketed = True + + if not is_ticketed: + ongoing_count += 1 + + if ongoing_count >= MAX_ONGOING_REVIEWS or total_count >= MAX_TOTAL_REVIEWS: + log.debug( + "There are %s ongoing and %s total reviews, above thresholds of %s and %s", + ongoing_count, total_count, + MAX_ONGOING_REVIEWS, MAX_TOTAL_REVIEWS + ) + return False + + return True + + @staticmethod + def is_nomination_old_enough(nomination: Nomination, now: datetime) -> bool: + """Check if a nomination is old enough to autoreview.""" + time_since_nomination = now - nomination.inserted_at + return time_since_nomination > MIN_NOMINATION_TIME - async def post_review(self, user_id: int, update_database: bool) -> None: + @staticmethod + def is_user_active_enough(user_message_count: int) -> bool: + """Check if a user's message count is enough for them to be autoreviewed.""" + return user_message_count > 0 + + async def is_nomination_ready_for_review( + self, + nomination: Nomination, + user_message_count: int, + now: datetime, + ) -> bool: + """ + Returns a boolean representing whether a nomination should be reviewed. + + Users will only be selected for review if: + - They have not already been reviewed. + - They have been nominated for longer than `MIN_NOMINATION_TIME`. + - They have sent at least one message in the server recently. + - They are still a member of the server. + """ + guild = self.bot.get_guild(Guild.id) + return ( + # Must be an active nomination + nomination.active and + # ... that has not already been reviewed + not nomination.reviewed and + # ... and has been nominated for long enough + self.is_nomination_old_enough(nomination, now) and + # ... and is for a user that has been active recently + self.is_user_active_enough(user_message_count) and + # ... and is currently a member of the server + await get_or_fetch_member(guild, nomination.user_id) is not None + ) + + async def sort_nominations_to_review(self, nominations: list[Nomination], now: datetime) -> list[Nomination]: + """ + Sorts a list of nominations by priority for review. + + The priority of the review is determined based on how many nominations the user has + (more nominations = higher priority), and the age of the nomination. + """ + if not nominations: + return [] + + oldest_date = min(nomination.inserted_at for nomination in nominations) + max_entries = max(len(nomination.entries) for nomination in nominations) + + def score_nomination(nomination: Nomination) -> float: + """ + Scores a nomination based on age and number of nomination entries. + + The higher the score, the higher the priority for being put up for review should be. + """ + num_entries = len(nomination.entries) + entries_score = num_entries / max_entries + + nomination_date = nomination.inserted_at + age_score = (nomination_date - now) / (oldest_date - now) + + return entries_score * REVIEW_SCORE_WEIGHT + age_score + + return sorted(nominations, key=score_nomination, reverse=True) + + async def get_nomination_to_review(self) -> Nomination | None: + """ + Returns the Nomination of the next user to review, or None if there are no users ready. + + See `is_ready_for_review` for the criteria for a user to be ready for review. + See `sort_nominations_to_review` for the criteria for a user to be prioritised for review. + """ + now = datetime.now(UTC) + nominations = await self.api.get_nominations(active=True) + if not nominations: + return None + + messages_per_user = await self.api.get_activity( + [nomination.user_id for nomination in nominations], + days=RECENT_ACTIVITY_DAYS, + ) + possible_nominations = [ + nomination for nomination in nominations + if await self.is_nomination_ready_for_review(nomination, messages_per_user[nomination.user_id], now) + ] + if not possible_nominations: + log.info("No nominations are ready to review") + return None + + sorted_nominations = await self.sort_nominations_to_review(possible_nominations, now) + return sorted_nominations[0] + + async def post_review(self, nomination: Nomination) -> None: """Format the review of a user and post it to the nomination voting channel.""" - review, seen_emoji = await self.make_review(user_id) - if not review: + review, reviewed_emoji, nominee, nominations = await self.make_review(nomination) + if not nominee: return guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.nomination_voting) - log.trace(f"Posting the review of {user_id}") - messages = await self._bulk_send(channel, review) + log.info(f"Posting the review of {nominee} ({nominee.id})") + vote_message = await channel.send(review) - await pin_no_system_message(messages[0]) + if reviewed_emoji: + for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): + await vote_message.add_reaction(reaction) - last_message = messages[-1] - if seen_emoji: - for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): - await last_message.add_reaction(reaction) + thread = await vote_message.create_thread( + name=f"Nomination - {nominee}", + ) - if update_database: - nomination = self._pool.watched_users.get(user_id) - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + nomination_messages = [] + for batch in nominations: + nomination_messages.append(await thread.send(batch)) - async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: - """Format a generic review of a user and return it with the seen emoji.""" - log.trace(f"Formatting the review of {user_id}") + # Pin the later messages first so the "Nominated by:" message is at the top of the pins list + for nom_message in nomination_messages[::-1]: + await nom_message.pin() - # Since `watched_users` is a defaultdict, we should take care - # not to accidentally insert the IDs of users that have no - # active nominated by using the `watched_users.get(user_id)` - # instead of `watched_users[user_id]`. - nomination = self._pool.watched_users.get(user_id) - if not nomination: - log.trace(f"There doesn't appear to be an active nomination for {user_id}") - return "", None + message = await thread.send(f"<@&{Roles.mod_team}> <@&{Roles.admins}>") + + now = datetime.now(tz=UTC) + await self.status_cache.set("last_vote_date", now.timestamp()) + + await self.api.edit_nomination(nomination.id, reviewed=True, thread_id=thread.id) + + bump_cog: ThreadBumper = self.bot.get_cog("ThreadBumper") + if bump_cog: + context = await self.bot.get_context(message) + await bump_cog.add_thread_to_bump_list(context, thread) + + async def make_review(self, nomination: Nomination) -> tuple[str, Emoji | None, Member | None]: + """Format a generic review of a user and return it with the reviewed emoji and the user themselves.""" + log.trace(f"Formatting the review of {nomination.user_id}") guild = self.bot.get_guild(Guild.id) - member = guild.get_member(user_id) + nominee = await get_or_fetch_member(guild, nomination.user_id) - if not member: + if not nominee: return ( - f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" - ), None + f"I tried to review the user with ID `{nomination.user_id}`," + " but they don't appear to be on the server :pensive:" + ), None, None - opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + opening = f"{nominee.mention} ({nominee}) for Helper!" - current_nominations = "\n\n".join( - f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" - for entry in nomination['entries'][::-1] - ) - current_nominations = f"**Nominated by:**\n{current_nominations}" + nominations = self._make_nomination_batches(nomination.entries) - review_body = await self._construct_review_body(member) + review_body = await self._construct_review_body(nominee, nomination) - seen_emoji = self._random_ducky(guild) + reviewed_emoji = self._random_ducky(guild) vote_request = ( - "*Refer to their nomination and infraction histories for further details*.\n" - f"*Please react {seen_emoji} if you've seen this post." - " Then react :+1: for approval, or :-1: for disapproval*." + "*Refer to their nomination and infraction histories for further details.*\n" + f"*Please react {reviewed_emoji} once you have reviewed this user," + " and react :+1: for approval, or :-1: for disapproval*." ) - review = "\n\n".join((opening, current_nominations, review_body, vote_request)) - return review, seen_emoji + review = "\n\n".join((opening, review_body, vote_request)) + return review, reviewed_emoji, nominee, nominations - async def archive_vote(self, message: PartialMessage, passed: bool) -> None: - """Archive this vote to #nomination-archive.""" - message = await message.fetch() + def _make_nomination_batches(self, entries: list[NominationEntry]) -> list[str]: + """Construct the batches of nominations to send into the voting thread.""" + messages = ["**Nominated by:**"] - # We consider the first message in the nomination to contain the two role pings - messages = [message] - if not len(ROLE_MENTION_RE.findall(message.content)) >= 2: - with contextlib.suppress(NoMoreItems): - async for new_message in message.channel.history(before=message.created_at): - messages.append(new_message) + formatted = [f"**<@{entry.actor_id}>:** {entry.reason or '*no reason given*'}" for entry in entries[::-1]] - if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2: - break + for entry in formatted: + # Add the nomination to the current last message in the message batches + potential_message = messages[-1] + f"\n\n{entry}" - log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}") + # Test if adding this entry pushes us over the character limit + if len(potential_message) >= MAX_MESSAGE_SIZE: + # If it does, create a new message starting with this entry + messages.append(entry) + else: + # If it doesn't, we will use this message + messages[-1] = potential_message + + return messages + + async def archive_vote(self, message: PartialMessage, passed: bool) -> None: + """Archive this vote to #nomination-archive.""" + message = await message.fetch() - parts = [] - for message_ in messages[::-1]: - parts.append(message_.content) - parts.append("\n" if message_.content.endswith(".") else " ") - content = "".join(parts) + # Thread channel IDs are the same as the message ID of the parent message. + nomination_thread = message.guild.get_thread(message.id) + if not nomination_thread: + try: + nomination_thread = await message.guild.fetch_channel(message.id) + except NotFound: + log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") # We assume that the first user mentioned is the user that we are voting on - user_id = int(MENTION_RE.search(content).group(1)) + user_id = int(NOMINATION_MESSAGE_REGEX.search(message.content).group(1)) # Get reaction counts - seen = await count_unique_users_reaction( - messages[0], + reviewed = await count_unique_users_reaction( + message, lambda r: "ducky" in str(r) or str(r) == "\N{EYES}", count_bots=False ) upvotes = await count_unique_users_reaction( - messages[0], + message, lambda r: str(r) == "\N{THUMBS UP SIGN}", count_bots=False ) downvotes = await count_unique_users_reaction( - messages[0], + message, lambda r: str(r) == "\N{THUMBS DOWN SIGN}", count_bots=False ) # Remove the first and last paragraphs - stripped_content = content.split("\n\n", maxsplit=1)[1].rsplit("\n\n", maxsplit=1)[0] + stripped_content = message.content.split("\n\n", maxsplit=1)[1].rsplit("\n\n", maxsplit=1)[0] result = f"**Passed** {Emojis.incident_actioned}" if passed else f"**Rejected** {Emojis.incident_unactioned}" colour = Colours.soft_green if passed else Colours.soft_red - timestamp = datetime.utcnow().strftime("%Y/%m/%d") + timestamp = datetime.now(tz=UTC).strftime("%Y/%m/%d") + + if nomination_thread: + thread_jump = f"[Jump to vote thread]({nomination_thread.jump_url})" + else: + thread_jump = "Failed to get thread" embed_content = ( f"{result} on {timestamp}\n" - f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n" + f"With {reviewed} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n" + f"{thread_jump}\n\n" f"{stripped_content}" ) @@ -197,30 +371,37 @@ async def archive_vote(self, message: PartialMessage, passed: bool) -> None: else: embed_title = f"Vote for `{user_id}`" - channel = self.bot.get_channel(Channels.nomination_archive) - for number, part in enumerate( - textwrap.wrap(embed_content, width=MAX_MESSAGE_SIZE, replace_whitespace=False, placeholder="") - ): - await channel.send(embed=Embed( - title=embed_title if number == 0 else None, - description="[...] " + part if number != 0 else part, - colour=colour - )) + channel = self.bot.get_channel(Channels.nomination_voting_archive) + await channel.send(embed=Embed( + title=embed_title, + description=embed_content, + colour=colour + )) + + await message.delete() - for message_ in messages: - await message_.delete() + if nomination_thread: + with contextlib.suppress(NotFound): + await nomination_thread.edit(archived=True) - async def _construct_review_body(self, member: Member) -> str: + async def _construct_review_body(self, member: Member, nomination: Nomination) -> str: """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" activity = await self._activity_review(member) + nominations = await self._nominations_review(nomination) infractions = await self._infractions_review(member) prev_nominations = await self._previous_nominations_review(member) - body = f"{activity}\n\n{infractions}" + body = f"{nominations}\n\n{activity}\n\n{infractions}" if prev_nominations: body += f"\n\n{prev_nominations}" return body + async def _nominations_review(self, nomination: Nomination) -> str: + """Format a brief summary of how many nominations in this voting round the nominee has.""" + entry_count = len(nomination.entries) + + return f"They have **{entry_count}** nomination{'s' if entry_count != 1 else ''} this round." + async def _activity_review(self, member: Member) -> str: """ Format the activity of the nominee. @@ -253,9 +434,9 @@ async def _activity_review(self, member: Member) -> str: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) + joined_at_formatted = time.format_relative(member.joined_at) review = ( - f"{member.name} has been on the server for **{time_on_server}**" + f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." ) @@ -269,8 +450,8 @@ async def _infractions_review(self, member: Member) -> str: """ log.trace(f"Fetching the infraction data for {member.id}'s review") infraction_list = await self.bot.api_client.get( - 'bot/infractions/expanded', - params={'user__id': str(member.id), 'ordering': '-inserted_at'} + "bot/infractions/expanded", + params={"user__id": str(member.id), "ordering": "-inserted_at"} ) log.trace(f"{len(infraction_list)} infractions found for {member.id}, formatting review.") @@ -301,7 +482,7 @@ async def _infractions_review(self, member: Member) -> str: infractions += ", with the last infraction issued " # Infractions were ordered by time since insertion descending. - infractions += get_time_delta(infraction_list[0]['inserted_at']) + infractions += time.format_relative(infraction_list[0]["inserted_at"]) return f"They have {infractions}." @@ -315,111 +496,62 @@ def _format_infr_name(infr_type: str, count: int) -> str: """ formatted = infr_type.replace("_", " ") if count > 1: - if infr_type.endswith(('ch', 'sh')): + if infr_type.endswith(("ch", "sh")): formatted += "e" formatted += "s" return formatted - async def _previous_nominations_review(self, member: Member) -> Optional[str]: + async def _previous_nominations_review(self, member: Member) -> str | None: """ Formats the review of the nominee's previous nominations. The number of previous nominations and unnominations are shown, as well as the reason the last one ended. """ log.trace(f"Fetching the nomination history data for {member.id}'s review") - history = await self.bot.api_client.get( - self._pool.api_endpoint, - params={ - "user__id": str(member.id), - "active": "false", - "ordering": "-inserted_at" - } - ) + history = await self.api.get_nominations(user_id=member.id, active=False) log.trace(f"{len(history)} previous nominations found for {member.id}, formatting review.") if not history: - return + return None - num_entries = sum(len(nomination["entries"]) for nomination in history) + num_entries = sum(len(nomination.entries) for nomination in history) nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) + thread_jump_urls = [] + + for nomination in history: + if nomination.thread_id is None: + continue + try: + thread = await get_or_fetch_channel(self.bot, nomination.thread_id) + except discord.HTTPException: + # Nothing to do here + pass + else: + thread_jump_urls.append(thread.jump_url) + + if not thread_jump_urls: + nomination_vote_threads = "No nomination threads have been found for this user." + else: + nomination_vote_threads = ", ".join(thread_jump_urls) + + end_time = time.format_relative(history[0].ended_at) review = ( f"They were nominated **{nomination_times}** before" f", but their nomination was called off **{rejection_times}**." - f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" + f"\nList of all of their nomination threads: {nomination_vote_threads}" + f"\nThe last one ended {end_time} with the reason: {history[0].end_reason}" ) return review @staticmethod - def _random_ducky(guild: Guild) -> Union[Emoji, str]: - """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns :eyes:.""" + def _random_ducky(guild: Guild) -> Emoji | str: + """Picks a random ducky emoji. If no duckies found returns 👀.""" duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] if not duckies: - return ":eyes:" + return "\N{EYES}" return random.choice(duckies) - - @staticmethod - async def _bulk_send(channel: TextChannel, text: str) -> List[Message]: - """ - Split a text into several if necessary, and post them to the channel. - - Returns the resulting message objects. - """ - messages = textwrap.wrap(text, width=MAX_MESSAGE_SIZE, replace_whitespace=False) - log.trace(f"The provided string will be sent to the channel {channel.id} as {len(messages)} messages.") - - results = [] - for message in messages: - await asyncio.sleep(1) - results.append(await channel.send(message)) - - return results - - async def mark_reviewed(self, ctx: Context, user_id: int) -> bool: - """ - Mark an active nomination as reviewed, updating the database and canceling the review task. - - Returns True if the user was successfully marked as reviewed, False otherwise. - """ - log.trace(f"Updating user {user_id} as reviewed") - await self._pool.fetch_user_cache() - if user_id not in self._pool.watched_users: - log.trace(f"Can't find a nominated user with id {user_id}") - await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`") - return False - - nomination = self._pool.watched_users.get(user_id) - if nomination["reviewed"]: - await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:") - return False - - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) - if user_id in self._review_scheduler: - self._review_scheduler.cancel(user_id) - - return True - - def cancel(self, user_id: int) -> None: - """ - Cancels the review of the nominee with ID `user_id`. - - It's important to note that this applies only until reschedule_reviews is called again. - To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. - """ - log.trace(f"Canceling the review of user {user_id}.") - self._review_scheduler.cancel(user_id) - - def cancel_all(self) -> None: - """ - Cancels all reviews. - - It's important to note that this applies only until reschedule_reviews is called again. - To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. - """ - log.trace("Canceling all reviews.") - self._review_scheduler.cancel_all() diff --git a/bot/exts/utils/attachment_pastebin_uploader.py b/bot/exts/utils/attachment_pastebin_uploader.py new file mode 100644 index 0000000000..c788111d99 --- /dev/null +++ b/bot/exts/utils/attachment_pastebin_uploader.py @@ -0,0 +1,166 @@ +import re + +import discord +from discord.ext import commands +from pydis_core.utils import paste_service + +from bot.bot import Bot +from bot.constants import Emojis +from bot.log import get_logger + +log = get_logger(__name__) + +UPLOAD_EMOJI = Emojis.check_mark +DELETE_EMOJI = Emojis.trashcan + + +class AutoTextAttachmentUploader(commands.Cog): + """ + Handles automatic uploading of attachments to the paste bin. + + Whenever a user uploads one or more attachments that is text-based (py, txt, csv, etc.), this cog offers to upload + all the attachments to the paste bin automatically. The steps are as follows: + - The bot replies to the message containing the attachments, asking the user to react with a checkmark to consent + to having the content uploaded. + - If consent is given, the bot uploads the contents and edits its own message to contain the link. + - The bot DMs the user the delete link for the paste. + - The bot waits for the user to react with a trashcan emoji, in which case the bot deletes the paste and its own + message. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.pending_messages = set[int]() + + @staticmethod + async def _convert_attachment(attachment: discord.Attachment) -> paste_service.PasteFile: + """Converts an attachment to a PasteFile, according to the attachment's file encoding.""" + encoding = re.search(r"charset=(\S+)", attachment.content_type).group(1) + file_content_bytes = await attachment.read() + file_content = file_content_bytes.decode(encoding) + return paste_service.PasteFile(content=file_content, name=attachment.filename) + + async def wait_for_user_reaction( + self, + message: discord.Message, + user: discord.User, + emoji: str, + timeout: float = 60, + ) -> bool: + """Wait for `timeout` seconds for `user` to react to `message` with `emoji`.""" + def wait_for_reaction(reaction: discord.Reaction, reactor: discord.User) -> bool: + return ( + reaction.message.id == message.id + and str(reaction.emoji) == emoji + and reactor == user + ) + + await message.add_reaction(emoji) + log.trace(f"Waiting for {user.name} to react to {message.id} with {emoji}") + + try: + await self.bot.wait_for("reaction_add", timeout=timeout, check=wait_for_reaction) + except TimeoutError: + log.trace(f"User {user.name} did not react to message {message.id} with {emoji}") + await message.clear_reactions() + return False + + return True + + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """Allows us to know which messages with attachments have been deleted.""" + self.pending_messages.discard(message.id) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Listens for messages containing attachments and offers to upload them to the pastebin.""" + # Check the message is not sent by a bot or in DMs. + if message.author.bot or not message.guild: + return + + # Check if the message contains any text-based attachments. + # we only require a charset here, as its setting is matched + attachments: list[discord.Attachment] = [] + for attachment in message.attachments: + if ( + attachment.content_type + and "charset" in attachment.content_type + ): + attachments.append(attachment) + + if not attachments: + return + + log.trace(f"Offering to upload attachments for {message.author} in {message.channel}, message {message.id}") + self.pending_messages.add(message.id) + + # Offer to upload the attachments and wait for the user's reaction. + bot_reply = await message.reply( + f"Please react with {UPLOAD_EMOJI} to upload your file(s) to our " + f"[paste bin](), which is more accessible for some users." + ) + + permission_granted = await self.wait_for_user_reaction(bot_reply, message.author, UPLOAD_EMOJI, 60. * 3) + + if not permission_granted: + log.trace(f"{message.author} didn't give permission to upload {message.id} content; aborting.") + await bot_reply.edit(content=f"~~{bot_reply.content}~~") + return + + if message.id not in self.pending_messages: + log.trace(f"{message.author}'s message was deleted before the attachments could be uploaded; aborting.") + await bot_reply.delete() + return + + # In either case, we do not want the message ID in pending_messages anymore. + self.pending_messages.discard(message.id) + + # Extract the attachments. + files = [await self._convert_attachment(f) for f in attachments] + + # Upload the files to the paste bin, exiting early if there's an error. + log.trace(f"Attempting to upload {len(files)} file(s) to pastebin.") + try: + paste_response = await paste_service.send_to_paste_service(files=files, http_session=self.bot.http_session) + except (paste_service.PasteTooLongError, ValueError): + log.trace(f"{message.author}'s attachments were too long.") + await bot_reply.edit(content="Your paste is too long, and couldn't be uploaded.") + return + except paste_service.PasteUploadError: + log.trace(f"Unexpected error uploading {message.author}'s attachments.") + await bot_reply.edit(content="There was an error uploading your paste.") + return + + # Send the user a DM with the delete link for the paste. + # The angle brackets around the remove link are required to stop Discord from visiting the URL to produce a + # preview, thereby deleting the paste + try: + await message.author.send( + f"[Click here](<{paste_response.removal}>) to delete the pasted attachment" + f" contents copied from [your message](<{message.jump_url}>)" + ) + except discord.Forbidden: + log.debug(f"User {message.author} has DMs disabled, skipping delete link DM.") + + # Edit the bot message to contain the link to the paste. + await bot_reply.edit(content=f"[Click here]({paste_response.link}) to see this code in our pastebin.") + await bot_reply.clear_reactions() + await bot_reply.add_reaction(DELETE_EMOJI) + + # Wait for the user to react with a trash can, which they can use to delete the paste. + log.trace(f"Offering to delete {message.author}'s attachments in {message.channel}, message {message.id}") + user_wants_delete = await self.wait_for_user_reaction(bot_reply, message.author, DELETE_EMOJI, 60. * 10) + + if not user_wants_delete: + return + + # Delete the paste and the bot's message. + await self.bot.http_session.get(paste_response.removal) + + await bot_reply.delete() + + +async def setup(bot: Bot) -> None: + """Load the EmbedFileHandler cog.""" + await bot.add_cog(AutoTextAttachmentUploader(bot)) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index a4c828f952..4b24ef2426 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,13 +1,12 @@ -import logging -from typing import Optional from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot -from bot.constants import Guild, MODERATION_ROLES, URLs +from bot.constants import Bot as BotConfig, Guild, MODERATION_ROLES, URLs +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class BotCog(Cog, name="Bot"): @@ -21,11 +20,14 @@ async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" await ctx.send_help(ctx.command) - @botinfo_group.command(name='about', aliases=('info',), hidden=True) + @botinfo_group.command(name="about", aliases=("info",), hidden=True) async def about_command(self, ctx: Context) -> None: """Get information about the bot.""" embed = Embed( - description="A utility bot designed just for the Python server! Try `!help` for more info.", + description=( + "A utility bot designed just for the Python server! " + f"Try `{BotConfig.prefix}help` for more info." + ), url="https://github.com/python-discord/bot" ) @@ -38,18 +40,20 @@ async def about_command(self, ctx: Context) -> None: await ctx.send(embed=embed) - @command(name='echo', aliases=('print',)) + @command(name="echo", aliases=("print",)) @has_any_role(*MODERATION_ROLES) - async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + async def echo_command(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None: """Repeat the given message in either a specified channel or the current channel.""" if channel is None: await ctx.send(text) + elif not channel.permissions_for(ctx.author).send_messages: + await ctx.send("You don't have permission to speak in that channel.") else: await channel.send(text) - @command(name='embed') + @command(name="embed") @has_any_role(*MODERATION_ROLES) - async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None: + async def embed_command(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None: """Send the input within an embed to either a specified channel or the current channel.""" embed = Embed(description=text) @@ -59,6 +63,6 @@ async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, t await channel.send(embed=embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Bot cog.""" - bot.add_cog(BotCog(bot)) + await bot.add_cog(BotCog(bot)) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py deleted file mode 100644 index cb662e852e..0000000000 --- a/bot/exts/utils/clean.py +++ /dev/null @@ -1,276 +0,0 @@ -import logging -import random -import re -from typing import Iterable, Optional - -from discord import Colour, Embed, Message, TextChannel, User, errors -from discord.ext import commands -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.bot import Bot -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES -) -from bot.exts.moderation.modlog import ModLog - -log = logging.getLogger(__name__) - - -class Clean(Cog): - """ - A cog that allows messages to be deleted in bulk, while applying various filters. - - You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a - specific regular expression. - - The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be - used to view the messages in the Discord dark theme style. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.cleaning = False - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - async def _clean_messages( - self, - amount: int, - ctx: Context, - channels: Iterable[TextChannel], - bots_only: bool = False, - user: User = None, - regex: Optional[str] = None, - until_message: Optional[Message] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return True if the message was sent by a bot.""" - return message.author.bot - - def predicate_specific_user(message: Message) -> bool: - """Return True if the message was sent by the user provided in the _clean_messages call.""" - return message.author == user - - def predicate_regex(message: Message) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - content = [message.content] - - # Add the content for all embed attributes - for embed in message.embeds: - content.append(embed.title) - content.append(embed.description) - content.append(embed.footer.text) - content.append(embed.author.name) - for field in embed.fields: - content.append(field.name) - content.append(field.value) - - # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) - - # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) - - # Is this an acceptable amount of messages to clean? - if amount > CleanMessages.message_limit: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description=f"You cannot clean more than {CleanMessages.message_limit} messages." - ) - await ctx.send(embed=embed) - return - - # Are we already performing a clean? - if self.cleaning: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="Please wait for the currently ongoing clean operation to complete." - ) - await ctx.send(embed=embed) - return - - # Set up the correct predicate - if bots_only: - predicate = predicate_bots_only # Delete messages from bots - elif user: - predicate = predicate_specific_user # Delete messages from specific user - elif regex: - predicate = predicate_regex # Delete messages that match regex - else: - predicate = None # Delete all messages - - # Default to using the invoking context's channel - if not channels: - channels = [ctx.channel] - - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - try: - await ctx.message.delete() - except errors.NotFound: - # Invocation message has already been deleted - log.info("Tried to delete invocation message, but it was already deleted.") - - messages = [] - message_ids = [] - self.cleaning = True - - # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. - for channel in channels: - async for message in channel.history(limit=amount): - - # If at any point the cancel command is invoked, we should stop. - if not self.cleaning: - return - - # If we are looking for specific message. - if until_message: - - # we could use ID's here however in case if the message we are looking for gets deleted, - # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # means we have found the message until which we were supposed to be deleting. - break - - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) - - # If the message passes predicate, let's save it. - if predicate is None or predicate(message): - message_ids.append(message.id) - - self.cleaning = False - - # Now let's delete the actual messages with purge. - self.mod_log.ignore(Event.message_delete, *message_ids) - for channel in channels: - if until_message: - for i in range(0, len(messages), 100): - # while purge automatically handles the amount of messages - # delete_messages only allows for up to 100 messages at once - # thus we need to paginate the amount to always be <= 100 - await channel.delete_messages(messages[i:i + 100]) - else: - messages += await channel.purge(limit=amount, check=predicate) - - # Reverse the list to restore chronological order - if messages: - messages = reversed(messages) - log_url = await self.mod_log.upload_log(messages, ctx.author.id) - else: - # Can't build an embed, nothing to clean! - embed = Embed( - color=Colour(Colours.soft_red), - description="No matching messages could be found." - ) - await ctx.send(embed=embed, delete_after=10) - return - - # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by " - f"{ctx.author.mention}\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) - - @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) - @has_any_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) - - @clean_group.command(name="user", aliases=["users"]) - @has_any_role(*MODERATION_ROLES) - async def clean_user( - self, - ctx: Context, - user: User, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels) - - @clean_group.command(name="all", aliases=["everything"]) - @has_any_role(*MODERATION_ROLES) - async def clean_all( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) - - @clean_group.command(name="bots", aliases=["bot"]) - @has_any_role(*MODERATION_ROLES) - async def clean_bots( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) - - @clean_group.command(name="regex", aliases=["word", "expression"]) - @has_any_role(*MODERATION_ROLES) - async def clean_regex( - self, - ctx: Context, - regex: str, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels) - - @clean_group.command(name="message", aliases=["messages"]) - @has_any_role(*MODERATION_ROLES) - async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[message.channel], - until_message=message - ) - - @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @has_any_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context) -> None: - """If there is an ongoing cleaning process, attempt to immediately cancel it.""" - self.cleaning = False - - embed = Embed( - color=Colour.blurple(), - description="Clean interrupted." - ) - await ctx.send(embed=embed, delete_after=10) - - -def setup(bot: Bot) -> None: - """Load the Clean cog.""" - bot.add_cog(Clean(bot)) diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 8a1ed98f4b..a687d42b40 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -1,7 +1,5 @@ -import functools -import logging import typing as t -from enum import Enum +from enum import Enum, member from discord import Colour, Embed from discord.ext import commands @@ -10,10 +8,11 @@ from bot import exts from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.converters import Extension +from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils.extensions import EXTENSIONS, unqualify -log = logging.getLogger(__name__) +log = get_logger(__name__) UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"} @@ -23,48 +22,9 @@ class Action(Enum): """Represents an action to perform on an extension.""" - # Need to be partial otherwise they are considered to be function definitions. - LOAD = functools.partial(Bot.load_extension) - UNLOAD = functools.partial(Bot.unload_extension) - RELOAD = functools.partial(Bot.reload_extension) - - -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if argument in EXTENSIONS: - return argument - elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: - return qualified_arg - - matches = [] - for ext in EXTENSIONS: - if argument == unqualify(ext): - matches.append(ext) - - if len(matches) > 1: - matches.sort() - names = "\n".join(matches) - raise commands.BadArgument( - f":x: `{argument}` is an ambiguous extension name. " - f"Please use one of the following fully-qualified names.```\n{names}```" - ) - elif matches: - return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") + LOAD = member(Bot.load_extension) + UNLOAD = member(Bot.unload_extension) + RELOAD = member(Bot.reload_extension) class Extensions(commands.Cog): @@ -72,8 +32,9 @@ class Extensions(commands.Cog): def __init__(self, bot: Bot): self.bot = bot + self.action_in_progress = False - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + @group(name="extensions", aliases=("ext", "exts", "c", "cog", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" await ctx.send_help(ctx.command) @@ -84,16 +45,15 @@ async def load_command(self, ctx: Context, *extensions: Extension) -> None: Load extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. - """ # noqa: W605 + """ if not extensions: await ctx.send_help(ctx.command) return if "*" in extensions or "**" in extensions: - extensions = set(EXTENSIONS) - set(self.bot.extensions.keys()) + extensions = set(self.bot.all_extensions) - set(self.bot.extensions.keys()) - msg = self.batch_manage(Action.LOAD, *extensions) - await ctx.send(msg) + await self.batch_manage(Action.LOAD, ctx, *extensions) @extensions_group.command(name="unload", aliases=("ul",)) async def unload_command(self, ctx: Context, *extensions: Extension) -> None: @@ -101,7 +61,7 @@ async def unload_command(self, ctx: Context, *extensions: Extension) -> None: Unload currently loaded extensions given their fully qualified or unqualified names. If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. - """ # noqa: W605 + """ if not extensions: await ctx.send_help(ctx.command) return @@ -109,14 +69,12 @@ async def unload_command(self, ctx: Context, *extensions: Extension) -> None: blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) if blacklisted: - msg = f":x: The following extension(s) may not be unloaded:```\n{blacklisted}```" + await ctx.send(f":x: The following extension(s) may not be unloaded:```\n{blacklisted}```") else: if "*" in extensions or "**" in extensions: extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST - msg = self.batch_manage(Action.UNLOAD, *extensions) - - await ctx.send(msg) + await self.batch_manage(Action.UNLOAD, ctx, *extensions) @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) async def reload_command(self, ctx: Context, *extensions: Extension) -> None: @@ -127,20 +85,18 @@ async def reload_command(self, ctx: Context, *extensions: Extension) -> None: If '\*' is given as the name, all currently loaded extensions will be reloaded. If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. - """ # noqa: W605 + """ if not extensions: await ctx.send_help(ctx.command) return if "**" in extensions: - extensions = EXTENSIONS + extensions = self.bot.all_extensions elif "*" in extensions: extensions = set(self.bot.extensions.keys()) | set(extensions) extensions.remove("*") - msg = self.batch_manage(Action.RELOAD, *extensions) - - await ctx.send(msg) + await self.batch_manage(Action.RELOAD, ctx, *extensions) @extensions_group.command(name="list", aliases=("all",)) async def list_command(self, ctx: Context) -> None: @@ -150,7 +106,7 @@ async def list_command(self, ctx: Context) -> None: Grey indicates that the extension is unloaded. Green indicates that the extension is currently loaded. """ - embed = Embed(colour=Colour.blurple()) + embed = Embed(colour=Colour.og_blurple()) embed.set_author( name="Extensions List", url=URLs.github_bot_repo, @@ -173,7 +129,7 @@ def group_extension_statuses(self) -> t.Mapping[str, str]: """Return a mapping of extension names and statuses to their categories.""" categories = {} - for ext in EXTENSIONS: + for ext in self.bot.all_extensions: if ext in self.bot.extensions: status = Emojis.status_online else: @@ -189,21 +145,31 @@ def group_extension_statuses(self) -> t.Mapping[str, str]: return categories - def batch_manage(self, action: Action, *extensions: str) -> str: + async def batch_manage(self, action: Action, ctx: Context, *extensions: str) -> None: """ - Apply an action to multiple extensions and return a message with the results. + Apply an action to multiple extensions, giving feedback to the invoker while doing so. If only one extension is given, it is deferred to `manage()`. """ - if len(extensions) == 1: - msg, _ = self.manage(action, extensions[0]) - return msg + if self.action_in_progress: + await ctx.send(":x: Another action is in progress, please try again later.") + return verb = action.name.lower() + + self.action_in_progress = True + loading_message = await ctx.send(f":hourglass_flowing_sand: {verb} in progress, please wait...") + + if len(extensions) == 1: + msg, _ = await self.manage(action, extensions[0]) + await loading_message.edit(content=msg) + self.action_in_progress = False + return + failures = {} for extension in extensions: - _, error = self.manage(action, extension) + _, error = await self.manage(action, extension) if error: failures[extension] = error @@ -216,19 +182,20 @@ def batch_manage(self, action: Action, *extensions: str) -> str: log.debug(f"Batch {verb}ed extensions.") - return msg + await loading_message.edit(content=msg) + self.action_in_progress = False - def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]: + async def manage(self, action: Action, ext: str) -> tuple[str, str | None]: """Apply an action to an extension and return the status message and any error message.""" verb = action.name.lower() error_msg = None try: - action.value(self.bot, ext) + await action.value(self.bot, ext) except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded): if action is Action.RELOAD: # When reloading, just load the extension if it was not loaded. - return self.manage(Action.LOAD, ext) + return await self.manage(Action.LOAD, ext) msg = f":x: Extension `{ext}` is already {verb}ed." log.debug(msg[4:]) @@ -253,12 +220,16 @@ async def cog_check(self, ctx: Context) -> bool: # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Handle BadArgument errors locally to prevent the help command from showing.""" + """Handle errors locally to prevent the error handler cog from interfering when not wanted.""" + # Safely clear the flag on unexpected errors to avoid deadlocks. + self.action_in_progress = False + + # Handle BadArgument errors locally to prevent the help command from showing. if isinstance(error, commands.BadArgument): await ctx.send(str(error)) error.handled = True -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Extensions cog.""" - bot.add_cog(Extensions(bot)) + await bot.add_cog(Extensions(bot)) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 6f2da31318..ea27a5f503 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -1,23 +1,24 @@ import contextlib import inspect -import logging import pprint import re import textwrap import traceback from collections import Counter -from datetime import datetime from io import StringIO -from typing import Any, Optional, Tuple +from typing import Any +import arrow import discord -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import Cog, Context, group, has_any_role, is_owner +from pydis_core.utils.paste_service import PasteFile, PasteTooLongError, PasteUploadError, send_to_paste_service from bot.bot import Bot -from bot.constants import Roles -from bot.utils import find_nth_occurrence, send_to_paste_service +from bot.constants import BaseURLs, DEBUG_MODE, Roles +from bot.log import get_logger +from bot.utils import find_nth_occurrence -log = logging.getLogger(__name__) +log = get_logger(__name__) class Internal(Cog): @@ -29,18 +30,20 @@ def __init__(self, bot: Bot): self.ln = 0 self.stdout = StringIO() - self.socket_since = datetime.utcnow() + self.socket_since = arrow.utcnow() self.socket_event_total = 0 self.socket_events = Counter() + if DEBUG_MODE: + self.eval.add_check(is_owner().predicate) + @Cog.listener() - async def on_socket_response(self, msg: dict) -> None: + async def on_socket_event_type(self, event_type: str) -> None: """When a websocket event is received, increase our counters.""" - if event_type := msg.get("t"): - self.socket_event_total += 1 - self.socket_events[event_type] += 1 + self.socket_event_total += 1 + self.socket_events[event_type] += 1 - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + def _format(self, inp: str, out: Any) -> tuple[str, discord.Embed | None]: """Format the eval output into a string & attempt to format it into an Embed.""" self._ = out @@ -134,7 +137,7 @@ def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: return res # Return (text, embed) - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + async def _eval(self, ctx: Context, code: str) -> discord.Message | None: """Eval the input code string & send an embed to the invoking context.""" self.ln += 1 @@ -163,18 +166,18 @@ async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: async def func(): # (None,) -> Any try: with contextlib.redirect_stdout(self.stdout): -{0} +{} if '_' in locals(): if inspect.isawaitable(_): _ = await _ return _ finally: self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) +""".format(textwrap.indent(code, " ")) try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] + exec(code_, self.env) # noqa: S102 + func = self.env["func"] res = await func() except Exception: @@ -192,34 +195,43 @@ async def func(): # (None,) -> Any truncate_index = newline_truncate_index if len(out) > truncate_index: - paste_link = await send_to_paste_service(out, extension="py") - if paste_link is not None: - paste_text = f"full contents at {paste_link}" - else: + file = PasteFile(content=out) + try: + resp = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, + ) + except PasteTooLongError: + paste_text = "too long to upload to paste service." + except PasteUploadError: paste_text = "failed to upload contents to paste service." + else: + paste_text = f"full contents at {resp.link}" await ctx.send( f"```py\n{out[:truncate_index]}\n```" f"... response truncated; {paste_text}", embed=embed ) - return + return None await ctx.send(f"```py\n{out}```", embed=embed) + return None - @group(name='internal', aliases=('int',)) + @group(name="internal", aliases=("int",)) @has_any_role(Roles.owners, Roles.admins, Roles.core_developers) async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) - @internal_group.command(name='eval', aliases=('e',)) + @internal_group.command(name="eval", aliases=("e",)) @has_any_role(Roles.admins, Roles.owners) async def eval(self, ctx: Context, *, code: str) -> None: """Run eval in a REPL-like format.""" code = code.strip("`") - if re.match('py(thon)?\n', code): + if re.match("py(thon)?\n", code): code = "\n".join(code.split("\n")[1:]) if not re.search( # Check if it's an expression @@ -230,18 +242,18 @@ async def eval(self, ctx: Context, *, code: str) -> None: await self._eval(ctx, code) - @internal_group.command(name='socketstats', aliases=('socket', 'stats')) + @internal_group.command(name="socketstats", aliases=("socket", "stats")) @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) async def socketstats(self, ctx: Context) -> None: """Fetch information on the socket events received from Discord.""" - running_s = (datetime.utcnow() - self.socket_since).total_seconds() + running_s = (arrow.utcnow() - self.socket_since).total_seconds() per_s = self.socket_event_total / running_s stats_embed = discord.Embed( title="WebSocket statistics", description=f"Receiving {per_s:0.2f} events per second.", - color=discord.Color.blurple() + color=discord.Color.og_blurple() ) for event_type, count in self.socket_events.most_common(25): @@ -250,6 +262,6 @@ async def socketstats(self, ctx: Context) -> None: await ctx.send(embed=stats_embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Internal cog.""" - bot.add_cog(Internal(bot)) + await bot.add_cog(Internal(bot)) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py deleted file mode 100644 index 98fbcb3036..0000000000 --- a/bot/exts/utils/jams.py +++ /dev/null @@ -1,145 +0,0 @@ -import logging -import typing as t - -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role -from discord.ext import commands -from more_itertools import unique_everseen - -from bot.bot import Bot -from bot.constants import Roles - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.command() - @commands.has_any_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: - """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. - - The first user passed will always be the team leader. - """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return - - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) - - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) - - async def get_category(self, guild: Guild) -> CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: - return category - - return await self.create_category(guild) - - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) - } - - return await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - # First member is always the team leader - team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), - } - - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True - ) - - return team_channel_overwrites - - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - team_channel = await guild.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() - - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category - ) - - return team_channel.mention - - @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 750ff46d2a..b40c37eaff 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -1,18 +1,15 @@ -import socket -import urllib.parse -from datetime import datetime - -import aioping +import arrow +from aiohttp import client_exceptions from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, Emojis, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_AND_COMMUNITY_ROLES, URLs from bot.decorators import in_whitelist DESCRIPTIONS = ( "Command processing time", - "Python Discord website latency", + "Python Discord website status", "Discord API latency" ) ROUND_LATENCY = 3 @@ -25,7 +22,7 @@ def __init__(self, bot: Bot) -> None: self.bot = bot @commands.command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_AND_COMMUNITY_ROLES) async def ping(self, ctx: commands.Context) -> None: """ Gets different measures of latency within the bot. @@ -34,35 +31,35 @@ async def ping(self, ctx: commands.Context) -> None: """ # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. - bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 + bot_ping = (arrow.utcnow() - ctx.message.created_at).total_seconds() * 1000 if bot_ping <= 0: bot_ping = "Your clock is out of sync, could not calculate ping." else: bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - url = urllib.parse.urlparse(URLs.site_schema + URLs.site).hostname - try: - delay = await aioping.ping(url, family=socket.AddressFamily.AF_INET) * 1000 - site_ping = f"{delay:.{ROUND_LATENCY}f} ms" - except OSError: - # Some machines do not have permission to run ping - site_ping = "Permission denied, could not ping." + async with self.bot.http_session.get(f"{URLs.site_api}/healthcheck") as request: + request.raise_for_status() + site_status = "Healthy" - except TimeoutError: - site_ping = f"{Emojis.cross_mark} Connection timed out." + except client_exceptions.ClientResponseError as e: + """The site returned an unexpected response.""" + site_status = f"The site returned an error in the response: ({e.status}) {e}" + except client_exceptions.ClientConnectionError: + """Something went wrong with the connection.""" + site_status = "Could not establish connection with the site." # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" embed = Embed(title="Pong!") - for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]): + for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_status, discord_ping], strict=True): embed.add_field(name=desc, value=latency, inline=False) await ctx.send(embed=embed) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Latency cog.""" - bot.add_cog(Latency(bot)) + await bot.add_cog(Latency(bot)) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6c21920a1b..1b386ec000 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,33 +1,213 @@ -import asyncio -import logging import random import textwrap import typing as t -from datetime import datetime, timedelta +from datetime import UTC, datetime from operator import itemgetter import discord from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta +from discord import Interaction from discord.ext.commands import Cog, Context, Greedy, group +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling +from pydis_core.utils.members import get_or_fetch_member +from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES -from bot.converters import Duration +from bot.constants import ( + Channels, + Guild, + Icons, + MODERATION_ROLES, + NEGATIVE_REPLIES, + POSITIVE_REPLIES, + Roles, + STAFF_AND_COMMUNITY_ROLES, +) +from bot.converters import Duration, UnambiguousUser +from bot.errors import LockedResourceError +from bot.log import get_logger from bot.pagination import LinePaginator +from bot.utils import time from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg from bot.utils.messages import send_denial -from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta -log = logging.getLogger(__name__) +log = get_logger(__name__) LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 +REMINDER_EDIT_CONFIRMATION_TIMEOUT = 60 +REMINDER_MENTION_BUTTON_TIMEOUT = 5*60 +# The number of mentions that can be sent when a reminder arrives is limited by +# the 2000-character message limit. +MAXIMUM_REMINDER_MENTION_OPT_INS = 80 -Mentionable = t.Union[discord.Member, discord.Role] +Mentionable = discord.Member | discord.Role +ReminderMention = UnambiguousUser | discord.Role + + +class ModifyReminderConfirmationView(discord.ui.View): + """A view to confirm modifying someone else's reminder by admins.""" + + def __init__(self, author: discord.Member): + super().__init__(timeout=REMINDER_EDIT_CONFIRMATION_TIMEOUT) + self.author = author + self.result: bool | None = None + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.author.id + + async def on_timeout(self) -> None: + """Default to not modifying if the user doesn't respond.""" + self.result = False + + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.blurple, row=0) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Confirm the reminder modification.""" + await interaction.response.edit_message(view=None) + self.result = True + self.stop() + + @discord.ui.button(label="Cancel", row=0) + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the reminder modification.""" + await interaction.response.edit_message(view=None) + self.result = False + self.stop() + + +class OptInReminderMentionView(discord.ui.View): + """A button to opt-in to get notified of someone else's reminder.""" + + def __init__(self, cog: Reminders, reminder: dict, expiration: Duration): + super().__init__() + + self.cog = cog + self.reminder = reminder + + self.timeout = min( + (expiration - datetime.now(UTC)).total_seconds(), + REMINDER_MENTION_BUTTON_TIMEOUT + ) + + async def get_embed( + self, + message: str = "Click on the button to add yourself to the list of mentions." + ) -> discord.Embed: + """Return an embed to show the button together with.""" + description = "The following user(s) will be notified when the reminder arrives:\n" + description += " ".join([ + mentionable.mention async for mentionable in self.cog.get_mentionables( + [self.reminder["author"]] + self.reminder["mentions"] + ) + ]) + + if message: + description += f"\n\n{message}" + + return discord.Embed(description=description) + + @discord.ui.button(emoji="🔔", label="Notify me", style=discord.ButtonStyle.green) + async def button_callback(self, interaction: Interaction, button: discord.ui.Button) -> None: + """The button callback.""" + # This is required in case the reminder was edited/deleted between + # creation and the opt-in button click. + try: + api_response = await self.cog.bot.api_client.get(f"bot/reminders/{self.reminder['id']}") + except ResponseCodeError as e: + await self.handle_api_error(interaction, button, e) + return + + self.reminder = api_response + + # Check whether the user should be added. + if interaction.user.id == self.reminder["author"]: + await interaction.response.send_message( + "As the author of that reminder, you will already be notified when the reminder arrives.", + ephemeral=True, + ) + return + + if interaction.user.id in self.reminder["mentions"]: + await interaction.response.send_message( + "You are already in the list of mentions for that reminder.", + ephemeral=True, + delete_after=5, + ) + return + + if len(self.reminder["mentions"]) >= MAXIMUM_REMINDER_MENTION_OPT_INS: + await interaction.response.send_message( + "Sorry, this reminder has reached the maximum number of allowed mentions.", + ephemeral=True, + delete_after=5, + ) + await self.disable(interaction, button, "Maximum number of allowed mentions reached!") + return + + # Add the user to the list of mentions. + try: + api_response = await self.cog.add_mention_opt_in(self.reminder, interaction.user.id) + except ResponseCodeError as e: + await self.handle_api_error(interaction, button, e) + return + + self.reminder = api_response + + # Confirm that it was successful. + await interaction.response.send_message( + "You were successfully added to the list of mentions for that reminder.", + ephemeral=True, + delete_after=5, + ) + + # Update the embed to show the new list of mentions. + await interaction.message.edit(embed=await self.get_embed()) + + async def handle_api_error( + self, + interaction: Interaction, + button: discord.ui.Button, + error: ResponseCodeError + ) -> None: + """Handle a ResponseCodeError from the API responsibly.""" + log.trace(f"API returned {error.status} for reminder #{self.reminder['id']}.") + + if error.status == 404: + # This might happen if the reminder was edited to arrive before the + # button was initially scheduled to timeout. + await interaction.response.send_message( + "This reminder was either deleted or has already arrived.", + ephemeral=True, + delete_after=5, + ) + # Don't delete the whole interaction message here or the user will + # see the above response message seemingly without context. + await self.disable(interaction, button) + + else: + await interaction.response.send_message( + "Sorry, an unexpected error occurred when performing this operation.\n" + "Please create your own reminder instead.", + ephemeral=True, + delete_after=5, + ) + await self.disable( + interaction, + button, + "An unexpected error occurred when attempting to add users." + ) + + async def disable(self, interaction: Interaction, button: discord.ui.Button, reason: str = "") -> None: + """Disable the button and add an optional reason to the original interaction message.""" + button.disabled = True + await interaction.message.edit( + embed=await self.get_embed(reason), + view=self, + ) class Reminders(Cog): @@ -37,57 +217,52 @@ def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self.bot.loop.create_task(self.reschedule_reminders()) - - def cog_unload(self) -> None: + async def cog_unload(self) -> None: """Cancel scheduled tasks.""" self.scheduler.cancel_all() - async def reschedule_reminders(self) -> None: + async def cog_load(self) -> None: """Get all current reminders from the API and reschedule them.""" await self.bot.wait_until_guild_available() response = await self.bot.api_client.get( - 'bot/reminders', - params={'active': 'true'} + "bot/reminders", + params={"active": "true"} ) - now = datetime.utcnow() + now = datetime.now(UTC) for reminder in response: is_valid, *_ = self.ensure_valid_reminder(reminder) if not is_valid: continue - remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) + remind_at = isoparse(reminder["expiration"]) # If the reminder is already overdue ... if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) + await self.send_reminder(reminder, remind_at) else: self.schedule_reminder(reminder) - def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: - """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" - user = self.bot.get_user(reminder['author']) - channel = self.bot.get_channel(reminder['channel_id']) + def ensure_valid_reminder(self, reminder: dict) -> tuple[bool, discord.TextChannel]: + """Ensure reminder channel can be fetched otherwise delete the reminder.""" + channel = self.bot.get_channel(reminder["channel_id"]) is_valid = True - if not user or not channel: + if not channel: is_valid = False log.info( f"Reminder {reminder['id']} invalid: " - f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." + f"Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) + scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) - return is_valid, user, channel + return is_valid, channel @staticmethod async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: t.Union[str, int], - delivery_dt: t.Optional[datetime], + reminder_id: str | int ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = discord.Embed( @@ -98,17 +273,12 @@ async def _send_confirmation( footer_str = f"ID: {reminder_id}" - if delivery_dt: - # Reminder deletion will have a `None` `delivery_dt` - footer_str += ', Due' - embed.timestamp = delivery_dt - embed.set_footer(text=footer_str) await ctx.send(embed=embed) @staticmethod - async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -118,12 +288,11 @@ async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t. If mentions aren't allowed, also return the type of mention(s) disallowed. """ - if await has_no_roles_check(ctx, *STAFF_ROLES): + if await has_no_roles_check(ctx, *STAFF_AND_COMMUNITY_ROLES): return False, "members/roles" - elif await has_no_roles_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, discord.Member) for mention in mentions), "roles" - else: - return True, "" + if await has_no_roles_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, discord.User | discord.Member) for mention in mentions), "roles" + return True, "" @staticmethod async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: @@ -136,20 +305,20 @@ async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> if not mentions or mentions_allowed: return True - else: - await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") - return False + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False - def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: + async def get_mentionables(self, mention_ids: list[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + member = await get_or_fetch_member(guild, mention_id) + if mentionable := (member or guild.get_role(mention_id)): yield mentionable def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) + reminder_datetime = isoparse(reminder["expiration"]) self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: @@ -160,7 +329,7 @@ async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: """ # Send the request to update the reminder in the database reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(reminder_id), + "bot/reminders/" + str(reminder_id), json=payload ) return reminder @@ -174,70 +343,133 @@ async def _reschedule_reminder(self, reminder: dict) -> None: self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + async def add_mention_opt_in(self, reminder: dict, user_id: int) -> dict: + """Add an opt-in user to a reminder's mentions and return the edited reminder.""" + if user_id in reminder["mentions"] or user_id == reminder["author"]: + return reminder + + reminder["mentions"].append(user_id) + reminder = await self._edit_reminder(reminder["id"], {"mentions": reminder["mentions"]}) + + await self._reschedule_reminder(reminder) + return reminder + + @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + async def send_reminder(self, reminder: dict, expected_time: time.Timestamp | None = None) -> None: """Send the reminder.""" - is_valid, user, channel = self.ensure_valid_reminder(reminder) + is_valid, channel = self.ensure_valid_reminder(reminder) if not is_valid: # No need to cancel the task too; it'll simply be done once this coroutine returns. return - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - embed.description = f"Here's your reminder: `{reminder['content']}`." - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - - if late: + if expected_time: embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + name="Sorry, your reminder should have arrived earlier!" + ) + else: + embed.colour = discord.Colour.og_blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" ) - additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) - ) + # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. + embed.description = f"Here's your reminder: {reminder['content']}" - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id + additional_mentions = " ".join([ + mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"]) + ]) + + jump_url = reminder.get("jump_url") + embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" + partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) + try: + await partial_message.reply(content=f"{additional_mentions}", embed=embed) + except discord.HTTPException as e: + log.info( + f"There was an error when trying to reply to a reminder invocation message, {e}, " + "fall back to using jump_url" + ) + await channel.send(content=f"<@{reminder['author']}> {additional_mentions}", embed=embed) log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") + @staticmethod + async def try_get_content_from_reply(ctx: Context) -> str: + """ + Attempts to get content from the referenced message, if applicable, or provides a default. + + Differs from pydis_core.utils.commands.clean_text_or_reply as allows for messages with no content. + """ + content = None + if reference := ctx.message.reference: + if isinstance((resolved_message := reference.resolved), discord.Message): + content = resolved_message.content + + # If the replied message has no content, we couldn't get the content, or no content was provided + # (e.g. only attachments/embeds) + if content is None or content == "": + content = "*See referenced message.*" + + return content + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str | None = None ) -> None: - """Commands for managing your reminders.""" + """ + Commands for managing your reminders. + + The `expiration` duration of `!remind new` supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. + """ await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str | None = None ) -> None: """ Set yourself a simple reminder. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if await has_no_roles_check(ctx, *STAFF_ROLES): + # If the user is not staff or part of the python community, + # we need to verify whether or not to make a reminder at all. + if await has_no_roles_check(ctx, *STAFF_AND_COMMUNITY_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - await send_denial(ctx, "Sorry, you can't do that here!") + bot_commands = ctx.guild.get_channel(Channels.bot_commands) + await send_denial(ctx, f"Sorry, you can only do that in {bot_commands.mention}!") return # Get their current active reminders active_reminders = await self.bot.api_client.get( - 'bot/reminders', + "bot/reminders", params={ - 'author__id': str(ctx.author.id) + "author__id": str(ctx.author.id) } ) @@ -257,35 +489,37 @@ async def new_reminder( mention_ids = [mention.id for mention in mentions] + # If `content` isn't provided then we try to get message content of a replied message + if not content: + content = await self.try_get_content_from_reply(ctx) + # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( - 'bot/reminders', + "bot/reminders", json={ - 'author': ctx.author.id, - 'channel_id': ctx.message.channel.id, - 'jump_url': ctx.message.jump_url, - 'content': content, - 'expiration': expiration.isoformat(), - 'mentions': mention_ids, + "author": ctx.author.id, + "channel_id": ctx.message.channel.id, + "jump_url": ctx.message.jump_url, + "content": content, + "expiration": expiration.isoformat(), + "mentions": mention_ids, } ) - now = datetime.utcnow() - timedelta(seconds=1) - humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = f"Your reminder will arrive in {humanized_delta}" - - if mentions: - mention_string += f" and will mention {len(mentions)} other(s)" - mention_string += "!" + formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) + success_message = f"Your reminder will arrive on {formatted_time}!" # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, + on_success=success_message, + reminder_id=reminder["id"] ) + # Add a button for others to also get notified. + view = OptInReminderMentionView(self, reminder, expiration) + await ctx.send(embed=await view.get_embed(), view=view, delete_after=view.timeout) + self.schedule_reminder(reminder) @remind_group.command(name="list") @@ -293,16 +527,14 @@ async def list_reminders(self, ctx: Context) -> None: """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( - 'bot/reminders', - params={'author__id': str(ctx.author.id)} + "bot/reminders", + params={"author__id": str(ctx.author.id)} ) - now = datetime.utcnow() - # Make a list of tuples so it can be sorted by time. reminders = sorted( ( - (rem['content'], rem['expiration'], rem['id'], rem['mentions']) + (rem["content"], rem["expiration"], rem["id"], rem["mentions"]) for rem in data ), key=itemgetter(1) @@ -312,24 +544,23 @@ async def list_reminders(self, ctx: Context) -> None: for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) + expiry = time.format_relative(remind_at) - mentions = ", ".join( - # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables(mentions) - ) + mentions = ", ".join([ + # Both Role and User objects have the `mention` attribute + f"{mentionable.mention} ({mentionable})" async for mentionable in self.get_mentionables(mentions) + ]) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + **Reminder #{id_}:** *expires {expiry}* (ID: {id_}){mention_string} {content} """).strip() lines.append(text) embed = discord.Embed() - embed.colour = discord.Colour.blurple() + embed.colour = discord.Colour.og_blurple() embed.title = f"Reminders for {ctx.author}" # Remind the user that they have no reminders :^) @@ -339,13 +570,13 @@ async def list_reminders(self, ctx: Context) -> None: return # Construct the embed and paginate it. - embed.colour = discord.Colour.blurple() + embed.colour = discord.Colour.og_blurple() await LinePaginator.paginate( lines, - ctx, embed, + ctx, + embed, max_lines=3, - empty=True ) @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) @@ -358,17 +589,36 @@ async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Durat """ Edit one of your reminder's expiration. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. """ - await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) + formatted_time = time.discord_timestamp(expiration, time.TimestampFormats.DAY_TIME) + message = f"It will arrive on {formatted_time}." + + await self.edit_reminder(ctx, id_, {"expiration": expiration.isoformat()}, message) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: - """Edit one of your reminder's content.""" + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str | None = None) -> None: + """ + Edit one of your reminder's content. + + You can either supply the new content yourself, or reply to a message to use its content. + """ + if not content: + content = await self.try_get_content_from_reply(ctx) + await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None: """Edit one of your reminder's mentions.""" # Remove duplicate mentions mentions = set(mentions) @@ -382,60 +632,123 @@ async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + async def edit_reminder(self, ctx: Context, id_: int, payload: dict, message: str = "") -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" if not await self._can_modify(ctx, id_): return reminder = await self._edit_reminder(id_, payload) - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - # Send a confirmation message to the channel await self._send_confirmation( ctx, - on_success="That reminder has been edited successfully!", + on_success=" ".join(("That reminder has been edited successfully!", message)).rstrip(), reminder_id=id_, - delivery_dt=expiration, ) await self._reschedule_reminder(reminder) - @remind_group.command("delete", aliases=("remove", "cancel")) @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - if not await self._can_modify(ctx, id_): - return + async def _delete_reminder(self, ctx: Context, id_: int) -> bool: + """Acquires a lock on `id_` and returns `True` if reminder is deleted, otherwise `False`.""" + if not await self._can_modify(ctx, id_, send_on_denial=False): + return False await self.bot.api_client.delete(f"bot/reminders/{id_}") self.scheduler.cancel(id_) + return True - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, + @remind_group.command("delete", aliases=("remove", "cancel")) + async def delete_reminder(self, ctx: Context, ids: Greedy[int]) -> None: + """Delete up to (and including) 5 of your active reminders.""" + if len(ids) > 5: + await send_denial(ctx, "You can only delete a maximum of 5 reminders at once.") + return + + deleted_ids = [] + for id_ in set(ids): + try: + reminder_deleted = await self._delete_reminder(ctx, id_) + except LockedResourceError: + continue + else: + if reminder_deleted: + deleted_ids.append(str(id_)) + + if deleted_ids: + colour = discord.Colour.green() + title = random.choice(POSITIVE_REPLIES) + deletion_message = f"Successfully deleted the following reminder(s): {', '.join(deleted_ids)}" + + if len(deleted_ids) != len(ids): + deletion_message += ( + "\n\nThe other reminder(s) could not be deleted as they're either locked, " + "belong to someone else, or don't exist." + ) + else: + colour = discord.Colour.red() + title = random.choice(NEGATIVE_REPLIES) + deletion_message = ( + "Could not delete the reminder(s) as they're either locked, " + "belong to someone else, or don't exist." + ) + + embed = discord.Embed( + description=deletion_message, + colour=colour, + title=title ) + await ctx.send(embed=embed) - async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + async def _can_modify(self, ctx: Context, reminder_id: str | int, send_on_denial: bool = True) -> bool: """ Check whether the reminder can be modified by the ctx author. - The check passes when the user is an admin, or if they created the reminder. + The check passes if the user created the reminder, or if they are an admin (with confirmation). """ - if await has_any_role_check(ctx, Roles.admins): + try: + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + except ResponseCodeError as e: + # Override error-handling so that a 404 message isn't sent to Discord when `send_on_denial` is `False` + if not send_on_denial: + if e.status == 404: + return False + raise e + owner_id = api_response["author"] + + if owner_id == ctx.author.id: + log.debug(f"{ctx.author} is the reminder's author and passes the check.") return True - api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") - if not api_response["author"] == ctx.author.id: - log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") - await send_denial(ctx, "You can't modify reminders of other users!") - return False + if await has_any_role_check(ctx, Roles.admins): + log.debug(f"{ctx.author} is an admin, asking for confirmation to modify someone else's.") - log.debug(f"{ctx.author} is the reminder author and passes the check.") - return True + if ctx.command == self.delete_reminder: + modify_action = "delete" + else: + modify_action = "edit" + + confirmation_view = ModifyReminderConfirmationView(ctx.author) + confirmation_message = await ctx.reply( + f"Are you sure you want to {modify_action} <@{owner_id}>'s reminder?", + view=confirmation_view, + ) + view_timed_out = await confirmation_view.wait() + # We don't have access to the message in `on_timeout` so we have to delete the view here + if view_timed_out: + await confirmation_message.edit(view=None) + + if confirmation_view.result: + log.debug(f"{ctx.author} has confirmed reminder modification.") + else: + await ctx.send("🚫 Operation canceled.") + log.debug(f"{ctx.author} has cancelled reminder modification.") + return confirmation_view.result + + log.debug(f"{ctx.author} is not the reminder's author and thus does not pass the check.") + if send_on_denial: + await send_denial(ctx, "You can't modify reminders of other users!") + return False -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Reminders cog.""" - bot.add_cog(Reminders(bot)) + await bot.add_cog(Reminders(bot)) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py deleted file mode 100644 index b1f1ba6a81..0000000000 --- a/bot/exts/utils/snekbox.py +++ /dev/null @@ -1,352 +0,0 @@ -import asyncio -import contextlib -import datetime -import logging -import re -import textwrap -from functools import partial -from signal import Signals -from typing import Optional, Tuple - -from discord import HTTPException, Message, NotFound, Reaction, User -from discord.ext.commands import Cog, Context, command, guild_only - -from bot.bot import Bot -from bot.constants import Categories, Channels, Roles, URLs -from bot.decorators import redirect_output -from bot.utils import send_to_paste_service -from bot.utils.messages import wait_for_deletion - -log = logging.getLogger(__name__) - -ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") -FORMATTED_CODE_REGEX = re.compile( - r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block - r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)", # match the exact same delimiter from the start again - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -RAW_CODE_REGEX = re.compile( - r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all the rest as code - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL # "." also matches newlines -) - -MAX_PASTE_LEN = 10000 - -# `!eval` command whitelists and blacklists. -NO_EVAL_CHANNELS = (Channels.python_general,) -NO_EVAL_CATEGORIES = () -EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) - -SIGKILL = 9 - -REEVAL_EMOJI = '\U0001f501' # :repeat: -REEVAL_TIMEOUT = 30 - - -class Snekbox(Cog): - """Safe evaluation of Python code using Snekbox.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.jobs = {} - - async def post_eval(self, code: str) -> dict: - """Send a POST request to the Snekbox API to evaluate code and return the results.""" - url = URLs.snekbox_eval_api - data = {"input": code} - async with self.bot.http_session.post(url, json=data, raise_for_status=True) as resp: - return await resp.json() - - async def upload_output(self, output: str) -> Optional[str]: - """Upload the eval output to a paste service and return a URL to it if successful.""" - log.trace("Uploading full output to paste service...") - - if len(output) > MAX_PASTE_LEN: - log.info("Full output is too long to upload") - return "too long to upload" - return await send_to_paste_service(output, extension="txt") - - @staticmethod - def prepare_input(code: str) -> str: - """ - Extract code from the Markdown, format it, and insert it into the code template. - - If there is any code block, ignore text outside the code block. - Use the first code block, but prefer a fenced code block. - If there are several fenced code blocks, concatenate only the fenced code blocks. - """ - if match := list(FORMATTED_CODE_REGEX.finditer(code)): - blocks = [block for block in match if block.group("block")] - - if len(blocks) > 1: - code = '\n'.join(block.group("code") for block in blocks) - info = "several code blocks" - else: - match = match[0] if len(blocks) == 0 else blocks[0] - code, block, lang, delim = match.group("code", "block", "lang", "delim") - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - else: - code = RAW_CODE_REGEX.fullmatch(code).group("code") - info = "unformatted or badly formatted code" - - code = textwrap.dedent(code) - log.trace(f"Extracted {info} for evaluation:\n{code}") - return code - - @staticmethod - def get_results_message(results: dict) -> Tuple[str, str]: - """Return a user-friendly message and error corresponding to the process's return code.""" - stdout, returncode = results["stdout"], results["returncode"] - msg = f"Your eval job has completed with return code {returncode}" - error = "" - - if returncode is None: - msg = "Your eval job has failed" - error = stdout.strip() - elif returncode == 128 + SIGKILL: - msg = "Your eval job timed out or ran out of memory" - elif returncode == 255: - msg = "Your eval job has failed" - error = "A fatal NsJail error occurred" - else: - # Try to append signal's name if one exists - try: - name = Signals(returncode - 128).name - msg = f"{msg} ({name})" - except ValueError: - pass - - return msg, error - - @staticmethod - def get_status_emoji(results: dict) -> str: - """Return an emoji corresponding to the status code or lack of output in result.""" - if not results["stdout"].strip(): # No output - return ":warning:" - elif results["returncode"] == 0: # No error - return ":white_check_mark:" - else: # Exception - return ":x:" - - async def format_output(self, output: str) -> Tuple[str, Optional[str]]: - """ - Format the output and return a tuple of the formatted output and a URL to the full output. - - Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters - and upload the full output to a paste service. - """ - log.trace("Formatting output...") - - output = output.rstrip("\n") - original_output = output # To be uploaded to a pasting service if needed - paste_link = None - - if "<@" in output: - output = output.replace("<@", "<@\u200B") # Zero-width space - - if " 0: - output = [f"{i:03d} | {line}" for i, line in enumerate(output.split('\n'), 1)] - output = output[:11] # Limiting to only 11 lines - output = "\n".join(output) - - if lines > 10: - truncated = True - if len(output) >= 1000: - output = f"{output[:1000]}\n... (truncated - too long, too many lines)" - else: - output = f"{output}\n... (truncated - too many lines)" - elif len(output) >= 1000: - truncated = True - output = f"{output[:1000]}\n... (truncated - too long)" - - if truncated: - paste_link = await self.upload_output(original_output) - - output = output or "[No output]" - - return output, paste_link - - async def send_eval(self, ctx: Context, code: str) -> Message: - """ - Evaluate code, format it, and send the output to the corresponding channel. - - Return the bot response. - """ - async with ctx.typing(): - results = await self.post_eval(code) - msg, error = self.get_results_message(results) - - if error: - output, paste_link = error, None - else: - output, paste_link = await self.format_output(results["stdout"]) - - icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" - if paste_link: - msg = f"{msg}\nFull output: {paste_link}" - - # Collect stats of eval fails + successes - if icon == ":x:": - self.bot.stats.incr("snekbox.python.fail") - else: - self.bot.stats.incr("snekbox.python.success") - - filter_cog = self.bot.get_cog("Filtering") - filter_triggered = False - if filter_cog: - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) - if filter_triggered: - response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") - else: - response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,))) - - log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") - return response - - async def continue_eval(self, ctx: Context, response: Message) -> Optional[str]: - """ - Check if the eval session should continue. - - Return the new code to evaluate or None if the eval session should be terminated. - """ - _predicate_eval_message_edit = partial(predicate_eval_message_edit, ctx) - _predicate_emoji_reaction = partial(predicate_eval_emoji_reaction, ctx) - - with contextlib.suppress(NotFound): - try: - _, new_message = await self.bot.wait_for( - 'message_edit', - check=_predicate_eval_message_edit, - timeout=REEVAL_TIMEOUT - ) - await ctx.message.add_reaction(REEVAL_EMOJI) - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=10 - ) - - code = await self.get_code(new_message) - await ctx.message.clear_reaction(REEVAL_EMOJI) - with contextlib.suppress(HTTPException): - await response.delete() - - except asyncio.TimeoutError: - await ctx.message.clear_reaction(REEVAL_EMOJI) - return None - - return code - - async def get_code(self, message: Message) -> Optional[str]: - """ - Return the code from `message` to be evaluated. - - If the message is an invocation of the eval command, return the first argument or None if it - doesn't exist. Otherwise, return the full content of the message. - """ - log.trace(f"Getting context for message {message.id}.") - new_ctx = await self.bot.get_context(message) - - if new_ctx.command is self.eval_command: - log.trace(f"Message {message.id} invokes eval command.") - split = message.content.split(maxsplit=1) - code = split[1] if len(split) > 1 else None - else: - log.trace(f"Message {message.id} does not invoke eval command.") - code = message.content - - return code - - @command(name="eval", aliases=("e",)) - @guild_only() - @redirect_output( - destination_channel=Channels.bot_commands, - bypass_roles=EVAL_ROLES, - categories=NO_EVAL_CATEGORIES, - channels=NO_EVAL_CHANNELS, - ping_user=False - ) - async def eval_command(self, ctx: Context, *, code: str = None) -> None: - """ - Run Python code and get the results. - - This command supports multiple lines of code, including code wrapped inside a formatted code - block. Code can be re-evaluated by editing the original message within 10 seconds and - clicking the reaction that subsequently appears. - - We've done our best to make this sandboxed, but do let us know if you manage to find an - issue with it! - """ - if ctx.author.id in self.jobs: - await ctx.send( - f"{ctx.author.mention} You've already got a job running - " - "please wait for it to finish!" - ) - return - - if not code: # None or empty string - await ctx.send_help(ctx.command) - return - - if Roles.helpers in (role.id for role in ctx.author.roles): - self.bot.stats.incr("snekbox_usages.roles.helpers") - else: - self.bot.stats.incr("snekbox_usages.roles.developers") - - if ctx.channel.category_id == Categories.help_in_use: - self.bot.stats.incr("snekbox_usages.channels.help") - elif ctx.channel.id == Channels.bot_commands: - self.bot.stats.incr("snekbox_usages.channels.bot_commands") - else: - self.bot.stats.incr("snekbox_usages.channels.topical") - - log.info(f"Received code from {ctx.author} for evaluation:\n{code}") - - while True: - self.jobs[ctx.author.id] = datetime.datetime.now() - code = self.prepare_input(code) - try: - response = await self.send_eval(ctx, code) - finally: - del self.jobs[ctx.author.id] - - code = await self.continue_eval(ctx, response) - if not code: - break - log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") - - -def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: - """Return True if the edited message is the context message and the content was indeed modified.""" - return new_msg.id == ctx.message.id and old_msg.content != new_msg.content - - -def predicate_eval_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: - """Return True if the reaction REEVAL_EMOJI was added by the context message author on this message.""" - return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REEVAL_EMOJI - - -def setup(bot: Bot) -> None: - """Load the Snekbox cog.""" - bot.add_cog(Snekbox(bot)) diff --git a/bot/exts/utils/snekbox/__init__.py b/bot/exts/utils/snekbox/__init__.py new file mode 100644 index 0000000000..b6ad82c7e3 --- /dev/null +++ b/bot/exts/utils/snekbox/__init__.py @@ -0,0 +1,13 @@ +from bot.bot import Bot +from bot.exts.utils.snekbox._cog import CodeblockConverter, Snekbox +from bot.exts.utils.snekbox._constants import SupportedPythonVersions +from bot.exts.utils.snekbox._eval import EvalJob, EvalResult + +__all__ = ("CodeblockConverter", "EvalJob", "EvalResult", "Snekbox", "SupportedPythonVersions") + + +async def setup(bot: Bot) -> None: + """Load the Snekbox cog.""" + # Defer import to reduce side effects from importing the codeblock package. + from bot.exts.utils.snekbox._cog import Snekbox + await bot.add_cog(Snekbox(bot)) diff --git a/bot/exts/utils/snekbox/_cog.py b/bot/exts/utils/snekbox/_cog.py new file mode 100644 index 0000000000..4124ca097a --- /dev/null +++ b/bot/exts/utils/snekbox/_cog.py @@ -0,0 +1,659 @@ +import contextlib +from collections.abc import Iterable +from functools import partial +from operator import attrgetter +from textwrap import dedent +from typing import NamedTuple, TYPE_CHECKING, get_args + +from discord import AllowedMentions, HTTPException, Interaction, Message, NotFound, Reaction, User, enums, ui +from discord.ext.commands import Cog, Command, Context, Converter, command, guild_only +from pydis_core.utils import interactions, paste_service +from pydis_core.utils.paste_service import PasteFile, send_to_paste_service +from pydis_core.utils.regex import FORMATTED_CODE_REGEX, RAW_CODE_REGEX + +from bot.bot import Bot +from bot.constants import BaseURLs, Channels, Emojis, MODERATION_ROLES, Roles, URLs +from bot.decorators import redirect_output +from bot.exts.filtering._filter_lists.extension import TXT_LIKE_FILES +from bot.exts.help_channels._channel import is_help_forum_post +from bot.exts.utils.snekbox._constants import ( + ANSI_REGEX, + DEFAULT_PYTHON_VERSION, + ESCAPE_REGEX, + MAX_OUTPUT_BLOCK_CHARS, + MAX_OUTPUT_BLOCK_LINES, + NO_SNEKBOX_CATEGORIES, + NO_SNEKBOX_CHANNELS, + REDO_EMOJI, + REDO_TIMEOUT, + SNEKBOX_ROLES, + SupportedPythonVersions, +) +from bot.exts.utils.snekbox._eval import EvalJob, EvalResult +from bot.exts.utils.snekbox._io import FileAttachment +from bot.log import get_logger +from bot.utils.lock import LockedResourceError, lock_arg + +if TYPE_CHECKING: + from bot.exts.filtering.filtering import Filtering + +log = get_logger(__name__) + +# The timeit command should only output the very last line, so all other output should be suppressed. +# This will be used as the setup code along with any setup code provided. +TIMEIT_SETUP_WRAPPER = """ +import atexit +import sys +from collections import deque + +if not hasattr(sys, "_setup_finished"): + class Writer(deque): + '''A single-item deque wrapper for sys.stdout that will return the last line when read() is called.''' + + def __init__(self): + super().__init__(maxlen=1) + + def write(self, string): + '''Append the line to the queue if it is not empty.''' + if string.strip(): + self.append(string) + + def read(self): + '''This method will be called when print() is called. + + The queue is emptied as we don't need the output later. + ''' + return self.pop() + + def flush(self): + '''This method will be called eventually, but we don't need to do anything here.''' + pass + + sys.stdout = Writer() + + def print_last_line(): + if sys.stdout: # If the deque is empty (i.e. an error happened), calling read() will raise an error + # Use sys.__stdout__ here because sys.stdout is set to a Writer() instance + print(sys.stdout.read(), file=sys.__stdout__) + + atexit.register(print_last_line) # When exiting, print the last line (hopefully it will be the timeit output) + sys._setup_finished = None +{setup} +""" + +class FilteredFiles(NamedTuple): + allowed: list[FileAttachment] + blocked: list[FileAttachment] + + +class CodeblockConverter(Converter): + """Attempts to extract code from a codeblock, if provided.""" + + @classmethod + async def convert(cls, ctx: Context, code: str) -> list[str]: + """ + Extract code from the Markdown, format it, and insert it into the code template. + + If there is any code block, ignore text outside the code block. + Use the first code block, but prefer a fenced code block. + If there are several fenced code blocks, concatenate only the fenced code blocks. + + Return a list of code blocks if any, otherwise return a list with a single string of code. + """ + if match := list(FORMATTED_CODE_REGEX.finditer(code)): + blocks = [block for block in match if block.group("block")] + + if len(blocks) > 1: + codeblocks = [block.group("code") for block in blocks] + info = "several code blocks" + else: + match = match[0] if len(blocks) == 0 else blocks[0] + code, block, lang, delim = match.group("code", "block", "lang", "delim") + codeblocks = [dedent(code)] + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + else: + codeblocks = [dedent(RAW_CODE_REGEX.fullmatch(code).group("code"))] + info = "unformatted or badly formatted code" + + code = "\n".join(codeblocks) + log.trace(f"Extracted {info} for evaluation:\n{code}") + return codeblocks + + +class PythonVersionSwitcherButton(ui.Button): + """A button that allows users to re-run their eval command in a different Python version.""" + + def __init__( + self, + version_to_run: SupportedPythonVersions, + snekbox_cog: Snekbox, + ctx: Context, + job: EvalJob, + ) -> None: + self.version_to_run = version_to_run + super().__init__(label=f"Run in {self.version_to_run}", style=enums.ButtonStyle.primary) + + self.snekbox_cog = snekbox_cog + self.ctx = ctx + self.job = job + + async def callback(self, interaction: Interaction) -> None: + """ + Tell snekbox to re-run the user's code in the alternative Python version. + + Use a task calling snekbox, as run_job is blocking while it waits for edit/reaction on the message. + """ + # Defer response here so that the Discord UI doesn't mark this interaction as failed if the job + # takes too long to run. + await interaction.response.defer() + + with contextlib.suppress(NotFound): + # Suppress delete to cover the case where a user re-runs code and very quickly clicks the button. + # The log arg on send_job will stop the actual job from running. + await interaction.message.delete() + + await self.snekbox_cog.run_job(self.ctx, self.job.as_version(self.version_to_run)) + + +class Snekbox(Cog): + """Safe evaluation of Python code using Snekbox.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.jobs = {} + + def build_python_version_switcher_view( + self, + current_python_version: SupportedPythonVersions, + ctx: Context, + job: EvalJob, + ) -> interactions.ViewWithUserAndRoleCheck: + """Return a view that allows the user to change what version of Python their code is run on.""" + other_versions = list(get_args(SupportedPythonVersions)) + other_versions.remove(current_python_version) + + view = interactions.ViewWithUserAndRoleCheck( + allowed_users=(ctx.author.id,), + allowed_roles=MODERATION_ROLES, + ) + for version in other_versions: + view.add_item(PythonVersionSwitcherButton(version, self, ctx, job)) + view.add_item(interactions.DeleteMessageButton()) + + return view + + async def post_job(self, job: EvalJob) -> EvalResult: + """Send a POST request to the Snekbox API to evaluate code and return the results.""" + data = job.to_dict() + + async with self.bot.http_session.post(URLs.snekbox_eval_api, json=data, raise_for_status=True) as resp: + return EvalResult.from_dict(await resp.json()) + + async def upload_output(self, output: str) -> str | None: + """Upload the job's output to a paste service and return a URL to it if successful.""" + log.trace("Uploading full output to paste service...") + + file = PasteFile(content=output, lexer="text") + try: + paste_response = await send_to_paste_service( + files=[file], + http_session=self.bot.http_session, + paste_url=BaseURLs.paste_url, + ) + return paste_response.link + except paste_service.PasteTooLongError: + return "too long to upload" + except paste_service.PasteUploadError: + return "unable to upload" + + @staticmethod + def prepare_timeit_input(codeblocks: list[str]) -> list[str]: + """ + Join the codeblocks into a single string, then return the arguments in a list. + + If there are multiple codeblocks, insert the first one into the wrapped setup code. + """ + args = ["-m", "timeit"] + setup_code = codeblocks.pop(0) if len(codeblocks) > 1 else "" + code = "\n".join(codeblocks) + + args.extend(["-s", TIMEIT_SETUP_WRAPPER.format(setup=setup_code), code]) + return args + + async def format_output( + self, + output: str, + max_lines: int = MAX_OUTPUT_BLOCK_LINES, + max_chars: int = MAX_OUTPUT_BLOCK_CHARS, + line_nums: bool = True, + output_default: str = "[No output]", + ) -> tuple[str, str | None]: + """ + Format the output and return a tuple of the formatted output and a URL to the full output. + + Prepend each line with a line number. Truncate if there are over 10 lines or 1000 characters + and upload the full output to a paste service. + """ + output = output.rstrip("\n") + original_output = output # To be uploaded to a pasting service if needed + paste_link = None + + if "<@" in output: + output = output.replace("<@", "<@\u200B") # Zero-width space + + if " 1 and line_nums: + lines = [f"{i:03d} | {line}" for i, line in enumerate(lines, 1)] + output = "\n".join(lines) + + if len(lines) > max_lines: + truncated = True + if len(lines) == max_lines + 1: + lines = lines[:max_lines - 1] + else: + lines = lines[:max_lines] + output = "\n".join(lines) + if len(output) >= max_chars: + output = f"{output[:max_chars]}\n... (truncated - too long, too many lines)" + else: + output = f"{output}\n... (truncated - too many lines)" + elif len(output) >= max_chars: + truncated = True + output = f"{output[:max_chars]}\n... (truncated - too long)" + + if truncated: + # ANSI colors are quite nice, but don't render in pinwand, + # thus mangling the output + ansiless_output = ANSI_REGEX.sub("", original_output) + paste_link = await self.upload_output(ansiless_output) + + if output_default and not output: + output = output_default + + return output, paste_link + + async def format_file_text(self, text_files: list[FileAttachment], output: str) -> str: + # Inline until budget, then upload to paste service + # Budget is shared with stdout, so subtract what we've already used + budget_lines = MAX_OUTPUT_BLOCK_LINES - (output.count("\n") + 1) + budget_chars = MAX_OUTPUT_BLOCK_CHARS - len(output) + msg = "" + + for file in text_files: + file_text = file.content.decode("utf-8", errors="replace") or "[Empty]" + # Override to always allow 1 line and <= 50 chars, since this is less than a link + if len(file_text) <= 50 and not file_text.count("\n"): + msg += f"\n`{file.name}`\n```\n{file_text}\n```" + # otherwise, use budget + else: + format_text, link_text = await self.format_output( + file_text, + budget_lines, + budget_chars, + line_nums=False, + output_default="[Empty]" + ) + # With any link, use it (don't use budget) + if link_text: + msg += f"\n`{file.name}`\n{link_text}" + else: + msg += f"\n`{file.name}`\n```\n{format_text}\n```" + budget_lines -= format_text.count("\n") + 1 + budget_chars -= len(file_text) + + return msg + + def format_blocked_extensions(self, blocked: list[FileAttachment]) -> str: + # Sort by length and then lexicographically to fit as many as possible before truncating. + blocked_sorted = sorted(set(f.suffix for f in blocked), key=lambda e: (len(e), e)) + + # Only no extension + if len(blocked_sorted) == 1 and blocked_sorted[0] == "": + blocked_msg = "Files with no extension can't be uploaded." + # Both + elif "" in blocked_sorted: + blocked_str = self.join_blocked_extensions(ext for ext in blocked_sorted if ext) + blocked_msg = ( + f"Files with no extension or disallowed extensions can't be uploaded: **{blocked_str}**" + ) + else: + blocked_str = self.join_blocked_extensions(blocked_sorted) + blocked_msg = f"Files with disallowed extensions can't be uploaded: **{blocked_str}**" + + return f"\n{Emojis.failed_file} {blocked_msg}" + + def join_blocked_extensions(self, extensions: Iterable[str], delimiter: str = ", ", char_limit: int = 100) -> str: + joined = "" + for ext in extensions: + cur_delimiter = delimiter if joined else "" + if len(joined) + len(cur_delimiter) + len(ext) >= char_limit: + joined += f"{cur_delimiter}..." + break + + joined += f"{cur_delimiter}{ext}" + + return joined + + + def _filter_files(self, ctx: Context, files: list[FileAttachment], blocked_exts: set[str]) -> FilteredFiles: + """Filter to restrict files to allowed extensions. Return a named tuple of allowed and blocked files lists.""" + # Filter files into allowed and blocked + blocked = [] + allowed = [] + for file in files: + if file.suffix in blocked_exts: + blocked.append(file) + else: + allowed.append(file) + + if blocked: + blocked_str = ", ".join(f.suffix for f in blocked) + log.info( + f"User '{ctx.author}' ({ctx.author.id}) uploaded blacklisted file(s) in eval: {blocked_str}", + extra={"attachment_list": [f.filename for f in files]} + ) + + return FilteredFiles(allowed, blocked) + + @lock_arg("snekbox.send_job", "ctx", attrgetter("author.id"), raise_error=True) + async def send_job(self, ctx: Context, job: EvalJob) -> Message: + """ + Evaluate code, format it, and send the output to the corresponding channel. + + Return the bot response. + """ + async with ctx.typing(): + result = await self.post_job(job) + # Collect stats of job fails + successes + if result.returncode != 0: + self.bot.stats.incr("snekbox.python.fail") + else: + self.bot.stats.incr("snekbox.python.success") + + log.trace("Formatting output...") + output = result.error_message if result.error_message else result.stdout + output, paste_link = await self.format_output(output) + + status_msg = result.get_status_message(job) + msg = f"{result.status_emoji} {status_msg}.\n" + + # This is done to make sure the last line of output contains the error + # and the error is not manually printed by the author with a syntax error. + if result.stdout.rstrip().endswith("EOFError: EOF when reading a line") and result.returncode == 1: + msg += "\n:warning: Note: `input` is not supported by the bot :warning:\n" + + # Skip output if it's empty and there are file uploads + if result.stdout or not result.has_files: + msg += f"\n```ansi\n{output}\n```" + + if paste_link: + msg += f"\nFull output: {paste_link}" + + # Additional files error message after output + if files_error := result.files_error_message: + msg += f"\n{files_error}" + + # Split text files + text_files = [f for f in result.files if f.suffix in TXT_LIKE_FILES] + msg += await self.format_file_text(text_files, output) + + filter_cog: Filtering | None = self.bot.get_cog("Filtering") + blocked_exts = set() + # Include failed files in the scan. + failed_files = [FileAttachment(name, b"") for name in result.failed_files] + total_files = result.files + failed_files + if filter_cog: + block_output, blocked_exts = await filter_cog.filter_snekbox_output(msg, total_files, ctx.message) + if block_output: + return await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + + # Filter file extensions + allowed, blocked = self._filter_files(ctx, result.files, blocked_exts) + blocked.extend(self._filter_files(ctx, failed_files, blocked_exts).blocked) + if blocked: + msg += self.format_blocked_extensions(blocked) + + # Upload remaining non-text files + files = [f.to_file() for f in allowed if f not in text_files] + allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + view = self.build_python_version_switcher_view(job.version, ctx, job) + + if ctx.message.channel == ctx.channel: + # Don't fail if the command invoking message was deleted. + message = ctx.message.to_reference(fail_if_not_exists=False) + response = await ctx.send( + msg, + allowed_mentions=allowed_mentions, + view=view, + files=files, + reference=message + ) + else: + # The command was redirected so a reply wont work, send a normal message with a mention. + msg = f"{ctx.author.mention} {msg}" + response = await ctx.send(msg, allowed_mentions=allowed_mentions, view=view, files=files) + view.message = response + + log.info(f"{ctx.author}'s {job.name} job had a return code of {result.returncode}") + return response + + async def continue_job( + self, ctx: Context, response: Message, job_name: str + ) -> EvalJob | None: + """ + Check if the job's session should continue. + + If the code is to be re-evaluated, return the new EvalJob. + Otherwise, return None if the job's session should be terminated. + """ + _predicate_message_edit = partial(predicate_message_edit, ctx) + _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx) + + with contextlib.suppress(NotFound): + try: + _, new_message = await self.bot.wait_for( + "message_edit", + check=_predicate_message_edit, + timeout=REDO_TIMEOUT + ) + await ctx.message.add_reaction(REDO_EMOJI) + await self.bot.wait_for( + "reaction_add", + check=_predicate_emoji_reaction, + timeout=10 + ) + + # Ensure the response that's about to be edited is still the most recent. + # This could have already been updated via a button press to switch to an alt Python version. + if self.jobs[ctx.message.id] != response.id: + return None + + code = await self.get_code(new_message, ctx.command) + with contextlib.suppress(HTTPException): + await ctx.message.clear_reaction(REDO_EMOJI) + await response.delete() + + if code is None: + return None + + except TimeoutError: + with contextlib.suppress(HTTPException): + await ctx.message.clear_reaction(REDO_EMOJI) + return None + + codeblocks = await CodeblockConverter.convert(ctx, code) + + if job_name == "timeit": + return EvalJob(self.prepare_timeit_input(codeblocks)) + return EvalJob.from_code("\n".join(codeblocks)) + + return None + + async def get_code(self, message: Message, command: Command) -> str | None: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is command: + log.trace(f"Message {message.id} invokes {command} command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke {command} command.") + code = message.content + + return code + + async def run_job( + self, + ctx: Context, + job: EvalJob, + ) -> None: + """Handles checks, stats and re-evaluation of a snekbox job.""" + if Roles.helpers in (role.id for role in ctx.author.roles): + self.bot.stats.incr("snekbox_usages.roles.helpers") + else: + self.bot.stats.incr("snekbox_usages.roles.developers") + + if is_help_forum_post(ctx.channel): + self.bot.stats.incr("snekbox_usages.channels.help") + elif ctx.channel.id == Channels.bot_commands: + self.bot.stats.incr("snekbox_usages.channels.bot_commands") + else: + self.bot.stats.incr("snekbox_usages.channels.topical") + + log.info(f"Received code from {ctx.author} for evaluation:\n{job}") + + while True: + try: + response = await self.send_job(ctx, job) + except LockedResourceError: + await ctx.send( + f"{ctx.author.mention} You've already got a job running - " + "please wait for it to finish!" + ) + return + + # Store the bot's response message id per invocation, to ensure the `wait_for`s in `continue_job` + # don't trigger if the response has already been replaced by a new response. + # This can happen when a button is pressed and then original code is edited and re-run. + self.jobs[ctx.message.id] = response.id + + job = await self.continue_job(ctx, response, job.name) + if not job: + break + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{job}") + + @command( + name="eval", + aliases=("e", "exec"), + usage="[python_version] ", + help=f""" + Run Python code and get the results. + + This command supports multiple lines of code, including formatted code blocks. + Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + The starting working directory `/home`, is a writeable temporary file system. + Files created, excluding names with leading underscores, will be uploaded in the response. + + If multiple codeblocks are in a message, all of them will be joined and evaluated, + ignoring the text outside them. + + The currently supported versions are {", ".join(get_args(SupportedPythonVersions))}. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + ) + @guild_only() + @redirect_output( + destination_channel=Channels.bot_commands, + bypass_roles=SNEKBOX_ROLES, + categories=NO_SNEKBOX_CATEGORIES, + channels=NO_SNEKBOX_CHANNELS, + ping_user=False + ) + async def eval_command( + self, + ctx: Context, + python_version: SupportedPythonVersions | None, + *, + code: CodeblockConverter + ) -> None: + """Run Python code and get the results.""" + code: list[str] + python_version = python_version or DEFAULT_PYTHON_VERSION + job = EvalJob.from_code("\n".join(code)).as_version(python_version) + await self.run_job(ctx, job) + + @command( + name="timeit", + aliases=("ti",), + usage="[python_version] [setup_code] ", + help=f""" + Profile Python code to find execution time. + + This command supports multiple lines of code, including formatted code blocks. + Code can be re-evaluated by editing the original message within 10 seconds and + clicking the reaction that subsequently appears. + + If multiple formatted codeblocks are provided, the first one will be the setup code, + which will not be timed. The remaining codeblocks will be joined together and timed. + + The currently supported versions are {", ".join(get_args(SupportedPythonVersions))}. + + We've done our best to make this sandboxed, but do let us know if you manage to find an + issue with it! + """ + ) + @guild_only() + @redirect_output( + destination_channel=Channels.bot_commands, + bypass_roles=SNEKBOX_ROLES, + categories=NO_SNEKBOX_CATEGORIES, + channels=NO_SNEKBOX_CHANNELS, + ping_user=False + ) + async def timeit_command( + self, + ctx: Context, + python_version: SupportedPythonVersions | None, + *, + code: CodeblockConverter + ) -> None: + """Profile Python Code to find execution time.""" + code: list[str] + python_version = python_version or DEFAULT_PYTHON_VERSION + args = self.prepare_timeit_input(code) + job = EvalJob(args, version=python_version, name="timeit") + + await self.run_job(ctx, job) + + +def predicate_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: + """Return True if the edited message is the context message and the content was indeed modified.""" + return new_msg.id == ctx.message.id and old_msg.content != new_msg.content + + +def predicate_emoji_reaction(ctx: Context, reaction: Reaction, user: User) -> bool: + """Return True if the reaction REDO_EMOJI was added by the context message author on this message.""" + return reaction.message.id == ctx.message.id and user.id == ctx.author.id and str(reaction) == REDO_EMOJI diff --git a/bot/exts/utils/snekbox/_constants.py b/bot/exts/utils/snekbox/_constants.py new file mode 100644 index 0000000000..92b0103b71 --- /dev/null +++ b/bot/exts/utils/snekbox/_constants.py @@ -0,0 +1,24 @@ +import re +from typing import Literal + +from bot.constants import Channels, Roles + +ANSI_REGEX = re.compile(r"\N{ESC}\[[0-9;:]*m") +ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") + +# Max to display in a codeblock before sending to a paste service +# This also applies to text files +MAX_OUTPUT_BLOCK_LINES = 10 +MAX_OUTPUT_BLOCK_CHARS = 1000 + +# The Snekbox commands' whitelists and blacklists. +NO_SNEKBOX_CHANNELS = (Channels.python_general,) +NO_SNEKBOX_CATEGORIES = () +SNEKBOX_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community) + +REDO_EMOJI = "\U0001f501" # :repeat: +REDO_TIMEOUT = 30 + +SupportedPythonVersions = Literal["3.13", "3.14", "3.14t", "3.14j"] + +DEFAULT_PYTHON_VERSION: SupportedPythonVersions = "3.14" diff --git a/bot/exts/utils/snekbox/_eval.py b/bot/exts/utils/snekbox/_eval.py new file mode 100644 index 0000000000..1a077f2bbc --- /dev/null +++ b/bot/exts/utils/snekbox/_eval.py @@ -0,0 +1,185 @@ +import contextlib +from dataclasses import dataclass, field +from signal import Signals + +from discord.utils import escape_markdown, escape_mentions + +from bot.constants import Emojis +from bot.exts.utils.snekbox._constants import DEFAULT_PYTHON_VERSION, SupportedPythonVersions +from bot.exts.utils.snekbox._io import FILE_COUNT_LIMIT, FILE_SIZE_LIMIT, FileAttachment, sizeof_fmt +from bot.log import get_logger + +log = get_logger(__name__) + +SIGKILL = 9 + + +@dataclass(frozen=True) +class EvalJob: + """Job to be evaluated by snekbox.""" + + args: list[str] + files: list[FileAttachment] = field(default_factory=list) + name: str = "eval" + version: SupportedPythonVersions = DEFAULT_PYTHON_VERSION + + @classmethod + def from_code(cls, code: str, path: str = "main.py") -> EvalJob: + """Create an EvalJob from a code string.""" + return cls( + args=[path], + files=[FileAttachment(path, code.encode())], + ) + + def as_version(self, version: SupportedPythonVersions) -> EvalJob: + """Return a copy of the job with a different Python version.""" + return EvalJob( + args=self.args, + files=self.files, + name=self.name, + version=version, + ) + + def to_dict(self) -> dict[str, list[str | dict[str, str]]]: + """Convert the job to a dict.""" + return { + "args": self.args, + "files": [file.to_dict() for file in self.files], + "executable_path": f"/snekbin/python/{self.version}/bin/python", + } + + +@dataclass(frozen=True) +class EvalResult: + """The result of an eval job.""" + + stdout: str + returncode: int | None + files: list[FileAttachment] = field(default_factory=list) + failed_files: list[str] = field(default_factory=list) + + @property + def has_output(self) -> bool: + """True if the result has any output (stdout, files, or failed files).""" + return bool(self.stdout.strip() or self.files or self.failed_files) + + @property + def has_files(self) -> bool: + """True if the result has any files or failed files.""" + return bool(self.files or self.failed_files) + + @property + def status_emoji(self) -> str: + """Return an emoji corresponding to the status code or lack of output in result.""" + if not self.has_output: + return ":warning:" + if self.returncode == 0: # No error + return ":white_check_mark:" + # Exception + return ":x:" + + @property + def error_message(self) -> str: + """Return an error message corresponding to the process's return code.""" + error = "" + if self.returncode is None: + error = self.stdout.strip() + elif self.returncode == 255: + error = "A fatal NsJail error occurred" + return error + + @property + def files_error_message(self) -> str: + """Return an error message corresponding to the failed files.""" + if not self.failed_files: + return "" + + failed_files = f"({self.get_failed_files_str()})" + + n_failed = len(self.failed_files) + s_upload = "uploads" if n_failed > 1 else "upload" + + msg = f"{Emojis.failed_file} {n_failed} file {s_upload} {failed_files} failed" + + # Exceeded file count limit + if (n_failed + len(self.files)) > FILE_COUNT_LIMIT: + s_it = "they" if n_failed > 1 else "it" + msg += f" as {s_it} exceeded the {FILE_COUNT_LIMIT} file limit." + # Exceeded file size limit + else: + s_each_file = "each file's" if n_failed > 1 else "its file" + msg += f" because {s_each_file} size exceeds {sizeof_fmt(FILE_SIZE_LIMIT)}." + + return msg + + def get_failed_files_str(self, char_max: int = 85) -> str: + """ + Return a string containing the names of failed files, truncated char_max. + + Will truncate on whole file names if less than 3 characters remaining. + """ + names = [] + for file in self.failed_files: + # Only attempt to truncate name if more than 3 chars remaining + if char_max < 3: + names.append("...") + break + + if len(file) > char_max: + names.append(file[:char_max] + "...") + break + char_max -= len(file) + names.append(file) + + text = ", ".join(names) + # Since the file names are provided by user + text = escape_markdown(text) + text = escape_mentions(text) + return text + + def get_status_message(self, job: EvalJob) -> str: + """Return a user-friendly message corresponding to the process's return code.""" + if job.version[-1] == "t": + version_text = job.version.replace("t", " [free threaded]()") + elif job.version[-1] == "j": + version_text = job.version.replace("j", " [JIT-compilation enabled]()") + else: + version_text = job.version + msg = f"Your {version_text} {job.name} job" + + if self.returncode is None: + msg += " has failed" + elif self.returncode == 128 + SIGKILL: + msg += " timed out or ran out of memory" + elif self.returncode == 255: + msg += " has failed" + else: + msg += f" has completed with return code {self.returncode}" + # Try to append signal's name if one exists + with contextlib.suppress(ValueError): + name = Signals(self.returncode - 128).name + msg += f" ({name})" + + return msg + + @classmethod + def from_dict(cls, data: dict[str, str | int | list[dict[str, str]]]) -> EvalResult: + """Create an EvalResult from a dict.""" + res = cls( + stdout=data["stdout"], + returncode=data["returncode"], + ) + + files = iter(data["files"]) + for i, file in enumerate(files): + # Limit to FILE_COUNT_LIMIT files + if i >= FILE_COUNT_LIMIT: + res.failed_files.extend(file["path"] for file in files) + break + try: + res.files.append(FileAttachment.from_dict(file)) + except ValueError as e: + log.info(f"Failed to parse file from snekbox response: {e}") + res.failed_files.append(file["path"]) + + return res diff --git a/bot/exts/utils/snekbox/_io.py b/bot/exts/utils/snekbox/_io.py new file mode 100644 index 0000000000..420f118501 --- /dev/null +++ b/bot/exts/utils/snekbox/_io.py @@ -0,0 +1,101 @@ +"""I/O File protocols for snekbox.""" + +from base64 import b64decode, b64encode +from dataclasses import dataclass +from io import BytesIO +from pathlib import PurePosixPath + +import regex +from discord import File + +# Note discord bot upload limit is 8 MiB per file, +# or 50 MiB for lvl 2 boosted servers +FILE_SIZE_LIMIT = 8 * 1024 * 1024 + +# Discord currently has a 10-file limit per message +FILE_COUNT_LIMIT = 10 + + +# ANSI escape sequences +RE_ANSI = regex.compile(r"\\u.*\[(.*?)m") +# Characters with a leading backslash +RE_BACKSLASH = regex.compile(r"\\.") +# Discord disallowed file name characters +RE_DISCORD_FILE_NAME_DISALLOWED = regex.compile(r"[^a-zA-Z0-9._-]+") + + +def sizeof_fmt(num: int | float, suffix: str = "B") -> str: + """Return a human-readable file size.""" + num = float(num) + for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): + if abs(num) < 1024: + num_str = f"{int(num)}" if num.is_integer() else f"{num:3.1f}" + return f"{num_str} {unit}{suffix}" + num /= 1024 + num_str = f"{int(num)}" if num.is_integer() else f"{num:3.1f}" + return f"{num_str} Yi{suffix}" + + +def normalize_discord_file_name(name: str) -> str: + """Return a normalized valid discord file name.""" + # Discord file names only allow A-Z, a-z, 0-9, underscores, dashes, and dots + # https://discord.com/developers/docs/reference#uploading-files + # Server will remove any other characters, but we'll get a 400 error for \ escaped chars + name = RE_ANSI.sub("_", name) + name = RE_BACKSLASH.sub("_", name) + # Replace any disallowed character with an underscore + name = RE_DISCORD_FILE_NAME_DISALLOWED.sub("_", name) + return name + + +@dataclass(frozen=True) +class FileAttachment: + """File Attachment from Snekbox eval.""" + + filename: str + content: bytes + + def __repr__(self) -> str: + """Return the content as a string.""" + content = f"{self.content[:10]}..." if len(self.content) > 10 else self.content + return f"FileAttachment(path={self.filename!r}, content={content})" + + @property + def suffix(self) -> str: + """Return the file suffix.""" + return PurePosixPath(self.filename).suffix + + @property + def name(self) -> str: + """Return the file name.""" + return PurePosixPath(self.filename).name + + @classmethod + def from_dict(cls, data: dict, size_limit: int = FILE_SIZE_LIMIT) -> FileAttachment: + """Create a FileAttachment from a dict response.""" + size = data.get("size") + if (size and size > size_limit) or (len(data["content"]) > size_limit): + raise ValueError("File size exceeds limit") + + content = b64decode(data["content"]) + + if len(content) > size_limit: + raise ValueError("File size exceeds limit") + + return cls(data["path"], content) + + def to_dict(self) -> dict[str, str]: + """Convert the attachment to a json dict.""" + content = self.content + if isinstance(content, str): + content = content.encode("utf-8") + + return { + "path": self.filename, + "content": b64encode(content).decode("ascii"), + } + + def to_file(self) -> File: + """Convert to a discord.File.""" + name = normalize_discord_file_name(self.name) + return File(BytesIO(self.content), filename=name) diff --git a/bot/exts/utils/thread_bumper.py b/bot/exts/utils/thread_bumper.py new file mode 100644 index 0000000000..59c9aca284 --- /dev/null +++ b/bot/exts/utils/thread_bumper.py @@ -0,0 +1,162 @@ + +import discord +import sentry_sdk +from discord.ext import commands +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils.channel import get_or_fetch_channel + +from bot import constants +from bot.bot import Bot +from bot.log import get_logger +from bot.pagination import LinePaginator + +log = get_logger(__name__) +THREAD_BUMP_ENDPOINT = "bot/bumped-threads" + + +class ThreadBumper(commands.Cog): + """Cog that allow users to add the current thread to a list that get reopened on archive.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def thread_exists_in_site(self, thread_id: int) -> bool: + """Return whether the given thread_id exists in the site api's bump list.""" + # If the thread exists, site returns a 204 with no content. + # Due to this, `api_client.request()` cannot be used, as it always attempts to decode the response as json. + # Instead, call the site manually using the api_client's session, to use the auth token logic in the wrapper. + + async with self.bot.api_client.session.get( + f"{self.bot.api_client._url_for(THREAD_BUMP_ENDPOINT)}/{thread_id}" + ) as response: + if response.status == 204: + return True + if response.status == 404: + return False + # A status other than 204/404 is undefined behaviour from site. Raise error for investigation. + raise ResponseCodeError(response, response.text()) + + async def unarchive_threads_not_manually_archived(self, threads: list[discord.Thread]) -> None: + """ + Iterate through and unarchive any threads that weren't manually archived recently. + + This is done by extracting the manually archived threads from the audit log. + + Only the last 200 thread_update logs are checked, + as this is assumed to be more than enough to cover bot downtime. + """ + guild = self.bot.get_guild(constants.Guild.id) + + recent_manually_archived_thread_ids = [] + async for thread_update in guild.audit_logs(limit=200, action=discord.AuditLogAction.thread_update): + if getattr(thread_update.after, "archived", False): + recent_manually_archived_thread_ids.append(thread_update.target.id) + + for thread in threads: + if thread.id in recent_manually_archived_thread_ids: + log.info( + "#%s (%d) was manually archived. Leaving archived, and removing from bumped threads.", + thread.name, + thread.id + ) + await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread.id}") + else: + await thread.edit(archived=False) + + async def cog_load(self) -> None: + """Ensure bumped threads are active, since threads could have been archived while the bot was down.""" + await self.bot.wait_until_guild_available() + + threads_to_maybe_bump = [] + with sentry_sdk.start_span(description="Fetch threads to bump from site"): + bumped_threads_from_site = await self.bot.api_client.get(THREAD_BUMP_ENDPOINT) + + with sentry_sdk.start_span(description="Sync bumped threads in site with current guild state"): + for thread_id in bumped_threads_from_site: + try: + thread = await get_or_fetch_channel(self.bot, thread_id) + except discord.NotFound: + log.info("Thread %d has been deleted, removing from bumped threads.", thread_id) + await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread_id}") + continue + + if not isinstance(thread, discord.Thread): + await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread_id}") + continue + + if thread.archived: + threads_to_maybe_bump.append(thread) + + with sentry_sdk.start_span(description="Unarchive threads that should be bumped"): + if threads_to_maybe_bump: + await self.unarchive_threads_not_manually_archived(threads_to_maybe_bump) + + @commands.group(name="bump") + async def thread_bump_group(self, ctx: commands.Context) -> None: + """A group of commands to manage the bumping of threads.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @thread_bump_group.command(name="add", aliases=("a",)) + async def add_thread_to_bump_list(self, ctx: commands.Context, thread: discord.Thread | None) -> None: + """Add a thread to the bump list.""" + if not thread: + if isinstance(ctx.channel, discord.Thread): + thread = ctx.channel + else: + raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + + if await self.thread_exists_in_site(thread.id): + raise commands.BadArgument("This thread is already in the bump list.") + + await self.bot.api_client.post(THREAD_BUMP_ENDPOINT, data={"thread_id": thread.id}) + await ctx.send(f":ok_hand:{thread.mention} has been added to the bump list.") + + @thread_bump_group.command(name="remove", aliases=("r", "rem", "d", "del", "delete")) + async def remove_thread_from_bump_list(self, ctx: commands.Context, thread: discord.Thread | None) -> None: + """Remove a thread from the bump list.""" + if not thread: + if isinstance(ctx.channel, discord.Thread): + thread = ctx.channel + else: + raise commands.BadArgument("You must provide a thread, or run this command within a thread.") + + if not await self.thread_exists_in_site(thread.id): + raise commands.BadArgument("This thread is not in the bump list.") + + await self.bot.api_client.delete(f"{THREAD_BUMP_ENDPOINT}/{thread.id}") + await ctx.send(f":ok_hand: {thread.mention} has been removed from the bump list.") + + @thread_bump_group.command(name="list", aliases=("get",)) + async def list_all_threads_in_bump_list(self, ctx: commands.Context) -> None: + """List all the threads in the bump list.""" + lines = [f"<#{thread_id}>" for thread_id in await self.bot.api_client.get(THREAD_BUMP_ENDPOINT)] + embed = discord.Embed( + title="Threads in the bump list", + colour=constants.Colours.blue + ) + await LinePaginator.paginate(lines, ctx, embed, max_lines=10) + + @commands.Cog.listener() + async def on_thread_update(self, _: discord.Thread, after: discord.Thread) -> None: + """ + Listen for thread updates and check if the thread has been archived. + + If the thread has been archived, and is in the bump list, un-archive it. + """ + if not after.archived: + return + + if await self.thread_exists_in_site(after.id): + await self.unarchive_threads_not_manually_archived([after]) + + async def cog_check(self, ctx: commands.Context) -> bool: + """Only allow staff & community roles to invoke the commands in this cog.""" + return await commands.has_any_role( + *constants.STAFF_AND_COMMUNITY_ROLES + ).predicate(ctx) + + +async def setup(bot: Bot) -> None: + """Load the ThreadBumper cog.""" + await bot.add_cog(ThreadBumper(bot)) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 4c39a7c2a8..2f4fc0166e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -1,25 +1,22 @@ import difflib -import logging import re import unicodedata -from typing import Tuple, Union from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_AND_COMMUNITY_ROLES from bot.converters import Snowflake -from bot.decorators import in_whitelist +from bot.decorators import in_whitelist, not_in_blacklist +from bot.log import get_logger from bot.pagination import LinePaginator -from bot.utils import messages -from bot.utils.checks import has_no_roles_check -from bot.utils.time import time_since +from bot.utils import messages, time -log = logging.getLogger(__name__) +log = get_logger(__name__) -ZEN_OF_PYTHON = """\ +ZEN_OF_PYTHON = """ Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. @@ -39,7 +36,8 @@ If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! -""" +""".strip() +LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.python_community) class Utils(Cog): @@ -49,52 +47,58 @@ def __init__(self, bot: Bot): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @not_in_blacklist(channels=(Channels.python_general,), override_roles=STAFF_AND_COMMUNITY_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: - return await messages.send_denial( + await messages.send_denial( ctx, "**Non-Character Detected**\n" "Only unicode characters can be processed, but a custom Discord emoji " "was found. Please remove it and try again." ) + return if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + return - def get_info(char: str) -> Tuple[str, str]: + def get_info(char: str) -> tuple[str, str]: digit = f"{ord(char):x}" if len(digit) <= 4: u_code = f"\\u{digit:>04}" else: u_code = f"\\U{digit:>08}" url = f"https://www.compart.com/en/unicode/U+{digit:>04}" - name = f"[{unicodedata.name(char, '')}]({url})" + name = f"[{unicodedata.name(char, 'Name not found')}]({url})" info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" return info, u_code - char_list, raw_list = zip(*(get_info(c) for c in characters)) + char_list, raw_list = zip(*(get_info(c) for c in characters), strict=True) embed = Embed().set_author(name="Character Info") if len(characters) > 1: # Maximum length possible is 502 out of 1024, so there's no need to truncate. - embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False) + embed.add_field(name="Full Raw Text", value=f"`{''.join(raw_list)}`", inline=False) await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False) @command() - async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None: + async def zen( + self, + ctx: Context, + search_value: str | None, + ) -> None: """ Show the Zen of Python. Without any arguments, the full Zen will be produced. - If an integer is provided, the line with that index will be produced. - If a string is provided, the line which matches best will be produced. + If an index or a slice is provided, the corresponding lines will be produced. + Otherwise, the line which matches best will be produced. """ embed = Embed( - colour=Colour.blurple(), + colour=Colour.og_blurple(), title="The Zen of Python", description=ZEN_OF_PYTHON ) @@ -106,16 +110,55 @@ async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) zen_lines = ZEN_OF_PYTHON.splitlines() - # handle if it's an index int - if isinstance(search_value, int): - upper_bound = len(zen_lines) - 1 - lower_bound = -1 * len(zen_lines) - if not (lower_bound <= search_value <= upper_bound): - raise BadArgument(f"Please provide an index between {lower_bound} and {upper_bound}.") - - embed.title += f" (line {search_value % len(zen_lines)}):" - embed.description = zen_lines[search_value] - await ctx.send(embed=embed) + # Prioritize checking for an index or slice + match = re.match( + r"(?P-?\d++(?!:))|(?P(?:-\d+)|\d*):(?:(?P(?:-\d+)|\d*)(?::(?P(?:-\d+)|\d*))?)?", + search_value.split(" ")[0], + ) + if match: + if match.group("index"): + index = int(match.group("index")) + if not (-19 <= index <= 18): + raise BadArgument("Please provide an index between -19 and 18.") + embed.title += f" (line {index % 19}):" + embed.description = zen_lines[index] + await ctx.send(embed=embed) + return + + start_index = int(match.group("start")) if match.group("start") else None + end_index = int(match.group("end")) if match.group("end") else None + step_size = int(match.group("step")) if match.group("step") else 1 + + if step_size == 0: + raise BadArgument("Step size must not be 0.") + + lines = zen_lines[start_index:end_index:step_size] + if not lines: + raise BadArgument("Slice returned 0 lines.") + + if len(lines) == 1: + embed.title += f" (line {zen_lines.index(lines[0])}):" + embed.description = lines[0] + await ctx.send(embed=embed) + elif lines == zen_lines: + embed.title += ", by Tim Peters" + await ctx.send(embed=embed) + elif len(lines) == 19: + embed.title += f" (step size {step_size}):" + embed.description = "\n".join(lines) + await ctx.send(embed=embed) + else: + if step_size != 1: + step_message = f", step size {step_size}" + else: + step_message = "" + first_position = zen_lines.index(lines[0]) + second_position = zen_lines.index(lines[-1]) + if first_position > second_position: + (first_position, second_position) = (second_position, first_position) + embed.title += f" (lines {first_position}-{second_position}{step_message}):" + embed.description = "\n".join(lines) + await ctx.send(embed=embed) return # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead @@ -156,12 +199,9 @@ async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) await ctx.send(embed=embed) @command(aliases=("snf", "snfl", "sf")) - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_AND_COMMUNITY_ROLES) async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: """Get Discord snowflake creation time.""" - if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES): - raise BadArgument("Cannot process more than one snowflake in one invocation.") - if not snowflakes: raise BadArgument("At least one snowflake must be provided.") @@ -174,18 +214,19 @@ async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: lines = [] for snowflake in snowflakes: created_at = snowflake_time(snowflake) - lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).") + lines.append(f"**{snowflake}**\nCreated at {created_at} ({time.format_relative(created_at)}).") await LinePaginator.paginate( lines, ctx=ctx, embed=embed, max_lines=5, - max_size=1000 + max_size=1000, + allowed_roles=MODERATION_ROLES, ) @command(aliases=("poll",)) - @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads) + @has_any_role(*MODERATION_ROLES, *LEADS_AND_COMMUNITY) async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: """ Build a quick voting poll with matching reactions with the provided options. @@ -208,6 +249,6 @@ async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=Tru await message.add_reaction(reaction) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Utils cog.""" - bot.add_cog(Utils(bot)) + await bot.add_cog(Utils(bot)) diff --git a/bot/log.py b/bot/log.py index 4e20c005ed..0651cc82f6 100644 --- a/bot/log.py +++ b/bot/log.py @@ -1,57 +1,35 @@ import logging -import os import sys -from logging import Logger, handlers +from logging import handlers from pathlib import Path -import coloredlogs import sentry_sdk +from pydis_core.utils import logging as core_logging +from sentry_sdk.integrations.asyncio import AsyncioIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration from bot import constants -TRACE_LEVEL = 5 +get_logger = core_logging.get_logger def setup() -> None: """Set up loggers.""" - logging.TRACE = TRACE_LEVEL - logging.addLevelName(TRACE_LEVEL, "TRACE") - Logger.trace = _monkeypatch_trace + root_log = get_logger() - format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" - log_format = logging.Formatter(format_string) + if constants.FILE_LOGS: + log_file = Path("logs", "bot.log") + log_file.parent.mkdir(exist_ok=True) + file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") + file_handler.setFormatter(core_logging.log_format) + root_log.addHandler(file_handler) - log_file = Path("logs", "bot.log") - log_file.parent.mkdir(exist_ok=True) - file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") - file_handler.setFormatter(log_format) - - root_log = logging.getLogger() - root_log.addHandler(file_handler) - - if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: - coloredlogs.DEFAULT_LEVEL_STYLES = { - **coloredlogs.DEFAULT_LEVEL_STYLES, - "trace": {"color": 246}, - "critical": {"background": "red"}, - "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"] - } - - if "COLOREDLOGS_LOG_FORMAT" not in os.environ: - coloredlogs.DEFAULT_LOG_FORMAT = format_string - - coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout) + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(core_logging.log_format) + root_log.addHandler(stream_handler) root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO) - logging.getLogger("discord").setLevel(logging.WARNING) - logging.getLogger("websockets").setLevel(logging.WARNING) - logging.getLogger("chardet").setLevel(logging.WARNING) - logging.getLogger("async_rediscache").setLevel(logging.WARNING) - - # Set back to the default of INFO even if asyncio's debug mode is enabled. - logging.getLogger("asyncio").setLevel(logging.INFO) _set_trace_loggers() @@ -68,24 +46,13 @@ def setup_sentry() -> None: integrations=[ sentry_logging, RedisIntegration(), + AsyncioIntegration(), ], - release=f"bot@{constants.GIT_SHA}" + release=f"bot@{constants.GIT_SHA}", + enable_tracing=True, ) -def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: - """ - Log 'msg % args' with severity 'TRACE'. - - To pass exception information, use the keyword argument exc_info with - a true value, e.g. - - logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) - """ - if self.isEnabledFor(TRACE_LEVEL): - self._log(TRACE_LEVEL, msg, args, **kwargs) - - def _set_trace_loggers() -> None: """ Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. @@ -101,13 +68,13 @@ def _set_trace_loggers() -> None: level_filter = constants.Bot.trace_loggers if level_filter: if level_filter.startswith("*"): - logging.getLogger().setLevel(logging.TRACE) + get_logger().setLevel(core_logging.TRACE_LEVEL) elif level_filter.startswith("!"): - logging.getLogger().setLevel(logging.TRACE) + get_logger().setLevel(core_logging.TRACE_LEVEL) for logger_name in level_filter.strip("!,").split(","): - logging.getLogger(logger_name).setLevel(logging.DEBUG) + get_logger(logger_name).setLevel(logging.DEBUG) else: for logger_name in level_filter.strip(",").split(","): - logging.getLogger(logger_name).setLevel(logging.TRACE) + get_logger(logger_name).setLevel(core_logging.TRACE_LEVEL) diff --git a/bot/pagination.py b/bot/pagination.py index c5c84afd93..6b2f767151 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,368 +1,61 @@ -import asyncio -import logging -import typing as t -from contextlib import suppress -from functools import partial +from collections.abc import Sequence import discord -from discord.abc import User -from discord.ext.commands import Context, Paginator +from discord.ext.commands import Context +from pydis_core.utils.pagination import LinePaginator as _LinePaginator, PaginationEmojis -from bot import constants -from bot.utils import messages +from bot.constants import Emojis -FIRST_EMOJI = "\u23EE" # [:track_previous:] -LEFT_EMOJI = "\u2B05" # [:arrow_left:] -RIGHT_EMOJI = "\u27A1" # [:arrow_right:] -LAST_EMOJI = "\u23ED" # [:track_next:] -DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] -PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI) - -log = logging.getLogger(__name__) - - -class EmptyPaginatorEmbed(Exception): - """Raised when attempting to paginate with empty contents.""" - - pass - - -class LinePaginator(Paginator): +class LinePaginator(_LinePaginator): """ A class that aids in paginating code blocks for Discord messages. - Available attributes include: - * prefix: `str` - The prefix inserted to every page. e.g. three backticks. - * suffix: `str` - The suffix appended at the end of every page. e.g. three backticks. - * max_size: `int` - The maximum amount of codepoints allowed in a page. - * scale_to_size: `int` - The maximum amount of characters a single line can scale up to. - * max_lines: `int` - The maximum amount of lines allowed in a page. + See the super class's docs for more info. """ - def __init__( - self, - prefix: str = '```', - suffix: str = '```', - max_size: int = 2000, - scale_to_size: int = 2000, - max_lines: t.Optional[int] = None - ) -> None: - """ - This function overrides the Paginator.__init__ from inside discord.ext.commands. - - It overrides in order to allow us to configure the maximum number of lines per page. - """ - self.prefix = prefix - self.suffix = suffix - - # Embeds that exceed 2048 characters will result in an HTTPException - # (Discord API limit), so we've set a limit of 2000 - if max_size > 2000: - raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") - - self.max_size = max_size - len(suffix) - - if scale_to_size < max_size: - raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") - - if scale_to_size > 2000: - raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)") - - self.scale_to_size = scale_to_size - len(suffix) - self.max_lines = max_lines - self._current_page = [prefix] - self._linecount = 0 - self._count = len(prefix) + 1 # prefix + newline - self._pages = [] - - def add_line(self, line: str = '', *, empty: bool = False) -> None: - """ - Adds a line to the current page. - - If a line on a page exceeds `max_size` characters, then `max_size` will go up to - `scale_to_size` for a single line before creating a new page for the overflow words. If it - is still exceeded, the excess characters are stored and placed on the next pages unti - there are none remaining (by word boundary). The line is truncated if `scale_to_size` is - still exceeded after attempting to continue onto the next page. - - In the case that the page already contains one or more lines and the new lines would cause - `max_size` to be exceeded, a new page is created. This is done in order to make a best - effort to avoid breaking up single lines across pages, while keeping the total length of the - page at a reasonable size. - - This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. - - It overrides in order to allow us to configure the maximum number of lines per page. - """ - remaining_words = None - if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): - if len(line) > self.scale_to_size: - line, remaining_words = self._split_remaining_words(line, max_chars) - if len(line) > self.scale_to_size: - log.debug("Could not continue to next page, truncating line.") - line = line[:self.scale_to_size] - - # Check if we should start a new page or continue the line on the current one - if self.max_lines is not None and self._linecount >= self.max_lines: - log.debug("max_lines exceeded, creating new page.") - self._new_page() - elif self._count + len(line) + 1 > self.max_size and self._linecount > 0: - log.debug("max_size exceeded on page with lines, creating new page.") - self._new_page() - - self._linecount += 1 - - self._count += len(line) + 1 - self._current_page.append(line) - - if empty: - self._current_page.append('') - self._count += 1 - - # Start a new page if there were any overflow words - if remaining_words: - self._new_page() - self.add_line(remaining_words) - - def _new_page(self) -> None: - """ - Internal: start a new page for the paginator. - - This closes the current page and resets the counters for the new page's line count and - character count. - """ - self._linecount = 0 - self._count = len(self.prefix) + 1 - self.close_page() - - def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]: - """ - Internal: split a line into two strings -- reduced_words and remaining_words. - - reduced_words: the remaining words in `line`, after attempting to remove all words that - exceed `max_chars` (rounding down to the nearest word boundary). - - remaining_words: the words in `line` which exceed `max_chars`. This value is None if - no words could be split from `line`. - - If there are any remaining_words, an ellipses is appended to reduced_words and a - continuation header is inserted before remaining_words to visually communicate the line - continuation. - - Return a tuple in the format (reduced_words, remaining_words). - """ - reduced_words = [] - remaining_words = [] - - # "(Continued)" is used on a line by itself to indicate the continuation of last page - continuation_header = "(Continued)\n-----------\n" - reduced_char_count = 0 - is_full = False - - for word in line.split(" "): - if not is_full: - if len(word) + reduced_char_count <= max_chars: - reduced_words.append(word) - reduced_char_count += len(word) + 1 - else: - # If reduced_words is empty, we were unable to split the words across pages - if not reduced_words: - return line, None - is_full = True - remaining_words.append(word) - else: - remaining_words.append(word) - - return ( - " ".join(reduced_words) + "..." if remaining_words else "", - continuation_header + " ".join(remaining_words) if remaining_words else None - ) - @classmethod async def paginate( cls, - lines: t.List[str], - ctx: Context, + lines: list[str], + ctx: Context | discord.Interaction, embed: discord.Embed, prefix: str = "", suffix: str = "", - max_lines: t.Optional[int] = None, + max_lines: int | None = None, max_size: int = 500, - scale_to_size: int = 2000, + scale_to_size: int = 4000, empty: bool = True, - restrict_to_user: User = None, + restrict_to_user: discord.User | None = None, timeout: int = 300, - footer_text: str = None, - url: str = None, + footer_text: str | None = None, url: str | None = None, exception_on_empty_embed: bool = False, - ) -> t.Optional[discord.Message]: + reply: bool = False, + allowed_roles: Sequence[int] | None = None, **kwargs + ) -> discord.Message | None: """ Use a paginator and set of reactions to provide pagination over a set of lines. - The reactions are used to switch page, or to finish with pagination. - - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. - - Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds). - - The interaction will be limited to `restrict_to_user` (ctx.author by default) or - to any user with a moderation role. - - Example: - >>> embed = discord.Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await LinePaginator.paginate([line for line in lines], ctx, embed) - """ - paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines, - scale_to_size=scale_to_size) - current_page = 0 - - if not restrict_to_user: - restrict_to_user = ctx.author - - if not lines: - if exception_on_empty_embed: - log.exception("Pagination asked for empty lines iterable") - raise EmptyPaginatorEmbed("No lines to paginate") - - log.debug("No lines to add to paginator, adding '(nothing to display)' message") - lines.append("(nothing to display)") - - for line in lines: - try: - paginator.add_line(line, empty=empty) - except Exception: - log.exception(f"Failed to add line to paginator: '{line}'") - raise # Should propagate - else: - log.trace(f"Added line to paginator: '{line}'") - - log.debug(f"Paginator created with {len(paginator.pages)} pages") - - embed.description = paginator.pages[current_page] - - if len(paginator.pages) <= 1: - if footer_text: - embed.set_footer(text=footer_text) - log.trace(f"Setting embed footer to '{footer_text}'") - - if url: - embed.url = url - log.trace(f"Setting embed url to '{url}'") - - log.debug("There's less than two pages, so we won't paginate - sending single page on its own") - return await ctx.send(embed=embed) - else: - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - log.trace(f"Setting embed footer to '{embed.footer.text}'") - - if url: - embed.url = url - log.trace(f"Setting embed url to '{url}'") - - log.debug("Sending first page to channel...") - message = await ctx.send(embed=embed) - - log.debug("Adding emoji reactions to message...") - - for emoji in PAGINATION_EMOJI: - # Add all the applicable emoji to the message - log.trace(f"Adding reaction: {repr(emoji)}") - await message.add_reaction(emoji) - - check = partial( - messages.reaction_check, - message_id=message.id, - allowed_emoji=PAGINATION_EMOJI, - allowed_users=(restrict_to_user.id,), + Acts as a wrapper for the super class' `paginate` method to provide the pagination emojis by default. + + Consult the super class's `paginate` method for detailed information. + """ + return await super().paginate( + pagination_emojis=PaginationEmojis(delete=Emojis.trashcan), + lines=lines, + ctx=ctx, + embed=embed, + prefix=prefix, + suffix=suffix, + max_lines=max_lines, + max_size=max_size, + scale_to_size=scale_to_size, + empty=empty, + restrict_to_user=restrict_to_user, + timeout=timeout, + footer_text=footer_text, + url=url, + exception_on_empty_embed=exception_on_empty_embed, + reply=reply, + allowed_roles=allowed_roles ) - - while True: - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check) - log.trace(f"Got reaction: {reaction}") - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - if str(reaction.emoji) == DELETE_EMOJI: - log.debug("Got delete reaction") - return await message.delete() - - if reaction.emoji == FIRST_EMOJI: - await message.remove_reaction(reaction.emoji, user) - current_page = 0 - - log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}") - - embed.description = paginator.pages[current_page] - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - await message.edit(embed=embed) - - if reaction.emoji == LAST_EMOJI: - await message.remove_reaction(reaction.emoji, user) - current_page = len(paginator.pages) - 1 - - log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - embed.description = paginator.pages[current_page] - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - await message.edit(embed=embed) - - if reaction.emoji == LEFT_EMOJI: - await message.remove_reaction(reaction.emoji, user) - - if current_page <= 0: - log.debug("Got previous page reaction, but we're on the first page - ignoring") - continue - - current_page -= 1 - log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - embed.description = paginator.pages[current_page] - - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - if reaction.emoji == RIGHT_EMOJI: - await message.remove_reaction(reaction.emoji, user) - - if current_page >= len(paginator.pages) - 1: - log.debug("Got next page reaction, but we're on the last page - ignoring") - continue - - current_page += 1 - log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - embed.description = paginator.pages[current_page] - - if footer_text: - embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") - else: - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - log.debug("Ending pagination and clearing reactions.") - with suppress(discord.NotFound): - await message.clear_reactions() diff --git a/bot/resources/media/print-return.gif b/bot/resources/media/print-return.gif new file mode 100644 index 0000000000..5d99329dcc Binary files /dev/null and b/bot/resources/media/print-return.gif differ diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md index b440a23464..d9d433faba 100644 --- a/bot/resources/tags/args-kwargs.md +++ b/bot/resources/tags/args-kwargs.md @@ -1,5 +1,7 @@ -`*args` and `**kwargs` - +--- +embed: + title: "The `*args` and `**kwargs` parameters" +--- These special parameters allow functions to take arbitrary amounts of positional and keyword arguments. The names `args` and `kwargs` are purely convention, and could be named any other valid variable name. The special functionality comes from the single and double asterisks (`*`). If both are used in a function signature, `*args` **must** appear before `**kwargs`. **Single asterisk** @@ -9,9 +11,9 @@ These special parameters allow functions to take arbitrary amounts of positional `**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. **Use cases** -• **Decorators** (see `!tags decorators`) -• **Inheritance** (overriding methods) -• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) -• **Flexibility** (writing functions that behave like `dict()` or `print()`) +- **Decorators** (see `/tag decorators`) +- **Inheritance** (overriding methods) +- **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) +- **Flexibility** (writing functions that behave like `dict()` or `print()`) -*See* `!tags positional-keyword` *for information about positional and keyword arguments* +*See* `/tag positional-keyword` *for information about positional and keyword arguments* diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index ff71ace07e..a3d8bde84b 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -1,28 +1,29 @@ -**Concurrency in Python** - +--- +embed: + title: "Concurrency in Python" +--- Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. -This works by running these coroutines in an event loop, where the context of which coroutine is being run is switches periodically to allow all of them to run, giving the appearance of running at the same time. This is different to using threads or processes in that all code is run in the main process and thread, although it is possible to run coroutines in threads. +This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in other threads. To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. -To create a coroutine that can be used with asyncio we need to define a function using the async keyword: +To create a coroutine that can be used with asyncio we need to define a function using the `async` keyword: ```py async def main(): await something_awaitable() ``` -Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function this would have raised an exception like: `SyntaxError: 'await' outside async function` +Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, it would raise the exception `SyntaxError: 'await' outside async function` -To run the top level async function from outside of the event loop we can get an event loop from `asyncio`, and then use that loop to run the function: +To run the top level async function from outside the event loop we need to use [`asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: ```py -from asyncio import get_event_loop +import asyncio async def main(): await something_awaitable() -loop = get_event_loop() -loop.run_until_complete(main()) +asyncio.run(main()) ``` -Note that in the `run_until_complete()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `run_until_complete()`. +Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `main() is not main()`) which is then handled and run by the event loop via `asyncio.run()`. To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). diff --git a/bot/resources/tags/blocking.md b/bot/resources/tags/blocking.md index 31d91294c8..4fe778e61a 100644 --- a/bot/resources/tags/blocking.md +++ b/bot/resources/tags/blocking.md @@ -1,9 +1,10 @@ -**Why do we need asynchronous programming?** - +--- +embed: + title: "Asynchronous programming" +--- Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming. **What is asynchronous programming?** - An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example: ```py @@ -14,15 +15,12 @@ import discord async def ping(ctx): await ctx.send("Pong!") ``` - **What does the term "blocking" mean?** - A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts. **`async` libraries** - -The standard async library - `asyncio` -Asynchronous web requests - `aiohttp` -Talking to PostgreSQL asynchronously - `asyncpg` -MongoDB interactions asynchronously - `motor` -Check out [this](https://github.com/timofurrer/awesome-asyncio) list for even more! +- The standard async library - `asyncio` +- Asynchronous web requests - `aiohttp` +- Talking to PostgreSQL asynchronously - `asyncpg` +- MongoDB interactions asynchronously - `motor` +- Check out [this](https://github.com/timofurrer/awesome-asyncio) list for even more! diff --git a/bot/resources/tags/botvar.md b/bot/resources/tags/botvar.md new file mode 100644 index 0000000000..e4ea8b87d6 --- /dev/null +++ b/bot/resources/tags/botvar.md @@ -0,0 +1,27 @@ +--- +embed: + title: "Bot variables" +--- +Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: + +```py +bot = commands.Bot(command_prefix="!") +# Set an attribute on our bot +bot.test = "I am accessible everywhere!" + +@bot.command() +async def get(ctx: commands.Context): + """A command to get the current value of `test`.""" + # Send what the test attribute is currently set to + await ctx.send(ctx.bot.test) + +@bot.command() +async def setval(ctx: commands.Context, *, new_text: str): + """A command to set a new value of `test`.""" + # Here we change the attribute to what was specified in new_text + bot.test = new_text +``` + +This all applies to cogs as well! You can set attributes to `self` as you wish. + +*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md index 4f73fc974a..1e8b3ae9b4 100644 --- a/bot/resources/tags/class.md +++ b/bot/resources/tags/class.md @@ -1,8 +1,10 @@ -**Classes** - +--- +embed: + title: "Classes" +--- Classes are used to create objects that have specific behavior. -Every object in python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component. +Every object in Python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component. Here is an example class: diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md index a4e803093a..2452de8e82 100644 --- a/bot/resources/tags/classmethod.md +++ b/bot/resources/tags/classmethod.md @@ -1,3 +1,7 @@ +--- +embed: + title: "The `@classmethod` decorator" +--- Although most methods are tied to an _object instance_, it can sometimes be useful to create a method that does something with _the class itself_. To achieve this in Python, you can use the `@classmethod` decorator. This is often used to provide alternative constructors for a class. For example, you may be writing a class that takes some magic token (like an API key) as a constructor argument, but you sometimes read this token from a configuration file. You could make use of a `@classmethod` to create an alternate constructor for when you want to read from the configuration file. diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index ac64656e58..c7f2990fd8 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Formatting code on Discord" +--- Here's how to format Python code on Discord: \`\`\`py @@ -5,3 +9,5 @@ print('Hello world!') \`\`\` **These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key. + +**For long code samples,** you can use our [pastebin](https://paste.pythondiscord.com/). diff --git a/bot/resources/tags/comparison.md b/bot/resources/tags/comparison.md index 12844bd2f5..5db17cc284 100644 --- a/bot/resources/tags/comparison.md +++ b/bot/resources/tags/comparison.md @@ -1,5 +1,7 @@ -**Assignment vs. Comparison** - +--- +embed: + title: "Assignment vs comparison`" +--- The assignment operator (`=`) is used to assign variables. ```python x = 5 diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md new file mode 100644 index 0000000000..b788aba214 --- /dev/null +++ b/bot/resources/tags/contribute.md @@ -0,0 +1,15 @@ +--- +embed: + title: "Contribute to Python Discord's open source projects" +--- +Looking to contribute to Open Source Projects for the first time? Want to add a feature or fix a bug on the bots on this server? We have on-going projects that people can contribute to, even if you've never contributed to open source before! + +**Projects to Contribute to** +- [Sir Lancebot](https://github.com/python-discord/sir-lancebot) - our fun, beginner-friendly bot +- [Python](https://github.com/python-discord/bot) - our utility & moderation bot +- [Site](https://github.com/python-discord/site) - resources, guides, and more + +**Where to start** +1. Read our [contribution guide](https://pythondiscord.com/pages/guides/pydis-guides/contributing/) +2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions +3. Open an issue or ask to be assigned to an issue to work on diff --git a/bot/resources/tags/customchecks.md b/bot/resources/tags/customchecks.md index 23ff7a66ff..46e4c8f3ef 100644 --- a/bot/resources/tags/customchecks.md +++ b/bot/resources/tags/customchecks.md @@ -1,5 +1,7 @@ -**Custom Command Checks in discord.py** - +--- +embed: + title: "Custom command checks in discord.py" +--- Often you may find the need to use checks that don't exist by default in discord.py. Fortunately, discord.py provides `discord.ext.commands.check` which allows you to create you own checks like this: ```py from discord.ext.commands import check, Context diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index ac7e70aee6..78d1d253e2 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -1,5 +1,7 @@ -**Cooldowns in discord.py** - +--- +embed: + title: "Cooldowns in discord.py" +--- Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message. ```python @@ -17,4 +19,4 @@ async def on_message(message): await message.channel.send("Not ratelimited!") ``` -`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). +`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.BucketType). diff --git a/bot/resources/tags/customhelp.md b/bot/resources/tags/customhelp.md index 6f0b17642e..5dc1a05411 100644 --- a/bot/resources/tags/customhelp.md +++ b/bot/resources/tags/customhelp.md @@ -1,3 +1,5 @@ -**Custom help commands in discord.py** - +--- +embed: + title: "Custom help commands in discord.py" +--- To learn more about how to create custom help commands in discord.py by subclassing the help command, please see [this tutorial](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96#embed-minimalhelpcommand) by Stella#2000 diff --git a/bot/resources/tags/dashmpip.md b/bot/resources/tags/dashmpip.md new file mode 100644 index 0000000000..0dd866aeb1 --- /dev/null +++ b/bot/resources/tags/dashmpip.md @@ -0,0 +1,12 @@ +--- +aliases: ["minusmpip"] +embed: + title: "Install packages with `python -m pip`" +--- +When trying to install a package via `pip`, it's recommended to invoke pip as a module: `python -m pip install your_package`. + +**Why would we use `python -m pip` instead of `pip`?** +Invoking pip as a module ensures you know *which* pip you're using. This is helpful if you have multiple Python versions. You always know which Python version you're installing packages to. + +**Note** +The exact `python` command you invoke can vary. It may be `python3` or `py`, ensure it's correct for your system. diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md index 39c943f0ac..ebfb40a104 100644 --- a/bot/resources/tags/decorators.md +++ b/bot/resources/tags/decorators.md @@ -1,5 +1,7 @@ -**Decorators** - +--- +embed: + title: "Decorators" +--- A decorator is a function that modifies another function. Consider the following example of a timer decorator: @@ -27,5 +29,5 @@ Finished! ``` More information: -• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) -• [Real python article](https://realpython.com/primer-on-python-decorators/) +- [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) +- [Real python article](https://realpython.com/primer-on-python-decorators/) diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md index b6c3175fc9..82555b7a1a 100644 --- a/bot/resources/tags/defaultdict.md +++ b/bot/resources/tags/defaultdict.md @@ -1,5 +1,7 @@ -**[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)** - +--- +embed: + title: "The `collections.defaultdict` class" +--- The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it. While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys. @@ -19,3 +21,4 @@ In this example, we've used the `int` class which returns 0 when called like a f >>> my_dict defaultdict(, {'foo': 0, 'bar': 5}) ``` +Check out the [`docs`](https://docs.python.org/3/library/collections.html#collections.defaultdict) to learn even more! diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md index e02df03abe..57d5349e1a 100644 --- a/bot/resources/tags/dict-get.md +++ b/bot/resources/tags/dict-get.md @@ -1,7 +1,9 @@ +--- +embed: + title: "The `dict.get` method" +--- Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. Python gives you some neat ways to handle them. -**The `dict.get` method** - The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, and None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError. ```py >>> my_dict = {"foo": 1, "bar": 2} diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index 6c8018761e..75f0f7b3a7 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Dictionary comprehensions" +--- Dictionary comprehensions (*dict comps*) provide a convenient way to make dictionaries, just like list comps: ```py >>> {word.lower(): len(word) for word in ('I', 'love', 'Python')} @@ -11,4 +15,4 @@ One can use a dict comp to change an existing dictionary using its `items` metho >>> {key.upper(): value * 2 for key, value in first_dict.items()} {'I': 2, 'LOVE': 8, 'PYTHON': 12} ``` -For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/) +For more information and examples, check out [PEP 274](https://peps.python.org/pep-0274/) diff --git a/bot/resources/tags/discord-bot-hosting.md b/bot/resources/tags/discord-bot-hosting.md new file mode 100644 index 0000000000..d82cdff822 --- /dev/null +++ b/bot/resources/tags/discord-bot-hosting.md @@ -0,0 +1,11 @@ +--- +embed: + title: Discord Bot Hosting +--- + +Using free hosting options like repl.it for continuous 24/7 bot hosting is strongly discouraged. +Instead, opt for a virtual private server (VPS) or use your own spare hardware if you'd rather not pay for hosting. + +See our [Discord Bot Hosting Guide](https://www.pythondiscord.com/pages/guides/python-guides/vps-services/) on our website that compares many hosting providers, both free and paid. + +You may also use <#965291480992321536> to discuss different discord bot hosting options. diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md new file mode 100644 index 0000000000..ee07ff5f7b --- /dev/null +++ b/bot/resources/tags/docstring.md @@ -0,0 +1,22 @@ +--- +embed: + title: "Docstrings" +--- +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string - always using triple quotes - that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: +```py +def greet(name: str, age: int) -> str: + """ + Return a string that greets the given person, using their name and age. + + :param name: The name of the person to greet. + :param age: The age of the person to greet. + + :return: The greeting. + """ + return f"Hello {name}, you are {age} years old!" +``` +You can get the docstring by using the [`inspect.getdoc`](https://docs.python.org/3/library/inspect.html#inspect.getdoc) function, from the built-in [`inspect`](https://docs.python.org/3/library/inspect.html) module, or by accessing the `.__doc__` attribute. `inspect.getdoc` is often preferred, as it clears indents from the docstring. + +For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`. + +For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://peps.python.org/pep-0257/#what-is-a-docstring). diff --git a/bot/resources/tags/dotenv.md b/bot/resources/tags/dotenv.md index acb9a216eb..b1dc57fa5d 100644 --- a/bot/resources/tags/dotenv.md +++ b/bot/resources/tags/dotenv.md @@ -1,23 +1,25 @@ -**Using .env files in Python** - +--- +embed: + title: "Using .env files in Python" +--- `.env` (dotenv) files are a type of file commonly used for storing application secrets and variables, for example API tokens and URLs, although they may also be used for storing other configurable values. While they are commonly used for storing secrets, at a high level their purpose is to load environment variables into a program. Dotenv files are especially suited for storing secrets as they are a key-value store in a file, which can be easily loaded in most programming languages and ignored by version control systems like Git with a single entry in a `.gitignore` file. -In python you can use dotenv files with the [`python-dotenv`](https://pypi.org/project/python-dotenv) module from PyPI, which can be installed with `pip install python-dotenv`. To use dotenv files you'll first need a file called `.env`, with content such as the following: +In Python you can use dotenv files with the [`python-dotenv`](https://pypi.org/project/python-dotenv) module from PyPI, which can be installed with `pip install python-dotenv`. To use dotenv files you'll first need a file called `.env`, with content such as the following: ``` TOKEN=a00418c85bff087b49f23923efe40aa5 ``` Next, in your main Python file, you need to load the environment variables from the dotenv file you just created: ```py -from dotenv import load_dotenv() +from dotenv import load_dotenv -load_dotenv(".env") +load_dotenv() ``` -The variables from the file have now been loaded into your programs environment, and you can access them using `os.getenv()` anywhere in your program, like this: +The variables from the file have now been loaded into your program's environment, and you can access them using `os.getenv()` anywhere in your program, like this: ```py from os import getenv my_token = getenv("TOKEN") ``` -For further reading about tokens and secrets, please read [this explanation](https://vcokltfre.dev/tips/tokens). +For further reading about tokens and secrets, please read [this explanation](https://tutorial.vco.sh/tips/tokens/). diff --git a/bot/resources/tags/dunder-methods.md b/bot/resources/tags/dunder-methods.md new file mode 100644 index 0000000000..e3f7538025 --- /dev/null +++ b/bot/resources/tags/dunder-methods.md @@ -0,0 +1,30 @@ +--- +embed: + title: "Dunder methods" +--- +Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class. + +When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs. + +Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback. + +```py +class Foo: + def __init__(self, value): # constructor + self.value = value + def __str__(self): + return f"This is a Foo object, with a value of {self.value}!" # string representation + def __repr__(self): + return f"Foo({self.value!r})" # way to recreate this object + + +bar = Foo(5) + +# print also implicitly calls __str__ +print(bar) # Output: This is a Foo object, with a value of 5! + +# dev-friendly representation +print(repr(bar)) # Output: Foo(5) +``` + +Another example: did you know that when you use the ` + ` syntax, you're implicitly calling `.__add__()`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information! diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md index 935544bb7f..8892976716 100644 --- a/bot/resources/tags/empty-json.md +++ b/bot/resources/tags/empty-json.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Empty JSON error" +--- When using JSON, you might run into the following error: ``` JSONDecodeError: Expecting value: line 1 column 1 (char 0) diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md index dd984af524..c84ad323af 100644 --- a/bot/resources/tags/enumerate.md +++ b/bot/resources/tags/enumerate.md @@ -1,3 +1,7 @@ +--- +embed: + title: "The `enumerate` function" +--- Ever find yourself in need of the current iteration number of your `for` loop? You should use **enumerate**! Using `enumerate`, you can turn code that looks like this: ```py index = 0 @@ -10,4 +14,4 @@ into beautiful, _pythonic_ code: for index, item in enumerate(my_list): print(f"{index}: {item}") ``` -For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). +For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://peps.python.org/pep-0279/). diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md index 7bc69bde4a..1c1677f4d7 100644 --- a/bot/resources/tags/environments.md +++ b/bot/resources/tags/environments.md @@ -1,12 +1,15 @@ -**Python Environments** - +--- +aliases: ["envs"] +embed: + title: "Python environments" +--- The main purpose of Python [virtual environments](https://docs.Python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using pip, regardless of what dependencies every other project has. To see the current environment in use by Python, you can run: ```py >>> import sys ->>> print(sys.executable) -/usr/bin/python3 +>>> sys.executable +'/usr/bin/python3' ``` To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use e.g. `sys.executable: /usr/bin/python3`. @@ -15,12 +18,12 @@ If Python's `sys.executable` doesn't match pip's, then they are currently using **Why use a virtual environment?** -• Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y. -• Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed! -• Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. +- Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y. +- Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed! +- Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project. **Further reading:** -• [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer) -• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) +- [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer) +- [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv) diff --git a/bot/resources/tags/equals-true.md b/bot/resources/tags/equals-true.md new file mode 100644 index 0000000000..d8e1a707e1 --- /dev/null +++ b/bot/resources/tags/equals-true.md @@ -0,0 +1,24 @@ +--- +embed: + title: "Comparisons to `True` and `False`" +--- +It's tempting to think that if statements always need a comparison operator like `==` or `!=`, but this isn't true. +If you're just checking if a value is truthy or falsey, you don't need `== True` or `== False`. +```py +# instead of this... +if user_input.startswith('y') == True: + my_func(user_input) + +# ...write this +if user_input.startswith('y'): + my_func(user_input) + +# for false conditions, instead of this... +if user_input.startswith('y') == False: + my_func(user_input) + +# ...just use `not` +if not user_input.startswith('y'): + my_func(user_input) +``` +This also applies to expressions that use `is True` or `is False`. diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md index 8f0abf156e..934efdd768 100644 --- a/bot/resources/tags/except.md +++ b/bot/resources/tags/except.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Error handling" +--- A key part of the Python philosophy is to ask for forgiveness, not permission. This means that it's okay to write code that may produce an error, as long as you specify how that error should be handled. Code written this way is readable and resilient. ```py try: diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md index 27da9f8667..57cfaaaaba 100644 --- a/bot/resources/tags/exit().md +++ b/bot/resources/tags/exit().md @@ -1,5 +1,7 @@ -**Exiting Programmatically** - +--- +embed: + title: "Exiting programmatically" +--- If you want to exit your code programmatically, you might think to use the functions `exit()` or `quit()`, however this is bad practice. These functions are constants added by the [`site`](https://docs.python.org/3/library/site.html#module-site) module as a convenient method for exiting the interactive interpreter shell, and should not be used in programs. You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 5ccafe7238..92d7c62e2c 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -1,3 +1,8 @@ +--- +aliases: ["fstrings", "fstring", "f-string"] +embed: + title: "Format-strings" +--- Creating a Python string with your variables using the `+` operator can be difficult to write and read. F-strings (*format-strings*) make it easy to insert values into a string. If you put an `f` in front of the first quote, you can then put Python expressions between curly braces in the string. ```py diff --git a/bot/resources/tags/faq.md b/bot/resources/tags/faq.md new file mode 100644 index 0000000000..6299240fcd --- /dev/null +++ b/bot/resources/tags/faq.md @@ -0,0 +1,5 @@ +--- +embed: + title: "Frequently asked questions" +--- +As the largest Python community on Discord, we get hundreds of questions every day. Many of these questions have been asked before. We've compiled a list of the most frequently asked questions along with their answers, which can be found on our [FAQ page](https://www.pythondiscord.com/pages/frequently-asked-questions/). diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md index 03fcd72683..d9d5373772 100644 --- a/bot/resources/tags/floats.md +++ b/bot/resources/tags/floats.md @@ -1,4 +1,7 @@ -**Floating Point Arithmetic** +--- +embed: + title: "Floating point arithmetic" +--- You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this: ```python >>> 0.1 + 0.2 diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md index 98529bfc03..5605ad67e1 100644 --- a/bot/resources/tags/foo.md +++ b/bot/resources/tags/foo.md @@ -1,10 +1,12 @@ -**Metasyntactic variables** - +--- +embed: + title: "Metasyntactic variables" +--- A specific word or set of words identified as a placeholder used in programming. They are used to name entities such as variables, functions, etc, whose exact identity is unimportant and serve only to demonstrate a concept, which is useful for teaching programming. Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). More information: -• [History of foobar](https://en.wikipedia.org/wiki/Foobar) -• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) +- [History of foobar](https://en.wikipedia.org/wiki/Foobar) +- [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) diff --git a/bot/resources/tags/for-else.md b/bot/resources/tags/for-else.md new file mode 100644 index 0000000000..a6e79add08 --- /dev/null +++ b/bot/resources/tags/for-else.md @@ -0,0 +1,19 @@ +--- +embed: + title: "The for-else block" +--- +In Python it's possible to attach an `else` clause to a for loop. The code under the `else` block will be run when the iterable is exhausted (there are no more items to iterate over). Code within the else block will **not** run if the loop is broken out using `break`. + +Here's an example of its usage: +```py +numbers = [1, 3, 5, 7, 9, 11] + +for number in numbers: + if number % 2 == 0: + print(f"Found an even number: {number}") + break + print(f"{number} is odd.") +else: + print("All numbers are odd. How odd.") +``` +Try running this example but with an even number in the list, see how the output changes as you do so. diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md index 01af7a7211..88f3ddeb44 100644 --- a/bot/resources/tags/functions-are-objects.md +++ b/bot/resources/tags/functions-are-objects.md @@ -1,5 +1,7 @@ -**Calling vs. Referencing functions** - +--- +embed: + title: "Calling vs. referencing functions" +--- When assigning a new name to a function, storing it in a container, or passing it as an argument, a common mistake made is to call the function. Instead of getting the actual function, you'll get its return value. In Python you can treat function names just like any other variable. Assume there was a function called `now` that returns the current time. If you did `x = now()`, the current time would be assigned to `x`, but if you did `x = now`, the function `now` itself would be assigned to `x`. `x` and `now` would both equally reference the function. diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md index 64c316b627..c463121ab7 100644 --- a/bot/resources/tags/global.md +++ b/bot/resources/tags/global.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Globals" +--- When adding functions or classes to a program, it can be tempting to reference inaccessible variables by declaring them as global. Doing this can result in code that is harder to read, debug and test. Instead of using globals, pass variables or objects as parameters and receive return values. Instead of writing diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md index 571abb99b9..565b01cefe 100644 --- a/bot/resources/tags/guilds.md +++ b/bot/resources/tags/guilds.md @@ -1,3 +1,5 @@ -**Communities** - +--- +embed: + title: "Communities" +--- The [communities page](https://pythondiscord.com/pages/resources/communities/) on our website contains a number of communities we have partnered with as well as a [curated list](https://github.com/mhxion/awesome-discord-communities) of other communities relating to programming and technology. diff --git a/bot/resources/tags/identity.md b/bot/resources/tags/identity.md index fb20107590..aa8974a292 100644 --- a/bot/resources/tags/identity.md +++ b/bot/resources/tags/identity.md @@ -1,5 +1,7 @@ -**Identity vs. Equality** - +--- +embed: + title: "Identity vs. equality" +--- Should I be using `is` or `==`? To check if two objects are equal, use the equality operator (`==`). @@ -13,12 +15,14 @@ if x == 3: ``` To check if two objects are actually the same thing in memory, use the identity comparison operator (`is`). ```py -list_1 = [1, 2, 3] -list_2 = [1, 2, 3] -if list_1 is [1, 2, 3]: - print("list_1 is list_2") -reference_to_list_1 = list_1 -if list_1 is reference_to_list_1: - print("list_1 is reference_to_list_1") -# Prints 'list_1 is reference_to_list_1' +>>> list_1 = [1, 2, 3] +>>> list_2 = [1, 2, 3] +>>> if list_1 is [1, 2, 3]: +... print("list_1 is list_2") +... +>>> reference_to_list_1 = list_1 +>>> if list_1 is reference_to_list_1: +... print("list_1 is reference_to_list_1") +... +list_1 is reference_to_list_1 ``` diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md index 9d88bb897f..437e76e529 100644 --- a/bot/resources/tags/if-name-main.md +++ b/bot/resources/tags/if-name-main.md @@ -1,26 +1,17 @@ -`if __name__ == '__main__'` +--- +aliases: ["main"] +embed: + title: '`if __name__ == "__main__"`' +--- -This is a statement that is only true if the module (your source code) it appears in is being run directly, as opposed to being imported into another module. When you run your module, the `__name__` special variable is automatically set to the string `'__main__'`. Conversely, when you import that same module into a different one, and run that, `__name__` is instead set to the filename of your module minus the `.py` extension. - -**Example** -```py -# foo.py - -print('spam') - -if __name__ == '__main__': - print('eggs') -``` -If you run the above module `foo.py` directly, both `'spam'`and `'eggs'` will be printed. Now consider this next example: +This is a convention for code that should run if the file is the main file of your program: ```py -# bar.py +def main(): + ... -import foo +if __name__ == "__main__": + main() ``` -If you run this module named `bar.py`, it will execute the code in `foo.py`. First it will print `'spam'`, and then the `if` statement will fail, because `__name__` will now be the string `'foo'`. - -**Why would I do this?** +If the file is run directly, then the `main()` function will be run. If the file is imported, it will not run. -• Your module is a library, but also has a special case where it can be run directly -• Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) -• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test +For more about why you would do this and how it works, see [`if __name__ == "__main__"`](https://pythondiscord.com/pages/guides/python-guides/if-name-main/). diff --git a/bot/resources/tags/in-place.md b/bot/resources/tags/in-place.md new file mode 100644 index 0000000000..b66c9d93ac --- /dev/null +++ b/bot/resources/tags/in-place.md @@ -0,0 +1,26 @@ +--- +embed: + title: "In-place vs. Out-of-place operations" +--- + +In programming, there are two types of operations: +- "In-place" operations, which modify the original object +- "Out-of-place" operations, which returns a new object and leaves the original object unchanged + +For example, the `.sort()` method of lists is in-place, so it modifies the list you call `.sort()` on: +```python +>>> my_list = [5, 2, 3, 1] +>>> my_list.sort() # Returns None +>>> my_list +[1, 2, 3, 5] +``` +On the other hand, the `sorted()` function is out-of-place, so it returns a new list and leaves the original list unchanged: +```python +>>> my_list = [5, 2, 3, 1] +>>> sorted_list = sorted(my_list) +>>> sorted_list +[1, 2, 3, 5] +>>> my_list +[5, 2, 3, 1] +``` +In general, methods of mutable objects tend to be in-place (since it can be expensive to create a new object), whereas operations on immutable objects are always out-of-place (since they cannot be modified). diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md index dec8407b03..96ef53310a 100644 --- a/bot/resources/tags/indent.md +++ b/bot/resources/tags/indent.md @@ -1,5 +1,7 @@ -**Indentation** - +--- +embed: + title: "Indentation" +--- Indentation is leading whitespace (spaces and tabs) at the beginning of a line of code. In the case of Python, they are used to determine the grouping of statements. Spaces should be preferred over tabs. To be clear, this is in reference to the character itself, not the keys on a keyboard. Your editor/IDE should be configured to insert spaces when the TAB key is pressed. The amount of spaces should be a multiple of 4, except optionally in the case of continuation lines. @@ -16,9 +18,9 @@ The first line is not indented. The next two lines are indented to be inside of **Indentation is used after:** **1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) -**2.** [Continuation lines](https://www.python.org/dev/peps/pep-0008/#indentation) +**2.** [Continuation lines](https://peps.python.org/pep-0008/#indentation) **More Info** -**1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation) -**2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) +**1.** [Indentation style guide](https://peps.python.org/pep-0008/#indentation) +**2.** [Tabs or Spaces?](https://peps.python.org/pep-0008/#tabs-or-spaces) **3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index 4ece74ef7e..f502434601 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -1,7 +1,11 @@ -**Inline codeblocks** - +--- +embed: + title: "Inline codeblocks" +--- Inline codeblocks look `like this`. To create them you surround text with single backticks, so \`hello\` would become `hello`. Note that backticks are not quotes, see [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you are struggling to find the backtick key. +If the wrapped code itself has a backtick, wrap it with two backticks from each side: \`\`back \` tick\`\` would become ``back ` tick``. + For how to make multiline codeblocks see the `!codeblock` tag. diff --git a/bot/resources/tags/intents.md b/bot/resources/tags/intents.md index 464caf0baf..9cb17b0d97 100644 --- a/bot/resources/tags/intents.md +++ b/bot/resources/tags/intents.md @@ -1,19 +1,21 @@ -**Using intents in discord.py** +--- +embed: + title: "Using intents in discord.py" +--- +Intents are a feature of Discord that tells the gateway exactly which events to send your bot. Various features of discord.py rely on having particular intents enabled, further detailed [in its documentation](https://discordpy.readthedocs.io/en/stable/api.html#intents). Since discord.py v2.0.0, it has become **mandatory** for developers to explicitly define the values of these intents in their code. -Intents are a feature of Discord that tells the gateway exactly which events to send your bot. By default, discord.py has all intents enabled, except for the `Members` and `Presences` intents, which are needed for events such as `on_member` and to get members' statuses. - -To enable one of these intents, you need to first go to the [Discord developer portal](https://discord.com/developers/applications), then to the bot page of your bot's application. Scroll down to the `Privileged Gateway Intents` section, then enable the intents that you need. - -Next, in your bot you need to set the intents you want to connect with in the bot's constructor using the `intents` keyword argument, like this: +There are *standard* and *privileged* intents. To use privileged intents like `Presences`, `Server Members`, and `Message Content`, you have to first enable them in the [Discord Developer Portal](https://discord.com/developers/applications). In there, go to the `Bot` page of your application, scroll down to the `Privileged Gateway Intents` section, and enable the privileged intents that you need. Standard intents can be used without any changes in the developer portal. +Afterwards in your code, you need to set the intents you want to connect with in the bot's constructor using the `intents` keyword argument, like this: ```py from discord import Intents from discord.ext import commands +# Enable all standard intents and message content +# (prefix commands generally require message content) intents = Intents.default() -intents.members = True +intents.message_content = True bot = commands.Bot(command_prefix="!", intents=intents) ``` - -For more info about using intents, see the [discord.py docs on intents](https://discordpy.readthedocs.io/en/latest/intents.html), and for general information about them, see the [Discord developer documentation on intents](https://discord.com/developers/docs/topics/gateway#gateway-intents). +For more info about using intents, see [discord.py's related guide](https://discordpy.readthedocs.io/en/stable/intents.html), and for general information about them, see the [Discord developer documentation on intents](https://discord.com/developers/docs/topics/gateway#gateway-intents). diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md index 78c067b204..40477444f3 100644 --- a/bot/resources/tags/iterate-dict.md +++ b/bot/resources/tags/iterate-dict.md @@ -1,3 +1,7 @@ +--- +embed: + title: "Iteration over dictionaries" +--- There are two common ways to iterate over a dictionary in Python. To iterate over the keys: ```py for key in my_dict: diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md index 54ed8c961a..3317c6a2f5 100644 --- a/bot/resources/tags/kindling-projects.md +++ b/bot/resources/tags/kindling-projects.md @@ -1,3 +1,5 @@ -**Kindling Projects** - -The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge. +--- +embed: + title: "Kindling Projects" +--- +The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) contains a list of projects and ideas programmers can tackle to build their skills and knowledge. diff --git a/bot/resources/tags/learn-python.md b/bot/resources/tags/learn-python.md new file mode 100644 index 0000000000..39c62b0d11 --- /dev/null +++ b/bot/resources/tags/learn-python.md @@ -0,0 +1,12 @@ +--- +aliases: ["learn", "start", "beginner", "slorb"] +embed: + title: Go-to beginner resources +--- +Here are the top free resources we recommend for people who are new to programming: +* [Automate the Boring Stuff]() — an online book (also available to purchase as a physical book) +* Harvard’s [CS50P course]() — video lectures (slides and notes provided) with exercises +* [Python Programming MOOC 2026]() course — text-based lessons with exercises +* [Corey Schafer's YouTube playlist]() + +For a full, curated list of educational resources we recommend, please see our [resources page]()! diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md index ba00a4bf77..1b51883eec 100644 --- a/bot/resources/tags/listcomps.md +++ b/bot/resources/tags/listcomps.md @@ -1,3 +1,7 @@ +--- +embed: + title: "List comprehensions" +--- Do you ever find yourself writing something like this? ```py >>> squares = [] @@ -10,8 +14,8 @@ Using list comprehensions can make this both shorter and more readable. As a lis >>> [n ** 2 for n in range(5)] [0, 1, 4, 9, 16] ``` -List comprehensions also get an `if` statement: -```python +List comprehensions also get an `if` clause: +```py >>> [n ** 2 for n in range(5) if n % 2 == 0] [0, 4, 16] ``` diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md index ae41d589c2..40e0a2380b 100644 --- a/bot/resources/tags/local-file.md +++ b/bot/resources/tags/local-file.md @@ -1,4 +1,8 @@ -Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/latest/api.html#discord.File) class: +--- +embed: + title: "Sending images in embeds using discord.py" +--- +Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/stable/api.html#discord.File) class: ```py # When you know the file exact path, you can pass it. file = discord.File("/this/is/path/to/my/file.png", filename="file.png") @@ -7,10 +11,10 @@ file = discord.File("/this/is/path/to/my/file.png", filename="file.png") with open("/this/is/path/to/my/file.png", "rb") as f: file = discord.File(f) ``` -When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing `filename` to it is not necessary. -Please note that `filename` can't contain underscores. This is a Discord limitation. +When using the file-like object, you have to open it in `rb` ('read binary') mode. Also, in this case, passing `filename` to it is not necessary. +Please note that `filename` must not contain underscores. This is a Discord limitation. -[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: +[`discord.Embed`](https://discordpy.readthedocs.io/en/stable/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/stable/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image: ```py embed = discord.Embed() # Set other fields @@ -20,4 +24,4 @@ After this, you can send an embed with an attachment to Discord: ```py await channel.send(file=file, embed=embed) ``` -This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/latest/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/latest/api.html#discord.abc.Messageable) can be used for sending. +This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/stable/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/stable/api.html#discord.abc.Messageable) can be used for sending. diff --git a/bot/resources/tags/loop-remove.md b/bot/resources/tags/loop-remove.md new file mode 100644 index 0000000000..f43097a2db --- /dev/null +++ b/bot/resources/tags/loop-remove.md @@ -0,0 +1,27 @@ +--- +aliases: ["loop-add", "loop-modify"] +embed: + title: "Removing items inside a for loop" +--- +Avoid adding to or removing from a collection, such as a list, as you iterate that collection in a `for` loop: +```py +data = [1, 2, 3, 4] +for item in data: + data.remove(item) +print(data) # [2, 4] <-- every OTHER item was removed! +``` +Inside the loop, an index tracks the current position. If the list is modified, this index may no longer refer to the same element, causing elements to be repeated or skipped. + +In the example above, `1` is removed, shifting `2` to index *0*. The loop then moves to index *1*, removing `3` (and skipping `2`!). + +You can avoid this pitfall by: +- using a **list comprehension** to produce a new list (as a way of filtering items): + ```py + data = [x for x in data if x % 2 == 0] + ``` +- using a `while` loop and `.pop()` (treating the list as a stack): + ```py + while data: + item = data.pop() + ``` +- considering whether you need to remove items in the first place! diff --git a/bot/resources/tags/message-content-intent.md b/bot/resources/tags/message-content-intent.md new file mode 100644 index 0000000000..c304ab0fbd --- /dev/null +++ b/bot/resources/tags/message-content-intent.md @@ -0,0 +1,19 @@ +--- +aliases: ["mcintent", "message_content", "message_content_intent"] +embed: + title: "Discord Message Content Intent" +--- + +The Discord gateway only dispatches events you subscribe to, which you can configure by using "intents." + +The message content intent is what determines if an app will receive the actual content of newly created messages. Without this intent, discord.py won't be able to detect prefix commands, so prefix commands won't respond. + +Privileged intents, such as message content, have to be explicitly enabled from the [Discord Developer Portal](https://discord.com/developers/applications) in addition to being enabled in the code: + +```py +intents = discord.Intents.default() # create a default Intents instance +intents.message_content = True # enable message content intents + +bot = commands.Bot(command_prefix="!", intents=intents) # actually pass it into the constructor +``` +For more information on intents, see `/tag intents`. If prefix commands are still not working, see `/tag on-message-event`. diff --git a/bot/resources/tags/microsoft-build-tools.md b/bot/resources/tags/microsoft-build-tools.md index 7c702e2965..87926a3452 100644 --- a/bot/resources/tags/microsoft-build-tools.md +++ b/bot/resources/tags/microsoft-build-tools.md @@ -1,5 +1,7 @@ -**Microsoft Visual C++ Build Tools** - +--- +embed: + title: "Microsoft Visual C++ Build Tools" +--- When you install a library through `pip` on Windows, sometimes you may encounter this error: ``` @@ -8,7 +10,7 @@ error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft This means the library you're installing has code written in other languages and needs additional tools to install. To install these tools, follow the following steps: (Requires 6GB+ disk space) -**1.** Open [https://visualstudio.microsoft.com/visual-cpp-build-tools/](https://visualstudio.microsoft.com/visual-cpp-build-tools/). +**1.** Open https://visualstudio.microsoft.com/visual-cpp-build-tools/. **2.** Click **`Download Build Tools >`**. A file named `vs_BuildTools` or `vs_BuildTools.exe` should start downloading. If no downloads start after a few seconds, click **`click here to retry`**. **3.** Run the downloaded file. Click **`Continue`** to proceed. **4.** Choose **C++ build tools** and press **`Install`**. You may need a reboot after the installation. diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md index 4124681741..15a7404e1f 100644 --- a/bot/resources/tags/modmail.md +++ b/bot/resources/tags/modmail.md @@ -1,9 +1,11 @@ -**Contacting the moderation team via ModMail** - +--- +embed: + title: "Contacting the moderation team via ModMail" +--- <@!683001325440860340> is a bot that will relay your messages to our moderation team, so that you can start a conversation with the moderation team. Your messages will be relayed to the entire moderator team, who will be able to respond to you via the bot. It supports attachments, codeblocks, and reactions. As communication happens over direct messages, the conversation will stay between you and the mod team. **To use it, simply send a direct message to the bot.** -Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead. +Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead. diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md index bde9b5e7ef..fd9319daf6 100644 --- a/bot/resources/tags/mutability.md +++ b/bot/resources/tags/mutability.md @@ -1,5 +1,7 @@ -**Mutable vs immutable objects** - +--- +embed: + title: "Mutable vs immutable objects" +--- Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method. You might think that this would work: @@ -35,3 +37,5 @@ Mutable data types like `list`, on the other hand, can be changed in-place: ``` Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable. + +For an in-depth guide on mutability see [Ned Batchelder's video on names and values](https://youtu.be/_AEJHKGk9ns/). diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md index a8f0c38b35..a1a6d3d2a4 100644 --- a/bot/resources/tags/mutable-default-args.md +++ b/bot/resources/tags/mutable-default-args.md @@ -1,6 +1,8 @@ -**Mutable Default Arguments** - -Default arguments in python are evaluated *once* when the function is +--- +embed: + title: "Mutable default arguments" +--- +Default arguments in Python are evaluated *once* when the function is **defined**, *not* each time the function is **called**. This means that if you have a mutable default argument and mutate it, you will have mutated that object for all future calls to the function as well. @@ -42,7 +44,7 @@ function is **called**: **Note**: -• This behavior can be used intentionally to maintain state between +- This behavior can be used intentionally to maintain state between calls of a function (eg. when writing a caching function). -• This behavior is not unique to mutable objects, all default +- This behavior is not unique to mutable objects, all default arguments are evaulated only once when the function is defined. diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md index 3e76269f7d..bc66fa099e 100644 --- a/bot/resources/tags/names.md +++ b/bot/resources/tags/names.md @@ -1,5 +1,7 @@ -**Naming and Binding** - +--- +embed: + title: "Naming and binding" +--- A name is a piece of text that is bound to an object. They are a **reference** to an object. Examples are function names, class names, module names, variables, etc. **Note:** Names **cannot** reference other names, and assignment **never** creates a copy. @@ -24,14 +26,14 @@ y ━━ 1 ``` **Names are created in multiple ways** You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: -• `import` statements -• `class` and `def` -• `for` loop headers -• `as` keyword when used with `except`, `import`, and `with` -• formal parameters in function headers +- `import` statements +- `class` and `def` +- `for` loop headers +- `as` keyword when used with `except`, `import`, and `with` +- formal parameters in function headers There is also `del` which has the purpose of *unbinding* a name. **More info** -• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples -• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) +- Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples +- [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) diff --git a/bot/resources/tags/nomodule.md b/bot/resources/tags/nomodule.md new file mode 100644 index 0000000000..8b02b1b7a7 --- /dev/null +++ b/bot/resources/tags/nomodule.md @@ -0,0 +1,15 @@ +--- +embed: + title: "The `ModuleNotFoundError` error" +--- +If you've installed a package but you're getting a ModuleNotFoundError when you try to import it, it's likely that the environment where your code is running is different from the one where you did the installation. + +You can read about Python environments at `/tag environments` and `/tag venv`. + +Common causes of this problem include: + +- You installed your package using `pip install ...`. It could be that the `pip` command is not pointing to the environment where your code runs. For greater control, you could instead run pip as a module within the python environment you specify: +``` +python -m pip install +``` +- Your editor/ide is configured to create virtual environments automatically (PyCharm is configured this way by default). diff --git a/bot/resources/tags/off-topic-names.md b/bot/resources/tags/off-topic-names.md new file mode 100644 index 0000000000..71a347e176 --- /dev/null +++ b/bot/resources/tags/off-topic-names.md @@ -0,0 +1,13 @@ +--- +embed: + title: "Off-topic channels" +--- +There are three off-topic channels: +- <#291284109232308226> +- <#463035241142026251> +- <#463035268514185226> + +Use any of the three, it doesn't matter. +The channel names change every night at midnight UTC and are often fun meta references to jokes or conversations that happened on the server. + +See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used. diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md deleted file mode 100644 index 6a864a1d58..0000000000 --- a/bot/resources/tags/off-topic.md +++ /dev/null @@ -1,10 +0,0 @@ -**Off-topic channels** - -There are three off-topic channels: -• <#291284109232308226> -• <#463035241142026251> -• <#463035268514185226> - -Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. - -Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. diff --git a/bot/resources/tags/on-message-event.md b/bot/resources/tags/on-message-event.md new file mode 100644 index 0000000000..fd2454eb81 --- /dev/null +++ b/bot/resources/tags/on-message-event.md @@ -0,0 +1,22 @@ +--- +embed: + title: "The discord.py `on_message` event" +--- + +Registering the `on_message` event with [`@bot.event`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.event) will override the default behavior of the event. This may cause prefix commands to stop working, because they rely on the default `on_message` event handler. + +Instead, use [`@bot.listen`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.listen) to add a listener. Listeners get added alongside the default `on_message` handler which allows you to have multiple handlers for the same event. This means prefix commands can still be invoked as usual. Here's an example: +```python +@bot.listen() +async def on_message(message): + ... # do stuff here + +# Or... + +@bot.listen('on_message') +async def message_listener(message): + ... # do stuff here +``` +You can also tell discord.py to process the message for commands as usual at the end of the `on_message` handler with [`bot.process_commands()`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html#discord.ext.commands.Bot.process_commands). However, this method isn't recommended as it does not allow you to add multiple `on_message` handlers. + +If your prefix commands are still not working, it may be because you haven't enabled the `message_content` intent. See `/tag message_content` for more info. diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md index 13b4555b95..97595b5ebf 100644 --- a/bot/resources/tags/open.md +++ b/bot/resources/tags/open.md @@ -1,11 +1,13 @@ -**Opening files** - +--- +embed: + title: "Opening files" +--- The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). See also: -• `!tags with` for information on context managers -• `!tags pathlib` for an alternative way of opening files -• `!tags seek` for information on changing your position in a file +- `!tags with` for information on context managers +- `!tags pathlib` for an alternative way of opening files +- `!tags seek` for information on changing your position in a file **The `file` parameter** @@ -19,8 +21,8 @@ See `!tags relative-path` for more information on relative paths. This is an optional string that specifies the mode in which the file should be opened. There's not enough room to discuss them all, but listed below are some of the more confusing modes. -`'r+'` Opens for reading and writing (file must already exist) -`'w+'` Opens for reading and writing and truncates (can create files) -`'x'` Creates file and opens for writing (file must **not** already exist) -`'x+'` Creates file and opens for reading and writing (file must **not** already exist) -`'a+'` Opens file for reading and writing at **end of file** (can create files) +- `'r+'` Opens for reading and writing (file must already exist) +- `'w+'` Opens for reading and writing and truncates (can create files) +- `'x'` Creates file and opens for writing (file must **not** already exist) +- `'x+'` Creates file and opens for reading and writing (file must **not** already exist) +- `'a+'` Opens file for reading and writing at **end of file** (can create files) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index d75a73d78c..86045c9663 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -1,5 +1,10 @@ +--- +embed: + title: "The or-gotcha" +--- When checking if something is equal to one thing or another, you might think that this is possible: ```py +# Incorrect... if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` @@ -12,6 +17,6 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': print("That's a weird favorite fruit to have.") # ...or like this. -if favorite_fruit in ('grapefruit', 'lemon'): +if favorite_fruit in ['grapefruit', 'lemon']: print("That's a weird favorite fruit to have.") ``` diff --git a/bot/resources/tags/ot.md b/bot/resources/tags/ot.md new file mode 100644 index 0000000000..46d33d615a --- /dev/null +++ b/bot/resources/tags/ot.md @@ -0,0 +1,7 @@ +--- +embed: + title: "Off-topic channel" +--- +<#463035268514185226> + +Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md index 88069d8bd1..852114921f 100644 --- a/bot/resources/tags/param-arg.md +++ b/bot/resources/tags/param-arg.md @@ -1,5 +1,7 @@ -**Parameters vs. Arguments** - +--- +embed: + title: "Parameters vs. arguments" +--- A parameter is a variable defined in a function signature (the line with `def` in it), while arguments are objects passed to a function call. ```py diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md index 2ed51def72..9e7c979eb0 100644 --- a/bot/resources/tags/paste.md +++ b/bot/resources/tags/paste.md @@ -1,6 +1,9 @@ -**Pasting large amounts of code** +--- +aliases: ["pb"] +embed: + title: "Pasting large amounts of code" +--- +So that everyone can easily read your code, you can paste it in this website: +https://paste.pythondiscord.com/ -If your code is too long to fit in a codeblock in discord, you can paste your code here: -https://paste.pydis.com/ - -After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. +After pasting your code, **save** it by clicking the Paste! button in the bottom left, or by pressing `CTRL + S`. After doing that, you will be navigated to the new paste's page. Copy the URL and post it here so others can see it. diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md index dfeb7ecac8..6e3ed85bcd 100644 --- a/bot/resources/tags/pathlib.md +++ b/bot/resources/tags/pathlib.md @@ -1,21 +1,23 @@ -**Pathlib** - +--- +embed: + title: "The `pathlib` module" +--- Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Path` objects work nearly everywhere that `os.path` can be used, meaning you can integrate your new code directly into legacy code without having to rewrite anything. Pathlib makes working with paths way simpler than `os.path` does. **Feature spotlight**: -• Normalizes file paths for all platforms automatically -• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files -• Can read and write files, and close them automatically -• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) -• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) -• Supports method chaining -• Move and delete files -• And much more +- Normalizes file paths for all platforms automatically +- Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files +- Can read and write files, and close them automatically +- Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) +- Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) +- Supports method chaining +- Move and delete files +- And much more **More Info**: -• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) -• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) -• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) -• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) +- [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +- [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +- [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) +- [**PEP 519** - Adding a file system path protocol](https://peps.python.org/pep-0519/) diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md index 57b1761226..620e2fa082 100644 --- a/bot/resources/tags/pep8.md +++ b/bot/resources/tags/pep8.md @@ -1,5 +1,9 @@ +--- +embed: + title: "PEP 8" +--- **PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like flake8 to verify that the code they're writing complies with the style guide. More information: -• [PEP 8 document](https://www.python.org/dev/peps/pep-0008) -• [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes: +- [PEP 8 document](https://peps.python.org/pep-0008/) +- [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes: diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md index dd6ddfc4bf..0e1eb464ea 100644 --- a/bot/resources/tags/positional-keyword.md +++ b/bot/resources/tags/positional-keyword.md @@ -1,5 +1,7 @@ -**Positional vs. Keyword arguments** - +--- +embed: + title: "Positional vs. keyword arguments" +--- Functions can take two different kinds of arguments. A positional argument is just the object itself. A keyword argument is a name assigned to an object. **Example** @@ -7,7 +9,7 @@ Functions can take two different kinds of arguments. A positional argument is ju >>> print('Hello', 'world!', sep=', ') Hello, world! ``` -The first two strings `'Hello'` and `world!'` are positional arguments. +The first two strings `'Hello'` and `'world!'` are positional arguments. The `sep=', '` is a keyword argument. **Note** @@ -19,7 +21,7 @@ def sum(a, b=1): sum(1, b=5) sum(1, 5) # same as above ``` -[Somtimes this is forced](https://www.python.org/dev/peps/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function. +[Sometimes this is forced](https://peps.python.org/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function. The reverse is also true: ```py @@ -33,6 +35,6 @@ The reverse is also true: ``` **More info** -• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) -• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) -• `!tags param-arg` (Parameters vs. Arguments) +- [Keyword only arguments](https://peps.python.org/pep-3102/) +- [Positional only arguments](https://peps.python.org/pep-0570/) +- `/tag param-arg` (Parameters vs. Arguments) diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md index ed399143ca..8a92c644bd 100644 --- a/bot/resources/tags/precedence.md +++ b/bot/resources/tags/precedence.md @@ -1,6 +1,8 @@ -**Operator Precedence** - -Operator precedence is essentially like an order of operations for python's operators. +--- +embed: + title: "Operator precedence" +--- +Operator precedence is essentially like an order of operations for Python's operators. **Example 1** (arithmetic) `2 * 3 + 1` is `7` because multiplication is first diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md index 8421748a18..dcc21c8139 100644 --- a/bot/resources/tags/quotes.md +++ b/bot/resources/tags/quotes.md @@ -1,5 +1,7 @@ -**String Quotes** - +--- +embed: + title: "String quotes" +--- Single and Double quoted strings are the **same** in Python. The choice of which one to use is up to you, just make sure that you **stick to that choice**. With that said, there are exceptions to this that are more important than consistency. If a single or double quote is needed *inside* the string, using the opposite quotation is better than using escape characters. @@ -16,5 +18,5 @@ Example: If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. **References:** -• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) -• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) +- [pep-8 on quotes](https://peps.python.org/pep-0008/#string-quotes) +- [convention for triple quoted strings](https://peps.python.org/pep-0257/) diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md index 65665eccf4..76fe9051e8 100644 --- a/bot/resources/tags/range-len.md +++ b/bot/resources/tags/range-len.md @@ -1,9 +1,13 @@ -Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection. +--- +embed: + title: "Pythonic way of iterating over ordered collections" +--- +Beginners often iterate over `range(len(...))` because they look like Java or C-style loops, but this is almost always a bad practice in Python. ```py for i in range(len(my_list)): do_something(my_list[i]) ``` -The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order: +It's much simpler to iterate over the list (or other sequence) directly: ```py for item in my_list: do_something(item) diff --git a/bot/resources/tags/regex.md b/bot/resources/tags/regex.md new file mode 100644 index 0000000000..3883db28c9 --- /dev/null +++ b/bot/resources/tags/regex.md @@ -0,0 +1,18 @@ +--- +embed: + title: "Regular expressions" +--- +Regular expressions (regex) are a tool for finding patterns in strings. The standard library's `re` module defines functions for using regex patterns. + +**Example** +We can use regex to pull out all the numbers in a sentence: +```py +>>> import re +>>> text = "On Oct 18 1963 a cat was launched aboard rocket #47" +>>> regex_pattern = r"[0-9]{1,3}" # Matches 1-3 digits +>>> re.findall(regex_pattern, text) +['18', '196', '3', '47'] # Notice the year is cut off +``` +**See Also** +- [The re docs](https://docs.python.org/3/library/re.html) - for functions that use regex +- [regex101.com](https://regex101.com) - an interactive site for testing your regular expression diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md index 6e97b78af6..15f00c9518 100644 --- a/bot/resources/tags/relative-path.md +++ b/bot/resources/tags/relative-path.md @@ -1,7 +1,9 @@ -**Relative Path** - -A relative path is a partial path that is relative to your current working directory. A common misconception is that your current working directory is the location of the module you're executing, **but this is not the case**. Your current working directory is actually the **directory you were in when you ran the python interpreter**. The reason for this misconception is because a common way to run your code is to navigate to the directory your module is stored, and run `python .py`. Thus, in this case your current working directory will be the same as the location of the module. However, if we instead did `python path/to/.py`, our current working directory would no longer be the same as the location of the module we're executing. +--- +embed: + title: "Relative path" +--- +A relative path is a partial path that is relative to your current working directory. A common misconception is that your current working directory is the location of the module you're executing, **but this is not the case**. Your current working directory is actually the **directory you were in when you ran the Python interpreter**. The reason for this misconception is because a common way to run your code is to navigate to the directory your module is stored, and run `python .py`. Thus, in this case your current working directory will be the same as the location of the module. However, if we instead did `python path/to/.py`, our current working directory would no longer be the same as the location of the module we're executing. **Why is this important?** -When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. +When opening files in Python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md index 875b4ec47b..410d08f741 100644 --- a/bot/resources/tags/repl.md +++ b/bot/resources/tags/repl.md @@ -1,13 +1,20 @@ -**Read-Eval-Print Loop** +--- +embed: + title: "Read-Eval-Print Loop (REPL)" +--- +A REPL is an interactive shell where you can execute individual lines of code one at a time, like so: +```python-repl +>>> x = 5 +>>> x + 2 +7 +>>> for i in range(3): +... print(i) +... +0 +1 +2 +>>> +``` +To enter the REPL, run `python` (`py` on Windows) in the command line without any arguments. The `>>>` or `...` at the start of some lines are prompts to enter code, and indicate that you are in the Python REPL. Any other lines show the output of the code. -A REPL is an interactive language shell environment. It first **reads** one or more expressions entered by the user, **evaluates** it, yields the result, and **prints** it out to the user. It will then **loop** back to the **read** step. - -To use python's REPL, execute the interpreter with no arguments. This will drop you into the interactive interpreter shell, print out some relevant information, and then prompt you with the primary prompt `>>>`. At this point it is waiting for your input. - -Firstly you can start typing in some valid python expressions, pressing to either bring you to the **eval** step, or prompting you with the secondary prompt `...` (or no prompt at all depending on your environment), meaning your expression isn't yet terminated and it's waiting for more input. This is useful for code that requires multiple lines like loops, functions, and classes. If you reach the secondary prompt in a clause that can have an arbitrary amount of expressions, you can terminate it by pressing on a blank line. In other words, for the last expression you write in the clause, must be pressed twice in a row. - -Alternatively, you can make use of the builtin `help()` function. `help(thing)` to get help on some `thing` object, or `help()` to start an interactive help session. This mode is extremely powerful, read the instructions when first entering the session to learn how to use it. - -Lastly you can run your code with the `-i` flag to execute your code normally, but be dropped into the REPL once execution is finished, giving you access to all your global variables/functions in the REPL. - -To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. +Trying to execute commands for the command-line (such as `pip install xyz`) in the REPL will throw an error. To run these commands, exit the REPL first by running `exit()` and then run the original command. diff --git a/bot/resources/tags/return-gif.md b/bot/resources/tags/return-gif.md new file mode 100644 index 0000000000..57b6afefbe --- /dev/null +++ b/bot/resources/tags/return-gif.md @@ -0,0 +1,10 @@ +--- +aliases: ["print-return", "return-jif"] +embed: + title: Print and return + image: + url: https://raw.githubusercontent.com/python-discord/bot/main/bot/resources/media/print-return.gif +--- +Here's a handy animation demonstrating how `print` and `return` differ in behavior. + +See also: `/tag return` diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md index e37f0eebce..0676bbbd98 100644 --- a/bot/resources/tags/return.md +++ b/bot/resources/tags/return.md @@ -1,27 +1,27 @@ -**Return Statement** - -When calling a function, you'll often want it to give you a value back. In order to do that, you must `return` it. The reason for this is because functions have their own scope. Any values defined within the function body are inaccessible outside of that function. - -*For more information about scope, see `!tags scope`* +--- +embed: + title: "Return statement" +--- +A value created inside a function can't be used outside of it unless you `return` it. Consider the following function: ```py def square(n): - return n*n + return n * n ``` -If we wanted to store 5 squared in a variable called `x`, we could do that like so: +If we wanted to store 5 squared in a variable called `x`, we would do: `x = square(5)`. `x` would now equal `25`. **Common Mistakes** ```py >>> def square(n): -... n*n # calculates then throws away, returns None +... n * n # calculates then throws away, returns None ... >>> x = square(5) >>> print(x) None >>> def square(n): -... print(n*n) # calculates and prints, then throws away and returns None +... print(n * n) # calculates and prints, then throws away and returns None ... >>> x = square(5) 25 @@ -29,7 +29,6 @@ None None ``` **Things to note** -• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. -• A function will return `None` if it ends without reaching an explicit `return` statement. -• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. -• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) +- `print()` and `return` do **not** accomplish the same thing. `print()` will show the value, and then it will be gone. +- A function will return `None` if it ends without a `return` statement. +- When you want to print a value from a function, it's best to return the value and print the *function call* instead, like `print(square(5))`. diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md index 0392bb41b2..aca11dec5a 100644 --- a/bot/resources/tags/round.md +++ b/bot/resources/tags/round.md @@ -1,5 +1,7 @@ -**Round half to even** - +--- +embed: + title: "Round half to even*" +--- Python 3 uses bankers' rounding (also known by other names), where if the fractional part of a number is `.5`, it's rounded to the nearest **even** result instead of away from zero. Example: @@ -18,7 +20,7 @@ The round half up technique creates a slight bias towards the larger number. Wit It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. **References:** -• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) -• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) -• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) -• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) +- [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) +- [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) +- [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) +- [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md index 5c1e64e1c8..963aaaa818 100644 --- a/bot/resources/tags/scope.md +++ b/bot/resources/tags/scope.md @@ -1,8 +1,10 @@ -**Scoping Rules** +--- +embed: + title: "Scoping rules" +--- +A *scope* defines the visibility of a name within a block, where a block is a piece of Python code executed as a unit. For simplicity, this would be a module, a function body, and a class definition. A name refers to text bound to an object. -A *scope* defines the visibility of a name within a block, where a block is a piece of python code executed as a unit. For simplicity, this would be a module, a function body, and a class definition. A name refers to text bound to an object. - -*For more information about names, see `!tags names`* +*For more information about names, see `/tag names`* A module is the source code file itself, and encompasses all blocks defined within it. Therefore if a variable is defined at the module level (top-level code block), it is a global variable and can be accessed anywhere in the module as long as the block in which it's referenced is executed after it was defined. diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md index bc013fe036..542ec25df4 100644 --- a/bot/resources/tags/seek.md +++ b/bot/resources/tags/seek.md @@ -1,5 +1,7 @@ -**Seek** - +--- +embed: + title: "Seek" +--- In the context of a [file object](https://docs.python.org/3/glossary.html#term-file-object), the `seek` function changes the stream position to a given byte offset, with an optional argument of where to offset from. While you can find the official documentation [here](https://docs.python.org/3/library/io.html#io.IOBase.seek), it can be unclear how to actually use this feature, so keep reading to see examples on how to use it. File named `example`: @@ -18,5 +20,5 @@ Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward r Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. **Note** -• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. -• `os.SEEK_CUR` is only usable when the file is in byte mode. +- For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. +- `os.SEEK_CUR` is only usable when the file is in byte mode. diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md index d20154fd5d..a90e0ffc58 100644 --- a/bot/resources/tags/self.md +++ b/bot/resources/tags/self.md @@ -1,5 +1,7 @@ -**Class instance** - +--- +embed: + title: "Class instance" +--- When calling a method from a class instance (ie. `instance.method()`), the instance itself will automatically be passed as the first argument implicitly. By convention, we call this `self`, but it could technically be called any valid variable name. ```py diff --git a/bot/resources/tags/site.md b/bot/resources/tags/site.md new file mode 100644 index 0000000000..d5ffc5e1fb --- /dev/null +++ b/bot/resources/tags/site.md @@ -0,0 +1,5 @@ +--- +embed: + title: "Python Discord website" +--- +[Our official website](https://www.pythondiscord.com/) is an open-source community project created with Python and Django. It contains information about the server itself, lets you sign up for upcoming events, has its own wiki, contains a list of valuable learning resources, and much more. diff --git a/bot/resources/tags/slicing.md b/bot/resources/tags/slicing.md new file mode 100644 index 0000000000..0d82c642cb --- /dev/null +++ b/bot/resources/tags/slicing.md @@ -0,0 +1,24 @@ +--- +aliases: ["slice", "seqslice", "seqslicing", "sequence-slice", "sequence-slicing"] +embed: + title: "Sequence slicing" +--- +**Slicing** is a way of accessing a part of a sequence by specifying a start, stop, and step. As with normal indexing, negative numbers can be used to count backwards. + +**Examples** +```py +>>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] +>>> letters[2:] # from element 2 to the end +['c', 'd', 'e', 'f', 'g'] +>>> letters[:4] # up to element 4 +['a', 'b', 'c', 'd'] +>>> letters[3:5] # elements 3 and 4 -- the right bound is not included +['d', 'e'] +>>> letters[2:-1:2] # Every other element between 2 and the last +['c', 'e'] +>>> letters[::-1] # The whole list in reverse +['g', 'f', 'e', 'd', 'c', 'b', 'a'] +>>> words = "Hello world!" +>>> words[2:7] # Strings are also sequences +"llo w" +``` diff --git a/bot/resources/tags/sql-fstring.md b/bot/resources/tags/sql-fstring.md new file mode 100644 index 0000000000..30de567b29 --- /dev/null +++ b/bot/resources/tags/sql-fstring.md @@ -0,0 +1,19 @@ +--- +embed: + title: "SQL & f-strings" +--- +Don't use f-strings (`f""`) or other forms of "string interpolation" (`%`, `+`, `.format`) to inject data into a SQL query. It is an endless source of bugs and syntax errors. Additionally, in user-facing applications, it presents a major security risk via SQL injection. + +Your database library should support "query parameters". A query parameter is a placeholder that you put in the SQL query. When the query is executed, you provide data to the database library, and the library inserts the data into the query for you, **safely**. + +For example, the sqlite3 package supports using `?` as a placeholder: +```py +query = "SELECT * FROM stocks WHERE symbol = ?;" +params = ("RHAT",) +db.execute(query, params) +``` +Note: Different database libraries support different placeholder styles, e.g. `%s` and `$1`. Consult your library's documentation for details. + +**See Also** +- [Python sqlite3 docs](https://docs.python.org/3/library/sqlite3.html#how-to-use-placeholders-to-bind-values-in-sql-queries) - How to use placeholders to bind values in SQL queries +- [PEP-249](https://peps.python.org/pep-0249/) - A specification of how database libraries in Python should work diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md index 3b1b6a858c..236c7b5037 100644 --- a/bot/resources/tags/star-imports.md +++ b/bot/resources/tags/star-imports.md @@ -1,4 +1,7 @@ -**Star / Wildcard imports** +--- +embed: + title: "Star / Wildcard imports" +--- Wildcard imports are import statements in the form `from import *`. What imports like these do is that they import everything **[1]** from the module into the current module's namespace **[2]**. This allows you to use names defined in the imported module without prefixing the module's name. @@ -16,18 +19,18 @@ Example: >>> from math import * >>> sin(pi / 2) # uses sin from math rather than your custom sin ``` -• Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. -• Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` -• Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. +- Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. +- Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` +- Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. **How should you import?** -• Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) +- Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) ```python >>> import math >>> math.sin(math.pi / 2) ``` -• Explicitly import certain names from the module +- Explicitly import certain names from the module ```python >>> from math import sin, pi >>> sin(pi / 2) @@ -36,4 +39,4 @@ Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3 **[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore) **[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) -**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) +**[3]** [Zen of Python](https://peps.python.org/pep-0020/) diff --git a/bot/resources/tags/str-join.md b/bot/resources/tags/str-join.md index c835f9313b..67fe583216 100644 --- a/bot/resources/tags/str-join.md +++ b/bot/resources/tags/str-join.md @@ -1,5 +1,7 @@ -**Joining Iterables** - +--- +embed: + title: "Joining iterables" +--- If you want to display a list (or some other iterable), you can write: ```py colors = ['red', 'green', 'blue', 'yellow'] diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md new file mode 100644 index 0000000000..281107e4a7 --- /dev/null +++ b/bot/resources/tags/string-formatting.md @@ -0,0 +1,27 @@ +--- +embed: + title: "String formatting mini-language" +--- +The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and `.format()`. + +Take a look at some of these examples! +```py +>>> my_num = 2134234523 +>>> print(f"{my_num:,}") +2,134,234,523 + +>>> my_smaller_num = -30.0532234 +>>> print(f"{my_smaller_num:=09.2f}") +-00030.05 + +>>> my_str = "Center me!" +>>> print(f"{my_str:-^20}") +-----Center me!----- + +>>> repr_str = "Spam \t Ham" +>>> print(f"{repr_str!r}") +'Spam \t Ham' +``` +**Full Specification & Resources** +[String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language) +[pyformat.info](https://pyformat.info/) diff --git a/bot/resources/tags/strip-gotcha.md b/bot/resources/tags/strip-gotcha.md new file mode 100644 index 0000000000..934b10f1a3 --- /dev/null +++ b/bot/resources/tags/strip-gotcha.md @@ -0,0 +1,21 @@ +--- +embed: + title: "The strip-gotcha" +--- +When working with `strip`, `lstrip`, or `rstrip`, you might think that this would be the case: +```py +>>> "Monty Python".rstrip(" Python") +"Monty" +``` +While this seems intuitive, it would actually result in: +```py +"M" +``` +as Python interprets the argument to these functions as a set of characters rather than a substring. + +If you want to remove a prefix/suffix from a string, `str.removeprefix` and `str.removesuffix` are recommended and were added in 3.9. +```py +>>> "Monty Python".removesuffix(" Python") +"Monty" +``` +See the documentation of [str.removeprefix](https://docs.python.org/3.10/library/stdtypes.html#str.removeprefix) and [str.removesuffix](https://docs.python.org/3.10/library/stdtypes.html#str.removesuffix) for more information. diff --git a/bot/resources/tags/system-python.md b/bot/resources/tags/system-python.md new file mode 100644 index 0000000000..d34faf3ad3 --- /dev/null +++ b/bot/resources/tags/system-python.md @@ -0,0 +1,15 @@ +--- +embed: + title: "System Python" +--- + +*Why Avoid System Python for Development on Unix-like Systems:* + +- **Critical Operating System Dependencies:** Altering the system Python installation may harm internal operating system dependencies. +- **Stability and Security Concerns:** System interpreters lag behind current releases, lacking the latest features and security patches. +- **Limited Package Control:** External package management restricts control over versions, leading to compatibility issues with outdated packages. + +*Recommended Approach:* + +- **Install Independent Interpreter:** Install Python from source or utilize a virtual environment for flexibility and control. +- **Utilize [Pyenv](https://github.com/pyenv/pyenv) or Similar Tools:** Manage multiple Python versions and create isolated development environments for smoother workflows. diff --git a/bot/resources/tags/tools.md b/bot/resources/tags/tools.md new file mode 100644 index 0000000000..3cae755523 --- /dev/null +++ b/bot/resources/tags/tools.md @@ -0,0 +1,6 @@ +--- +embed: + title: "Tools" +--- + +The [Tools page](https://www.pythondiscord.com/resources/tools/) on our website contains a couple of the most popular tools for programming in Python. diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index e770fa86df..005f23b1c1 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -1,18 +1,20 @@ -Please provide a full traceback to your exception in order for us to identify your issue. +--- +embed: + title: "Traceback" +--- +Please provide the full traceback for your exception in order to help us identify your issue. +While the last line of the error message tells us what kind of error you got, +the full traceback will tell us which line, and other critical information to solve your problem. +Please avoid screenshots so we can copy and paste parts of the message. A full traceback could look like: ```py Traceback (most recent call last): - File "tiny", line 3, in - do_something() - File "tiny", line 2, in do_something - a = 6 / 0 -ZeroDivisionError: integer division or modulo by zero + File "my_file.py", line 5, in + add_three("6") + File "my_file.py", line 2, in add_three + a = num + 3 + ~~~~^~~ +TypeError: can only concatenate str (not "int") to str ``` -The best way to read your traceback is bottom to top. - -• Identify the exception raised (e.g. ZeroDivisionError) -• Make note of the line number, and navigate there in your program. -• Try to understand why the error occurred. - -To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) +If the traceback is long, use [our pastebin](https://paste.pythondiscord.com/). diff --git a/bot/resources/tags/type-hint.md b/bot/resources/tags/type-hint.md new file mode 100644 index 0000000000..315257349b --- /dev/null +++ b/bot/resources/tags/type-hint.md @@ -0,0 +1,21 @@ +--- +embed: + title: "Type hints" +--- +A type hint indicates what type a variable is expected to be. +```python +def add(a: int, b: int) -> int: + return a + b +``` +The type hints indicate that for our `add` function the parameters `a` and `b` should be integers, and the function should return an integer when called. + +It's important to note these are just hints and are not enforced at runtime. + +```python +add("hello ", "world") +``` +The above code won't error even though it doesn't follow the function's type hints; the two strings will be concatenated as normal. + +Third party tools like [mypy](https://mypy.readthedocs.io/en/stable/introduction.html) can validate your code to ensure it is type hinted correctly. This can help you identify potentially buggy code, for example it would error on the second example as our `add` function is not intended to concatenate strings. + +[mypy's documentation](https://mypy.readthedocs.io/en/stable/builtin_types.html) contains useful information on type hinting, and for more information check out [this documentation page](https://typing.readthedocs.io/en/latest/index.html). diff --git a/bot/resources/tags/underscore.md b/bot/resources/tags/underscore.md new file mode 100644 index 0000000000..3dc1ede01a --- /dev/null +++ b/bot/resources/tags/underscore.md @@ -0,0 +1,27 @@ +--- +aliases: ["under"] +embed: + title: "Meanings of underscores in identifier names" +--- + +- `__name__`: Used to implement special behaviour, such as the `+` operator for classes with the `__add__` method. [More info](https://dbader.org/blog/python-dunder-methods) +- `_name`: Indicates that a variable is "private" and should only be used by the class or module that defines it +- `name_`: Used to avoid naming conflicts. For example, as `class` is a keyword, you could call a variable `class_` instead +- `__name`: Causes the name to be "mangled" if defined inside a class. [More info](https://docs.python.org/3/tutorial/classes.html#private-variables) + +A single underscore, **`_`**, has multiple uses: +- To indicate an unused variable, e.g. in a for loop if you don't care which iteration you are on +```python +for _ in range(10): + print("Hello World") +``` +- In the REPL, where the previous result is assigned to the variable `_` +```python +>>> 1 + 1 # Evaluated and stored in `_` + 2 +>>> _ + 3 # Take the previous result and add 3 + 5 +``` +- In integer literals, e.g. `x = 1_500_000` can be written instead of `x = 1500000` to improve readability + +See also ["Reserved classes of identifiers"](https://docs.python.org/3/reference/lexical_analysis.html#reserved-classes-of-identifiers) in the Python docs, and [this more detailed guide](https://dbader.org/blog/meaning-of-underscores-in-python). diff --git a/bot/resources/tags/venv.md b/bot/resources/tags/venv.md new file mode 100644 index 0000000000..bf0158fdeb --- /dev/null +++ b/bot/resources/tags/venv.md @@ -0,0 +1,26 @@ +--- +aliases: ["virtualenv"] +embed: + title: "Virtual environments" +--- + +Virtual environments are isolated Python environments, which make it easier to keep your system clean and manage dependencies. By default, when activated, only libraries and scripts installed in the virtual environment are accessible, preventing cross-project dependency conflicts, and allowing easy isolation of requirements. + +To create a new virtual environment, you can use the standard library `venv` module: `python3 -m venv .venv` (replace `python3` with `python` or `py` on Windows) + +Then, to activate the new virtual environment: + +**Windows** (PowerShell): `.venv\Scripts\Activate.ps1` +or (Command Prompt): `.venv\Scripts\activate.bat` +**MacOS / Linux** (Bash): `source .venv/bin/activate` + +Packages can then be installed to the virtual environment using `pip`, as normal. + +For more information, take a read of the [documentation](https://docs.python.org/3/library/venv.html). If you run code through your editor, check its documentation on how to make it use your virtual environment. For example, see the [VSCode](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) or [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) docs. + +Tools such as [poetry](https://python-poetry.org/docs/basic-usage/) and [pipenv](https://pipenv.pypa.io/en/latest/) can manage the creation of virtual environments as well as project dependencies, making packaging and installing your project easier. + +**Note:** When using PowerShell in Windows, you may need to change the [execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) first. This is only required once per user: +```ps1 +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` diff --git a/bot/resources/tags/voice-verification.md b/bot/resources/tags/voice-verification.md index 3d88b0c71f..5ec3ec2b39 100644 --- a/bot/resources/tags/voice-verification.md +++ b/bot/resources/tags/voice-verification.md @@ -1,3 +1,5 @@ -**Voice verification** - +--- +embed: + title: "Voice verification" +--- Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there. diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index da8edf685f..ee86772bbe 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,30 +1,19 @@ -**PATH on Windows** +--- +embed: + title: "PATH on Windows" +--- +If you have installed Python but forgot to check the `Add Python to PATH` option during the installation, you may still be able to access your installation with ease. -If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. +If you did not uncheck the option to install the `py launcher`, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `Add Python to PATH` checkbox). -If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. +You can pass any options to the Python interpreter, e.g. to install the [`numpy`](https://pypi.org/project/numpy/) module from PyPI you can run `py -3 -m pip install numpy` or `python -m pip install numpy`. -Otherwise, you can access your install using the `py` command in Command Prompt. Where you may type something with the `python` command like: -``` -C:\Users\Username> python3 my_application_file.py -``` - -You can achieve the same result using the `py` command like this: -``` -C:\Users\Username> py -3 my_application_file.py -``` - -You can pass any options to the Python interpreter after you specify a version, for example, to install a Python module using `pip` you can run: -``` -C:\Users\Username> py -3 -m pip install numpy -``` - -You can also access different versions of Python using the version flag, like so: +You can also access different versions of Python using the version flag of the `py` command, like so: ``` C:\Users\Username> py -3.7 ... Python 3.7 starts ... C:\Users\Username> py -3.6 -... Python 3.6 stars ... +... Python 3.6 starts ... C:\Users\Username> py -2 ... Python 2 (any version installed) starts ... ``` diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md index 62d5612f26..b429165123 100644 --- a/bot/resources/tags/with.md +++ b/bot/resources/tags/with.md @@ -1,3 +1,7 @@ +--- +embed: + title: "The `with` keyword" +--- The `with` keyword triggers a context manager. Context managers automatically set up and take down data connections, or any other kind of object that implements the magic methods `__enter__` and `__exit__`. ```py with open("test.txt", "r") as file: @@ -5,4 +9,4 @@ with open("test.txt", "r") as file: ``` The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. -For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). +For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://peps.python.org/pep-0343/). diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md index b77bd27e8f..f831eaf1d3 100644 --- a/bot/resources/tags/xy-problem.md +++ b/bot/resources/tags/xy-problem.md @@ -1,7 +1,9 @@ -**xy-problem** - -Asking about your attempted solution rather than your actual problem. +--- +embed: + title: "xy-problem" +--- +The XY problem can be summarised as asking about your attempted solution, rather than your actual problem. Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. -For more information and examples: http://xyproblem.info/ +For more information and examples, see http://xyproblem.info/. diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index f96b7f8535..611b2c48c8 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,4 +1,8 @@ -Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders as their usage violates YouTube's Terms of Service. +--- +embed: + title: "Our youtube-dl, or equivalents, policy" +--- +Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders, as their usage violates YouTube's Terms of Service. For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?gl=GB&template=terms), as of 2021-03-17: ``` diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md index 6b05f0282a..6d82456945 100644 --- a/bot/resources/tags/zip.md +++ b/bot/resources/tags/zip.md @@ -1,9 +1,13 @@ +--- +embed: + title: "The `zip` function" +--- The zip function allows you to iterate through multiple iterables simultaneously. It joins the iterables together, almost like a zipper, so that each new element is a tuple with one element from each iterable. ```py letters = 'abc' numbers = [1, 2, 3] -# zip(letters, numbers) --> [('a', 1), ('b', 2), ('c', 3)] +# list(zip(letters, numbers)) --> [('a', 1), ('b', 2), ('c', 3)] for letter, number in zip(letters, numbers): print(letter, number) ``` diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py deleted file mode 100644 index a01ceae73d..0000000000 --- a/bot/rules/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# flake8: noqa - -from .attachments import apply as apply_attachments -from .burst import apply as apply_burst -from .burst_shared import apply as apply_burst_shared -from .chars import apply as apply_chars -from .discord_emojis import apply as apply_discord_emojis -from .duplicates import apply as apply_duplicates -from .links import apply as apply_links -from .mentions import apply as apply_mentions -from .newlines import apply as apply_newlines -from .role_mentions import apply as apply_role_mentions diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py deleted file mode 100644 index 8903c385cb..0000000000 --- a/bot/rules/attachments.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total attachments exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if ( - msg.author == last_message.author - and len(msg.attachments) > 0 - ) - ) - total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages) - - if total_recent_attachments > config['max']: - return ( - f"sent {total_recent_attachments} attachments in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/burst.py b/bot/rules/burst.py deleted file mode 100644 index 25c5a2f335..0000000000 --- a/bot/rules/burst.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects repeated messages sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - total_relevant = len(relevant_messages) - - if total_relevant > config['max']: - return ( - f"sent {total_relevant} messages in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py deleted file mode 100644 index bbe9271b3b..0000000000 --- a/bot/rules/burst_shared.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects repeated messages sent by multiple users.""" - total_recent = len(recent_messages) - - if total_recent > config['max']: - return ( - f"sent {total_recent} messages in {config['interval']}s", - set(msg.author for msg in recent_messages), - recent_messages - ) - return None diff --git a/bot/rules/chars.py b/bot/rules/chars.py deleted file mode 100644 index 1f587422c5..0000000000 --- a/bot/rules/chars.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total message char count exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - - total_recent_chars = sum(len(msg.content) for msg in relevant_messages) - - if total_recent_chars > config['max']: - return ( - f"sent {total_recent_chars} characters in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py deleted file mode 100644 index 41faf7ee86..0000000000 --- a/bot/rules/discord_emojis.py +++ /dev/null @@ -1,35 +0,0 @@ -import re -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message -from emoji import demojize - - -DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") -CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total Discord emojis exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - - # Get rid of code blocks in the message before searching for emojis. - # Convert Unicode emojis to :emoji: format to get their count. - total_emojis = sum( - len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content)))) - for msg in relevant_messages - ) - - if total_emojis > config['max']: - return ( - f"sent {total_emojis} emojis in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py deleted file mode 100644 index 8e4fbc12df..0000000000 --- a/bot/rules/duplicates.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects duplicated messages sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if ( - msg.author == last_message.author - and msg.content == last_message.content - and msg.content - ) - ) - - total_duplicated = len(relevant_messages) - - if total_duplicated > config['max']: - return ( - f"sent {total_duplicated} duplicated messages in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/links.py b/bot/rules/links.py deleted file mode 100644 index ec75a19c53..0000000000 --- a/bot/rules/links.py +++ /dev/null @@ -1,37 +0,0 @@ -import re -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -LINK_RE = re.compile(r"(https?://[^\s]+)") - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total links exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - total_links = 0 - messages_with_links = 0 - - for msg in relevant_messages: - total_matches = len(LINK_RE.findall(msg.content)) - if total_matches: - messages_with_links += 1 - total_links += total_matches - - # Only apply the filter if we found more than one message with - # links to prevent wrongfully firing the rule on users posting - # e.g. an installation log of pip packages from GitHub. - if total_links > config['max'] and messages_with_links > 1: - return ( - f"sent {total_links} links in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py deleted file mode 100644 index 79725a4b10..0000000000 --- a/bot/rules/mentions.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total mentions exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - - total_recent_mentions = sum(len(msg.mentions) for msg in relevant_messages) - - if total_recent_mentions > config['max']: - return ( - f"sent {total_recent_mentions} mentions in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py deleted file mode 100644 index 4e66e1359b..0000000000 --- a/bot/rules/newlines.py +++ /dev/null @@ -1,45 +0,0 @@ -import re -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total newlines exceeding the set limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - - # Identify groups of newline characters and get group & total counts - exp = r"(\n+)" - newline_counts = [] - for msg in relevant_messages: - newline_counts += [len(group) for group in re.findall(exp, msg.content)] - total_recent_newlines = sum(newline_counts) - - # Get maximum newline group size - if newline_counts: - max_newline_group = max(newline_counts) - else: - # If no newlines are found, newline_counts will be an empty list, which will error out max() - max_newline_group = 0 - - # Check first for total newlines, if this passes then check for large groupings - if total_recent_newlines > config['max']: - return ( - f"sent {total_recent_newlines} newlines in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - elif max_newline_group > config['max_consecutive']: - return ( - f"sent {max_newline_group} consecutive newlines in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - - return None diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py deleted file mode 100644 index 0649540b68..0000000000 --- a/bot/rules/role_mentions.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Dict, Iterable, List, Optional, Tuple - -from discord import Member, Message - - -async def apply( - last_message: Message, recent_messages: List[Message], config: Dict[str, int] -) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: - """Detects total role mentions exceeding the limit sent by a single user.""" - relevant_messages = tuple( - msg - for msg in recent_messages - if msg.author == last_message.author - ) - - total_recent_mentions = sum(len(msg.role_mentions) for msg in relevant_messages) - - if total_recent_mentions > config['max']: - return ( - f"sent {total_recent_mentions} role mentions in {config['interval']}s", - (last_message.author,), - relevant_messages - ) - return None diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 13533a467e..f803add1c5 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,4 +1,8 @@ from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64 -from bot.utils.services import send_to_paste_service -__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service'] +__all__ = [ + "CogABCMeta", + "find_nth_occurrence", + "has_lines", + "pad_base64", +] diff --git a/bot/utils/cache.py b/bot/utils/cache.py deleted file mode 100644 index 68ce156076..0000000000 --- a/bot/utils/cache.py +++ /dev/null @@ -1,41 +0,0 @@ -import functools -from collections import OrderedDict -from typing import Any, Callable - - -class AsyncCache: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - - def __init__(self, max_size: int = 128): - self._cache = OrderedDict() - self._max_size = max_size - - def __call__(self, arg_offset: int = 0) -> Callable: - """Decorator for async cache.""" - - def decorator(function: Callable) -> Callable: - """Define the async cache decorator.""" - - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = args[arg_offset:] - - if key not in self._cache: - if len(self._cache) > self._max_size: - self._cache.popitem(last=False) - - self._cache[key] = await function(*args) - return self._cache[key] - return wrapper - return decorator - - def clear(self) -> None: - """Clear cache instance.""" - self._cache.clear() diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 72603c521e..b819237c4e 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -1,35 +1,28 @@ -import logging import discord import bot from bot import constants -from bot.constants import Categories +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) -def is_help_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is in one of the help categories (excluding dormant).""" - log.trace(f"Checking if #{channel} is a help channel.") - categories = (Categories.help_available, Categories.help_in_use) +def is_mod_channel(channel: discord.TextChannel | discord.Thread) -> bool: + """True if channel, or channel.parent for threads, is considered a mod channel.""" + if isinstance(channel, discord.Thread): + channel = channel.parent - return any(is_in_category(channel, category) for category in categories) - - -def is_mod_channel(channel: discord.TextChannel) -> bool: - """True if `channel` is considered a mod channel.""" if channel.id in constants.MODERATION_CHANNELS: log.trace(f"Channel #{channel} is a configured mod channel") return True - elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): + if any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): log.trace(f"Channel #{channel} is in a configured mod category") return True - else: - log.trace(f"Channel #{channel} is not a mod channel") - return False + log.trace(f"Channel #{channel} is not a mod channel") + return False def is_staff_channel(channel: discord.TextChannel) -> bool: @@ -51,16 +44,3 @@ def is_staff_channel(channel: discord.TextChannel) -> bool: def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" return getattr(channel, "category_id", None) == category_id - - -async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" - log.trace(f"Getting the channel {channel_id}.") - - channel = bot.instance.get_channel(channel_id) - if not channel: - log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await bot.instance.fetch_channel(channel_id) - - log.trace(f"Channel #{channel} ({channel_id}) retrieved.") - return channel diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 3d0c8a50cd..e46c8209b0 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,6 +1,4 @@ -import datetime -import logging -from typing import Callable, Container, Iterable, Optional, Union +from collections.abc import Callable, Container, Iterable from discord.ext.commands import ( BucketType, @@ -16,14 +14,15 @@ ) from bot import constants +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class ContextCheckFailure(CheckFailure): """Raised when a context-specific check fails.""" - def __init__(self, redirect_channel: Optional[int]) -> None: + def __init__(self, redirect_channel: int | None) -> None: self.redirect_channel = redirect_channel if redirect_channel: @@ -45,7 +44,7 @@ def in_whitelist_check( channels: Container[int] = (), categories: Container[int] = (), roles: Container[int] = (), - redirect: Optional[int] = constants.Channels.bot_commands, + redirect: int | None = constants.Channels.bot_commands, fail_silently: bool = False, ) -> bool: """ @@ -95,7 +94,7 @@ def in_whitelist_check( return False -async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool: +async def has_any_role_check(ctx: Context, *roles: str | int) -> bool: """ Returns True if the context's author has any of the specified roles. @@ -108,7 +107,7 @@ async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool: return False -async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool: +async def has_no_roles_check(ctx: Context, *roles: str | int) -> bool: """ Returns True if the context's author doesn't have any of the specified roles. @@ -123,8 +122,13 @@ async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool: return True -def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *, - bypass_roles: Iterable[int]) -> Callable: +def cooldown_with_role_bypass( + rate: int, + per: float, + type: BucketType = BucketType.default, + *, + bypass_roles: Iterable[int] +) -> Callable: """ Applies a cooldown to a command, but allows members with certain roles to be ignored. @@ -134,7 +138,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy bypass = set(bypass_roles) # this handles the actual cooldown logic - buckets = CooldownMapping(Cooldown(rate, per, type)) + buckets = CooldownMapping(Cooldown(rate, per), type) # will be called after the command has been parse but before it has been invoked, ensures that # the cooldown won't be updated if the user screws up their input to the command @@ -145,11 +149,11 @@ async def predicate(cog: Cog, ctx: Context) -> None: return # cooldown logic, taken from discord.py internals - current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() + current = ctx.message.created_at.timestamp() bucket = buckets.get_bucket(ctx.message) retry_after = bucket.update_rate_limit(current) if retry_after: - raise CommandOnCooldown(bucket, retry_after) + raise CommandOnCooldown(bucket, retry_after, type) def wrapper(command: Command) -> Command: # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it @@ -157,8 +161,10 @@ def wrapper(command: Command) -> Command: # # if the `before_invoke` detail is ever a problem then I can quickly just swap over. if not isinstance(command, Command): - raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' - 'This means it has to be above the command decorator in the code.') + raise TypeError( + "Decorator `cooldown_with_role_bypass` must be applied after the command decorator. " + "This means it has to be above the command decorator in the code." + ) command._before_invoke = predicate diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py deleted file mode 100644 index 50350ea8d9..0000000000 --- a/bot/utils/extensions.py +++ /dev/null @@ -1,34 +0,0 @@ -import importlib -import inspect -import pkgutil -from typing import Iterator, NoReturn - -from bot import exts - - -def unqualify(name: str) -> str: - """Return an unqualified name given a qualified module/package `name`.""" - return name.rsplit(".", maxsplit=1)[-1] - - -def walk_extensions() -> Iterator[str]: - """Yield extension names from the bot.exts subpackage.""" - - def on_error(name: str) -> NoReturn: - raise ImportError(name=name) # pragma: no cover - - for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error): - if unqualify(module.name).startswith("_"): - # Ignore module/package names starting with an underscore. - continue - - if module.ispkg: - imported = importlib.import_module(module.name) - if not inspect.isfunction(getattr(imported, "setup", None)): - # If it lacks a setup function, it's not an extension. - continue - - yield module.name - - -EXTENSIONS = frozenset(walk_extensions()) diff --git a/bot/utils/function.py b/bot/utils/function.py index 9bc44e7538..e77ed794ed 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -2,13 +2,14 @@ import functools import inspect -import logging import types import typing as t -log = logging.getLogger(__name__) +from bot.log import get_logger -Argument = t.Union[int, str] +log = get_logger(__name__) + +Argument = int | str BoundArgs = t.OrderedDict[str, t.Any] Decorator = t.Callable[[t.Callable], t.Callable] ArgValGetter = t.Callable[[BoundArgs], t.Any] @@ -33,7 +34,7 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: arg_pos = name_or_pos try: - name, value = arg_values[arg_pos] + _name, value = arg_values[arg_pos] return value except IndexError: raise ValueError(f"Argument position {arg_pos} is out of bounds.") @@ -50,7 +51,7 @@ def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: def get_arg_value_wrapper( decorator_func: t.Callable[[ArgValGetter], Decorator], name_or_pos: Argument, - func: t.Callable[[t.Any], t.Any] = None, + func: t.Callable[[t.Any], t.Any] | None = None, ) -> Decorator: """ Call `decorator_func` with the value of the arg at the given name/position. @@ -71,7 +72,7 @@ def wrapper(args: BoundArgs) -> t.Any: return decorator_func(wrapper) -def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: +def get_bound_args(func: t.Callable, args: tuple, kwargs: dict[str, t.Any]) -> BoundArgs: """ Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. @@ -88,7 +89,7 @@ def update_wrapper_globals( wrapper: types.FunctionType, wrapped: types.FunctionType, *, - ignored_conflict_names: t.Set[str] = frozenset(), + ignored_conflict_names: set[str] = frozenset(), ) -> types.FunctionType: """ Update globals of `wrapper` with the globals from `wrapped`. @@ -133,7 +134,7 @@ def command_wraps( assigned: t.Sequence[str] = functools.WRAPPER_ASSIGNMENTS, updated: t.Sequence[str] = functools.WRAPPER_UPDATES, *, - ignored_conflict_names: t.Set[str] = frozenset(), + ignored_conflict_names: set[str] = frozenset(), ) -> t.Callable[[types.FunctionType], types.FunctionType]: """Update the decorated function to look like `wrapped` and update globals for discordpy forwardref evaluation.""" def decorator(wrapper: types.FunctionType) -> types.FunctionType: diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index 3501a39334..4a85f46f50 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,14 +1,15 @@ from abc import ABCMeta -from typing import Optional +from urllib.parse import urlparse from discord.ext.commands import CogMeta +from tldextract import extract class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" -def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: +def find_nth_occurrence(string: str, substring: str, n: int) -> int | None: """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" index = 0 for _ in range(n): @@ -24,9 +25,19 @@ def has_lines(string: str, count: int) -> bool: split = string.split("\n", count - 1) # Make sure the last part isn't empty, which would happen if there was a final newline. - return split[-1] and len(split) == count + return split[-1] != "" and len(split) == count def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) + + +def remove_subdomain_from_url(url: str) -> str: + """Removes subdomains from a URL whilst preserving the original URL composition.""" + parsed_url = urlparse(url) + extracted_url = extract(url) + # Eliminate subdomain by using the registered domain only + netloc = extracted_url.registered_domain + parsed_url = parsed_url._replace(netloc=netloc) + return parsed_url.geturl() diff --git a/bot/utils/lock.py b/bot/utils/lock.py index ec6f92cd42..2c8420a58a 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,22 +1,23 @@ import asyncio import inspect -import logging import types from collections import defaultdict +from collections.abc import Awaitable, Callable, Hashable from functools import partial -from typing import Any, Awaitable, Callable, Hashable, Union +from typing import Any from weakref import WeakValueDictionary from bot.errors import LockedResourceError +from bot.log import get_logger from bot.utils import function from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) -_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_IdCallableReturn = Hashable | Awaitable[Hashable] _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] -ResourceId = Union[Hashable, _IdCallable] +ResourceId = Hashable | _IdCallable class SharedEvent: @@ -110,6 +111,7 @@ async def wrapper(*args, **kwargs) -> Any: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") if raise_error: raise LockedResourceError(str(namespace), id_) + return None return wrapper return decorator @@ -118,7 +120,7 @@ async def wrapper(*args, **kwargs) -> Any: def lock_arg( namespace: Hashable, name_or_pos: function.Argument, - func: Callable[[Any], _IdCallableReturn] = None, + func: Callable[[Any], _IdCallableReturn] | None = None, *, raise_error: bool = False, wait: bool = False, diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py new file mode 100644 index 0000000000..17667e8ea9 --- /dev/null +++ b/bot/utils/message_cache.py @@ -0,0 +1,208 @@ +import typing as t +from math import ceil + +from discord import Message + + +class MessageCache: + """ + A data structure for caching messages. + + The cache is implemented as a circular buffer to allow constant time append, prepend, pop from either side, + and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be + implemented to work in linear time relative to the maximum size). + + The object additionally holds a mapping from Discord message ID's to the index in which the corresponding message + is stored, to allow for constant time lookup by message ID. + + The cache has a size limit operating the same as with a collections.deque, and most of its method names mirror those + of a deque. + + The implementation is transparent to the user: to the user the first element is always at index 0, and there are + only as many elements as were inserted (meaning, without any pre-allocated placeholder values). + """ + + def __init__(self, maxlen: int, *, newest_first: bool = False): + if maxlen <= 0: + raise ValueError("maxlen must be positive") + self.maxlen = maxlen + self.newest_first = newest_first + + self._start = 0 + self._end = 0 + + self._messages: list[Message | None] = [None] * self.maxlen + self._message_id_mapping = {} + self._message_metadata = {} + + def append(self, message: Message, *, metadata: dict | None = None) -> None: + """Add the received message to the cache, depending on the order of messages defined by `newest_first`.""" + if self.newest_first: + self._appendleft(message) + else: + self._appendright(message) + self._message_metadata[message.id] = metadata + + def _appendright(self, message: Message) -> None: + """Add the received message to the end of the cache.""" + if self._is_full(): + del self._message_id_mapping[self._messages[self._start].id] + del self._message_metadata[self._messages[self._start].id] + self._start = (self._start + 1) % self.maxlen + + self._messages[self._end] = message + self._message_id_mapping[message.id] = self._end + self._end = (self._end + 1) % self.maxlen + + def _appendleft(self, message: Message) -> None: + """Add the received message to the beginning of the cache.""" + if self._is_full(): + self._end = (self._end - 1) % self.maxlen + del self._message_id_mapping[self._messages[self._end].id] + del self._message_metadata[self._messages[self._end].id] + + self._start = (self._start - 1) % self.maxlen + self._messages[self._start] = message + self._message_id_mapping[message.id] = self._start + + def pop(self) -> Message: + """Remove the last message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + self._end = (self._end - 1) % self.maxlen + message = self._messages[self._end] + del self._message_id_mapping[message.id] + del self._message_metadata[message.id] + self._messages[self._end] = None + + return message + + def popleft(self) -> Message: + """Return the first message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + message = self._messages[self._start] + del self._message_id_mapping[message.id] + del self._message_metadata[message.id] + self._messages[self._start] = None + self._start = (self._start + 1) % self.maxlen + + return message + + def clear(self) -> None: + """Remove all messages from the cache.""" + self._messages = [None] * self.maxlen + self._message_id_mapping = {} + self._message_metadata = {} + + self._start = 0 + self._end = 0 + + def get_message(self, message_id: int) -> Message | None: + """Return the message that has the given message ID, if it is cached.""" + index = self._message_id_mapping.get(message_id, None) + return self._messages[index] if index is not None else None + + def get_message_metadata(self, message_id: int) -> dict | None: + """Return the metadata of the message that has the given message ID, if it is cached.""" + return self._message_metadata.get(message_id, None) + + def update(self, message: Message, *, metadata: dict | None = None) -> bool: + """ + Update a cached message with new contents. + + Return True if the given message had a matching ID in the cache. + """ + index = self._message_id_mapping.get(message.id, None) + if index is None: + return False + self._messages[index] = message + if metadata is not None: + self._message_metadata[message.id] = metadata + return True + + def __contains__(self, message_id: int) -> bool: + """Return True if the cache contains a message with the given ID .""" + return message_id in self._message_id_mapping + + def __getitem__(self, item: int | slice) -> Message | list[Message]: + """ + Return the message(s) in the index or slice provided. + + This method makes the circular buffer implementation transparent to the user. + Providing 0 will return the message at the position perceived by the user to be the beginning of the cache, + meaning at `self._start`. + """ + # Keep in mind that for the modulo operator used throughout this function, Python modulo behaves similarly when + # the left operand is negative. E.g -1 % 5 == 4, because the closest number from the bottom that wholly divides + # by 5 is -5. + if isinstance(item, int): + if item >= len(self) or item < -len(self): + raise IndexError("cache index out of range") + return self._messages[(item + self._start) % self.maxlen] + + if isinstance(item, slice): + length = len(self) + start, stop, step = item.indices(length) + + # This needs to be checked explicitly now, because otherwise self._start >= self._end is a valid state. + if (start >= stop and step >= 0) or (start <= stop and step <= 0): + return [] + + start = (start + self._start) % self.maxlen + stop = (stop + self._start) % self.maxlen + + # Having empty cells is an implementation detail. To the user the cache contains as many elements as they + # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail. + if step > 0: + if ( + (self._start < self._end and not self._start < stop <= self._end) + or (self._start > self._end and self._end < stop <= self._start) + ): + stop = self._end + else: + lower_boundary = (self._start - 1) % self.maxlen + if ( + (self._start < self._end and not self._start - 1 <= stop < self._end) + or (self._start > self._end and self._end < stop < lower_boundary) + ): + stop = lower_boundary + + if (start < stop and step > 0) or (start > stop and step < 0): + return self._messages[start:stop:step] + # step != 1 may require a start offset in the second slicing. + if step > 0: + offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen + return self._messages[start::step] + self._messages[offset:stop:step] + offset = ceil((start + 1) / -step) * -step - start - 1 + return self._messages[start::step] + self._messages[self.maxlen - 1 - offset:stop:step] + + raise TypeError(f"cache indices must be integers or slices, not {type(item)}") + + def __iter__(self) -> t.Iterator[Message]: + if self._is_empty(): + return + + if self._start < self._end: + yield from self._messages[self._start:self._end] + else: + yield from self._messages[self._start:] + yield from self._messages[:self._end] + + def __len__(self): + """Get the number of non-empty cells in the cache.""" + if self._is_empty(): + return 0 + if self._end > self._start: + return self._end - self._start + return self.maxlen - self._start + self._end + + def _is_empty(self) -> bool: + """Return True if the cache has no messages.""" + return self._messages[self._start] is None + + def _is_full(self) -> bool: + """Return True if every cell in the cache already contains a message.""" + return self._messages[self._end] is not None diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b6f6c1f660..57bb2c8e31 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,22 +1,23 @@ -import asyncio -import contextlib -import logging import random import re +from collections.abc import Callable, Iterable, Sequence +from datetime import UTC, datetime from functools import partial from io import BytesIO -from typing import Callable, List, Optional, Sequence, Union +from itertools import zip_longest import discord -from discord import Message, MessageType, Reaction, User -from discord.errors import HTTPException +from discord import Message from discord.ext.commands import Context +from pydis_core.site_api import ResponseCodeError +from pydis_core.utils import scheduling +from sentry_sdk import add_breadcrumb import bot -from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES -from bot.utils import scheduling +from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES, URLs +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) def reaction_check( @@ -50,18 +51,18 @@ def reaction_check( if user.id in allowed_users or is_moderator: log.trace(f"Allowed reaction {reaction} by {user} on {reaction.message.id}.") return True - else: - log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.") - scheduling.create_task( - reaction.message.remove_reaction(reaction.emoji, user), - HTTPException, # Suppress the HTTPException if adding the reaction fails - name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}" - ) - return False + + log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.") + scheduling.create_task( + reaction.message.remove_reaction(reaction.emoji, user), + suppressed_exceptions=(discord.HTTPException,), + name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}" + ) + return False async def wait_for_deletion( - message: discord.Message, + message: discord.Message | discord.InteractionMessage, user_ids: Sequence[int], deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, @@ -69,7 +70,9 @@ async def wait_for_deletion( allow_mods: bool = True ) -> None: """ - Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + Wait for any of `user_ids` to react with one of the `deletion_emojis` within `timeout` seconds to delete `message`. + + If `timeout` expires then reactions are cleared to indicate the option to delete has expired. An `attach_emojis` bool may be specified to determine whether to attach the given `deletion_emojis` to the message in the given `context`. @@ -95,18 +98,30 @@ async def wait_for_deletion( allow_mods=allow_mods, ) - with contextlib.suppress(asyncio.TimeoutError): - await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) - await message.delete() + try: + try: + await bot.instance.wait_for("reaction_add", check=check, timeout=timeout) + except TimeoutError: + await message.clear_reactions() + else: + await message.delete() + + except discord.NotFound: + log.trace(f"wait_for_deletion: message {message.id} deleted prematurely.") + + except discord.HTTPException: + if not isinstance(message.channel, discord.Thread): + # Threads might not be accessible by the time the timeout expires + raise async def send_attachments( message: discord.Message, - destination: Union[discord.TextChannel, discord.Webhook], + destination: discord.TextChannel | discord.Webhook, link_large: bool = True, use_cached: bool = False, **kwargs -) -> List[str]: +) -> list[str]: """ Re-upload the message's attachments to the destination and return a list of their new URLs. @@ -115,11 +130,11 @@ async def send_attachments( embed which links to them. Extra kwargs will be passed to send() when sending the attachment. """ webhook_send_kwargs = { - 'username': message.author.display_name, - 'avatar_url': message.author.avatar_url, + "username": message.author.display_name, + "avatar_url": message.author.display_avatar.url, } webhook_send_kwargs.update(kwargs) - webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) + webhook_send_kwargs["username"] = sub_clyde(webhook_send_kwargs["username"]) large = [] urls = [] @@ -146,7 +161,7 @@ async def send_attachments( large.append(attachment) else: log.info(f"{failure_msg} because it's too large.") - except HTTPException as e: + except discord.HTTPException as e: if link_large and e.status == 413: large.append(attachment) else: @@ -167,8 +182,8 @@ async def send_attachments( async def count_unique_users_reaction( message: discord.Message, - reaction_predicate: Callable[[Reaction], bool] = lambda _: True, - user_predicate: Callable[[User], bool] = lambda _: True, + reaction_predicate: Callable[[discord.Reaction], bool] = lambda _: True, + user_predicate: Callable[[discord.User], bool] = lambda _: True, count_bots: bool = True ) -> int: """ @@ -188,36 +203,20 @@ async def count_unique_users_reaction( return len(unique_users) -async def pin_no_system_message(message: Message) -> bool: - """Pin the given message, wait a couple of seconds and try to delete the system message.""" - await message.pin() - - # Make sure that we give it enough time to deliver the message - await asyncio.sleep(2) - # Search for the system message in the last 10 messages - async for historical_message in message.channel.history(limit=10): - if historical_message.type == MessageType.pins_add: - await historical_message.delete() - return True - - return False - - -def sub_clyde(username: Optional[str]) -> Optional[str]: +def sub_clyde(username: str | None) -> str | None: """ - Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. + Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"Е" and return the new string. Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. Return None only if `username` is None. - """ + """ # noqa: RUF002 def replace_e(match: re.Match) -> str: - char = "е" if match[2] == "e" else "Е" + char = "е" if match[2] == "e" else "Е" # noqa: RUF001 return match[1] + char if username: return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) - else: - return username # Empty string or None + return username # Empty string or None async def send_denial(ctx: Context, reason: str) -> discord.Message: @@ -230,6 +229,59 @@ async def send_denial(ctx: Context, reason: str) -> discord.Message: return await ctx.send(embed=embed) -def format_user(user: discord.abc.User) -> str: +def format_user(user: discord.User | discord.Member) -> str: """Return a string for `user` which has their mention and ID.""" return f"{user.mention} (`{user.id}`)" + + +def format_channel(channel: discord.abc.Messageable) -> str: + """Return a string for `channel` with its mention, ID, and the parent channel if it is a thread.""" + formatted = f"{channel.mention} ({channel.category}/#{channel}" + if hasattr(channel, "parent"): + formatted += f"/{channel.parent}" + formatted += ")" + return formatted + + +async def upload_log( + messages: Iterable[Message], + actor_id: int, + attachments: dict[int, list[str]] | None = None, +) -> str: + """Upload message logs to the database and return a URL to a page for viewing the logs.""" + if attachments is None: + attachments = [] + else: + attachments = [attachments.get(message.id, []) for message in messages] + + deletedmessage_set = [ + { + "id": message.id, + "author": message.author.id, + "channel_id": message.channel.id, + "content": message.content.replace("\0", ""), # Null chars cause 400. + "embeds": [embed.to_dict() for embed in message.embeds], + "attachments": attachment, + } + for message, attachment in zip_longest(messages, attachments, fillvalue=[]) + ] + + try: + response = await bot.instance.api_client.post( + "bot/deleted-messages", + json={ + "actor": actor_id, + "creation": datetime.now(UTC).isoformat(), + "deletedmessage_set": deletedmessage_set, + } + ) + except ResponseCodeError as e: + add_breadcrumb( + category="api_error", + message=str(e), + level="error", + data=deletedmessage_set, + ) + raise + + return f"{URLs.site_logs_view}/{response['id']}" diff --git a/bot/utils/modlog.py b/bot/utils/modlog.py new file mode 100644 index 0000000000..6a432e65e3 --- /dev/null +++ b/bot/utils/modlog.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime + +import discord + +from bot.bot import Bot +from bot.constants import Channels, Roles + + +async def send_log_message( + bot: Bot, + icon_url: str | None, + colour: discord.Colour | int, + title: str | None, + text: str, + *, + thumbnail: str | discord.Asset | None = None, + channel_id: int = Channels.mod_log, + ping_everyone: bool = False, + files: list[discord.File] | None = None, + content: str | None = None, + additional_embeds: list[discord.Embed] | None = None, + timestamp_override: datetime | None = None, + footer: str | None = None, +) -> discord.Message: + """Generate log embed and send to logging channel.""" + await bot.wait_until_guild_available() + # Truncate string directly here to avoid removing newlines + embed = discord.Embed( + description=text[:4093] + "..." if len(text) > 4096 else text + ) + + if title and icon_url: + embed.set_author(name=title, icon_url=icon_url) + elif title: + raise ValueError("title cannot be set without icon_url") + elif icon_url: + raise ValueError("icon_url cannot be set without title") + + embed.colour = colour + embed.timestamp = timestamp_override or datetime.now(tz=UTC) + + if footer: + embed.set_footer(text=footer) + + if thumbnail: + embed.set_thumbnail(url=thumbnail) + + if ping_everyone: + if content: + content = f"<@&{Roles.moderators}> {content}" + else: + content = f"<@&{Roles.moderators}>" + + # Truncate content to 2000 characters and append an ellipsis. + if content and len(content) > 2000: + content = content[:2000 - 3] + "..." + + channel = bot.get_channel(channel_id) + log_message = await channel.send( + content=content, + embed=embed, + files=files + ) + + if additional_embeds: + for additional_embed in additional_embeds: + await channel.send(embed=additional_embed) + + return log_message diff --git a/bot/utils/regex.py b/bot/utils/regex.py deleted file mode 100644 index a8efe14468..0000000000 --- a/bot/utils/regex.py +++ /dev/null @@ -1,13 +0,0 @@ -import re - -INVITE_RE = re.compile( - r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ - r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ - r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ - r"discord(?:[\.,]|dot)me|" # or discord.me - r"discord(?:[\.,]|dot)li|" # or discord.li - r"discord(?:[\.,]|dot)io" # or discord.io. - r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9\-]+)", # the invite code itself - flags=re.IGNORECASE -) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py deleted file mode 100644 index 2dc485f244..0000000000 --- a/bot/utils/scheduling.py +++ /dev/null @@ -1,178 +0,0 @@ -import asyncio -import contextlib -import inspect -import logging -import typing as t -from datetime import datetime -from functools import partial - - -class Scheduler: - """ - Schedule the execution of coroutines and keep track of them. - - When instantiating a Scheduler, a name must be provided. This name is used to distinguish the - instance's log messages from other instances. Using the name of the class or module containing - the instance is suggested. - - Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` - or `schedule_later`. A unique ID is required to be given in order to keep track of the - resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing - the same ID used to schedule it. The `in` operator is supported for checking if a task with a - given ID is currently scheduled. - - Any exception raised in a scheduled task is logged when the task is done. - """ - - def __init__(self, name: str): - self.name = name - - self._log = logging.getLogger(f"{__name__}.{name}") - self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} - - def __contains__(self, task_id: t.Hashable) -> bool: - """Return True if a task with the given `task_id` is currently scheduled.""" - return task_id in self._scheduled_tasks - - def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """ - Schedule the execution of a `coroutine`. - - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This - prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. - """ - self._log.trace(f"Scheduling task #{task_id}...") - - msg = f"Cannot schedule an already started coroutine for #{task_id}" - assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg - - if task_id in self._scheduled_tasks: - self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") - coroutine.close() - return - - task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") - task.add_done_callback(partial(self._task_done_callback, task_id)) - - self._scheduled_tasks[task_id] = task - self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - - def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """ - Schedule `coroutine` to be executed at the given `time`. - - If `time` is timezone aware, then use that timezone to calculate now() when subtracting. - If `time` is naïve, then use UTC. - - If `time` is in the past, schedule `coroutine` immediately. - - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This - prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. - """ - now_datetime = datetime.now(time.tzinfo) if time.tzinfo else datetime.utcnow() - delay = (time - now_datetime).total_seconds() - if delay > 0: - coroutine = self._await_later(delay, task_id, coroutine) - - self.schedule(task_id, coroutine) - - def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """ - Schedule `coroutine` to be executed after the given `delay` number of seconds. - - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This - prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. - """ - self.schedule(task_id, self._await_later(delay, task_id, coroutine)) - - def cancel(self, task_id: t.Hashable) -> None: - """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" - self._log.trace(f"Cancelling task #{task_id}...") - - try: - task = self._scheduled_tasks.pop(task_id) - except KeyError: - self._log.warning(f"Failed to unschedule {task_id} (no task found).") - else: - task.cancel() - - self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") - - def cancel_all(self) -> None: - """Unschedule all known tasks.""" - self._log.debug("Unscheduling all tasks") - - for task_id in self._scheduled_tasks.copy(): - self.cancel(task_id) - - async def _await_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Await `coroutine` after the given `delay` number of seconds.""" - try: - self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") - await asyncio.sleep(delay) - - # Use asyncio.shield to prevent the coroutine from cancelling itself. - self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.") - await asyncio.shield(coroutine) - finally: - # Close it to prevent unawaited coroutine warnings, - # which would happen if the task was cancelled during the sleep. - # Only close it if it's not been awaited yet. This check is important because the - # coroutine may cancel this task, which would also trigger the finally block. - state = inspect.getcoroutinestate(coroutine) - if state == "CORO_CREATED": - self._log.debug(f"Explicitly closing the coroutine for #{task_id}.") - coroutine.close() - else: - self._log.debug(f"Finally block reached for #{task_id}; {state=}") - - def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: - """ - Delete the task and raise its exception if one exists. - - If `done_task` and the task associated with `task_id` are different, then the latter - will not be deleted. In this case, a new task was likely rescheduled with the same ID. - """ - self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") - - scheduled_task = self._scheduled_tasks.get(task_id) - - if scheduled_task and done_task is scheduled_task: - # A task for the ID exists and is the same as the done task. - # Since this is the done callback, the task is already done so no need to cancel it. - self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") - del self._scheduled_tasks[task_id] - elif scheduled_task: - # A new task was likely rescheduled with the same ID. - self._log.debug( - f"The scheduled task #{task_id} {id(scheduled_task)} " - f"and the done task {id(done_task)} differ." - ) - elif not done_task.cancelled(): - self._log.warning( - f"Task #{task_id} not found while handling task {id(done_task)}! " - f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." - ) - - with contextlib.suppress(asyncio.CancelledError): - exception = done_task.exception() - # Log the exception if one exists. - if exception: - self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) - - -def create_task(coro: t.Awaitable, *suppressed_exceptions: t.Type[Exception], **kwargs) -> asyncio.Task: - """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" - task = asyncio.create_task(coro, **kwargs) - task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions)) - return task - - -def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: t.Tuple[t.Type[Exception]]) -> None: - """Retrieve and log the exception raised in `task` if one exists.""" - with contextlib.suppress(asyncio.CancelledError): - exception = task.exception() - # Log the exception if one exists. - if exception and not isinstance(exception, suppressed_exceptions): - log = logging.getLogger(__name__) - log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) diff --git a/bot/utils/services.py b/bot/utils/services.py deleted file mode 100644 index db9c93d0f3..0000000000 --- a/bot/utils/services.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -from typing import Optional - -from aiohttp import ClientConnectorError - -import bot -from bot.constants import URLs - -log = logging.getLogger(__name__) - -FAILED_REQUEST_ATTEMPTS = 3 - - -async def send_to_paste_service(contents: str, *, extension: str = "") -> Optional[str]: - """ - Upload `contents` to the paste service. - - `extension` is added to the output URL - - When an error occurs, `None` is returned, otherwise the generated URL with the suffix. - """ - extension = extension and f".{extension}" - log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") - paste_url = URLs.paste_service.format(key="documents") - for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): - try: - async with bot.instance.http_session.post(paste_url, data=contents) as response: - response_json = await response.json() - except ClientConnectorError: - log.warning( - f"Failed to connect to paste service at url {paste_url}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - except Exception: - log.exception( - f"An unexpected error has occurred during handling of the request, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - - if "message" in response_json: - log.warning( - f"Paste service returned error {response_json['message']} with status code {response.status}, " - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) - continue - elif "key" in response_json: - log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") - - paste_link = URLs.paste_service.format(key=response_json['key']) + extension - - if extension == '.py': - return paste_link - - return paste_link + "?noredirect" - - log.warning( - f"Got unexpected JSON response from paste service: {response_json}\n" - f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." - ) diff --git a/bot/utils/time.py b/bot/utils/time.py index d55a0e5328..fcc39d7500 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,15 @@ import datetime import re -from typing import Optional +from copy import copy +from enum import Enum +from time import struct_time +from typing import Literal, TYPE_CHECKING, overload -import dateutil.parser +import arrow from dateutil.relativedelta import relativedelta -RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -INFRACTION_FORMAT = "%Y-%m-%d %H:%M" +if TYPE_CHECKING: + from bot.converters import DurationOrExpiry _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" @@ -18,10 +21,40 @@ r"((?P\d+?) ?(seconds|second|S|s))?" ) +# All supported types for the single-argument overload of arrow.get(). tzinfo is excluded because +# it's too implicit of a way for the caller to specify that they want the current time. +Timestamp = ( + arrow.Arrow + | datetime.datetime + | datetime.date + | struct_time + | int # POSIX timestamp + | float # POSIX timestamp + | str # ISO 8601-formatted string + | tuple[int, int, int] # ISO calendar tuple +) +_Precision = Literal["years", "months", "days", "hours", "minutes", "seconds"] + + +class TimestampFormats(Enum): + """ + Represents the different formats possible for Discord timestamps. + + Examples are given in epoch time. + """ + + DATE_TIME = "f" # January 1, 1970 1:00 AM + DAY_TIME = "F" # Thursday, January 1, 1970 1:00 AM + DATE_SHORT = "d" # 01/01/1970 + DATE = "D" # January 1, 1970 + TIME = "t" # 1:00 AM + TIME_SECONDS = "T" # 1:00:00 AM + RELATIVE = "R" # 52 years ago + def _stringify_time_unit(value: int, unit: str) -> str: """ - Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. + Return a string to represent a value and time unit, ensuring the unit's correct plural form is used. >>> _stringify_time_unit(1, "seconds") "1 second" @@ -32,23 +65,147 @@ def _stringify_time_unit(value: int, unit: str) -> str: """ if unit == "seconds" and value == 0: return "0 seconds" - elif value == 1: + if value == 1: return f"{value} {unit[:-1]}" - elif value == 0: + if value == 0: return f"less than a {unit[:-1]}" - else: - return f"{value} {unit}" + return f"{value} {unit}" -def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: +def discord_timestamp(timestamp: Timestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: + """ + Format a timestamp as a Discord-flavored Markdown timestamp. + + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. """ - Returns a human-readable version of the relativedelta. + timestamp = int(arrow.get(timestamp).timestamp()) + return f"" + + +# region humanize_delta overloads +@overload +def humanize_delta( + arg1: relativedelta | Timestamp, + /, + *, + precision: _Precision = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... + + +@overload +def humanize_delta( + end: Timestamp, + start: Timestamp, + /, + *, + precision: _Precision = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... + + +@overload +def humanize_delta( + *, + years: int = 0, + months: int = 0, + weeks: float = 0, + days: float = 0, + hours: float = 0, + minutes: float = 0, + seconds: float = 0, + precision: _Precision = "seconds", + max_units: int = 6, + absolute: bool = True, +) -> str: + ... +# endregion + + +def humanize_delta( + *args, + precision: _Precision = "seconds", + max_units: int = 6, + absolute: bool = True, + **kwargs, +) -> str: + """ + Return a human-readable version of a time duration. + + `precision` is the smallest unit of time to include (e.g. "seconds", "minutes"). + + `max_units` is the maximum number of units of time to include. + Count units from largest to smallest (e.g. count days before months). + + Use the absolute value of the duration if `absolute` is True. + + Usage: + + Keyword arguments specifying values for time units, to construct a `relativedelta` and humanize + the duration represented by it: + + >>> humanize_delta(days=2, hours=16, seconds=23) + '2 days, 16 hours and 23 seconds' + + **One** `relativedelta` object, to humanize the duration represented by it: + + >>> humanize_delta(relativedelta(years=12, months=6)) + '12 years and 6 months' + + Note that `leapdays` and absolute info (singular names) will be ignored during humanization. + + **One** timestamp of a type supported by the single-arg `arrow.get()`, except for `tzinfo`, + to humanize the duration between it and the current time: + + >>> humanize_delta('2021-08-06T12:43:01Z', absolute=True) # now = 2021-08-06T12:33:33Z + '9 minutes and 28 seconds' + + >>> humanize_delta('2021-08-06T12:43:01Z', absolute=False) # now = 2021-08-06T12:33:33Z + '-9 minutes and -28 seconds' + + **Two** timestamps, each of a type supported by the single-arg `arrow.get()`, except for + `tzinfo`, to humanize the duration between them: + + >>> humanize_delta(datetime.datetime(2020, 1, 1), '2021-01-01T12:00:00Z', absolute=False) + '1 year and 12 hours' + + >>> humanize_delta('2021-01-01T12:00:00Z', datetime.datetime(2020, 1, 1), absolute=False) + '-1 years and -12 hours' - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). + Note that order of the arguments can result in a different output even if `absolute` is True: + + >>> x = datetime.datetime(3000, 11, 1) + >>> y = datetime.datetime(3000, 9, 2) + >>> humanize_delta(y, x, absolute=True), humanize_delta(x, y, absolute=True) + ('1 month and 30 days', '1 month and 29 days') + + This is due to the nature of `relativedelta`; it does not represent a fixed period of time. + Instead, it's relative to the `datetime` to which it's added to get the other `datetime`. + In the example, the difference arises because all months don't have the same number of days. """ + if args and kwargs: + raise ValueError("Unsupported combination of positional and keyword arguments.") + + if len(args) == 0: + delta = relativedelta(**kwargs) + elif len(args) == 1 and isinstance(args[0], relativedelta): + delta = args[0] + elif len(args) <= 2: + end = arrow.get(args[0]) + start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() + delta = round_delta(relativedelta(end.datetime, start.datetime)) + + if absolute: + delta = abs(delta) + else: + raise ValueError(f"Received {len(args)} positional arguments, but expected 1 or 2.") + if max_units <= 0: - raise ValueError("max_units must be positive") + raise ValueError("max_units must be positive.") units = ( ("years", delta.years), @@ -59,7 +216,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: ("seconds", delta.seconds), ) - # Add the time units that are >0, but stop at accuracy or max_units. + # Add the time units that are >0, but stop at precision or max_units. time_strings = [] unit_count = 0 for unit, value in units: @@ -70,7 +227,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: if unit == precision or unit_count >= max_units: break - # Add the 'and' between the last two units, if necessary + # Add the 'and' between the last two units, if necessary. if len(time_strings) > 1: time_strings[-1] = f"{time_strings[-2]} and {time_strings[-1]}" del time_strings[-2] @@ -84,19 +241,12 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: return humanized -def get_time_delta(time_string: str) -> str: - """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) - - return time_delta - - -def parse_duration_string(duration: str) -> Optional[relativedelta]: +def parse_duration_string(duration: str) -> relativedelta | None: """ - Converts a `duration` string to a relativedelta object. + Convert a `duration` string to a relativedelta object. + + The following symbols are supported for each unit of time: - The function supports the following symbols for each unit of time: - years: `Y`, `y`, `year`, `years` - months: `m`, `month`, `months` - weeks: `w`, `W`, `week`, `weeks` @@ -104,8 +254,9 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: - hours: `H`, `h`, `hour`, `hours` - minutes: `M`, `minute`, `minutes` - seconds: `S`, `s`, `second`, `seconds` + The units need to be provided in descending order of magnitude. - If the string does represent a durationdelta object, it will return None. + Return None if the `duration` string cannot be parsed according to the symbols above. """ match = _DURATION_REGEX.fullmatch(duration) if not match: @@ -118,93 +269,96 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: - """Converts a relativedelta object to a timedelta object.""" - utcnow = datetime.datetime.utcnow() + """Convert a relativedelta object to a timedelta object.""" + utcnow = arrow.utcnow() return utcnow + delta - utcnow -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: +def format_relative(timestamp: Timestamp) -> str: """ - Takes a datetime and returns a human-readable string that describes how long ago that datetime was. + Format `timestamp` as a relative Discord timestamp. - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - """ - now = datetime.datetime.utcnow() - delta = abs(relativedelta(now, past_datetime)) + A relative timestamp describes how much time has elapsed since `timestamp` or how much time + remains until `timestamp` is reached. - humanized = humanize_delta(delta, precision, max_units) + `timestamp` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. + """ + return discord_timestamp(timestamp, TimestampFormats.RELATIVE) - return f"{humanized} ago" +def format_with_duration( + timestamp: Timestamp | None, + other_timestamp: Timestamp | None = None, + max_units: int = 2, +) -> str | None: + """ + Return `timestamp` formatted as a discord timestamp with the timestamp duration since `other_timestamp`. -def parse_rfc1123(stamp: str) -> datetime.datetime: - """Parse RFC1123 time string into datetime.""" - return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc) + `timestamp` and `other_timestamp` can be any type supported by the single-arg `arrow.get()`, + except for a `tzinfo`. Use the current time if `other_timestamp` is None or unspecified. + `max_units` is forwarded to `time.humanize_delta`. See its documentation for more information. -def format_infraction(timestamp: str) -> str: - """Format an infraction timestamp to a more readable ISO 8601 format.""" - return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) + Return None if `timestamp` is None. + """ + if timestamp is None: + return None + if other_timestamp is None: + other_timestamp = arrow.utcnow() -def format_infraction_with_duration( - date_to: Optional[str], - date_from: Optional[datetime.datetime] = None, - max_units: int = 2, - absolute: bool = True -) -> Optional[str]: - """ - Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`. + formatted_timestamp = discord_timestamp(timestamp) + duration = humanize_delta(timestamp, other_timestamp, max_units=max_units) - `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from - `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the - current time is used. + return f"{formatted_timestamp} ({duration})" - `max_units` specifies the maximum number of units of time to include in the duration. For - example, a value of 1 may include days but not hours. - If `absolute` is True, the absolute value of the duration delta is used. This prevents negative - values in the case that `date_to` is in the past relative to `date_from`. +def until_expiration(expiry: Timestamp | None) -> str: """ - if not date_to: - return None + Get the remaining time until an infraction's expiration as a Discord timestamp. - date_to_formatted = format_infraction(date_to) + `expiry` can be any type supported by the single-arg `arrow.get()`, except for a `tzinfo`. - date_from = date_from or datetime.datetime.utcnow() - date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0) - - delta = relativedelta(date_to, date_from) - if absolute: - delta = abs(delta) + Return "Permanent" if `expiry` is None. Return "Expired" if `expiry` is in the past. + """ + if expiry is None: + return "Permanent" - duration = humanize_delta(delta, max_units=max_units) - duration_formatted = f" ({duration})" if duration else "" + expiry = arrow.get(expiry) + if expiry < arrow.utcnow(): + return "Expired" - return f"{date_to_formatted}{duration_formatted}" + return format_relative(expiry) -def until_expiration( - expiry: Optional[str], - now: Optional[datetime.datetime] = None, - max_units: int = 2 -) -> Optional[str]: +def unpack_duration( + duration_or_expiry: DurationOrExpiry, + origin: datetime.datetime | arrow.Arrow | None = None +) -> tuple[datetime.datetime, datetime.datetime]: """ - Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta. + Unpacks a DurationOrExpiry into a tuple of (origin, expiry). - Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. - Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. - `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - By default, max_units is 2. + The `origin` defaults to the current UTC time at function call. """ - if not expiry: - return None + if origin is None: + origin = datetime.datetime.now(tz=datetime.UTC) - now = now or datetime.datetime.utcnow() - since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) + if isinstance(origin, arrow.Arrow): + origin = origin.datetime - if since < now: - return None + if isinstance(duration_or_expiry, relativedelta): + return origin, origin + duration_or_expiry + return origin, duration_or_expiry + + +def round_delta(delta: relativedelta) -> relativedelta: + """ + Rounds `delta` to the nearest second. - return humanize_delta(relativedelta(since, now), max_units=max_units) + Returns a copy with microsecond values of 0. + """ + delta = copy(delta) + if delta.microseconds >= 500000: + delta += relativedelta(seconds=1) + delta.microseconds = 0 + return delta diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py index 66f82ec66f..1d144638d1 100644 --- a/bot/utils/webhooks.py +++ b/bot/utils/webhooks.py @@ -1,21 +1,20 @@ -import logging -from typing import Optional import discord from discord import Embed +from bot.log import get_logger from bot.utils.messages import sub_clyde -log = logging.getLogger(__name__) +log = get_logger(__name__) async def send_webhook( webhook: discord.Webhook, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - wait: Optional[bool] = False + content: str | None = None, + username: str | None = None, + avatar_url: str | None = None, + embed: Embed | None = None, + wait: bool | None = False ) -> discord.Message: """ Send a message using the provided webhook. diff --git a/botstrap.py b/botstrap.py new file mode 100644 index 0000000000..1b33ed7ee2 --- /dev/null +++ b/botstrap.py @@ -0,0 +1,514 @@ +import base64 +import logging +import os +import re +import sys +from pathlib import Path +from types import TracebackType +from typing import Any, Final, cast + +import dotenv +from httpx import Client, HTTPStatusError, Response + +log = logging.getLogger("botstrap") # Note this instance will not have the .trace level + +# TODO: Remove once better error handling for constants.py is in place. +if (dotenv.dotenv_values().get("BOT_TOKEN") or os.getenv("BOT_TOKEN")) is None: + msg = ( + "Couldn't find the `BOT_TOKEN` environment variable. " + "Make sure to add it to your `.env` file like this: `BOT_TOKEN=value_of_your_bot_token`" + ) + log.fatal(msg) + sys.exit(1) + +# Filter out the send typing monkeypatch logs from bot core when we import to get constants +logging.getLogger("pydis_core").setLevel(logging.WARNING) + +# As a side effect, this also configures our logging styles +from bot.constants import ( # noqa: E402 + Bot as BotConstants, + Guild as GuildConstants, + Webhooks, + _Categories, # pyright: ignore[reportPrivateUsage] + _Channels, # pyright: ignore[reportPrivateUsage] + _Emojis, # pyright: ignore[reportPrivateUsage] + _Roles, # pyright: ignore[reportPrivateUsage] +) + +# Silence noisy loggers +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + + +ENV_FILE = Path(".env.server") + +COMMUNITY_FEATURE = "COMMUNITY" +ANNOUNCEMENTS_CHANNEL_NAME = "announcements" +RULES_CHANNEL_NAME = "rules" +GUILD_CATEGORY_TYPE = 4 +EMOJI_REGEX = re.compile(r"<:(\w+):(\d+)>") + +MINIMUM_FLAGS: int = ( + 1 << 15 # guild_members_limited + | 1 << 19 # message_content_limited +) + +if GuildConstants.id == type(GuildConstants).model_fields["id"].default: + msg = ( + "Couldn't find the `GUILD_ID` environment variable. " + "Make sure to add it to your `.env` file like this: `GUILD_ID=value_of_your_discord_server_id`" + ) + log.error(msg) + sys.exit(1) + + +class SilencedDict[T](dict[str, T]): + """A dictionary that silences KeyError exceptions upon subscription to non existent items.""" + + def __init__(self, name: str): + self.name: str = name + super().__init__() + + def __getitem__(self, item: str): + try: + return super().__getitem__(item) + except KeyError: + log.fatal("Couldn't find key: %s in dict: %s", item, self.name) + log.warning( + "Please follow our contribution guidelines " + "https://pydis.com/contributing-bot " + "to guarantee a successful run of botstrap " + ) + sys.exit(-1) + + +class BotstrapError(Exception): + """Raised when an error occurs during the botstrap process.""" + + +class DiscordClient(Client): + """An HTTP client to communicate with Discord's APIs.""" + + CDN_BASE_URL: Final[str] = "https://cdn.discordapp.com" + + def __init__(self, *, guild_id: int | str, bot_token: str): + super().__init__( + base_url="https://discord.com/api/v10", + headers={"Authorization": f"Bot {bot_token}"}, + event_hooks={"response": [self._raise_for_status]}, + ) + self.guild_id: int | str = guild_id + self._app_info: dict[str, object] | None = None + self._guild_info: dict[str, object] | None = None + self._guild_channels: list[dict[str, object]] | None = None + + @staticmethod + def _raise_for_status(response: Response) -> None: + response.raise_for_status() + + def get_guild_info(self) -> dict[str, Any]: + """Fetches the guild's information.""" + if self._guild_info is None: + response = self.get(f"/guilds/{self.guild_id}") + self._guild_info = cast("dict[str, object]", response.json()) + return self._guild_info + + def get_guild_channels(self) -> list[dict[str, Any]]: + """Fetches the guild's channels.""" + if self._guild_channels is None: + response = self.get(f"/guilds/{self.guild_id}/channels") + self._guild_channels = cast("list[dict[str, object]]", response.json()) + return self._guild_channels + + def get_channel(self, id_: int | str) -> dict[str, object]: + """Fetches a channel by its ID.""" + for channel in self.get_guild_channels(): + if channel["id"] == str(id_): + return channel + raise KeyError(f"Channel with ID {id_} not found.") + + def get_app_info(self) -> dict[str, Any]: + """Fetches the application's information.""" + if self._app_info is None: + response = self.get("/applications/@me") + self._app_info = cast("dict[str, object]", response.json()) + return self._app_info + + def upgrade_application_flags_if_necessary(self) -> bool: + """ + Set the app's flags to allow the intents that we need. + + Returns a boolean defining whether changes were made. + """ + # Fetch first to modify, not overwrite + current_flags = self.get_app_info().get("flags", 0) + new_flags = current_flags | MINIMUM_FLAGS + + if new_flags != current_flags: + resp = self.patch("/applications/@me", json={"flags": new_flags}) + self._app_info = cast("dict[str, object]", resp.json()) + return True + + return False + + def check_if_in_guild(self) -> bool: + """Check if the bot is a member of the guild.""" + try: + self.get_guild_info() + except HTTPStatusError as e: + if e.response.status_code == 403 or e.response.status_code == 404: + return False + raise + return True + + def upgrade_server_to_community_if_necessary( + self, + rules_channel_id: int | str, + announcements_channel_id: int | str, + ) -> bool: + """Fetches server info & upgrades to COMMUNITY if necessary.""" + payload = self.get_guild_info() + + if COMMUNITY_FEATURE not in payload["features"]: + log.info("This server is currently not a community, upgrading.") + payload["features"].append(COMMUNITY_FEATURE) + payload["rules_channel_id"] = rules_channel_id + payload["public_updates_channel_id"] = announcements_channel_id + self._guild_info = self.patch(f"/guilds/{self.guild_id}", json=payload).json() + log.info("Server %s has been successfully updated to a community.", self.guild_id) + return True + return False + + def get_all_roles(self) -> dict[str, int]: + """Fetches all the roles in a guild.""" + result = SilencedDict[int](name="Roles dictionary") + + roles = self.get_guild_info()["roles"] + + for role in roles: + name = "_".join(part.lower() for part in role["name"].split(" ")).replace("-", "_") + result[name] = role["id"] + + return result + + def get_all_channels_and_categories(self) -> tuple[dict[str, str], dict[str, str]]: + """Fetches all the text channels & categories in a guild.""" + off_topic_channel_name_regex = r"ot\d{1}(_.*)+" + off_topic_count = 0 + channels = SilencedDict[str](name="Channels dictionary") + categories = SilencedDict[str](name="Categories dictionary") + + for channel in self.get_guild_channels(): + channel_type = channel["type"] + name = "_".join(part.lower() for part in channel["name"].split(" ")).replace("-", "_") + if re.match(off_topic_channel_name_regex, name): + name = f"off_topic_{off_topic_count}" + off_topic_count += 1 + + if channel_type == GUILD_CATEGORY_TYPE: + categories[name] = channel["id"] + else: + channels[name] = channel["id"] + + return channels, categories + + def get_all_guild_webhooks(self) -> list[dict[str, Any]]: + """Lists all the webhooks for the guild.""" + response = self.get(f"/guilds/{self.guild_id}/webhooks") + return response.json() + + def create_webhook(self, name: str, channel_id: int | str) -> str: + """Creates a new webhook for a particular channel.""" + payload = {"name": name} + response = self.post( + f"/channels/{channel_id}/webhooks", + json=payload, + headers={"X-Audit-Log-Reason": "Creating webhook as part of PyDis botstrap"}, + ) + new_webhook = response.json() + log.info("Creating webhook: %s has been successfully created.", name) + return new_webhook["id"] + + def list_emojis(self) -> list[dict[str, Any]]: + """Lists all the emojis for the guild.""" + response = self.get(f"/guilds/{self.guild_id}/emojis") + return response.json() + + def get_emoji_contents(self, id_: str | int) -> bytes | None: + """Fetches the image data for an emoji by ID.""" + # Emojis are located at https://cdn.discordapp.com/emojis/{emoji_id}.{ext} + response = self.get(f"{self.CDN_BASE_URL}/emojis/{id_!s}.webp") + return response.content + + def clone_emoji(self, *, new_name: str, original_emoji_id: str | int) -> str: + """Creates a new emoji in the guild, cloned from another emoji by ID.""" + emoji_data = self.get_emoji_contents(original_emoji_id) + if not emoji_data: + log.warning("Couldn't find emoji with ID %s.", original_emoji_id) + return "" + + image_data = base64.b64encode(emoji_data).decode("utf-8") + + payload = { + "name": new_name, + "image": f"data:image/webp;base64,{image_data}", + } + + response = self.post( + f"/guilds/{self.guild_id}/emojis", + json=payload, + headers={"X-Audit-Log-Reason": "Creating emoji as part of PyDis botstrap"}, + ) + + new_emoji = response.json() + return new_emoji["id"] + + +class BotStrapper: + """Bootstrap the bot configuration for a given guild.""" + + def __init__( + self, + *, + guild_id: int | str, + env_file: Path, + bot_token: str, + ): + self.guild_id: int | str = guild_id + self.client: DiscordClient = DiscordClient(guild_id=guild_id, bot_token=bot_token) + self.env_file: Path = env_file + + self.client_id = self.client.get_app_info()["id"] + + def __enter__(self): + return self + + def __exit__( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + self.client.__exit__(exc_type, exc_value, traceback) + + def _find_webhook_for_channel( + self, + *, + webhook_name: str, + webhook_id: int | str, + channel_id: int | str, + webhooks: list[dict[str, object]], + ) -> dict[str, object] | None: + """Find a usuable webhook by its name or ID from a list of existing webhooks.""" + matches: list[dict[str, object]] = [] + # This matches to a list to prefer webhooks created by this application first, + # which may be encountered AFTER reaching a webhook with the same name. + for webhook in webhooks: + if not webhook.get("token"): + continue # Webhook is unusable without a token + if webhook["id"] == str(webhook_id): + return webhook # Keep existing configuration + if webhook["channel_id"] != str(channel_id): + continue # Exclude webhooks from other channels + if webhook["application_id"] == str(self.client_id): + matches.insert(0, webhook) # Prefer webhooks created by this application + elif webhook["name"] == webhook_name: + if webhook in matches: + return webhook # owned, and the name is the name, Use this one + matches.append(webhook) # Fallback to matching by name + + return matches[0] if matches else None + + def upgrade_client(self) -> bool: + """Upgrade the application's flags if necessary.""" + if self.client.upgrade_application_flags_if_necessary(): + log.info("Application flags upgraded successfully, and necessary intents are now enabled.") + return True + return False + + def check_guild_membership(self) -> None: + """Check the bot is in the required guild.""" + if not self.client.check_if_in_guild(): + log.error("The bot is not a member of the configured guild with ID %s.", self.guild_id) + log.warning( + "Please invite with the following URL and rerun this script: " + "https://discord.com/oauth2/authorize?client_id=%s&guild_id=%s&scope=bot+applications.commands&permissions=8", + self.client_id, + self.guild_id, + ) + raise BotstrapError("Bot is not a member of the configured guild.") + + def upgrade_guild(self, announcements_channel_id: str, rules_channel_id: str) -> bool: + """Upgrade the guild to a community if necessary.""" + return self.client.upgrade_server_to_community_if_necessary( + rules_channel_id=rules_channel_id, + announcements_channel_id=announcements_channel_id, + ) + + def get_roles(self) -> dict[str, Any]: + """Get a config map of all of the roles in the guild.""" + log.debug("Syncing roles with bot configuration.") + all_roles = self.client.get_all_roles() + + data: dict[str, int] = {} + + for role_name in _Roles.model_fields: + role_id = all_roles.get(role_name, None) + if not role_id: + log.warning("Couldn't find the role %s in the guild, PyDis' default values will be used.", role_name) + continue + + data[role_name] = role_id + + return data + + def get_channels(self) -> dict[str, Any]: + """Get a config map of all of the channels in the guild.""" + log.debug("Syncing channels with bot configuration.") + all_channels, _categories = self.client.get_all_channels_and_categories() + + data: dict[str, str] = {} + for channel_name in _Channels.model_fields: + channel_id = all_channels.get(channel_name, None) + if not channel_id: + log.warning( + "Couldn't find the channel %s in the guild, PyDis' default values will be used.", channel_name + ) + continue + + data[channel_name] = channel_id + + return data + + def get_categories(self) -> dict[str, str]: + """Get a config map of all of the categories in guild.""" + log.debug("Syncing categories with bot configuration.") + _channels, all_categories = self.client.get_all_channels_and_categories() + + data: dict[str, str] = {} + for category_name in _Categories.model_fields: + category_id = all_categories.get(category_name, None) + if not category_id: + log.warning( + "Couldn't find the category %s in the guild, PyDis' default values will be used.", category_name + ) + continue + + data[category_name] = category_id + return data + + def sync_webhooks(self) -> dict[str, object]: + """Get webhook config. Will create all webhooks that cannot be found.""" + log.debug("Syncing webhooks with bot configuration.") + + all_channels, _categories = self.client.get_all_channels_and_categories() + + data: dict[str, object] = {} + + existing_webhooks = self.client.get_all_guild_webhooks() + for webhook_name, configured_webhook in Webhooks.model_dump().items(): + formatted_webhook_name = webhook_name.replace("_", " ").title() + webhook_channel_id = all_channels[webhook_name] + + webhook = self._find_webhook_for_channel( + webhook_name= formatted_webhook_name, + webhook_id=configured_webhook["id"], + channel_id=webhook_channel_id, + webhooks=existing_webhooks, + ) + + if not webhook: + webhook_id = self.client.create_webhook(formatted_webhook_name, webhook_channel_id) + else: + webhook_id = webhook["id"] + + data[webhook_name + "__id"] = webhook_id + + return data + + def sync_emojis(self) -> dict[str, str]: + """Get emoji config. Will create all emojis that cannot be found.""" + existing_emojis = self.client.list_emojis() + log.debug("Syncing emojis with bot configuration.") + data: dict[str, str] = {} + for emoji_config_name, emoji_config in _Emojis.model_fields.items(): + if not (match := EMOJI_REGEX.fullmatch(emoji_config.default)): + continue + emoji_name = match.group(1) + emoji_id: str = match.group(2) + + for emoji in existing_emojis: + if emoji["name"] == emoji_name: + emoji_id = emoji["id"] + break + else: + log.info("Creating emoji %s", emoji_name) + emoji_id = self.client.clone_emoji(new_name=emoji_name, original_emoji_id=emoji_id) + + data[emoji_config_name] = f"<:{emoji_name}:{emoji_id}>" + + return data + + def write_config_env(self, config: dict[str, dict[str, object]]) -> bool: + """Write the configuration to the specified env_file.""" + if not self.env_file.exists(): + self.env_file.touch() + + with self.env_file.open("r+", encoding="utf-8") as file: + before = file.read() + file.seek(0) + for num, (category, category_values) in enumerate(config.items()): + # In order to support commented sections, we write the following + file.write(f"# {category.capitalize()}\n") + # Format the dictionary into .env style + for key, value in category_values.items(): + file.write(f"{category}_{key}={value}\n") + if num < len(config) - 1: + file.write("\n") + + file.truncate() + file.seek(0) + after = file.read() + + return before != after + + def run(self) -> bool: + """Runs the botstrap process.""" + # Track if any changes were made and exit with an error code if so. + changes: bool = False + config: dict[str, dict[str, object | Any]] = {} + changes |= self.upgrade_client() + self.check_guild_membership() + + channels = self.get_channels() + + # Ensure the guild is upgraded to a community if necessary. + # This isn't strictly necessary for bot functionality, but + # it prevents weird transients since PyDis is a community server. + changes |= self.upgrade_guild(channels[ANNOUNCEMENTS_CHANNEL_NAME], channels[RULES_CHANNEL_NAME]) + + # Though sync_webhooks and sync_emojis DO make api calls that may modify server state, + # those changes will be reflected in the config written to the .env file. + # Therefore, we don't need to track if any emojis or webhooks are being changed within those settings. + config = { + "categories": self.get_categories(), + "channels": channels, + "roles": self.get_roles(), + "webhooks": self.sync_webhooks(), + "emojis": self.sync_emojis(), + } + + changes |= self.write_config_env(config) + return changes + + +if __name__ == "__main__": + botstrap = BotStrapper(guild_id=GuildConstants.id, env_file=ENV_FILE, bot_token=BotConstants.token) + with botstrap: + changes_made = botstrap.run() + + if changes_made: + log.info("Botstrap completed successfully. Updated configuration has been written to %s", ENV_FILE) + else: + log.info("Botstrap completed successfully. No changes were necessary.") + sys.exit(0) diff --git a/config-default.yml b/config-default.yml deleted file mode 100644 index 55388247c3..0000000000 --- a/config-default.yml +++ /dev/null @@ -1,554 +0,0 @@ -bot: - prefix: "!" - sentry_dsn: !ENV "BOT_SENTRY_DSN" - token: !ENV "BOT_TOKEN" - trace_loggers: !ENV "BOT_TRACE_LOGGERS" - - clean: - # Maximum number of messages to traverse for clean commands - message_limit: 10000 - - cooldowns: - # Per channel, per tag. - tags: 60 - - redis: - host: "redis.default.svc.cluster.local" - password: !ENV "REDIS_PASSWORD" - port: 6379 - use_fakeredis: false - - stats: - presence_update_timeout: 300 - statsd_host: "graphite.default.svc.cluster.local" - - -style: - colours: - blue: 0x3775a8 - bright_green: 0x01d277 - orange: 0xe67e22 - pink: 0xcf84e0 - purple: 0xb734eb - soft_green: 0x68c290 - soft_orange: 0xf9cb54 - soft_red: 0xcd6d6d - white: 0xfffffe - yellow: 0xffd241 - - emojis: - badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" - badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" - badge_early_supporter: "<:early_supporter:743882896909140058>" - badge_hypesquad: "<:hypesquad_events:743882896892362873>" - badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" - badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" - badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" - badge_partner: "<:partner:748666453242413136>" - badge_staff: "<:discord_staff:743882896498098226>" - badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" - bot: "<:bot:812712599464443914>" - verified_bot: "<:verified_bot:811645219220750347>" - - defcon_shutdown: "<:defcondisabled:470326273952972810>" - defcon_unshutdown: "<:defconenabled:470326274213150730>" - defcon_update: "<:defconsettingsupdated:470326274082996224>" - - failmail: "<:failmail:633660039931887616>" - - incident_actioned: "<:incident_actioned:714221559279255583>" - incident_investigating: "<:incident_investigating:714224190928191551>" - incident_unactioned: "<:incident_unactioned:714223099645526026>" - - status_dnd: "<:status_dnd:470326272082313216>" - status_idle: "<:status_idle:470326266625785866>" - status_offline: "<:status_offline:470326266537705472>" - status_online: "<:status_online:470326272351010816>" - - ducky_dave: "<:ducky_dave:742058418692423772>" - - trashcan: "<:trashcan:637136429717389331>" - - bullet: "\u2022" - check_mark: "\u2705" - cross_mark: "\u274C" - new: "\U0001F195" - pencil: "\u270F" - - ok_hand: ":ok_hand:" - - icons: - crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" - crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png" - crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png" - - defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png" - defcon_shutdown: "https://cdn.discordapp.com/emojis/470326273952972810.png" - defcon_unshutdown: "https://cdn.discordapp.com/emojis/470326274213150730.png" - defcon_update: "https://cdn.discordapp.com/emojis/472472638342561793.png" - - filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png" - - green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png" - green_questionmark: "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png" - guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png" - - hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png" - hash_green: "https://cdn.discordapp.com/emojis/469950144918585344.png" - hash_red: "https://cdn.discordapp.com/emojis/469950145413251072.png" - - message_bulk_delete: "https://cdn.discordapp.com/emojis/469952898994929668.png" - message_delete: "https://cdn.discordapp.com/emojis/472472641320648704.png" - message_edit: "https://cdn.discordapp.com/emojis/472472638976163870.png" - - pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png" - - questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png" - - remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png" - remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png" - remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png" - - sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png" - sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png" - - superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png" - unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png" - - token_removed: "https://cdn.discordapp.com/emojis/470326273298792469.png" - - user_ban: "https://cdn.discordapp.com/emojis/469952898026045441.png" - user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png" - user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png" - user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png" - user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png" - user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png" - user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png" - - voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png" - voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" - voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" - - -guild: - id: 267624335836053506 - invite: "https://discord.gg/python" - - categories: - help_available: 691405807388196926 - help_dormant: 691405908919451718 - help_in_use: 696958401460043776 - logs: &LOGS 468520609152892958 - moderators: &MODS_CATEGORY 749736277464842262 - modmail: &MODMAIL 714494672835444826 - voice: 356013253765234688 - - channels: - # Public announcement and news channels - announcements: &ANNOUNCEMENTS 354619224620138496 - change_log: &CHANGE_LOG 748238795236704388 - mailing_lists: &MAILING_LISTS 704372456592506880 - python_events: &PYEVENTS_CHANNEL 729674110270963822 - python_news: &PYNEWS_CHANNEL 704372456592506880 - reddit: &REDDIT_CHANNEL 458224812528238616 - - # Development - dev_contrib: &DEV_CONTRIB 635950537262759947 - dev_core: &DEV_CORE 411200599653351425 - dev_log: &DEV_LOG 622895325144940554 - - # Discussion - meta: 429409067623251969 - python_general: &PY_GENERAL 267624335836053506 - - # Python Help: Available - cooldown: 720603994149486673 - how_to_get_help: 704250143020417084 - - # Topical - discord_py: 343944376055103488 - - # Logs - attachment_log: &ATTACH_LOG 649243850006855680 - message_log: &MESSAGE_LOG 467752170159079424 - mod_log: &MOD_LOG 282638479504965634 - nomination_archive: 833371042046148738 - user_log: 528976905546760203 - voice_log: 640292421988646961 - - # Off-topic - off_topic_0: 291284109232308226 - off_topic_1: 463035241142026251 - off_topic_2: 463035268514185226 - - # Special - bot_commands: &BOT_CMD 267659945086812160 - esoteric: 470884583684964352 - voice_gate: 764802555427029012 - - # Staff - admins: &ADMINS 365960823622991872 - admin_spam: &ADMIN_SPAM 563594791770914816 - defcon: &DEFCON 464469101889454091 - duck_pond: &DUCK_POND 637820308341915648 - helpers: &HELPERS 385474242440986624 - incidents: 714214212200562749 - incidents_archive: 720668923636351037 - mod_alerts: 473092532147060736 - nominations: 822920136150745168 - nomination_voting: 822853512709931008 - organisation: &ORGANISATION 551789653284356126 - staff_lounge: &STAFF_LOUNGE 464905259261755392 - staff_info: &STAFF_INFO 396684402404622347 - - # Staff announcement channels - admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 - mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225 - staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042 - - # Voice Channels - admins_voice: &ADMINS_VOICE 500734494840717332 - code_help_voice_1: 751592231726481530 - code_help_voice_2: 764232549840846858 - general_voice: 751591688538947646 - staff_voice: &STAFF_VOICE 412375055910043655 - - # Voice Chat - code_help_chat_1: 755154969761677312 - code_help_chat_2: 766330079135268884 - staff_voice_chat: 541638762007101470 - voice_chat: 412357430186344448 - - # Watch - big_brother_logs: &BB_LOGS 468507907357409333 - talent_pool: &TALENT_POOL 534321732593647616 - - moderation_categories: - - *MODS_CATEGORY - - *MODMAIL - - *LOGS - - moderation_channels: - - *ADMINS - - *ADMIN_SPAM - - # Modlog cog ignores events which occur in these channels - modlog_blacklist: - - *ADMINS - - *ADMINS_VOICE - - *ATTACH_LOG - - *MESSAGE_LOG - - *MOD_LOG - - *STAFF_VOICE - - reminder_whitelist: - - *BOT_CMD - - *DEV_CONTRIB - - roles: - announcements: 463658397560995840 - contributors: 295488872404484098 - help_cooldown: 699189276025421825 - muted: &MUTED_ROLE 277914926603829249 - partners: 323426753857191936 - python_community: &PY_COMMUNITY_ROLE 458226413825294336 - sprinters: &SPRINTERS 758422482289426471 - voice_verified: 764802720779337729 - - # Staff - admins: &ADMINS_ROLE 267628507062992896 - core_developers: 587606783669829632 - devops: 409416496733880320 - domain_leads: 807415650778742785 - helpers: &HELPERS_ROLE 267630620367257601 - moderators: &MODS_ROLE 831776746206265384 - mod_team: &MOD_TEAM_ROLE 267629731250176001 - owners: &OWNERS_ROLE 267627879762755584 - project_leads: 815701647526330398 - - # Code Jam - jammers: 737249140966162473 - team_leaders: 737250302834638889 - - # Streaming - video: 764245844798079016 - - moderation_roles: - - *ADMINS_ROLE - - *MOD_TEAM_ROLE - - *MODS_ROLE - - *OWNERS_ROLE - - staff_roles: - - *ADMINS_ROLE - - *HELPERS_ROLE - - *MOD_TEAM_ROLE - - *OWNERS_ROLE - - webhooks: - big_brother: 569133704568373283 - dev_log: 680501655111729222 - duck_pond: 637821475327311927 - incidents_archive: 720671599790915702 - python_news: &PYNEWS_WEBHOOK 704381182279942324 - talent_pool: 569145364800602132 - - -filter: - # What do we filter? - filter_domains: true - filter_everyone_ping: true - filter_invites: true - filter_zalgo: false - watch_regex: true - watch_rich_embeds: true - - # Notify user on filter? - # Notifications are not expected for "watchlist" type filters - notify_user_domains: false - notify_user_everyone_ping: true - notify_user_invites: true - notify_user_zalgo: false - - # Filter configuration - offensive_msg_delete_days: 7 # How many days before deleting an offensive message? - ping_everyone: true - - # Censor doesn't apply to these - channel_whitelist: - - *ADMINS - - *BB_LOGS - - *DEV_LOG - - *MESSAGE_LOG - - *MOD_LOG - - *STAFF_LOUNGE - - *TALENT_POOL - - role_whitelist: - - *ADMINS_ROLE - - *HELPERS_ROLE - - *MODS_ROLE - - *OWNERS_ROLE - - *PY_COMMUNITY_ROLE - - *SPRINTERS - - -keys: - github: !ENV "GITHUB_API_KEY" - site_api: !ENV "BOT_API_KEY" - - -urls: - # PyDis site vars - connect_max_retries: 3 - connect_cooldown: 5 - site: &DOMAIN "pythondiscord.com" - site_api: &API "pydis-api.default.svc.cluster.local" - site_api_schema: "https://" - site_paste: &PASTE !JOIN ["paste.", *DOMAIN] - site_schema: &SCHEMA "https://" - site_staff: &STAFF !JOIN ["staff.", *DOMAIN] - - paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] - site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - - # Snekbox - snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" - - # Discord API URLs - discord_api: &DISCORD_API "https://discordapp.com/api/v7/" - discord_invite_api: !JOIN [*DISCORD_API, "invites"] - - # Misc URLsw - bot_avatar: "https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png" - github_bot_repo: "https://github.com/python-discord/bot" - - -anti_spam: - # Clean messages that violate a rule. - clean_offending: true - ping_everyone: true - - punishment: - remove_after: 600 - role_id: *MUTED_ROLE - - rules: - attachments: - interval: 10 - max: 6 - - burst: - interval: 10 - max: 7 - - # Burst shared it (temporarily) disabled to prevent - # the bug that triggers multiple infractions/DMs per - # user. It also tends to catch a lot of innocent users - # now that we're so big. - # burst_shared: - # interval: 10 - # max: 20 - - chars: - interval: 5 - max: 3_000 - - discord_emojis: - interval: 10 - max: 20 - - duplicates: - interval: 10 - max: 3 - - links: - interval: 10 - max: 10 - - mentions: - interval: 10 - max: 5 - - newlines: - interval: 10 - max: 100 - max_consecutive: 10 - - role_mentions: - interval: 10 - max: 3 - - - -metabase: - username: !ENV "METABASE_USERNAME" - password: !ENV "METABASE_PASSWORD" - url: "http://metabase.default.svc.cluster.local/api" - # 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age - max_session_age: 20160 - - - -big_brother: - header_message_limit: 15 - log_delay: 15 - - -code_block: - # The channels in which code blocks will be detected. They are not subject to a cooldown. - channel_whitelist: - - *BOT_CMD - - # The channels which will be affected by a cooldown. These channels are also whitelisted. - cooldown_channels: - - *PY_GENERAL - - # Sending instructions triggers a cooldown on a per-channel basis. - # More instruction messages will not be sent in the same channel until the cooldown has elapsed. - cooldown_seconds: 300 - - # The minimum amount of lines a message or code block must have for instructions to be sent. - minimum_lines: 4 - - -free: - # Seconds to elapse for a channel - # to be considered inactive. - activity_timeout: 600 - cooldown_per: 60.0 - cooldown_rate: 1 - - -help_channels: - enable: true - - # Roles which are allowed to use the command which makes channels dormant - cmd_whitelist: - - *HELPERS_ROLE - - # Allowed duration of inactivity by claimant before making a channel dormant - idle_minutes_claimant: 30 - - # Allowed duration of inactivity by others before making a channel dormant - # `idle_minutes_claimant` must also be met, before a channel is closed - idle_minutes_others: 10 - - # Allowed duration of inactivity when channel is empty (due to deleted messages) - # before message making a channel dormant - deleted_idle_minutes: 5 - - # Maximum number of channels to put in the available category - max_available: 3 - - # Maximum number of channels across all 3 categories - # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 - max_total_channels: 42 - - # Prefix for help channel names - name_prefix: 'help-' - - # Notify if more available channels are needed but there are no more dormant ones - notify: true - - # Channel in which to send notifications - notify_channel: *HELPERS - - # Minimum interval between helper notifications - notify_minutes: 15 - - # Mention these roles in notifications - notify_roles: - - *HELPERS_ROLE - - -redirect_output: - delete_delay: 15 - delete_invocation: true - - -duck_pond: - threshold: 7 - channel_blacklist: - - *ANNOUNCEMENTS - - *PYNEWS_CHANNEL - - *PYEVENTS_CHANNEL - - *MAILING_LISTS - - *REDDIT_CHANNEL - - *DUCK_POND - - *CHANGE_LOG - - *STAFF_ANNOUNCEMENTS - - *MOD_ANNOUNCEMENTS - - *ADMIN_ANNOUNCEMENTS - - *STAFF_INFO - - -python_news: - channel: *PYNEWS_CHANNEL - webhook: *PYNEWS_WEBHOOK - - mail_lists: - - 'python-ideas' - - 'python-announce-list' - - 'pypi-announce' - - 'python-dev' - - -voice_gate: - bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate - minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active - minimum_days_member: 3 # How many days the user must have been a member for - minimum_messages: 50 # How many messages a user must have to be eligible for voice - voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate - - -branding: - cycle_frequency: 3 # How many days bot wait before refreshing server icon - - -config: - required_keys: ['bot.token'] - - -video_permission: - default_permission_duration: 5 # Default duration for stream command in minutes diff --git a/docker-compose.yml b/docker-compose.yml index 1761d8940c..9dd7e682cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,38 +2,52 @@ # the bot project relies on for testing. Use it if you haven't got a # ready-to-use site environment already setup. -version: "3.7" - -x-logging: &logging - logging: - driver: "json-file" - options: - max-file: "5" - max-size: "10m" - -x-restart-policy: &restart_policy - restart: unless-stopped +x-logging: &default-logging + driver: "json-file" + options: + max-file: "5" + max-size: "10m" services: postgres: - << : *logging - << : *restart_policy - image: postgres:12-alpine + logging : *default-logging + restart: unless-stopped + image: postgres:15-alpine environment: POSTGRES_DB: pysite POSTGRES_PASSWORD: pysite POSTGRES_USER: pysite + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pysite"] + interval: 2s + timeout: 1s + retries: 5 redis: - << : *logging - << : *restart_policy - image: redis:5.0.9 + logging : *default-logging + restart: unless-stopped + image: redis:latest ports: - "127.0.0.1:6379:6379" + metricity: + logging : *default-logging + restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error + depends_on: + postgres: + condition: service_healthy + image: ghcr.io/python-discord/metricity:latest + env_file: + - .env + environment: + DATABASE_URI: postgres://pysite:pysite@postgres/metricity + USE_METRICITY: ${USE_METRICITY-false} + volumes: + - .:/tmp/bot:ro + snekbox: - << : *logging - << : *restart_policy + logging : *default-logging + restart: unless-stopped image: ghcr.io/python-discord/snekbox:latest init: true ipc: none @@ -42,10 +56,10 @@ services: privileged: true web: - << : *logging - << : *restart_policy + logging : *default-logging + restart: unless-stopped image: ghcr.io/python-discord/site:latest - command: ["run", "--debug"] + command: ["python", "manage.py", "run"] networks: default: aliases: @@ -56,7 +70,7 @@ services: - "127.0.0.1:8000:8000" tty: true depends_on: - - postgres + - metricity environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity @@ -64,13 +78,12 @@ services: STATIC_ROOT: /var/www/static bot: - << : *logging - << : *restart_policy + logging : *default-logging + restart: unless-stopped build: context: . dockerfile: Dockerfile volumes: - - ./logs:/bot/logs - .:/bot:ro tty: true depends_on: @@ -80,4 +93,8 @@ services: env_file: - .env environment: - BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531 + API_KEYS_SITE_API: "badbot13m0n8f570f942013fc818f234916ca531" + URLS_SITE_API: "http://web:8000/api" + URLS_SNEKBOX_EVAL_API: "http://snekbox:8060/eval" + REDIS_HOST: "redis" + STATS_STATSD_HOST: "http://localhost" diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index ba8b7af4b8..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,1602 +0,0 @@ -[[package]] -name = "aio-pika" -version = "6.8.0" -description = "Wrapper for the aiormq for asyncio and humans." -category = "main" -optional = false -python-versions = ">3.5.*, <4" - -[package.dependencies] -aiormq = ">=3.2.3,<4" -yarl = "*" - -[package.extras] -develop = ["aiomisc (>=10.1.6,<10.2.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "shortuuid", "nox", "sphinx", "sphinx-autobuild", "timeout-decorator", "tox (>=2.4)"] - -[[package]] -name = "aiodns" -version = "2.0.0" -description = "Simple DNS resolver for asyncio" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -pycares = ">=3.0.0" - -[[package]] -name = "aiohttp" -version = "3.7.4.post0" -description = "Async http client/server framework (asyncio)" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -async-timeout = ">=3.0,<4.0" -attrs = ">=17.3.0" -chardet = ">=2.0,<5.0" -multidict = ">=4.5,<7.0" -typing-extensions = ">=3.6.5" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["aiodns", "brotlipy", "cchardet"] - -[[package]] -name = "aioping" -version = "0.3.1" -description = "Asyncio ping implementation" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -aiodns = "*" -async-timeout = "*" - -[[package]] -name = "aioredis" -version = "1.3.1" -description = "asyncio (PEP 3156) Redis support" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -async-timeout = "*" -hiredis = "*" - -[[package]] -name = "aiormq" -version = "3.3.1" -description = "Pure python AMQP asynchronous client library" -category = "main" -optional = false -python-versions = ">3.5.*" - -[package.dependencies] -pamqp = "2.3.0" -yarl = "*" - -[package.extras] -develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"] - -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "arrow" -version = "1.0.3" -description = "Better dates & times for Python" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -python-dateutil = ">=2.7.0" - -[[package]] -name = "async-rediscache" -version = "0.1.4" -description = "An easy to use asynchronous Redis cache" -category = "main" -optional = false -python-versions = "~=3.7" - -[package.dependencies] -aioredis = ">=1" -fakeredis = {version = ">=1.3.1", optional = true, markers = "extra == \"fakeredis\""} - -[package.extras] -fakeredis = ["fakeredis (>=1.3.1)"] - -[[package]] -name = "async-timeout" -version = "3.0.1" -description = "Timeout context manager for asyncio programs" -category = "main" -optional = false -python-versions = ">=3.5.3" - -[[package]] -name = "attrs" -version = "21.2.0" -description = "Classes Without Boilerplate" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] - -[[package]] -name = "beautifulsoup4" -version = "4.9.3" -description = "Screen-scraping library" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "certifi" -version = "2020.12.5" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "cffi" -version = "1.14.5" -description = "Foreign Function Interface for Python calling C code." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "cfgv" -version = "3.2.0" -description = "Validate configuration and produce human readable error messages." -category = "dev" -optional = false -python-versions = ">=3.6.1" - -[[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "colorama" -version = "0.4.4" -description = "Cross-platform colored terminal text." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "coloredlogs" -version = "14.3" -description = "Colored terminal output for Python's logging module" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -humanfriendly = ">=7.1" - -[package.extras] -cron = ["capturer (>=2.4)"] - -[[package]] -name = "coverage" -version = "5.5" -description = "Code coverage measurement for Python" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" - -[package.extras] -toml = ["toml"] - -[[package]] -name = "coveralls" -version = "2.2.0" -description = "Show coverage stats online via coveralls.io" -category = "dev" -optional = false -python-versions = ">= 3.5" - -[package.dependencies] -coverage = ">=4.1,<6.0" -docopt = ">=0.6.1" -requests = ">=1.0.0" - -[package.extras] -yaml = ["PyYAML (>=3.10)"] - -[[package]] -name = "deepdiff" -version = "4.3.2" -description = "Deep Difference and Search of any Python object/data." -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -ordered-set = ">=3.1.1" - -[package.extras] -murmur = ["mmh3"] - -[[package]] -name = "discord.py" -version = "1.6.0" -description = "A Python wrapper for the Discord API" -category = "main" -optional = false -python-versions = ">=3.5.3" - -[package.dependencies] -aiohttp = ">=3.6.0,<3.8.0" - -[package.extras] -docs = ["sphinx (==3.0.3)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"] -voice = ["PyNaCl (>=1.3.0,<1.5)"] - -[[package]] -name = "distlib" -version = "0.3.1" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "emoji" -version = "0.6.0" -description = "Emoji for Python" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -dev = ["pytest", "coverage", "coveralls"] - -[[package]] -name = "fakeredis" -version = "1.5.0" -description = "Fake implementation of redis API for testing purposes." -category = "main" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -redis = "<3.6.0" -six = ">=1.12" -sortedcontainers = "*" - -[package.extras] -aioredis = ["aioredis"] -lua = ["lupa"] - -[[package]] -name = "feedparser" -version = "6.0.2" -description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -sgmllib3k = "*" - -[[package]] -name = "filelock" -version = "3.0.12" -description = "A platform independent file lock." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[package.dependencies] -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.0" - -[[package]] -name = "flake8-annotations" -version = "2.6.2" -description = "Flake8 Type Annotation Checks" -category = "dev" -optional = false -python-versions = ">=3.6.1,<4.0.0" - -[package.dependencies] -flake8 = ">=3.7,<4.0" - -[[package]] -name = "flake8-bugbear" -version = "20.11.1" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -attrs = ">=19.2.0" -flake8 = ">=3.0.0" - -[package.extras] -dev = ["coverage", "black", "hypothesis", "hypothesmith"] - -[[package]] -name = "flake8-docstrings" -version = "1.6.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-import-order" -version = "0.18.1" -description = "Flake8 and pylama plugin that checks the ordering of import statements." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -pycodestyle = "*" - -[[package]] -name = "flake8-polyfill" -version = "1.0.2" -description = "Polyfill package for Flake8 plugins" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -flake8 = "*" - -[[package]] -name = "flake8-string-format" -version = "0.3.0" -description = "string format checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -flake8 = "*" - -[[package]] -name = "flake8-tidy-imports" -version = "4.3.0" -description = "A flake8 plugin that helps you write tidier imports." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" - -[[package]] -name = "flake8-todo" -version = "0.7" -description = "TODO notes checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -pycodestyle = ">=2.0.0,<3.0.0" - -[[package]] -name = "fuzzywuzzy" -version = "0.18.0" -description = "Fuzzy string matching in python" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -speedup = ["python-levenshtein (>=0.12)"] - -[[package]] -name = "hiredis" -version = "2.0.0" -description = "Python wrapper for hiredis" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "humanfriendly" -version = "9.1" -description = "Human friendly output for text interfaces using Python" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -pyreadline = {version = "*", markers = "sys_platform == \"win32\""} - -[[package]] -name = "identify" -version = "2.2.4" -description = "File identification library for Python" -category = "dev" -optional = false -python-versions = ">=3.6.1" - -[package.extras] -license = ["editdistance-s"] - -[[package]] -name = "idna" -version = "3.1" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=3.4" - -[[package]] -name = "lxml" -version = "4.6.3" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html5 = ["html5lib"] -htmlsoup = ["beautifulsoup4"] -source = ["Cython (>=0.29.7)"] - -[[package]] -name = "markdownify" -version = "0.6.1" -description = "Convert HTML to markdown." -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -beautifulsoup4 = "*" -six = "*" - -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "more-itertools" -version = "8.7.0" -description = "More routines for operating on iterables, beyond itertools" -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "mslex" -version = "0.3.0" -description = "shlex for windows" -category = "dev" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "multidict" -version = "5.1.0" -description = "multidict implementation" -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "nodeenv" -version = "1.6.0" -description = "Node.js virtual environment builder" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "ordered-set" -version = "4.0.2" -description = "A set that remembers its order, and allows looking up its items by their index in that order." -category = "main" -optional = false -python-versions = ">=3.5" - -[[package]] -name = "pamqp" -version = "2.3.0" -description = "RabbitMQ Focused AMQP low-level library" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -codegen = ["lxml"] - -[[package]] -name = "pep8-naming" -version = "0.11.1" -description = "Check PEP-8 naming conventions, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -flake8-polyfill = ">=1.0.2,<2" - -[[package]] -name = "pre-commit" -version = "2.12.1" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" -optional = false -python-versions = ">=3.6.1" - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" - -[[package]] -name = "psutil" -version = "5.8.0" -description = "Cross-platform lib for process and system monitoring in Python." -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.extras] -test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] - -[[package]] -name = "pycares" -version = "3.2.3" -description = "Python interface for c-ares" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -cffi = ">=1.5.0" - -[package.extras] -idna = ["idna (>=2.1)"] - -[[package]] -name = "pycodestyle" -version = "2.7.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pycparser" -version = "2.20" -description = "C parser in Python" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pydocstyle" -version = "6.0.0" -description = "Python docstring style checker" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -snowballstemmer = "*" - -[[package]] -name = "pyflakes" -version = "2.3.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "pyreadline" -version = "2.1" -description = "A python implmementation of GNU readline." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "python-dateutil" -version = "2.8.1" -description = "Extensions to the standard Python datetime module" -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-dotenv" -version = "0.17.1" -description = "Read key-value pairs from a .env file and set them as environment variables" -category = "dev" -optional = false -python-versions = "*" - -[package.extras] -cli = ["click (>=5.0)"] - -[[package]] -name = "python-frontmatter" -version = "1.0.0" -description = "Parse and manage posts with YAML (or other) frontmatter" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -PyYAML = "*" - -[package.extras] -docs = ["sphinx"] -test = ["pytest", "toml", "pyaml"] - -[[package]] -name = "pyyaml" -version = "5.4.1" -description = "YAML parser and emitter for Python" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" - -[[package]] -name = "redis" -version = "3.5.3" -description = "Python client for Redis key-value store" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.extras] -hiredis = ["hiredis (>=0.1.3)"] - -[[package]] -name = "regex" -version = "2021.4.4" -description = "Alternative regular expression module, to replace re." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "requests" -version = "2.15.1" -description = "Python HTTP for Humans." -category = "dev" -optional = false -python-versions = "*" - -[package.extras] -security = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)"] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] - -[[package]] -name = "sentry-sdk" -version = "0.20.3" -description = "Python client for Sentry (https://sentry.io)" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -certifi = "*" -urllib3 = ">=1.10.0" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -chalice = ["chalice (>=1.16.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -flask = ["flask (>=0.11)", "blinker (>=1.1)"] -pure_eval = ["pure-eval", "executing", "asttokens"] -pyspark = ["pyspark (>=2.4.4)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -tornado = ["tornado (>=5)"] - -[[package]] -name = "sgmllib3k" -version = "1.0.0" -description = "Py3k port of sgmllib." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "snowballstemmer" -version = "2.1.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "sortedcontainers" -version = "2.3.0" -description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "soupsieve" -version = "2.2.1" -description = "A modern CSS selector implementation for Beautiful Soup." -category = "main" -optional = false -python-versions = ">=3.6" - -[[package]] -name = "statsd" -version = "3.3.0" -description = "A simple statsd client." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "taskipy" -version = "1.7.0" -description = "tasks runner for python projects" -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" - -[package.dependencies] -mslex = ">=0.3.0,<0.4.0" -psutil = ">=5.7.2,<6.0.0" -toml = ">=0.10.0,<0.11.0" - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "typing-extensions" -version = "3.10.0.0" -description = "Backported and Experimental Type Hints for Python 3.5+" -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "urllib3" -version = "1.26.4" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" - -[package.extras] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] - -[[package]] -name = "virtualenv" -version = "20.4.6" -description = "Virtual Python Environment builder" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" - -[package.dependencies] -appdirs = ">=1.4.3,<2" -distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" -six = ">=1.9.0,<2" - -[package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] - -[[package]] -name = "yarl" -version = "1.6.3" -description = "Yet another URL library" -category = "main" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" - -[metadata] -lock-version = "1.1" -python-versions = "3.9.*" -content-hash = "ece3b915901a62911ff7ff4a616b3972e815c0e1c7097c8994163af13cadde0e" - -[metadata.files] -aio-pika = [ - {file = "aio-pika-6.8.0.tar.gz", hash = "sha256:1d4305a5f78af3857310b4fe48348cdcf6c097e0e275ea88c2cd08570531a369"}, - {file = "aio_pika-6.8.0-py3-none-any.whl", hash = "sha256:e69afef8695f47c5d107bbdba21bdb845d5c249acb3be53ef5c2d497b02657c0"}, -] -aiodns = [ - {file = "aiodns-2.0.0-py2.py3-none-any.whl", hash = "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de"}, - {file = "aiodns-2.0.0.tar.gz", hash = "sha256:815fdef4607474295d68da46978a54481dd1e7be153c7d60f9e72773cd38d77d"}, -] -aiohttp = [ - {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, - {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, -] -aioping = [ - {file = "aioping-0.3.1-py3-none-any.whl", hash = "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c"}, - {file = "aioping-0.3.1.tar.gz", hash = "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275"}, -] -aioredis = [ - {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, - {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, -] -aiormq = [ - {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"}, - {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"}, -] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] -arrow = [ - {file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"}, - {file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"}, -] -async-rediscache = [ - {file = "async-rediscache-0.1.4.tar.gz", hash = "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f"}, - {file = "async_rediscache-0.1.4-py3-none-any.whl", hash = "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"}, -] -async-timeout = [ - {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, - {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, -] -attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, -] -beautifulsoup4 = [ - {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, - {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, - {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, -] -certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, -] -cffi = [ - {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, - {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, - {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, - {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, - {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, - {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, - {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, - {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, - {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, - {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, - {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, - {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, - {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, - {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, - {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, - {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, - {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, - {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, - {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, -] -cfgv = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, -] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, -] -colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, -] -coloredlogs = [ - {file = "coloredlogs-14.3-py2.py3-none-any.whl", hash = "sha256:e244a892f9d97ffd2c60f15bf1d2582ef7f9ac0f848d132249004184785702b3"}, - {file = "coloredlogs-14.3.tar.gz", hash = "sha256:7ef1a7219870c7f02c218a2f2877ce68f2f8e087bb3a55bd6fbaa2a4362b4d52"}, -] -coverage = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, -] -coveralls = [ - {file = "coveralls-2.2.0-py2.py3-none-any.whl", hash = "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc"}, - {file = "coveralls-2.2.0.tar.gz", hash = "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"}, -] -deepdiff = [ - {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"}, - {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"}, -] -"discord.py" = [ - {file = "discord.py-1.6.0-py3-none-any.whl", hash = "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12"}, - {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"}, -] -distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, -] -docopt = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] -emoji = [ - {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"}, -] -fakeredis = [ - {file = "fakeredis-1.5.0-py3-none-any.whl", hash = "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"}, - {file = "fakeredis-1.5.0.tar.gz", hash = "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623"}, -] -feedparser = [ - {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"}, - {file = "feedparser-6.0.2.tar.gz", hash = "sha256:1b00a105425f492f3954fd346e5b524ca9cef3a4bbf95b8809470e9857aa1074"}, -] -filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, -] -flake8 = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, -] -flake8-annotations = [ - {file = "flake8-annotations-2.6.2.tar.gz", hash = "sha256:0d6cd2e770b5095f09689c9d84cc054c51b929c41a68969ea1beb4b825cac515"}, - {file = "flake8_annotations-2.6.2-py3-none-any.whl", hash = "sha256:d10c4638231f8a50c0a597c4efce42bd7b7d85df4f620a0ddaca526138936a4f"}, -] -flake8-bugbear = [ - {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, - {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"}, -] -flake8-docstrings = [ - {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, - {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, -] -flake8-import-order = [ - {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, - {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, -] -flake8-polyfill = [ - {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, - {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, -] -flake8-string-format = [ - {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, - {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, -] -flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"}, - {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"}, -] -flake8-todo = [ - {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, -] -fuzzywuzzy = [ - {file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"}, - {file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"}, -] -hiredis = [ - {file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"}, - {file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"}, - {file = "hiredis-2.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea"}, - {file = "hiredis-2.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99"}, - {file = "hiredis-2.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05"}, - {file = "hiredis-2.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a"}, - {file = "hiredis-2.0.0-cp36-cp36m-win32.whl", hash = "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63"}, - {file = "hiredis-2.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6"}, - {file = "hiredis-2.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485"}, - {file = "hiredis-2.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a"}, - {file = "hiredis-2.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc"}, - {file = "hiredis-2.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579"}, - {file = "hiredis-2.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e"}, - {file = "hiredis-2.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79"}, - {file = "hiredis-2.0.0-cp37-cp37m-win32.whl", hash = "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc"}, - {file = "hiredis-2.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"}, - {file = "hiredis-2.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb"}, - {file = "hiredis-2.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5"}, - {file = "hiredis-2.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298"}, - {file = "hiredis-2.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d"}, - {file = "hiredis-2.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db"}, - {file = "hiredis-2.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048"}, - {file = "hiredis-2.0.0-cp38-cp38-win32.whl", hash = "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426"}, - {file = "hiredis-2.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581"}, - {file = "hiredis-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5"}, - {file = "hiredis-2.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e"}, - {file = "hiredis-2.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce"}, - {file = "hiredis-2.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443"}, - {file = "hiredis-2.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0"}, - {file = "hiredis-2.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e"}, - {file = "hiredis-2.0.0-cp39-cp39-win32.whl", hash = "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d"}, - {file = "hiredis-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9"}, - {file = "hiredis-2.0.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54"}, - {file = "hiredis-2.0.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27"}, - {file = "hiredis-2.0.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d"}, - {file = "hiredis-2.0.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163"}, - {file = "hiredis-2.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a"}, - {file = "hiredis-2.0.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87"}, - {file = "hiredis-2.0.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41"}, - {file = "hiredis-2.0.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0"}, - {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"}, -] -humanfriendly = [ - {file = "humanfriendly-9.1-py2.py3-none-any.whl", hash = "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"}, - {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"}, -] -identify = [ - {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"}, - {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"}, -] -idna = [ - {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, - {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, -] -lxml = [ - {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"}, - {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"}, - {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"}, - {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"}, - {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"}, - {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"}, - {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"}, - {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"}, - {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"}, - {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"}, - {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"}, - {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"}, - {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"}, - {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"}, - {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"}, - {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"}, - {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"}, - {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"}, -] -markdownify = [ - {file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"}, - {file = "markdownify-0.6.1.tar.gz", hash = "sha256:31d7c13ac2ada8bfc7535a25fee6622ca720e1b5f2d4a9cbc429d167c21f886d"}, -] -mccabe = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] -more-itertools = [ - {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, - {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, -] -mslex = [ - {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, - {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"}, -] -multidict = [ - {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, - {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, - {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, - {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, - {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, - {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, - {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, - {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, - {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, - {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, - {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, - {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, - {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, -] -nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, -] -ordered-set = [ - {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, -] -pamqp = [ - {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"}, - {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, -] -pep8-naming = [ - {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, - {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, -] -pre-commit = [ - {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, - {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, -] -psutil = [ - {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, - {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c"}, - {file = "psutil-5.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df"}, - {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131"}, - {file = "psutil-5.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60"}, - {file = "psutil-5.8.0-cp27-none-win32.whl", hash = "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876"}, - {file = "psutil-5.8.0-cp27-none-win_amd64.whl", hash = "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65"}, - {file = "psutil-5.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8"}, - {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6"}, - {file = "psutil-5.8.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac"}, - {file = "psutil-5.8.0-cp36-cp36m-win32.whl", hash = "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2"}, - {file = "psutil-5.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d"}, - {file = "psutil-5.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935"}, - {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d"}, - {file = "psutil-5.8.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023"}, - {file = "psutil-5.8.0-cp37-cp37m-win32.whl", hash = "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394"}, - {file = "psutil-5.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"}, - {file = "psutil-5.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef"}, - {file = "psutil-5.8.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28"}, - {file = "psutil-5.8.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b"}, - {file = "psutil-5.8.0-cp38-cp38-win32.whl", hash = "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d"}, - {file = "psutil-5.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d"}, - {file = "psutil-5.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7"}, - {file = "psutil-5.8.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4"}, - {file = "psutil-5.8.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b"}, - {file = "psutil-5.8.0-cp39-cp39-win32.whl", hash = "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0"}, - {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"}, - {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, -] -pycares = [ - {file = "pycares-3.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ebff743643e54aa70dce0b7098094edefd371641cf79d9c944e9f4a25e9242b0"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55272411b46787936e8db475b9b6e9b81a8d8cdc253fa8779a45ef979f554fab"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f33ed0e403f98e746f721aeacde917f1bdc7558cb714d713c264848bddff660f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:72807e0c80b705e21c3a39347c12edf43aa4f80373bb37777facf810169372ed"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a51df0a8b3eaf225e0dae3a737fd6ce6f3cb2a3bc947e884582fdda9a159d55f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:663b5c7bd0f66436adac7257ee22ccfe185c3e7830b9bada3d19b79870e1d134"}, - {file = "pycares-3.2.3-cp36-cp36m-win32.whl", hash = "sha256:c2b1e19262ce91c3288b1905b0d41f7ad0fff4b258ce37b517aa2c8d22eb82f1"}, - {file = "pycares-3.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e16399654a6c81cfaee2745857c119c20357b5d93de2f169f506b048b5e75d1d"}, - {file = "pycares-3.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88e5131570d7323b29866aa5ac245a9a5788d64677111daa1bde5817acdf012f"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1552ffd823dc595fa8744c996926097a594f4f518d7c147657234b22cf17649d"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f9e28b917373818817aca746238fcd621ec7e4ae9cbc8615f1a045e234eec298"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:206d5a652990f10a1f1f3f62bc23d7fe46d99c2dc4b8b8a5101e5a472986cd02"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b8c9670225cdeeeb2b85ea92a807484622ca59f8f578ec73e8ec292515f35a91"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6329160885fc318f80692d4d0a83a8854f9144e7a80c4f25245d0c26f11a4b84"}, - {file = "pycares-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:cd0f7fb40e1169f00b26a12793136bf5c711f155e647cd045a0ce6c98a527b57"}, - {file = "pycares-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a5d419215543d154587590d9d4485e985387ca10c7d3e1a2e5689dd6c0f20e5f"}, - {file = "pycares-3.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54f1c0642935515f27549f09486e72b6b2b1d51ad27a90ce17b760e9ce5e86d"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6ce80eed538dd6106cd7e6136ceb3af10178d1254f07096a827c12e82e5e45c8"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ed972a04067e91f552da84945d38b94c3984c898f699faa8bb066e9f3a114c32"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:99a62b101cfb36ab6ebf19cb1ad60db2f9b080dc52db4ca985fe90924f60c758"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2246adcbc948dd31925c9bff5cc41c06fc640f7d982e6b41b6d09e4f201e5c11"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7fd15d3f32be5548f38f95f4762ca73eef9fd623b101218a35d433ee0d4e3b58"}, - {file = "pycares-3.2.3-cp38-cp38-win32.whl", hash = "sha256:4bb0c708d8713741af7c4649d2f11e47c5f4e43131831243aeb18cff512c5469"}, - {file = "pycares-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:a53d921956d1e985e510ca0ffa84fbd7ecc6ac7d735d8355cba4395765efcd31"}, - {file = "pycares-3.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0312d25fa9d7c242f66115c4b3ae6ed8aedb457513ba33acef31fa265fc602b4"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9960de8254525d9c3b485141809910c39d5eb1bb8119b1453702aacf72234934"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:929f708a7bb4b2548cbbfc2094b2f90c4d8712056cdc0204788b570ab69c8838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4dd1237f01037cf5b90dd599c7fa79d9d8fb2ab2f401e19213d24228b2d17838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5eea61a74097976502ce377bb75c4fed381d4986bc7fb85e70b691165133d3da"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1c72c0fda4b08924fe04680475350e09b8d210365d950a6dcdde8c449b8d5b98"}, - {file = "pycares-3.2.3-cp39-cp39-win32.whl", hash = "sha256:b1555d51ce29510ffd20f9e0339994dff8c5d1cb093c8e81d5d98f474e345aa7"}, - {file = "pycares-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:43c15138f620ed28e61e51b884490eb8387e5954668f919313753f88dd8134fd"}, - {file = "pycares-3.2.3.tar.gz", hash = "sha256:da1899fde778f9b8736712283eccbf7b654248779b349d139cd28eb30b0fa8cd"}, -] -pycodestyle = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, -] -pycparser = [ - {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, - {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, -] -pydocstyle = [ - {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"}, - {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"}, -] -pyflakes = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, -] -pyreadline = [ - {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, - {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, - {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, -] -python-dateutil = [ - {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, - {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, -] -python-dotenv = [ - {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, - {file = "python_dotenv-0.17.1-py2.py3-none-any.whl", hash = "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544"}, -] -python-frontmatter = [ - {file = "python-frontmatter-1.0.0.tar.gz", hash = "sha256:e98152e977225ddafea6f01f40b4b0f1de175766322004c826ca99842d19a7cd"}, - {file = "python_frontmatter-1.0.0-py3-none-any.whl", hash = "sha256:766ae75f1b301ffc5fe3494339147e0fd80bc3deff3d7590a93991978b579b08"}, -] -pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, -] -redis = [ - {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, - {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, -] -regex = [ - {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, - {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, - {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, - {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, - {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, - {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, - {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, - {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, - {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, - {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, - {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, - {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, - {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, -] -requests = [ - {file = "requests-2.15.1-py2.py3-none-any.whl", hash = "sha256:ff753b2196cd18b1bbeddc9dcd5c864056599f7a7d9a4fb5677e723efa2b7fb9"}, - {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, -] -sentry-sdk = [ - {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, - {file = "sentry_sdk-0.20.3-py2.py3-none-any.whl", hash = "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"}, -] -sgmllib3k = [ - {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, -] -six = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, -] -sortedcontainers = [ - {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, - {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, -] -soupsieve = [ - {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, - {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, -] -statsd = [ - {file = "statsd-3.3.0-py2.py3-none-any.whl", hash = "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa"}, - {file = "statsd-3.3.0.tar.gz", hash = "sha256:e3e6db4c246f7c59003e51c9720a51a7f39a396541cb9b147ff4b14d15b5dd1f"}, -] -taskipy = [ - {file = "taskipy-1.7.0-py3-none-any.whl", hash = "sha256:9e284c10898e9dee01a3e72220b94b192b1daa0f560271503a6df1da53d03844"}, - {file = "taskipy-1.7.0.tar.gz", hash = "sha256:960e480b1004971e76454ecd1a0484e640744a30073a1069894a311467f85ed8"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, -] -urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, -] -virtualenv = [ - {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"}, - {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"}, -] -yarl = [ - {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, - {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, - {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, - {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, - {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, - {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, - {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, - {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, - {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, - {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, - {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, - {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, - {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, -] diff --git a/pyproject.toml b/pyproject.toml index 320bf88cc4..beb96859a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,63 +1,103 @@ -[tool.poetry] +[project] +authors = [ + {name = "Python Discord", email = "info@pythondiscord.com"}, +] +license = {text = "MIT"} +requires-python = "==3.14.*" +dependencies = [ + "pydis-core[all]==11.8.0", + "aiohttp==3.13.4", + "arrow==1.4.0", + "beautifulsoup4==4.14.2", + "deepdiff==9.0.0", + "emoji==2.15.0", + "feedparser==6.0.12", + "lxml==6.1.0", + "markdownify==1.2.0", + "pydantic==2.12.3", + "pydantic-settings==2.11.0", + "python-dateutil==2.9.0.post0", + "python-frontmatter==1.1.0", + "rapidfuzz==3.14.1", + "regex==2026.3.32", + "sentry-sdk==2.43.0", + "tenacity==9.1.2", + "tldextract==5.3.0", + "yarl==1.22.0", +] name = "bot" -version = "1.0.0" +version = "1.0.1" description = "The community bot for the Python Discord community." -authors = ["Python Discord "] -license = "MIT" - -[tool.poetry.dependencies] -python = "3.9.*" -aio-pika = "~=6.1" -aiodns = "~=2.0" -aiohttp = "~=3.7" -aioping = "~=0.3.1" -aioredis = "~=1.3.1" -arrow = "~=1.0.3" -async-rediscache = { version = "~=0.1.2", extras = ["fakeredis"] } -beautifulsoup4 = "~=4.9" -colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" } -coloredlogs = "~=14.0" -deepdiff = "~=4.0" -"discord.py" = "~=1.6.0" -emoji = "~=0.6" -feedparser = "~=6.0.2" -fuzzywuzzy = "~=0.17" -lxml = "~=4.4" -markdownify = "==0.6.1" -more_itertools = "~=8.2" -python-dateutil = "~=2.8" -python-frontmatter = "~=1.0.0" -pyyaml = "~=5.1" -regex = "==2021.4.4" -sentry-sdk = "~=0.19" -statsd = "~=3.3" - -[tool.poetry.dev-dependencies] -coverage = "~=5.0" -coveralls = "~=2.1" -flake8 = "~=3.8" -flake8-annotations = "~=2.0" -flake8-bugbear = "~=20.1" -flake8-docstrings = "~=1.4" -flake8-import-order = "~=0.18" -flake8-string-format = "~=0.2" -flake8-tidy-imports = "~=4.0" -flake8-todo = "~=0.7" -pep8-naming = "~=0.9" -pre-commit = "~=2.1" -taskipy = "~=1.7.0" -python-dotenv = "~=0.17.1" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" + +[tool.uv] +prerelease = "allow" +required-version = ">=0.9.7" + +[dependency-groups] +dev = [ + "coverage==7.11.0", + "httpx==0.28.1", + "pre-commit==4.3.0", + "pytest==8.4.2", + "pytest-cov==7.0.0", + "pytest-subtests==0.14.1", + "pytest-xdist==3.8.0", + "ruff==0.14.2", + "taskipy==1.14.1", +] [tool.taskipy.tasks] start = "python -m bot" +configure = "python -m botstrap" lint = "pre-commit run --all-files" precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" -test = "coverage run -m unittest" +test = "pytest -n auto --ff" +retest = "pytest -n auto --lf" +test-cov = "pytest -n auto --cov-report= --cov" html = "coverage html" report = "coverage report" + +[tool.coverage.run] +branch = true +source_pkgs = ["bot"] +source = ["tests"] + +[tool.ruff] +extend-exclude = [".cache"] +line-length = 120 +output-format = "concise" +unsafe-fixes = true + +[tool.ruff.lint] +select = ["ANN", "B", "C4", "D", "DTZ", "E", "F", "I", "ISC", "INT", "N", "PGH", "PIE", "Q", "RET", "RSE", "RUF", "S", "SIM", "T20", "TID", "UP", "W"] +ignore = [ + "ANN002", "ANN003", "ANN204", "ANN206", "ANN401", + "B904", + "C401", "C408", + "D100", "D104", "D105", "D107", "D203", "D212", "D214", "D215", "D301", + "D400", "D401", "D402", "D404", "D405", "D406", "D407", "D408", "D409", "D410", "D411", "D412", "D413", "D414", "D416", "D417", + "E731", + "RET504", + "RUF005", "RUF012", "RUF015", + "S311", + "SIM102", "SIM108", +] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"__future__.annotations".msg = "No longer required in Python 3.14+, see https://docs.python.org/3/reference/compound_stmts.html#annotations" + +[tool.ruff.lint.isort] +order-by-type = false +case-sensitive = true +combine-as-imports = true + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ANN", "D"] + +[tool.pytest.ini_options] +# We don't use nose style tests so disable them in pytest. +# This stops pytest from running functions named `setup` in test files. +# See https://github.com/python-discord/bot/pull/2229#issuecomment-1204436420 +addopts = "-p no:nose" diff --git a/tests/README.md b/tests/README.md index 1a17c09bda..ee09b0a102 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._ +### Table of contents: +- [Tools](#tools) +- [Running tests](#running-tests) +- [Writing tests](#writing-tests) +- [Mocking](#mocking) +- [Some considerations](#some-considerations) +- [Additional resources](#additional-resources) + ## Tools We are using the following modules and packages for our unit tests: @@ -11,14 +19,42 @@ We are using the following modules and packages for our unit tests: - [unittest](https://docs.python.org/3/library/unittest.html) (standard library) - [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) - [coverage.py](https://coverage.readthedocs.io/en/stable/) +- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/index.html) + +We also use the following package as a test runner: +- [pytest](https://docs.pytest.org/en/6.2.x/) + +To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [UV Project](/pyproject.toml). To run your tests with `uv`, we've provided the following "script" shortcuts: -To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts: +- `uv run task test` will run `pytest`. +- `uv run task test path/to/test.py` will run a specific test. +- `uv run task test-cov` will run `pytest` with `pytest-cov`. +- `uv run task report` will generate a coverage report of the tests you've run with `uv run task test-cov`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -- `poetry run task test` will run `unittest` with `coverage.py` -- `poetry run task test path/to/test.py` will run a specific test. -- `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. +If you want a coverage report, make sure to run the tests with `uv run task test-cov` *first*. + +## Running tests +There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development. + +When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite. +To run just one file, and save time, you can use the following command: +```shell +uv run task test +``` -If you want a coverage report, make sure to run the tests with `poetry run task test` *first*. +For example: +```shell +uv run task test tests/bot/exts/test_cogs.py +``` +will run the test suite in the `test_cogs` file. + +If you'd like to collect coverage as well, you can append `--cov` to the command above. + + +If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check: +```shell +uv run task test +``` ## Writing tests @@ -80,12 +116,12 @@ TagContentConverter should return correct values for valid input. ## Mocking -As we are trying to test our "units" of code independently, we want to make sure that we do not rely objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. +As we are trying to test our "units" of code independently, we want to make sure that we do not rely on objects and data generated by "external" code. If we we did, then we wouldn't know if the failure we're observing was caused by the code we are actually trying to test or something external to it. -However, the features that we are trying to test often depend on those objects generated by external pieces of code. It would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". +However, the features that we are trying to test often depend on objects generated by external pieces of code. For example, it would be difficult to test a bot command without having access to a `Context` instance. Fortunately, there's a solution for that: we use fake objects that act like the true object. We call these fake objects "mocks". -To create these mock object, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section on the below.). +To create these mock objects, we mainly use the [`unittest.mock`](https://docs.python.org/3/library/unittest.mock.html) module. In addition, we have also defined a couple of specialized mock objects that mock specific `discord.py` types (see the section about them below.). An example of mocking is when we provide a command with a mocked version of `discord.ext.commands.Context` object instead of a real `Context` object. This makes sure we can then check (_assert_) if the `send` method of the mocked Context object was called with the correct message content (without having to send a real message to the Discord API!): diff --git a/tests/__init__.py b/tests/__init__.py index 2228110ad1..c2b9d12dca 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@ import logging +from bot.log import get_logger -log = logging.getLogger() +log = get_logger() log.setLevel(logging.CRITICAL) diff --git a/tests/_autospec.py b/tests/_autospec.py index ee2fc19736..a9cd59dc09 100644 --- a/tests/_autospec.py +++ b/tests/_autospec.py @@ -1,7 +1,8 @@ import contextlib import functools +import pkgutil import unittest.mock -from typing import Callable +from collections.abc import Callable @functools.wraps(unittest.mock._patch.decoration_helper) @@ -50,8 +51,8 @@ def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) # Import the target if it's a string. # This is to support both object and string targets like patch.multiple. - if type(target) is str: - target = unittest.mock._importer(target) + if isinstance(target, str): + target = pkgutil.resolve_name(target) def decorator(func): for attribute in attributes: diff --git a/tests/base.py b/tests/base.py index d99b9ac319..cad187b6ab 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,11 +1,12 @@ import logging import unittest from contextlib import contextmanager -from typing import Dict import discord +from async_rediscache import RedisSession from discord.ext import commands +from bot.log import get_logger from tests import helpers @@ -42,7 +43,7 @@ def assertNotLogs(self, logger=None, level=None, msg=None): # noqa: N802 manager when we're testing under the assumption that no log records will be emitted. """ if not isinstance(logger, logging.Logger): - logger = logging.getLogger(logger) + logger = get_logger(logger) if level: level = logging._nameToLevel.get(level, level) @@ -84,7 +85,7 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): async def assertHasPermissionsCheck( # noqa: N802 self, cmd: commands.Command, - permissions: Dict[str, bool], + permissions: dict[str, bool], ) -> None: """ Test that `cmd` raises a `MissingPermissions` exception if author lacks `permissions`. @@ -102,4 +103,27 @@ async def assertHasPermissionsCheck( # noqa: N802 with self.assertRaises(commands.MissingPermissions) as cm: await cmd.can_run(ctx) - self.assertCountEqual(permissions.keys(), cm.exception.missing_perms) + self.assertCountEqual(permissions.keys(), cm.exception.missing_permissions) + + +class RedisTestCase(unittest.IsolatedAsyncioTestCase): + """ + Use this as a base class for any test cases that require a redis session. + + This will prepare a fresh redis instance for each test function, and will + not make any assertions on its own. Tests can mutate the instance as they wish. + """ + + session = None + + async def flush(self): + """Flush everything from the redis database to prevent carry-overs between tests.""" + await self.session.client.flushall() + + async def asyncSetUp(self): + self.session = await RedisSession(use_fakeredis=True).connect() + await self.flush() + + async def asyncTearDown(self): + if self.session: + await self.session.client.close() diff --git a/tests/bot/.testenv b/tests/bot/.testenv new file mode 100644 index 0000000000..484c8809da --- /dev/null +++ b/tests/bot/.testenv @@ -0,0 +1,2 @@ +unittests_goat=volcyy +unittests_nested__server_name=pydis diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 3ad9db9c30..4dacfda17a 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,8 +1,8 @@ import unittest from unittest import mock +from pydis_core.site_api import ResponseCodeError -from bot.api import ResponseCodeError from bot.exts.backend.sync._syncers import Syncer from tests import helpers diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 22a07313e3..6d7356bf27 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -1,10 +1,12 @@ +import types import unittest +import unittest.mock from unittest import mock import discord +from pydis_core.site_api import ResponseCodeError from bot import constants -from bot.api import ResponseCodeError from bot.exts.backend import sync from bot.exts.backend.sync._cog import Sync from bot.exts.backend.sync._syncers import Syncer @@ -16,11 +18,11 @@ class SyncExtensionTests(unittest.IsolatedAsyncioTestCase): """Tests for the sync extension.""" @staticmethod - def test_extension_setup(): + async def test_extension_setup(): """The Sync cog should be added.""" bot = helpers.MockBot() - sync.setup(bot) - bot.add_cog.assert_called_once() + await sync.setup(bot) + bot.add_cog.assert_awaited_once() class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): @@ -60,43 +62,54 @@ def response_error(status: int) -> ResponseCodeError: class SyncCogTests(SyncCogTestCase): """Tests for the Sync cog.""" - @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) - def test_sync_cog_init(self, sync_guild): - """Should instantiate syncers and run a sync for the guild.""" - # Reset because a Sync cog was already instantiated in setUp. - self.RoleSyncer.reset_mock() - self.UserSyncer.reset_mock() - self.bot.loop.create_task = mock.MagicMock() - - mock_sync_guild_coro = mock.MagicMock() - sync_guild.return_value = mock_sync_guild_coro - - Sync(self.bot) - - sync_guild.assert_called_once_with() - self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) - - async def test_sync_cog_sync_guild(self): - """Roles and users should be synced only if a guild is successfully retrieved.""" + @unittest.mock.patch("bot.exts.backend.sync._cog.create_task", new_callable=unittest.mock.MagicMock) + async def test_sync_cog_sync_on_load(self, mock_create_task: unittest.mock.MagicMock): + """Sync function should be synced on cog load only if guild is found.""" for guild in (helpers.MockGuild(), None): with self.subTest(guild=guild): + mock_create_task.reset_mock() self.bot.reset_mock() self.RoleSyncer.reset_mock() self.UserSyncer.reset_mock() self.bot.get_guild = mock.MagicMock(return_value=guild) - - await self.cog.sync_guild() - - self.bot.wait_until_guild_available.assert_called_once() - self.bot.get_guild.assert_called_once_with(constants.Guild.id) + error_raised = False + try: + await self.cog.cog_load() + except ValueError: + if guild is None: + error_raised = True + else: + raise if guild is None: - self.RoleSyncer.sync.assert_not_called() - self.UserSyncer.sync.assert_not_called() + self.assertTrue(error_raised) + mock_create_task.assert_not_called() else: - self.RoleSyncer.sync.assert_called_once_with(guild) - self.UserSyncer.sync.assert_called_once_with(guild) + mock_create_task.assert_called_once() + create_task_arg = mock_create_task.call_args[0][0] + self.assertIsInstance(create_task_arg, types.CoroutineType) + self.assertEqual(create_task_arg.__qualname__, self.cog.sync.__qualname__) + create_task_arg.close() + + async def test_sync_cog_sync_guild(self): + """Roles and users should be synced only if a guild is successfully retrieved.""" + guild = helpers.MockGuild() + self.bot.reset_mock() + self.RoleSyncer.reset_mock() + self.UserSyncer.reset_mock() + + self.bot.get_guild = mock.MagicMock(return_value=guild) + await self.cog.cog_load() + + with mock.patch("asyncio.sleep", new_callable=unittest.mock.AsyncMock): + await self.cog.sync() + + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(constants.Guild.id) + + self.RoleSyncer.sync.assert_called_once() + self.UserSyncer.sync.assert_called_once() async def patch_user_helper(self, side_effect: BaseException) -> None: """Helper to set a side effect for bot.api_client.patch and then assert it is called.""" diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 27932be95a..26bf0d9a0f 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,6 +1,8 @@ import unittest from unittest import mock +from discord.errors import NotFound + from bot.exts.backend.sync._syncers import UserSyncer, _Diff from tests import helpers @@ -9,8 +11,9 @@ def fake_user(**kwargs): """Fixture to return a dictionary representing a user with default values set.""" kwargs.setdefault("id", 43) kwargs.setdefault("name", "bob the test man") + kwargs.setdefault("display_name", "bob") kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", [666]) + kwargs.setdefault("roles", [helpers.MockRole(id=666)]) kwargs.setdefault("in_guild", True) return kwargs @@ -134,6 +137,7 @@ async def test_diff_sets_in_guild_false_for_leaving_users(self): self.get_mock_member(fake_user()), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [{"id": 63, "in_guild": False}], None) @@ -158,6 +162,7 @@ async def test_diff_for_new_updated_and_leaving_users(self): self.get_mock_member(updated_user), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) @@ -177,6 +182,7 @@ async def test_empty_diff_for_db_users_not_in_guild(self): self.get_mock_member(fake_user()), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [], None) @@ -204,8 +210,8 @@ async def test_sync_created_users(self): diff = _Diff(self.users, [], None) await UserSyncer._sync(diff) - self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[:self.chunk_size]) - self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[self.chunk_size:]) + self.bot.api_client.post.assert_any_call("bot/users", json=tuple(diff.created[:self.chunk_size])) + self.bot.api_client.post.assert_any_call("bot/users", json=tuple(diff.created[self.chunk_size:])) self.assertEqual(self.bot.api_client.post.call_count, self.chunk_count) self.bot.api_client.put.assert_not_called() @@ -216,8 +222,8 @@ async def test_sync_updated_users(self): diff = _Diff([], self.users, None) await UserSyncer._sync(diff) - self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[:self.chunk_size]) - self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[self.chunk_size:]) + self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=tuple(diff.updated[:self.chunk_size])) + self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=tuple(diff.updated[self.chunk_size:])) self.assertEqual(self.bot.api_client.patch.call_count, self.chunk_count) self.bot.api_client.post.assert_not_called() diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb59422..85dc33999d 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -1,15 +1,15 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch from discord.ext.commands import errors +from pydis_core.site_api import ResponseCodeError -from bot.api import ResponseCodeError -from bot.errors import InvalidInfractedUser, LockedResourceError -from bot.exts.backend.error_handler import ErrorHandler, setup +from bot.errors import InvalidInfractedUserError, LockedResourceError +from bot.exts.backend import error_handler from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence from bot.utils.checks import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext, MockGuild, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockRole, MockTextChannel, MockVoiceChannel class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -18,14 +18,14 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.ctx = MockContext(bot=self.bot) + self.cog = error_handler.ErrorHandler(self.bot) async def test_error_handler_already_handled(self): """Should not do anything when error is already handled by local error handler.""" self.ctx.reset_mock() - cog = ErrorHandler(self.bot) error = errors.CommandError() error.handled = "foo" - self.assertIsNone(await cog.on_command_error(self.ctx, error)) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) self.ctx.send.assert_not_awaited() async def test_error_handler_command_not_found_error_not_invoked_by_handler(self): @@ -45,27 +45,30 @@ async def test_error_handler_command_not_found_error_not_invoked_by_handler(self "called_try_get_tag": True } ) - cog = ErrorHandler(self.bot) - cog.try_silence = AsyncMock() - cog.try_get_tag = AsyncMock() + self.cog.try_silence = AsyncMock() + self.cog.try_get_tag = AsyncMock() + self.cog.try_run_fixed_codeblock = AsyncMock(return_value=False) for case in test_cases: with self.subTest(try_silence_return=case["try_silence_return"], try_get_tag=case["called_try_get_tag"]): self.ctx.reset_mock() - cog.try_silence.reset_mock(return_value=True) - cog.try_get_tag.reset_mock() + self.cog.try_silence.reset_mock(return_value=True) + self.cog.try_get_tag.reset_mock() + self.ctx.invoked_from_error_handler = False - cog.try_silence.return_value = case["try_silence_return"] + self.cog.try_silence.return_value = case["try_silence_return"] self.ctx.channel.id = 1234 - self.assertIsNone(await cog.on_command_error(self.ctx, error)) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + + self.assertTrue(self.ctx.invoked_from_error_handler) if case["try_silence_return"]: - cog.try_get_tag.assert_not_awaited() - cog.try_silence.assert_awaited_once() + self.cog.try_get_tag.assert_not_awaited() + self.cog.try_silence.assert_awaited_once() else: - cog.try_silence.assert_awaited_once() - cog.try_get_tag.assert_awaited_once() + self.cog.try_silence.assert_awaited_once() + self.cog.try_get_tag.assert_awaited_once() self.ctx.send.assert_not_awaited() @@ -73,64 +76,61 @@ async def test_error_handler_command_not_found_error_invoked_by_handler(self): """Should do nothing when error is `CommandNotFound` and have attribute `invoked_from_error_handler`.""" ctx = MockContext(bot=self.bot, invoked_from_error_handler=True) - cog = ErrorHandler(self.bot) - cog.try_silence = AsyncMock() - cog.try_get_tag = AsyncMock() + self.cog.try_silence = AsyncMock() + self.cog.try_get_tag = AsyncMock() + self.cog.try_run_fixed_codeblock = AsyncMock() error = errors.CommandNotFound() - self.assertIsNone(await cog.on_command_error(ctx, error)) + self.assertIsNone(await self.cog.on_command_error(ctx, error)) - cog.try_silence.assert_not_awaited() - cog.try_get_tag.assert_not_awaited() + self.cog.try_silence.assert_not_awaited() + self.cog.try_get_tag.assert_not_awaited() + self.cog.try_run_fixed_codeblock.assert_not_awaited() self.ctx.send.assert_not_awaited() async def test_error_handler_user_input_error(self): """Should await `ErrorHandler.handle_user_input_error` when error is `UserInputError`.""" self.ctx.reset_mock() - cog = ErrorHandler(self.bot) - cog.handle_user_input_error = AsyncMock() + self.cog.handle_user_input_error = AsyncMock() error = errors.UserInputError() - self.assertIsNone(await cog.on_command_error(self.ctx, error)) - cog.handle_user_input_error.assert_awaited_once_with(self.ctx, error) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + self.cog.handle_user_input_error.assert_awaited_once_with(self.ctx, error) async def test_error_handler_check_failure(self): """Should await `ErrorHandler.handle_check_failure` when error is `CheckFailure`.""" self.ctx.reset_mock() - cog = ErrorHandler(self.bot) - cog.handle_check_failure = AsyncMock() + self.cog.handle_check_failure = AsyncMock() error = errors.CheckFailure() - self.assertIsNone(await cog.on_command_error(self.ctx, error)) - cog.handle_check_failure.assert_awaited_once_with(self.ctx, error) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) + self.cog.handle_check_failure.assert_awaited_once_with(self.ctx, error) async def test_error_handler_command_on_cooldown(self): """Should send error with `ctx.send` when error is `CommandOnCooldown`.""" self.ctx.reset_mock() - cog = ErrorHandler(self.bot) - error = errors.CommandOnCooldown(10, 9) - self.assertIsNone(await cog.on_command_error(self.ctx, error)) + error = errors.CommandOnCooldown(10, 9, type=None) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) self.ctx.send.assert_awaited_once_with(error) async def test_error_handler_command_invoke_error(self): """Should call `handle_api_error` or `handle_unexpected_error` depending on original error.""" - cog = ErrorHandler(self.bot) - cog.handle_api_error = AsyncMock() - cog.handle_unexpected_error = AsyncMock() + self.cog.handle_api_error = AsyncMock() + self.cog.handle_unexpected_error = AsyncMock() test_cases = ( { "args": (self.ctx, errors.CommandInvokeError(ResponseCodeError(AsyncMock()))), - "expect_mock_call": cog.handle_api_error + "expect_mock_call": self.cog.handle_api_error }, { "args": (self.ctx, errors.CommandInvokeError(TypeError)), - "expect_mock_call": cog.handle_unexpected_error + "expect_mock_call": self.cog.handle_unexpected_error }, { "args": (self.ctx, errors.CommandInvokeError(LockedResourceError("abc", "test"))), "expect_mock_call": "send" }, { - "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))), + "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUserError(self.ctx.author))), "expect_mock_call": "send" } ) @@ -138,7 +138,7 @@ async def test_error_handler_command_invoke_error(self): for case in test_cases: with self.subTest(args=case["args"], expect_mock_call=case["expect_mock_call"]): self.ctx.send.reset_mock() - self.assertIsNone(await cog.on_command_error(*case["args"])) + self.assertIsNone(await self.cog.on_command_error(*case["args"])) if case["expect_mock_call"] == "send": self.ctx.send.assert_awaited_once() else: @@ -148,46 +148,42 @@ async def test_error_handler_command_invoke_error(self): async def test_error_handler_conversion_error(self): """Should call `handle_api_error` or `handle_unexpected_error` depending on original error.""" - cog = ErrorHandler(self.bot) - cog.handle_api_error = AsyncMock() - cog.handle_unexpected_error = AsyncMock() + self.cog.handle_api_error = AsyncMock() + self.cog.handle_unexpected_error = AsyncMock() cases = ( { "error": errors.ConversionError(AsyncMock(), ResponseCodeError(AsyncMock())), - "mock_function_to_call": cog.handle_api_error + "mock_function_to_call": self.cog.handle_api_error }, { "error": errors.ConversionError(AsyncMock(), TypeError), - "mock_function_to_call": cog.handle_unexpected_error + "mock_function_to_call": self.cog.handle_unexpected_error } ) for case in cases: with self.subTest(**case): - self.assertIsNone(await cog.on_command_error(self.ctx, case["error"])) + self.assertIsNone(await self.cog.on_command_error(self.ctx, case["error"])) case["mock_function_to_call"].assert_awaited_once_with(self.ctx, case["error"].original) - async def test_error_handler_two_other_errors(self): - """Should call `handle_unexpected_error` if error is `MaxConcurrencyReached` or `ExtensionError`.""" - cog = ErrorHandler(self.bot) - cog.handle_unexpected_error = AsyncMock() + async def test_error_handler_unexpected_errors(self): + """Should call `handle_unexpected_error` if error is `ExtensionError`.""" + self.cog.handle_unexpected_error = AsyncMock() errs = ( - errors.MaxConcurrencyReached(1, MagicMock()), - errors.ExtensionError(name="foo") + errors.ExtensionError(name="foo"), ) for err in errs: with self.subTest(error=err): - cog.handle_unexpected_error.reset_mock() - self.assertIsNone(await cog.on_command_error(self.ctx, err)) - cog.handle_unexpected_error.assert_awaited_once_with(self.ctx, err) + self.cog.handle_unexpected_error.reset_mock() + self.assertIsNone(await self.cog.on_command_error(self.ctx, err)) + self.cog.handle_unexpected_error.assert_awaited_once_with(self.ctx, err) @patch("bot.exts.backend.error_handler.log") async def test_error_handler_other_errors(self, log_mock): """Should `log.debug` other errors.""" - cog = ErrorHandler(self.bot) error = errors.DisabledCommand() # Use this just as a other error - self.assertIsNone(await cog.on_command_error(self.ctx, error)) + self.assertIsNone(await self.cog.on_command_error(self.ctx, error)) log_mock.debug.assert_called_once() @@ -198,15 +194,17 @@ def setUp(self): self.bot = MockBot() self.silence = Silence(self.bot) self.bot.get_command.return_value = self.silence.silence - self.ctx = MockContext(bot=self.bot) - self.cog = ErrorHandler(self.bot) - async def test_try_silence_context_invoked_from_error_handler(self): - """Should set `Context.invoked_from_error_handler` to `True`.""" - self.ctx.invoked_with = "foo" - await self.cog.try_silence(self.ctx) - self.assertTrue(hasattr(self.ctx, "invoked_from_error_handler")) - self.assertTrue(self.ctx.invoked_from_error_handler) + # Use explicit mock channels so that discord.utils.get doesn't think + # guild.text_channels is an async iterable due to the MagicMock having + # a __aiter__ attr. + guild_overrides = { + "text_channels": [MockTextChannel(), MockTextChannel()], + "voice_channels": [MockVoiceChannel(), MockVoiceChannel()], + } + self.guild = MockGuild(**guild_overrides) + self.ctx = MockContext(bot=self.bot, guild=self.guild) + self.cog = error_handler.ErrorHandler(self.bot) async def test_try_silence_get_command(self): """Should call `get_command` with `silence`.""" @@ -226,8 +224,8 @@ async def test_try_silence_no_permissions_to_run_command_error(self): self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError()) self.assertFalse(await self.cog.try_silence(self.ctx)) - async def test_try_silence_silencing(self): - """Should run silence command with correct arguments.""" + async def test_try_silence_silence_duration(self): + """Should run silence command with correct duration argument.""" self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) test_cases = ("shh", "shhh", "shhhhhh", "shhhhhhhhhhhhhhhhhhh") @@ -238,21 +236,85 @@ async def test_try_silence_silencing(self): self.assertTrue(await self.cog.try_silence(self.ctx)) self.ctx.invoke.assert_awaited_once_with( self.bot.get_command.return_value, - duration=min(case.count("h")*2, 15) + duration_or_channel=None, + duration=min(case.count("h")*2, 15), + kick=False + ) + + async def test_try_silence_silence_arguments(self): + """Should run silence with the correct channel, duration, and kick arguments.""" + self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) + + test_cases = ( + (MockTextChannel(), None), # None represents the case when no argument is passed + (MockTextChannel(), False), + (MockTextChannel(), True) + ) + + for channel, kick in test_cases: + with self.subTest(kick=kick, channel=channel): + self.ctx.reset_mock() + self.ctx.invoked_with = "shh" + + self.ctx.message.content = f"!shh {channel.name} {kick if kick is not None else ''}" + self.ctx.guild.text_channels = [channel] + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with( + self.bot.get_command.return_value, + duration_or_channel=channel, + duration=4, + kick=(kick if kick is not None else False) ) + async def test_try_silence_silence_message(self): + """If the words after the command could not be converted to a channel, None should be passed as channel.""" + self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) + self.ctx.invoked_with = "shh" + self.ctx.message.content = "!shh not_a_channel true" + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with( + self.bot.get_command.return_value, + duration_or_channel=None, + duration=4, + kick=False + ) + async def test_try_silence_unsilence(self): - """Should call unsilence command.""" + """Should call unsilence command with correct duration and channel arguments.""" self.silence.silence.can_run = AsyncMock(return_value=True) - test_cases = ("unshh", "unshhhhh", "unshhhhhhhhh") + test_cases = ( + ("unshh", None), + ("unshhhhh", None), + ("unshhhhhhhhh", None), + ("unshh", MockTextChannel()) + ) - for case in test_cases: - with self.subTest(message=case): + for invoke, channel in test_cases: + with self.subTest(message=invoke, channel=channel): self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence) self.ctx.reset_mock() - self.ctx.invoked_with = case + + self.ctx.invoked_with = invoke + self.ctx.message.content = f"!{invoke}" + if channel is not None: + self.ctx.message.content += f" {channel.name}" + self.ctx.guild.text_channels = [channel] + self.assertTrue(await self.cog.try_silence(self.ctx)) - self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence) + self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=channel) + + async def test_try_silence_unsilence_message(self): + """If the words after the command could not be converted to a channel, None should be passed as channel.""" + self.silence.silence.can_run = AsyncMock(return_value=True) + self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence) + + self.ctx.invoked_with = "unshh" + self.ctx.message.content = "!unshh not_a_channel" + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=None) async def test_try_silence_no_match(self): """Should return `False` when message don't match.""" @@ -267,67 +329,25 @@ def setUp(self): self.bot = MockBot() self.ctx = MockContext() self.tag = Tags(self.bot) - self.cog = ErrorHandler(self.bot) - self.bot.get_command.return_value = self.tag.get_command + self.cog = error_handler.ErrorHandler(self.bot) + self.bot.get_cog.return_value = self.tag async def test_try_get_tag_get_command(self): """Should call `Bot.get_command` with `tags get` argument.""" - self.bot.get_command.reset_mock() - self.ctx.invoked_with = "foo" + self.bot.get_cog.reset_mock() await self.cog.try_get_tag(self.ctx) - self.bot.get_command.assert_called_once_with("tags get") - - async def test_try_get_tag_invoked_from_error_handler(self): - """`self.ctx` should have `invoked_from_error_handler` `True`.""" - self.ctx.invoked_from_error_handler = False - self.ctx.invoked_with = "foo" - await self.cog.try_get_tag(self.ctx) - self.assertTrue(self.ctx.invoked_from_error_handler) + self.bot.get_cog.assert_called_once_with("Tags") async def test_try_get_tag_no_permissions(self): """Test how to handle checks failing.""" - self.tag.get_command.can_run = AsyncMock(return_value=False) - self.ctx.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - - async def test_try_get_tag_command_error(self): - """Should call `on_command_error` when `CommandError` raised.""" - err = errors.CommandError() - self.tag.get_command.can_run = AsyncMock(side_effect=err) - self.cog.on_command_error = AsyncMock() - self.ctx.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) - - @patch("bot.exts.backend.error_handler.TagNameConverter") - async def test_try_get_tag_convert_success(self, tag_converter): - """Converting tag should successful.""" - self.ctx.invoked_with = "foo" - tag_converter.convert = AsyncMock(return_value="foo") - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") - self.ctx.invoke.assert_awaited_once() - - @patch("bot.exts.backend.error_handler.TagNameConverter") - async def test_try_get_tag_convert_fail(self, tag_converter): - """Converting tag should raise `BadArgument`.""" - self.ctx.reset_mock() - self.ctx.invoked_with = "bar" - tag_converter.convert = AsyncMock(side_effect=errors.BadArgument()) - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.ctx.invoke.assert_not_awaited() - - async def test_try_get_tag_ctx_invoke(self): - """Should call `ctx.invoke` with proper args/kwargs.""" - self.ctx.reset_mock() + self.bot.can_run = AsyncMock(return_value=False) self.ctx.invoked_with = "foo" self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") async def test_dont_call_suggestion_tag_sent(self): """Should never call command suggestion if tag is already sent.""" - self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=True) + self.ctx.message = MagicMock(content="foo") + self.tag.get_command_ctx = AsyncMock(return_value=True) self.cog.send_command_suggestion = AsyncMock() await self.cog.try_get_tag(self.ctx) @@ -337,7 +357,7 @@ async def test_dont_call_suggestion_tag_sent(self): async def test_dont_call_suggestion_if_user_mod(self): """Should not call command suggestion if user is a mod.""" self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=False) + self.tag.get_command_ctx = AsyncMock(return_value=False) self.ctx.author.roles = [MockRole(id=1234)] self.cog.send_command_suggestion = AsyncMock() @@ -347,7 +367,7 @@ async def test_dont_call_suggestion_if_user_mod(self): async def test_call_suggestion(self): """Should call command suggestion if user is not a mod.""" self.ctx.invoked_with = "foo" - self.ctx.invoke = AsyncMock(return_value=False) + self.tag.get_command_ctx = AsyncMock(return_value=False) self.cog.send_command_suggestion = AsyncMock() await self.cog.try_get_tag(self.ctx) @@ -360,7 +380,7 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.ctx = MockContext(bot=self.bot) - self.cog = ErrorHandler(self.bot) + self.cog = error_handler.ErrorHandler(self.bot) async def test_handle_input_error_handler_errors(self): """Should handle each error probably.""" @@ -394,12 +414,13 @@ async def test_handle_input_error_handler_errors(self): for case in test_cases: with self.subTest(error=case["error"], call_prepared=case["call_prepared"]): self.ctx.reset_mock() + self.cog.send_error_with_help = AsyncMock() self.assertIsNone(await self.cog.handle_user_input_error(self.ctx, case["error"])) - self.ctx.send.assert_awaited_once() if case["call_prepared"]: - self.ctx.send_help.assert_awaited_once() + self.cog.send_error_with_help.assert_awaited_once() else: - self.ctx.send_help.assert_not_awaited() + self.ctx.send.assert_awaited_once() + self.cog.send_error_with_help.assert_not_awaited() async def test_handle_check_failure_errors(self): """Should await `ctx.send` when error is check failure.""" @@ -441,11 +462,11 @@ async def test_handle_check_failure_errors(self): @patch("bot.exts.backend.error_handler.log") async def test_handle_api_error(self, log_mock): - """Should `ctx.send` on HTTP error codes, `log.debug|warning` depends on code.""" + """Should `ctx.send` on HTTP error codes, and log at correct level.""" test_cases = ( { "error": ResponseCodeError(AsyncMock(status=400)), - "log_level": "debug" + "log_level": "error" }, { "error": ResponseCodeError(AsyncMock(status=404)), @@ -469,25 +490,31 @@ async def test_handle_api_error(self, log_mock): self.ctx.send.assert_awaited_once() if case["log_level"] == "warning": log_mock.warning.assert_called_once() + elif case["log_level"] == "error": + log_mock.error.assert_called_once() else: log_mock.debug.assert_called_once() - @patch("bot.exts.backend.error_handler.push_scope") + @patch("bot.exts.backend.error_handler.new_scope") @patch("bot.exts.backend.error_handler.log") - async def test_handle_unexpected_error(self, log_mock, push_scope_mock): + async def test_handle_unexpected_error(self, log_mock, new_scope_mock): """Should `ctx.send` this error, error log this and sent to Sentry.""" for case in (None, MockGuild()): with self.subTest(guild=case): self.ctx.reset_mock() log_mock.reset_mock() - push_scope_mock.reset_mock() + new_scope_mock.reset_mock() + scope_mock = Mock() + + # Mock `with push_scope_mock() as scope:` + new_scope_mock.return_value.__enter__.return_value = scope_mock self.ctx.guild = case await self.cog.handle_unexpected_error(self.ctx, errors.CommandError()) self.ctx.send.assert_awaited_once() log_mock.error.assert_called_once() - push_scope_mock.assert_called_once() + new_scope_mock.assert_called_once() set_tag_calls = [ call("command", self.ctx.command.qualified_name), @@ -504,47 +531,15 @@ async def test_handle_unexpected_error(self, log_mock, push_scope_mock): ) set_extra_calls.append(call("jump_to", url)) - push_scope_mock.set_tag.has_calls(set_tag_calls) - push_scope_mock.set_extra.has_calls(set_extra_calls) - - -class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): - """Other `ErrorHandler` tests.""" - - def setUp(self): - self.bot = MockBot() - self.ctx = MockContext() - - async def test_get_help_command_command_specified(self): - """Should return coroutine of help command of specified command.""" - self.ctx.command = "foo" - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help("foo") - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected - - async def test_get_help_command_no_command_specified(self): - """Should return coroutine of help command.""" - self.ctx.command = None - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help() - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected + scope_mock.set_tag.assert_has_calls(set_tag_calls) + scope_mock.set_extra.assert_has_calls(set_extra_calls) -class ErrorHandlerSetupTests(unittest.TestCase): +class ErrorHandlerSetupTests(unittest.IsolatedAsyncioTestCase): """Tests for `ErrorHandler` `setup` function.""" - def test_setup(self): + async def test_setup(self): """Should call `bot.add_cog` with `ErrorHandler`.""" bot = MockBot() - setup(bot) - bot.add_cog.assert_called_once() + await error_handler.setup(bot) + bot.add_cog.assert_awaited_once() diff --git a/tests/bot/exts/backend/test_logging.py b/tests/bot/exts/backend/test_logging.py index 466f207d93..3a41220adc 100644 --- a/tests/bot/exts/backend/test_logging.py +++ b/tests/bot/exts/backend/test_logging.py @@ -3,7 +3,7 @@ from bot import constants from bot.exts.backend.logging import Logging -from tests.helpers import MockBot, MockTextChannel +from tests.helpers import MockBot, MockTextChannel, no_create_task class LoggingTests(unittest.IsolatedAsyncioTestCase): @@ -11,7 +11,8 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.cog = Logging(self.bot) + with no_create_task(): + self.cog = Logging(self.bot) self.dev_log = MockTextChannel(id=1234, name="dev-log") @patch("bot.exts.backend.logging.DEBUG_MODE", False) diff --git a/tests/bot/exts/filters/test_security.py b/tests/bot/exts/backend/test_security.py similarity index 87% rename from tests/bot/exts/filters/test_security.py rename to tests/bot/exts/backend/test_security.py index c0c3baa420..c3985c609f 100644 --- a/tests/bot/exts/filters/test_security.py +++ b/tests/bot/exts/backend/test_security.py @@ -1,9 +1,8 @@ import unittest -from unittest.mock import MagicMock from discord.ext.commands import NoPrivateMessage -from bot.exts.filters import security +from bot.exts.backend import security from tests.helpers import MockBot, MockContext @@ -44,11 +43,11 @@ def test_check_on_guild_returns_true_inside_of_guild(self): self.assertTrue(self.cog.check_on_guild(self.ctx)) -class SecurityCogLoadTests(unittest.TestCase): +class SecurityCogLoadTests(unittest.IsolatedAsyncioTestCase): """Tests loading the `Security` cog.""" - def test_security_cog_load(self): + async def test_security_cog_load(self): """Setup of the extension should call add_cog.""" - bot = MagicMock() - security.setup(bot) - bot.add_cog.assert_called_once() + bot = MockBot() + await security.setup(bot) + bot.add_cog.assert_awaited_once() diff --git a/tests/bot/exts/filtering/__init__.py b/tests/bot/exts/filtering/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bot/exts/filtering/test_discord_token_filter.py b/tests/bot/exts/filtering/test_discord_token_filter.py new file mode 100644 index 0000000000..1cb9e16fac --- /dev/null +++ b/tests/bot/exts/filtering/test_discord_token_filter.py @@ -0,0 +1,276 @@ +import unittest +from re import Match +from unittest import mock +from unittest.mock import MagicMock, patch + +import arrow + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.unique import discord_token +from bot.exts.filtering._filters.unique.discord_token import DiscordTokenFilter, Token +from tests.helpers import MockBot, MockMember, MockMessage, MockTextChannel, autospec + + +class DiscordTokenFilterTests(unittest.IsolatedAsyncioTestCase): + """Tests the DiscordTokenFilter class.""" + + def setUp(self): + """Adds the filter, a bot, and a message to the instance for usage in tests.""" + now = arrow.utcnow().timestamp() + self.filter = DiscordTokenFilter({ + "id": 1, + "content": "discord_token", + "description": None, + "settings": {}, + "additional_settings": {}, + "created_at": now, + "updated_at": now + }) + + self.msg = MockMessage(id=555, content="hello world") + self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) + + member = MockMember(id=123) + channel = MockTextChannel(id=345) + self.ctx = FilterContext(Event.MESSAGE, member, channel, "", self.msg) + + def test_extract_user_id_valid(self): + """Should consider user IDs valid if they decode into an integer ID.""" + id_pairs = ( + ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), + ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), + ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641), + ) + + for token_id, user_id in id_pairs: + with self.subTest(token_id=token_id): + result = DiscordTokenFilter.extract_user_id(token_id) + self.assertEqual(result, user_id) + + def test_extract_user_id_invalid(self): + """Should consider non-digit and non-ASCII IDs invalid.""" + ids = ( + ("SGVsbG8gd29ybGQ", "non-digit ASCII"), + ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), + ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), + ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), + ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for user_id, msg in ids: + with self.subTest(msg=msg): + result = DiscordTokenFilter.extract_user_id(user_id) + self.assertIsNone(result) + + def test_is_valid_timestamp_valid(self): + """Should consider timestamps valid if they're greater than the Discord epoch.""" + timestamps = ( + "XsyRkw", + "Xrim9Q", + "XsyR-w", + "XsySD_", + "Dn9r_A", + ) + + for timestamp in timestamps: + with self.subTest(timestamp=timestamp): + result = DiscordTokenFilter.is_valid_timestamp(timestamp) + self.assertTrue(result) + + def test_is_valid_timestamp_invalid(self): + """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" + timestamps = ( + ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), + ("ew", "123"), + ("AoIKgA", "42076800"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) + + for timestamp, msg in timestamps: + with self.subTest(msg=msg): + result = DiscordTokenFilter.is_valid_timestamp(timestamp) + self.assertFalse(result) + + def test_is_valid_hmac_valid(self): + """Should consider an HMAC valid if it has at least 3 unique characters.""" + valid_hmacs = ( + "VXmErH7j511turNpfURmb0rVNm8", + "Ysnu2wacjaKs7qnoo46S8Dm2us8", + "sJf6omBPORBPju3WJEIAcwW9Zds", + "s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for hmac in valid_hmacs: + with self.subTest(msg=hmac): + result = DiscordTokenFilter.is_maybe_valid_hmac(hmac) + self.assertTrue(result) + + def test_is_invalid_hmac_invalid(self): + """Should consider an HMAC invalid if has fewer than 3 unique characters.""" + invalid_hmacs = ( + ("xxxxxxxxxxxxxxxxxx", "Single character"), + ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), + ("ASFasfASFasfASFASsf", "Three characters alternating-case"), + ("asdasdasdasdasdasdasd", "Three characters one case"), + ) + + for hmac, msg in invalid_hmacs: + with self.subTest(msg=msg): + result = DiscordTokenFilter.is_maybe_valid_hmac(hmac) + self.assertFalse(result) + + async def test_no_trigger_when_no_token(self): + """False should be returned if the message doesn't contain a Discord token.""" + return_value = await self.filter.triggered_on(self.ctx) + + self.assertFalse(return_value) + + @autospec(DiscordTokenFilter, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec("bot.exts.filtering._filters.unique.discord_token", "Token") + @autospec("bot.exts.filtering._filters.unique.discord_token", "TOKEN_RE") + def test_find_token_valid_match( + self, + token_re, + token_cls, + extract_user_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): + """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" + matches = [ + mock.create_autospec(Match, spec_set=True, instance=True), + mock.create_autospec(Match, spec_set=True, instance=True), + ] + tokens = [ + mock.create_autospec(Token, spec_set=True, instance=True), + mock.create_autospec(Token, spec_set=True, instance=True), + ] + + token_re.finditer.return_value = matches + token_cls.side_effect = tokens + extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid. + is_valid_timestamp.return_value = True + is_maybe_valid_hmac.return_value = True + + return_value = DiscordTokenFilter.find_token_in_message(self.msg) + + self.assertEqual(tokens[1], return_value) + + @autospec(DiscordTokenFilter, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec("bot.exts.filtering._filters.unique.discord_token", "Token") + @autospec("bot.exts.filtering._filters.unique.discord_token", "TOKEN_RE") + def test_find_token_invalid_matches( + self, + token_re, + token_cls, + extract_user_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): + """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" + token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] + token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) + extract_user_id.return_value = None + is_valid_timestamp.return_value = False + is_maybe_valid_hmac.return_value = False + + return_value = DiscordTokenFilter.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + + def test_regex_invalid_tokens(self): + """Messages without anything looking like a token are not matched.""" + tokens = ( + "", + "lemon wins", + "..", + "x.y", + "x.y.", + ".y.z", + ".y.", + "..z", + "x..z", + " . . ", + "\n.\n.\n", + "hellö.world.bye", + "base64.nötbåse64.morebase64", + "19jd3J.dfkm3d.€víł§tüff", + ) + + for token in tokens: + with self.subTest(token=token): + results = discord_token.TOKEN_RE.findall(token) + self.assertEqual(len(results), 0) + + def test_regex_valid_tokens(self): + """Messages that look like tokens should be matched.""" + # Don't worry, these tokens have been invalidated. + tokens = ( + "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", + "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", + "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for token in tokens: + with self.subTest(token=token): + results = discord_token.TOKEN_RE.fullmatch(token) + self.assertIsNotNone(results, f"{token} was not matched by the regex") + + def test_regex_matches_multiple_valid(self): + """Should support multiple matches in the middle of a string.""" + token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" # noqa: S105 + token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" # noqa: S105 + message = f"garbage {token_1} hello {token_2} world" + + results = discord_token.TOKEN_RE.finditer(message) + results = [match[0] for match in results] + self.assertCountEqual((token_1, token_2), results) + + @autospec("bot.exts.filtering._filters.unique.discord_token", "LOG_MESSAGE") + def test_format_log_message(self, log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + + return_value = DiscordTokenFilter.format_log_message(self.msg.author, self.msg.channel, token) + + self.assertEqual(return_value, log_message.format.return_value) + + @patch("bot.instance", MockBot()) + @autospec("bot.exts.filtering._filters.unique.discord_token", "UNKNOWN_USER_LOG_MESSAGE") + @autospec("bot.exts.filtering._filters.unique.discord_token", "get_or_fetch_member") + async def test_format_userid_log_message_unknown(self, get_or_fetch_member, unknown_user_log_message): + """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + unknown_user_log_message.format.return_value = " Partner" + get_or_fetch_member.return_value = None + + return_value = await DiscordTokenFilter.format_userid_log_message(token) + + self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) + + @patch("bot.instance", MockBot()) + @autospec("bot.exts.filtering._filters.unique.discord_token", "KNOWN_USER_LOG_MESSAGE") + async def test_format_userid_log_message_bot(self, known_user_log_message): + """Should correctly format the user ID portion when the ID belongs to a known bot.""" + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + known_user_log_message.format.return_value = " Partner" + + return_value = await DiscordTokenFilter.format_userid_log_message(token) + + self.assertEqual(return_value, (known_user_log_message.format.return_value, True)) + + @patch("bot.instance", MockBot()) + @autospec("bot.exts.filtering._filters.unique.discord_token", "KNOWN_USER_LOG_MESSAGE") + async def test_format_log_message_user_token_user(self, user_token_message): + """Should correctly format the user ID portion when the ID belongs to a known user.""" + token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + user_token_message.format.return_value = "Partner" + + return_value = await DiscordTokenFilter.format_userid_log_message(token) + + self.assertEqual(return_value, (user_token_message.format.return_value, True)) diff --git a/tests/bot/exts/filtering/test_extension_filter.py b/tests/bot/exts/filtering/test_extension_filter.py new file mode 100644 index 0000000000..67a503b306 --- /dev/null +++ b/tests/bot/exts/filtering/test_extension_filter.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import MagicMock, patch + +import arrow + +from bot.constants import Channels +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filter_lists import extension +from bot.exts.filtering._filter_lists.extension import ExtensionsList +from bot.exts.filtering._filter_lists.filter_list import ListType +from tests.helpers import MockAttachment, MockBot, MockMember, MockMessage, MockTextChannel + +BOT = MockBot() + + +class ExtensionsListTests(unittest.IsolatedAsyncioTestCase): + """Test the ExtensionsList class.""" + + def setUp(self): + """Sets up fresh objects for each test.""" + self.filter_list = ExtensionsList(MagicMock()) + now = arrow.utcnow().timestamp() + filters = [] + self.whitelist = [".first", ".second", ".third"] + for i, filter_content in enumerate(self.whitelist, start=1): + filters.append({ + "id": i, "content": filter_content, "description": None, "settings": {}, + "additional_settings": {}, "created_at": now, "updated_at": now + }) + self.filter_list.add_list({ + "id": 1, + "list_type": 1, + "created_at": now, + "updated_at": now, + "settings": {}, + "filters": filters + }) + + self.message = MockMessage() + member = MockMember(id=123) + channel = MockTextChannel(id=345) + self.ctx = FilterContext(Event.MESSAGE, member, channel, "", self.message) + + @patch("bot.instance", BOT) + async def test_message_with_allowed_attachment(self): + """Messages with allowed extensions should trigger the whitelist and result in no actions or messages.""" + attachment = MockAttachment(filename="python.first") + ctx = self.ctx.replace(attachments=[attachment]) + + result = await self.filter_list.actions_for(ctx) + + self.assertEqual(result, (None, [], {ListType.ALLOW: [self.filter_list[ListType.ALLOW].filters[1]]})) + + @patch("bot.instance", BOT) + async def test_message_without_attachment(self): + """Messages without attachments should return no triggers, messages, or actions.""" + result = await self.filter_list.actions_for(self.ctx) + + self.assertEqual(result, (None, [], {})) + + @patch("bot.instance", BOT) + async def test_message_with_illegal_extension(self): + """A message with an illegal extension shouldn't trigger the whitelist, and return some action and message.""" + attachment = MockAttachment(filename="python.disallowed") + ctx = self.ctx.replace(attachments=[attachment]) + + result = await self.filter_list.actions_for(ctx) + + self.assertEqual(result, ({}, ["`.disallowed`"], {ListType.ALLOW: []})) + + @patch("bot.instance", BOT) + async def test_other_disallowed_extension_embed_description(self): + """Test the description for a non .py/.txt/.json/.csv disallowed extension.""" + attachment = MockAttachment(filename="python.disallowed") + ctx = self.ctx.replace(attachments=[attachment]) + + await self.filter_list.actions_for(ctx) + meta_channel = BOT.get_channel(Channels.meta) + + self.assertEqual( + ctx.dm_embed, + extension.DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=", ".join(self.whitelist), + joined_blacklist=".disallowed", + meta_channel_mention=meta_channel.mention + ) + ) + + @patch("bot.instance", BOT) + async def test_get_disallowed_extensions(self): + """The return value should include all non-whitelisted extensions.""" + test_values = ( + ([], []), + (self.whitelist, []), + ([".first"], []), + ([".first", ".disallowed"], ["`.disallowed`"]), + ([".disallowed"], ["`.disallowed`"]), + ([".disallowed", ".illegal"], ["`.disallowed`", "`.illegal`"]), + ) + + for extensions, expected_disallowed_extensions in test_values: + with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): + ctx = self.ctx.replace(attachments=[MockAttachment(filename=f"filename{ext}") for ext in extensions]) + result = await self.filter_list.actions_for(ctx) + self.assertCountEqual(result[1], expected_disallowed_extensions) diff --git a/tests/bot/exts/filtering/test_settings.py b/tests/bot/exts/filtering/test_settings.py new file mode 100644 index 0000000000..5a289c1cf1 --- /dev/null +++ b/tests/bot/exts/filtering/test_settings.py @@ -0,0 +1,20 @@ +import unittest + +import bot.exts.filtering._settings +from bot.exts.filtering._settings import create_settings + + +class FilterTests(unittest.TestCase): + """Test functionality of the Settings class and its subclasses.""" + + def test_create_settings_returns_none_for_empty_data(self): + """`create_settings` should return a tuple of two Nones when passed an empty dict.""" + result = create_settings({}) + + self.assertEqual(result, (None, None)) + + def test_unrecognized_entry_makes_a_warning(self): + """When an unrecognized entry name is passed to `create_settings`, it should be added to `_already_warned`.""" + create_settings({"abcd": {}}) + + self.assertIn("abcd", bot.exts.filtering._settings._already_warned) diff --git a/tests/bot/exts/filtering/test_settings_entries.py b/tests/bot/exts/filtering/test_settings_entries.py new file mode 100644 index 0000000000..f12b2caa55 --- /dev/null +++ b/tests/bot/exts/filtering/test_settings_entries.py @@ -0,0 +1,220 @@ +import unittest + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._settings_types.actions.infraction_and_notification import ( + Infraction, + InfractionAndNotification, + InfractionDuration, +) +from bot.exts.filtering._settings_types.validations.bypass_roles import RoleBypass +from bot.exts.filtering._settings_types.validations.channel_scope import ChannelScope +from bot.exts.filtering._settings_types.validations.filter_dm import FilterDM +from tests.helpers import MockCategoryChannel, MockDMChannel, MockMember, MockMessage, MockRole, MockTextChannel + + +class FilterTests(unittest.TestCase): + """Test functionality of the Settings class and its subclasses.""" + + def setUp(self) -> None: + member = MockMember(id=123) + channel = MockTextChannel(id=345) + message = MockMessage(author=member, channel=channel) + self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) + + def test_role_bypass_is_off_for_user_without_roles(self): + """The role bypass should trigger when a user has no roles.""" + member = MockMember() + self.ctx.author = member + bypass_entry = RoleBypass(bypass_roles=["123"]) + + result = bypass_entry.triggers_on(self.ctx) + + self.assertTrue(result) + + def test_role_bypass_is_on_for_a_user_with_the_right_role(self): + """The role bypass should not trigger when the user has one of its roles.""" + cases = ( + ([123], ["123"]), + ([123, 234], ["123"]), + ([123], ["123", "234"]), + ([123, 234], ["123", "234"]) + ) + + for user_role_ids, bypasses in cases: + with self.subTest(user_role_ids=user_role_ids, bypasses=bypasses): + user_roles = [MockRole(id=role_id) for role_id in user_role_ids] + member = MockMember(roles=user_roles) + self.ctx.author = member + bypass_entry = RoleBypass(bypass_roles=bypasses) + + result = bypass_entry.triggers_on(self.ctx) + + self.assertFalse(result) + + def test_context_doesnt_trigger_for_empty_channel_scope(self): + """A filter is enabled for all channels by default.""" + channel = MockTextChannel() + scope = ChannelScope( + disabled_channels=None, disabled_categories=None, enabled_channels=None, enabled_categories=None + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertTrue(result) + + def test_context_doesnt_trigger_for_disabled_channel(self): + """A filter shouldn't trigger if it's been disabled in the channel.""" + channel = MockTextChannel(id=123) + scope = ChannelScope( + disabled_channels=["123"], disabled_categories=None, enabled_channels=None, enabled_categories=None + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertFalse(result) + + def test_context_doesnt_trigger_in_disabled_category(self): + """A filter shouldn't trigger if it's been disabled in the category.""" + channel = MockTextChannel(category=MockCategoryChannel(id=456)) + scope = ChannelScope( + disabled_channels=None, disabled_categories=["456"], enabled_channels=None, enabled_categories=None + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertFalse(result) + + def test_context_triggers_in_enabled_channel_in_disabled_category(self): + """A filter should trigger in an enabled channel even if it's been disabled in the category.""" + channel = MockTextChannel(id=123, category=MockCategoryChannel(id=234)) + scope = ChannelScope( + disabled_channels=None, disabled_categories=["234"], enabled_channels=["123"], enabled_categories=None + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertTrue(result) + + def test_context_triggers_inside_enabled_category(self): + """A filter shouldn't trigger outside enabled categories, if there are any.""" + channel = MockTextChannel(id=123, category=MockCategoryChannel(id=234)) + scope = ChannelScope( + disabled_channels=None, disabled_categories=None, enabled_channels=None, enabled_categories=["234"] + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertTrue(result) + + def test_context_doesnt_trigger_outside_enabled_category(self): + """A filter shouldn't trigger outside enabled categories, if there are any.""" + channel = MockTextChannel(id=123, category=MockCategoryChannel(id=234)) + scope = ChannelScope( + disabled_channels=None, disabled_categories=None, enabled_channels=None, enabled_categories=["789"] + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertFalse(result) + + def test_context_doesnt_trigger_inside_disabled_channel_in_enabled_category(self): + """A filter shouldn't trigger outside enabled categories, if there are any.""" + channel = MockTextChannel(id=123, category=MockCategoryChannel(id=234)) + scope = ChannelScope( + disabled_channels=["123"], disabled_categories=None, enabled_channels=None, enabled_categories=["234"] + ) + self.ctx.channel = channel + + result = scope.triggers_on(self.ctx) + + self.assertFalse(result) + + def test_filtering_dms_when_necessary(self): + """A filter correctly ignores or triggers in a channel depending on the value of FilterDM.""" + cases = ( + (True, MockDMChannel(), True), + (False, MockDMChannel(), False), + (True, MockTextChannel(), True), + (False, MockTextChannel(), True) + ) + + for apply_in_dms, channel, expected in cases: + with self.subTest(apply_in_dms=apply_in_dms, channel=channel): + filter_dms = FilterDM(filter_dm=apply_in_dms) + self.ctx.channel = channel + + result = filter_dms.triggers_on(self.ctx) + + self.assertEqual(expected, result) + + def test_infraction_merge_of_same_infraction_type(self): + """When both infractions are of the same type, the one with the longer duration wins.""" + infraction1 = InfractionAndNotification( + infraction_type="TIMEOUT", + infraction_reason="hi", + infraction_duration=InfractionDuration(10), + dm_content="how", + dm_embed="what is", + infraction_channel=0 + ) + infraction2 = InfractionAndNotification( + infraction_type="TIMEOUT", + infraction_reason="there", + infraction_duration=InfractionDuration(20), + dm_content="are you", + dm_embed="your name", + infraction_channel=0 + ) + + result = infraction1.union(infraction2) + + self.assertDictEqual( + result.model_dump(), + { + "infraction_type": Infraction.TIMEOUT, + "infraction_reason": "there", + "infraction_duration": InfractionDuration(20.0), + "dm_content": "are you", + "dm_embed": "your name", + "infraction_channel": 0 + } + ) + + def test_infraction_merge_of_different_infraction_types(self): + """If there are two different infraction types, the one higher up the hierarchy should be picked.""" + infraction1 = InfractionAndNotification( + infraction_type="TIMEOUT", + infraction_reason="hi", + infraction_duration=InfractionDuration(20), + dm_content="", + dm_embed="", + infraction_channel=0 + ) + infraction2 = InfractionAndNotification( + infraction_type="BAN", + infraction_reason="", + infraction_duration=InfractionDuration(10), + dm_content="there", + dm_embed="", + infraction_channel=0 + ) + + result = infraction1.union(infraction2) + + self.assertDictEqual( + result.model_dump(), + { + "infraction_type": Infraction.BAN, + "infraction_reason": "", + "infraction_duration": InfractionDuration(10), + "dm_content": "there", + "dm_embed": "", + "infraction_channel": 0 + } + ) diff --git a/tests/bot/exts/filtering/test_token_filter.py b/tests/bot/exts/filtering/test_token_filter.py new file mode 100644 index 0000000000..03fa6b4b9e --- /dev/null +++ b/tests/bot/exts/filtering/test_token_filter.py @@ -0,0 +1,49 @@ +import unittest + +import arrow + +from bot.exts.filtering._filter_context import Event, FilterContext +from bot.exts.filtering._filters.token import TokenFilter +from tests.helpers import MockMember, MockMessage, MockTextChannel + + +class TokenFilterTests(unittest.IsolatedAsyncioTestCase): + """Test functionality of the token filter.""" + + def setUp(self) -> None: + member = MockMember(id=123) + channel = MockTextChannel(id=345) + message = MockMessage(author=member, channel=channel) + self.ctx = FilterContext(Event.MESSAGE, member, channel, "", message) + + async def test_token_filter_triggers(self): + """The filter should evaluate to True only if its token is found in the context content.""" + test_cases = ( + (r"hi", "oh hi there", True), + (r"hi", "goodbye", False), + (r"bla\d{2,4}", "bla18", True), + (r"bla\d{2,4}", "bla1", False), + # See advisory https://github.com/python-discord/bot/security/advisories/GHSA-j8c3-8x46-8pp6 + (r"TOKEN", "https://google.com TOKEN", True), + (r"TOKEN", "https://google.com something else", False) + ) + now = arrow.utcnow().timestamp() + + for pattern, content, expected in test_cases: + with self.subTest( + pattern=pattern, + content=content, + expected=expected, + ): + filter_ = TokenFilter({ + "id": 1, + "content": pattern, + "description": None, + "settings": {}, + "additional_settings": {}, + "created_at": now, + "updated_at": now + }) + self.ctx.content = content + result = await filter_.triggered_on(self.ctx) + self.assertEqual(result, expected) diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py deleted file mode 100644 index 06d78de9d3..0000000000 --- a/tests/bot/exts/filters/test_antimalware.py +++ /dev/null @@ -1,202 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, Mock - -from discord import NotFound - -from bot.constants import Channels, STAFF_ROLES -from bot.exts.filters import antimalware -from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole - - -class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): - """Test the AntiMalware cog.""" - - def setUp(self): - """Sets up fresh objects for each test.""" - self.bot = MockBot() - self.bot.filter_list_cache = { - "FILE_FORMAT.True": { - ".first": {}, - ".second": {}, - ".third": {}, - } - } - self.cog = antimalware.AntiMalware(self.bot) - self.message = MockMessage() - self.message.webhook_id = None - self.message.author.bot = None - self.whitelist = [".first", ".second", ".third"] - - async def test_message_with_allowed_attachment(self): - """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename="python.first") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_message_without_attachment(self): - """Messages without attachments should result in no action.""" - await self.cog.on_message(self.message) - self.message.delete.assert_not_called() - - async def test_direct_message_with_attachment(self): - """Direct messages should have no action taken.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.guild = None - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_webhook_message_with_illegal_extension(self): - """A webhook message containing an illegal extension should be ignored.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.webhook_id = 697140105563078727 - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_bot_message_with_illegal_extension(self): - """A bot message containing an illegal extension should be ignored.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.author.bot = 409107086526644234 - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_message_with_illegal_extension_gets_deleted(self): - """A message containing an illegal extension should send an embed.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_called_once() - - async def test_message_send_by_staff(self): - """A message send by a member of staff should be ignored.""" - staff_role = MockRole(id=STAFF_ROLES[0]) - self.message.author.roles.append(staff_role) - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - await self.cog.on_message(self.message) - - self.message.delete.assert_not_called() - - async def test_python_file_redirect_embed_description(self): - """A message containing a .py file should result in an embed redirecting the user to our paste site""" - attachment = MockAttachment(filename="python.py") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - - self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) - - async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt/.json/.csv file should result in the correct embed.""" - test_values = ( - ("text", ".txt"), - ("json", ".json"), - ("csv", ".csv"), - ) - - for file_name, disallowed_extension in test_values: - with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension): - - attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.TXT_EMBED_DESCRIPTION = Mock() - antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - cmd_channel = self.bot.get_channel(Channels.bot_commands) - - self.assertEqual( - embed.description, - antimalware.TXT_EMBED_DESCRIPTION.format.return_value - ) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( - blocked_extension=disallowed_extension, - cmd_channel_mention=cmd_channel.mention - ) - - async def test_other_disallowed_extension_embed_description(self): - """Test the description for a non .py/.txt/.json/.csv disallowed extension.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.DISALLOWED_EMBED_DESCRIPTION = Mock() - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - meta_channel = self.bot.get_channel(Channels.meta) - - self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) - antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( - joined_whitelist=", ".join(self.whitelist), - blocked_extensions_str=".disallowed", - meta_channel_mention=meta_channel.mention - ) - - async def test_removing_deleted_message_logs(self): - """Removing an already deleted message logs the correct message""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - self.message.delete = AsyncMock(side_effect=NotFound(response=Mock(status=""), message="")) - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - self.message.delete.assert_called_once() - - async def test_message_with_illegal_attachment_logs(self): - """Deleting a message with an illegal attachment should result in a log.""" - attachment = MockAttachment(filename="python.disallowed") - self.message.attachments = [attachment] - - with self.assertLogs(logger=antimalware.log, level="INFO"): - await self.cog.on_message(self.message) - - async def test_get_disallowed_extensions(self): - """The return value should include all non-whitelisted extensions.""" - test_values = ( - ([], []), - (self.whitelist, []), - ([".first"], []), - ([".first", ".disallowed"], [".disallowed"]), - ([".disallowed"], [".disallowed"]), - ([".disallowed", ".illegal"], [".disallowed", ".illegal"]), - ) - - for extensions, expected_disallowed_extensions in test_values: - with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): - self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog._get_disallowed_extensions(self.message) - self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) - - -class AntiMalwareSetupTests(unittest.TestCase): - """Tests setup of the `AntiMalware` cog.""" - - def test_setup(self): - """Setup of the extension should call add_cog.""" - bot = MockBot() - antimalware.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/filters/test_antispam.py b/tests/bot/exts/filters/test_antispam.py deleted file mode 100644 index 6a0e4fded1..0000000000 --- a/tests/bot/exts/filters/test_antispam.py +++ /dev/null @@ -1,35 +0,0 @@ -import unittest - -from bot.exts.filters import antispam - - -class AntispamConfigurationValidationTests(unittest.TestCase): - """Tests validation of the antispam cog configuration.""" - - def test_default_antispam_config_is_valid(self): - """The default antispam configuration is valid.""" - validation_errors = antispam.validate_config() - self.assertEqual(validation_errors, {}) - - def test_unknown_rule_returns_error(self): - """Configuring an unknown rule returns an error.""" - self.assertEqual( - antispam.validate_config({'invalid-rule': {}}), - {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."} - ) - - def test_missing_keys_returns_error(self): - """Not configuring required keys returns an error.""" - keys = (('interval', 'max'), ('max', 'interval')) - for configured_key, unconfigured_key in keys: - with self.subTest( - configured_key=configured_key, - unconfigured_key=unconfigured_key - ): - config = {'burst': {configured_key: 10}} - error = f"Key `{unconfigured_key}` is required but not set for rule `burst`" - - self.assertEqual( - antispam.validate_config(config), - {'burst': error} - ) diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py deleted file mode 100644 index 51feae9cbb..0000000000 --- a/tests/bot/exts/filters/test_token_remover.py +++ /dev/null @@ -1,408 +0,0 @@ -import unittest -from re import Match -from unittest import mock -from unittest.mock import MagicMock - -from discord import Colour, NotFound - -from bot import constants -from bot.exts.filters import token_remover -from bot.exts.filters.token_remover import Token, TokenRemover -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user -from tests.helpers import MockBot, MockMessage, autospec - - -class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): - """Tests the `TokenRemover` cog.""" - - def setUp(self): - """Adds the cog, a bot, and a message to the instance for usage in tests.""" - self.bot = MockBot() - self.cog = TokenRemover(bot=self.bot) - - self.msg = MockMessage(id=555, content="hello world") - self.msg.channel.mention = "#lemonade-stand" - self.msg.guild.get_member.return_value.bot = False - self.msg.guild.get_member.return_value.__str__.return_value = "Woody" - self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) - self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - - def test_extract_user_id_valid(self): - """Should consider user IDs valid if they decode into an integer ID.""" - id_pairs = ( - ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), - ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), - ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641), - ) - - for token_id, user_id in id_pairs: - with self.subTest(token_id=token_id): - result = TokenRemover.extract_user_id(token_id) - self.assertEqual(result, user_id) - - def test_extract_user_id_invalid(self): - """Should consider non-digit and non-ASCII IDs invalid.""" - ids = ( - ("SGVsbG8gd29ybGQ", "non-digit ASCII"), - ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), - ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), - ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), - ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for user_id, msg in ids: - with self.subTest(msg=msg): - result = TokenRemover.extract_user_id(user_id) - self.assertIsNone(result) - - def test_is_valid_timestamp_valid(self): - """Should consider timestamps valid if they're greater than the Discord epoch.""" - timestamps = ( - "XsyRkw", - "Xrim9Q", - "XsyR-w", - "XsySD_", - "Dn9r_A", - ) - - for timestamp in timestamps: - with self.subTest(timestamp=timestamp): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertTrue(result) - - def test_is_valid_timestamp_invalid(self): - """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" - timestamps = ( - ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), - ("ew", "123"), - ("AoIKgA", "42076800"), - ("{hello}[world]&(bye!)", "ASCII invalid Base64"), - ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), - ) - - for timestamp, msg in timestamps: - with self.subTest(msg=msg): - result = TokenRemover.is_valid_timestamp(timestamp) - self.assertFalse(result) - - def test_is_valid_hmac_valid(self): - """Should consider an HMAC valid if it has at least 3 unique characters.""" - valid_hmacs = ( - "VXmErH7j511turNpfURmb0rVNm8", - "Ysnu2wacjaKs7qnoo46S8Dm2us8", - "sJf6omBPORBPju3WJEIAcwW9Zds", - "s45jqDV_Iisn-symw0yDRrk_jf4", - ) - - for hmac in valid_hmacs: - with self.subTest(msg=hmac): - result = TokenRemover.is_maybe_valid_hmac(hmac) - self.assertTrue(result) - - def test_is_invalid_hmac_invalid(self): - """Should consider an HMAC invalid if has fewer than 3 unique characters.""" - invalid_hmacs = ( - ("xxxxxxxxxxxxxxxxxx", "Single character"), - ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), - ("ASFasfASFasfASFASsf", "Three characters alternating-case"), - ("asdasdasdasdasdasdasd", "Three characters one case"), - ) - - for hmac, msg in invalid_hmacs: - with self.subTest(msg=msg): - result = TokenRemover.is_maybe_valid_hmac(hmac) - self.assertFalse(result) - - def test_mod_log_property(self): - """The `mod_log` property should ask the bot to return the `ModLog` cog.""" - self.bot.get_cog.return_value = 'lemon' - self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) - self.bot.get_cog.assert_called_once_with('ModLog') - - async def test_on_message_edit_uses_on_message(self): - """The edit listener should delegate handling of the message to the normal listener.""" - self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) - - await self.cog.on_message_edit(MockMessage(), self.msg) - self.cog.on_message.assert_awaited_once_with(self.msg) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_takes_action(self, find_token_in_message, take_action): - """Should take action if a valid token is found when a message is sent.""" - cog = TokenRemover(self.bot) - found_token = "foobar" - find_token_in_message.return_value = found_token - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_awaited_once_with(cog, self.msg, found_token) - - @autospec(TokenRemover, "find_token_in_message", "take_action") - async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): - """Shouldn't take action if a valid token isn't found when a message is sent.""" - cog = TokenRemover(self.bot) - find_token_in_message.return_value = False - - await cog.on_message(self.msg) - - find_token_in_message.assert_called_once_with(self.msg) - take_action.assert_not_awaited() - - @autospec(TokenRemover, "find_token_in_message") - async def test_on_message_ignores_dms_bots(self, find_token_in_message): - """Shouldn't parse a message if it is a DM or authored by a bot.""" - cog = TokenRemover(self.bot) - dm_msg = MockMessage(guild=None) - bot_msg = MockMessage(author=MagicMock(bot=True)) - - for msg in (dm_msg, bot_msg): - await cog.on_message(msg) - find_token_in_message.assert_not_called() - - @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_no_matches(self, token_re): - """None should be returned if the regex matches no tokens in a message.""" - token_re.finditer.return_value = () - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") - @autospec("bot.exts.filters.token_remover", "Token") - @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match( - self, - token_re, - token_cls, - extract_user_id, - is_valid_timestamp, - is_maybe_valid_hmac, - ): - """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" - matches = [ - mock.create_autospec(Match, spec_set=True, instance=True), - mock.create_autospec(Match, spec_set=True, instance=True), - ] - tokens = [ - mock.create_autospec(Token, spec_set=True, instance=True), - mock.create_autospec(Token, spec_set=True, instance=True), - ] - - token_re.finditer.return_value = matches - token_cls.side_effect = tokens - extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid. - is_valid_timestamp.return_value = True - is_maybe_valid_hmac.return_value = True - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertEqual(tokens[1], return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") - @autospec("bot.exts.filters.token_remover", "Token") - @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches( - self, - token_re, - token_cls, - extract_user_id, - is_valid_timestamp, - is_maybe_valid_hmac, - ): - """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" - token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] - token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) - extract_user_id.return_value = None - is_valid_timestamp.return_value = False - is_maybe_valid_hmac.return_value = False - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_called_once_with(self.msg.content) - - def test_regex_invalid_tokens(self): - """Messages without anything looking like a token are not matched.""" - tokens = ( - "", - "lemon wins", - "..", - "x.y", - "x.y.", - ".y.z", - ".y.", - "..z", - "x..z", - " . . ", - "\n.\n.\n", - "hellö.world.bye", - "base64.nötbåse64.morebase64", - "19jd3J.dfkm3d.€víł§tüff", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.findall(token) - self.assertEqual(len(results), 0) - - def test_regex_valid_tokens(self): - """Messages that look like tokens should be matched.""" - # Don't worry, these tokens have been invalidated. - tokens = ( - "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", - "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", - "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", - "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", - ) - - for token in tokens: - with self.subTest(token=token): - results = token_remover.TOKEN_RE.fullmatch(token) - self.assertIsNotNone(results, f"{token} was not matched by the regex") - - def test_regex_matches_multiple_valid(self): - """Should support multiple matches in the middle of a string.""" - token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" - token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" - message = f"garbage {token_1} hello {token_2} world" - - results = token_remover.TOKEN_RE.finditer(message) - results = [match[0] for match in results] - self.assertCountEqual((token_1, token_2), results) - - @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE") - def test_format_log_message(self, log_message): - """Should correctly format the log message with info from the message and token.""" - token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - log_message.format.return_value = "Howdy" - - return_value = TokenRemover.format_log_message(self.msg, token) - - self.assertEqual(return_value, log_message.format.return_value) - log_message.format.assert_called_once_with( - author=format_user(self.msg.author), - channel=self.msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac="xxxxxxxxxxxxxxxxxxxxxxxxjf4", - ) - - @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") - def test_format_userid_log_message_unknown(self, unknown_user_log_message): - """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" - token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - unknown_user_log_message.format.return_value = " Partner" - msg = MockMessage(id=555, content="hello world") - msg.guild.get_member.return_value = None - - return_value = TokenRemover.format_userid_log_message(msg, token) - - self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) - unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) - - @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") - def test_format_userid_log_message_bot(self, known_user_log_message): - """Should correctly format the user ID portion when the ID belongs to a known bot.""" - token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - known_user_log_message.format.return_value = " Partner" - msg = MockMessage(id=555, content="hello world") - msg.guild.get_member.return_value.__str__.return_value = "Sam" - msg.guild.get_member.return_value.bot = True - - return_value = TokenRemover.format_userid_log_message(msg, token) - - self.assertEqual(return_value, (known_user_log_message.format.return_value, True)) - - known_user_log_message.format.assert_called_once_with( - user_id=472265943062413332, - user_name="Sam", - kind="BOT", - ) - - @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") - def test_format_log_message_user_token_user(self, user_token_message): - """Should correctly format the user ID portion when the ID belongs to a known user.""" - token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - user_token_message.format.return_value = "Partner" - - return_value = TokenRemover.format_userid_log_message(self.msg, token) - - self.assertEqual(return_value, (user_token_message.format.return_value, True)) - user_token_message.format.assert_called_once_with( - user_id=467223230650777641, - user_name="Woody", - kind="USER", - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - @autospec("bot.exts.filters.token_remover", "log") - @autospec(TokenRemover, "format_log_message", "format_userid_log_message") - async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property): - """Should delete the message and send a mod log.""" - cog = TokenRemover(self.bot) - mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) - token = mock.create_autospec(Token, spec_set=True, instance=True) - token.user_id = "no-id" - log_msg = "testing123" - userid_log_message = "userid-log-message" - - mod_log_property.return_value = mod_log - format_log_message.return_value = log_msg - format_userid_log_message.return_value = (userid_log_message, True) - - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) - ) - - format_log_message.assert_called_once_with(self.msg, token) - format_userid_log_message.assert_called_once_with(self.msg, token) - logger.debug.assert_called_with(log_msg) - self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") - - mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=constants.Icons.token_removed, - colour=Colour(constants.Colours.soft_red), - title="Token removed!", - text=log_msg + "\n" + userid_log_message, - thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=constants.Channels.mod_alerts, - ping_everyone=True, - ) - - @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) - async def test_take_action_delete_failure(self, mod_log_property): - """Shouldn't send any messages if the token message can't be deleted.""" - cog = TokenRemover(self.bot) - mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) - self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) - - token = mock.create_autospec(Token, spec_set=True, instance=True) - await cog.take_action(self.msg, token) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_not_awaited() - - -class TokenRemoverExtensionTests(unittest.TestCase): - """Tests for the token_remover extension.""" - - @autospec("bot.exts.filters.token_remover", "TokenRemover") - def test_extension_setup(self, cog): - """The TokenRemover cog should be added.""" - bot = MockBot() - token_remover.setup(bot) - - cog.assert_called_once_with(bot) - bot.add_cog.assert_called_once() - self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) diff --git a/tests/bot/exts/info/codeblock/__init__.py b/tests/bot/exts/info/codeblock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bot/exts/info/codeblock/test_parsing.py b/tests/bot/exts/info/codeblock/test_parsing.py new file mode 100644 index 0000000000..4507fcaa54 --- /dev/null +++ b/tests/bot/exts/info/codeblock/test_parsing.py @@ -0,0 +1,153 @@ +import unittest + +from bot.exts.info.codeblock import _parsing as parsing + + +class FindFaultyCodeblocksTest(unittest.TestCase): + def test_should_recognize_missing_language(self): + message = """``` + x = 4 + y = 2 + print("abc") + ```""" + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) + + def test_should_recognize_contained_codeblock(self): + message = """' + wouldn't it be easier to do: + ```py + say_hi = lambda: + print('hello') + print('world') + say_hi() + + ' + ``` + + '""" + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNone(faulty_code_blocks) + + def test_should_recognize_contained_codeblock_even_if_that_breaks_formatting(self): + message = """``` + ```py + x = 4 + y = 3 + z = 2 + print("abc") + ``` + ``` + """ + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNone(faulty_code_blocks) + + def test_should_not_recognize_normal_single_quotes(self): + """normal single quotes refers to single quotes that appear normally in text, + like for example in "I'll", "We're", etc.""" + message = """I'm writing line 1 + and we're writing line 2 + we'll also be checking another of those + and some odd 'variations + isn't it beautiful?""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 0) + + def test_should_not_recognize_quoting_single_quotes(self): + message = """ 'I am doing a long quote. + Sure, I could just use the > character + for correct quoting + but whatever... + End of quote' """ + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 0) + + + def test_should_not_recognize_normal_double_quotes(self): + """normal double quotes refer to double quotes that appear normally in text to quote something""" + message = """ "I am doing a long quote. + Sure, I could just use the > character + for correct quoting + but whatever... + End of quote" """ + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 0) + + def test_should_not_recognize_normal_double_quotes_python_text(self): + message = """ "python is a great language + great + great + great language + enough lines? + yes" """ + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 0) + + def test_should_recognize_single_backtick_no_language(self): + message = """` + x = 4 + y = 3 + z = 2 + print("abc") + `""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) + + def test_should_recognize_single_backtick_with_language(self): + message = """`py + x = 4 + y = 3 + z = 2 + print("abc") + `""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) + + def test_should_recognize_single_single_quote_with_py_language(self): + message = """'py + x = 4 + y = 3 + z = 2 + print("abc") + '""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) + + def test_should_recognize_single_single_quote_with_python_language(self): + message = """'python + x = 4 + y = 3 + z = 2 + print("abc") + '""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) + + def test_should_recognize_wrong_number_of_backticks(self): + message = """``py + x = 4 + y = 3 + z = 2 + print("abc") + ``""" + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + self.assertIsNotNone(faulty_code_blocks) + self.assertEqual(len(faulty_code_blocks), 1) diff --git a/tests/bot/exts/info/doc/test_parsing.py b/tests/bot/exts/info/doc/test_parsing.py index 1663d84919..7136fc32c5 100644 --- a/tests/bot/exts/info/doc/test_parsing.py +++ b/tests/bot/exts/info/doc/test_parsing.py @@ -1,6 +1,9 @@ from unittest import TestCase +from bs4 import BeautifulSoup + from bot.exts.info.doc import _parsing as parsing +from bot.exts.info.doc._markdown import DocMarkdownConverter class SignatureSplitter(TestCase): @@ -64,3 +67,41 @@ def _run_tests(self, test_cases): for input_string, expected_output in test_cases: with self.subTest(input_string=input_string): self.assertEqual(list(parsing._split_parameters(input_string)), expected_output) + + +class MarkdownConverterTest(TestCase): + def test_hr_removed(self): + test_cases = ( + ('
', ""), + ("
", ""), + ) + self._run_tests(test_cases) + + def test_whitespace_removed(self): + test_cases = ( + ("lines\nof\ntext", "lines of text"), + ("lines\n\nof\n\ntext", "lines of text"), + ) + self._run_tests(test_cases) + + def _run_tests(self, test_cases: tuple[tuple[str, str], ...]): + for input_string, expected_output in test_cases: + with self.subTest(input_string=input_string): + d = DocMarkdownConverter(page_url="https://example.com") + self.assertEqual(d.convert(input_string), expected_output) + + +class MarkdownCreationTest(TestCase): + def test_surrounding_whitespace(self): + test_cases = ( + ("

Hello World

", "Hello World"), + ("

Hello

World

", "Hello\n\nWorld"), + ("

Title

", "**Title**") + ) + self._run_tests(test_cases) + + def _run_tests(self, test_cases: tuple[tuple[str, str], ...]): + for input_string, expected_output in test_cases: + with self.subTest(input_string=input_string): + tags = BeautifulSoup(input_string, "html.parser") + self.assertEqual(parsing._create_markdown(None, tags, "https://example.com"), expected_output) diff --git a/tests/bot/exts/info/test_help.py b/tests/bot/exts/info/test_help.py new file mode 100644 index 0000000000..2644ae40df --- /dev/null +++ b/tests/bot/exts/info/test_help.py @@ -0,0 +1,22 @@ +import unittest + +import rapidfuzz + +from bot.exts.info import help +from tests.helpers import MockBot, MockContext, autospec + + +class HelpCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = help.Help(self.bot) + self.ctx = MockContext(bot=self.bot) + + @autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False) + async def test_help_fuzzy_matching(self): + """Test fuzzy matching of commands when called from help.""" + result = await self.bot.help_command.command_not_found("holp") + + match = {"help": rapidfuzz.fuzz.ratio("help", "holp")} + self.assertEqual(match, result.possible_matches) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 770660fe3d..68b83ad036 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,6 +1,8 @@ import textwrap import unittest import unittest.mock +from datetime import UTC, datetime +from textwrap import shorten import discord @@ -39,10 +41,10 @@ async def test_roles_command_command(self): self.ctx.send.assert_called_once() _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') + embed = kwargs.pop("embed") self.assertEqual(embed.title, "Role information (Total 1 role)") - self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.colour, discord.Colour.og_blurple()) self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") async def test_role_info_command(self): @@ -50,7 +52,7 @@ async def test_role_info_command(self): dummy_role = helpers.MockRole( name="Dummy", id=112233445566778899, - colour=discord.Colour.blurple(), + colour=discord.Colour.og_blurple(), position=10, members=[self.ctx.author], permissions=discord.Permissions(0) @@ -80,7 +82,7 @@ async def test_role_info_command(self): admin_embed = admin_kwargs["embed"] self.assertEqual(dummy_embed.title, "Dummy info") - self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + self.assertEqual(dummy_embed.colour, discord.Colour.og_blurple()) self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") @@ -108,15 +110,15 @@ async def test_user_command_helper_method_get_requests(self): test_values = ( { "helper_method": self.cog.basic_user_infraction_counts, - "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}), + "expected_args": ("bot/infractions", {"hidden": "False", "user__id": str(self.member.id)}), }, { "helper_method": self.cog.expanded_user_infraction_counts, - "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}), + "expected_args": ("bot/infractions", {"user__id": str(self.member.id)}), }, { "helper_method": self.cog.user_nomination_counts, - "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}), + "expected_args": ("bot/nominations", {"user__id": str(self.member.id)}), }, ) @@ -239,19 +241,19 @@ async def test_user_nomination_counts_returns_correct_strings(self): "expected_lines": ["No nominations"], }, { - "api response": [{'active': True}], + "api response": [{"active": True}], "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"], }, { - "api response": [{'active': True}, {'active': False}], + "api response": [{"active": True}, {"active": False}], "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"], }, { - "api response": [{'active': False}], + "api response": [{"active": False}], "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."], }, { - "api response": [{'active': False}, {'active': False}], + "api response": [{"active": False}, {"active": False}], "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."], }, @@ -262,7 +264,6 @@ async def test_user_nomination_counts_returns_correct_strings(self): await self._method_subtests(self.cog.user_nomination_counts, test_values, header) -@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) @unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """Tests for the creation of the `!user` embed.""" @@ -277,6 +278,10 @@ def setUp(self): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -285,8 +290,9 @@ async def test_create_user_embed_uses_string_representation_of_user_in_title_if_ user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.now(UTC) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.title, "Mr. Hemlock") @@ -294,6 +300,10 @@ async def test_create_user_embed_uses_string_representation_of_user_in_title_if_ f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -302,8 +312,9 @@ async def test_create_user_embed_uses_nick_in_title_if_available(self): user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 + user.created_at = user.joined_at = datetime.now(UTC) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -311,21 +322,30 @@ async def test_create_user_embed_uses_nick_in_title_if_available(self): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) - admins_role = helpers.MockRole(name='Admins') + admins_role = helpers.MockRole(name="Admins") # A `MockMember` has the @Everyone role by default; we add the Admins to that. user = helpers.MockMember(roles=[admins_role], colour=100) + user.created_at = user.joined_at = datetime.now(UTC) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertIn("&Admins", embed.fields[1].value) self.assertNotIn("&Everyone", embed.fields[1].value) @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_expanded_information_in_moderation_channels( self, nomination_counts, @@ -334,20 +354,21 @@ async def test_create_user_embed_expanded_information_in_moderation_channels( """The embed should contain expanded infractions and nomination info in mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) - moderators_role = helpers.MockRole(name='Moderators') + moderators_role = helpers.MockRole(name="Moderators") infraction_counts.return_value = ("Infractions", "expanded infractions info") nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + user.created_at = user.joined_at = datetime.fromtimestamp(1, tz=UTC) + embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) nomination_counts.assert_called_once_with(user) self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -356,7 +377,7 @@ async def test_create_user_embed_expanded_information_in_moderation_channels( self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Verified: {"True"} Roles: &Moderators """).strip(), @@ -364,22 +385,29 @@ async def test_create_user_embed_expanded_information_in_moderation_channels( ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + @unittest.mock.patch(f"{COG_PATH}.user_messages", new_callable=unittest.mock.AsyncMock) + async def test_create_user_embed_basic_information_outside_of_moderation_channels( + self, + user_messages, + infraction_counts, + ): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) - moderators_role = helpers.MockRole(name='Moderators') + moderators_role = helpers.MockRole(name="Moderators") infraction_counts.return_value = ("Infractions", "basic infractions info") + user_messages.return_value = ("Messages", "user message counts") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + user.created_at = user.joined_at = datetime.fromtimestamp(1, tz=UTC) + embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -388,29 +416,39 @@ async def test_create_user_embed_basic_information_outside_of_moderation_channel self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Roles: &Moderators """).strip(), embed.fields[1].value ) self.assertEqual( - "basic infractions info", + "user message counts", embed.fields[2].value ) + self.assertEqual( + "basic infractions info", + embed.fields[3].value + ) + @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() - moderators_role = helpers.MockRole(name='Moderators') + moderators_role = helpers.MockRole(name="Moderators") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + user.created_at = user.joined_at = datetime.now(UTC) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour(100)) @@ -418,28 +456,37 @@ async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) - async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): - """The embed should be created with a blurple colour if the user has no assigned roles.""" + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) + async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with the og blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=discord.Colour.default()) - embed = await self.cog.create_user_embed(ctx, user) + user.created_at = user.joined_at = datetime.now(UTC) + embed = await self.cog.create_user_embed(ctx, user, False) - self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.colour, discord.Colour.og_blurple()) @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=0) - user.avatar_url_as.return_value = "avatar url" - embed = await self.cog.create_user_embed(ctx, user) + user.created_at = user.joined_at = datetime.now(UTC) + user.display_avatar.url = "avatar url" + embed = await self.cog.create_user_embed(ctx, user, False) - user.avatar_url_as.assert_called_once_with(static_format="png") self.assertEqual(embed.thumbnail.url, "avatar url") @@ -491,7 +538,7 @@ async def test_regular_user_may_use_command_in_bot_commands_channel(self, create await self.cog.user_info(self.cog, ctx) - create_embed.assert_called_once_with(ctx, self.author) + create_embed.assert_called_once_with(ctx, self.author, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -502,28 +549,104 @@ async def test_regular_user_can_explicitly_target_themselves(self, create_embed, await self.cog.user_info(self.cog, ctx, self.author) - create_embed.assert_called_once_with(ctx, self.author) + create_embed.assert_called_once_with(ctx, self.author, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" - constants.STAFF_ROLES = [self.moderator_role.id] + constants.STAFF_AND_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) await self.cog.user_info(self.cog, ctx) - create_embed.assert_called_once_with(ctx, self.moderator) + create_embed.assert_called_once_with(ctx, self.moderator, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") async def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] + constants.STAFF_AND_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) await self.cog.user_info(self.cog, ctx, self.target) - create_embed.assert_called_once_with(ctx, self.target) + create_embed.assert_called_once_with(ctx, self.target, False) ctx.send.assert_called_once() + + +class RuleCommandTests(unittest.IsolatedAsyncioTestCase): + """Tests for the `!rule` command.""" + + def setUp(self) -> None: + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + self.ctx = helpers.MockContext(author=helpers.MockMember(id=1, name="Bellaluma")) + self.full_rules = [ + ( + "First rule", + ["first", "number_one"] + ), + ( + "Second rule", + ["second", "number_two"] + ), + ( + "Third rule", + ["third", "number_three"] + ) + ] + self.bot.api_client.get.return_value = self.full_rules + # Patch get_channel to handle the rule alerts being sent to a thread for non-staff (as our mock user is). + self.bot.get_channel.return_value = helpers.MockTextChannel(id=50, name="rules") + + + async def test_return_none_if_one_rule_number_is_invalid(self): + + test_cases = [ + ("1 6 7 8", (6, 7, 8)), + ("10 first", (10,)), + ("first 10", (10,)) + ] + + for raw_user_input, extracted_rule_numbers in test_cases: + with self.subTest(identifier=raw_user_input): + invalid = ", ".join( + str(rule_number) for rule_number in extracted_rule_numbers + if rule_number < 1 or rule_number > len(self.full_rules)) + + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input) + + self.assertEqual( + self.ctx.send.call_args, + unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) + self.assertEqual(None, final_rule_numbers) + + async def test_return_correct_rule_numbers(self): + + test_cases = [ + ("1 2 first", {1, 2}), + ("1 hello 2 second", {1}), + ("second third unknown 999", {2, 3}), + ] + + for raw_user_input, expected_matched_rule_numbers in test_cases: + with self.subTest(identifier=raw_user_input): + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) + + async def test_return_default_rules_when_no_input_or_no_match_are_found(self): + test_cases = [ + ("", None), + ("hello 2 second", None), + ("hello 999", None), + ] + + for raw_user_input, expected_matched_rule_numbers in test_cases: + with self.subTest(identifier=raw_user_input): + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, args=raw_user_input) + embed = self.ctx.send.call_args.kwargs["embed"] + self.assertEqual(information.DEFAULT_RULES_DESCRIPTION, embed.description) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b9d5277709..f257bec7db 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,11 +1,15 @@ import inspect import textwrap import unittest -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, DEFAULT, MagicMock, Mock, patch + +from discord.errors import NotFound from bot.constants import Event +from bot.exts.moderation.clean import Clean from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions +from bot.exts.moderation.infraction.management import ModManagement from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec @@ -13,12 +17,13 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): """Tests for ban and kick command reason truncation.""" def setUp(self): + self.me = MockMember(id=7890, roles=[MockRole(id=7890, position=5)]) self.bot = MockBot() self.cog = Infractions(self.bot) - self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) - self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) + self.user = MockMember(id=1234, roles=[MockRole(id=3577, position=10)]) + self.target = MockMember(id=1265, roles=[MockRole(id=9876, position=1)]) self.guild = MockGuild(id=4567) - self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + self.ctx = MockContext(me=self.me, bot=self.bot, author=self.user, guild=self.guild) @patch("bot.exts.moderation.infraction._utils.get_active_infraction") @patch("bot.exts.moderation.infraction._utils.post_infraction") @@ -30,17 +35,26 @@ async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_activ self.cog.apply_infraction = AsyncMock() self.bot.get_cog.return_value = AsyncMock() self.cog.mod_log.ignore = Mock() - self.ctx.guild.ban = Mock() + self.ctx.guild.ban = AsyncMock() + + infraction_reason = "foo bar" * 3000 + + await self.cog.apply_ban(self.ctx, self.target, infraction_reason) + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar", "purge": ""}, self.target, ANY + ) - await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) - self.ctx.guild.ban.assert_called_once_with( + action = self.cog.apply_infraction.call_args.args[-1] + await action() + self.ctx.guild.ban.assert_awaited_once_with( self.target, - reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), + reason=textwrap.shorten(infraction_reason, 512, placeholder="..."), delete_message_days=0 ) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar", "purge": ""}, self.target, self.ctx.guild.ban.return_value - ) + + # Assert that the reason sent to the database isn't truncated. + post_infraction_mock.assert_awaited_once() + self.assertEqual(post_infraction_mock.call_args.args[3], infraction_reason) @patch("bot.exts.moderation.infraction._utils.post_infraction") async def test_apply_kick_reason_truncation(self, post_infraction_mock): @@ -49,80 +63,95 @@ async def test_apply_kick_reason_truncation(self, post_infraction_mock): self.cog.apply_infraction = AsyncMock() self.cog.mod_log.ignore = Mock() - self.target.kick = Mock() + self.target.kick = AsyncMock() - await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) - self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) + infraction_reason = "foo bar" * 3000 + + await self.cog.apply_kick(self.ctx, self.target, infraction_reason) self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value + self.ctx, {"foo": "bar"}, self.target, ANY ) + action = self.cog.apply_infraction.call_args.args[-1] + await action() + self.target.kick.assert_awaited_once_with(reason=textwrap.shorten(infraction_reason, 512, placeholder="...")) + + # Assert that the reason sent to the database isn't truncated. + post_infraction_mock.assert_awaited_once() + self.assertEqual(post_infraction_mock.call_args.args[3], infraction_reason) + @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) -class VoiceBanTests(unittest.IsolatedAsyncioTestCase): - """Tests for voice ban related functions and commands.""" +class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice mute related functions and commands.""" def setUp(self): self.bot = MockBot() - self.mod = MockMember(top_role=10) - self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) self.guild = MockGuild() self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) - async def test_permanent_voice_ban(self): - """Should call voice ban applying function without expiry.""" - self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) - self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) + async def test_permanent_voice_mute(self): + """Should call voice mute applying function without expiry.""" + self.cog.apply_voice_mute = AsyncMock() + self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry=None) - async def test_temporary_voice_ban(self): - """Should call voice ban applying function with expiry.""" - self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) - self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + async def test_temporary_voice_mute(self): + """Should call voice mute applying function with expiry.""" + self.cog.apply_voice_mute = AsyncMock() + self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry="baz") - async def test_voice_unban(self): + async def test_voice_unmute(self): """Should call infraction pardoning function.""" self.cog.pardon_infraction = AsyncMock() - self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) - self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user, pardon_reason="foobar")) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, "foobar") + + async def test_voice_unmute_reasonless(self): + """Should call infraction pardoning function without a pardon reason.""" + self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user)) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, None) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): - """Should return early when user already have Voice Ban infraction.""" + async def test_voice_mute_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Mute infraction.""" get_active_infraction.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_mute") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + async def test_voice_mute_infraction_post_failed(self, get_active_infraction, post_infraction_mock): """Should return early when posting infraction fails.""" self.cog.mod_log.ignore = MagicMock() get_active_infraction.return_value = None post_infraction_mock.return_value = None - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) post_infraction_mock.assert_awaited_once() self.cog.mod_log.ignore.assert_not_called() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): - """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + async def test_voice_mute_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_mute to post_infraction.""" get_active_infraction.return_value = None # We don't want that this continue yet post_infraction_mock.return_value = None - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar", my_kwarg=23)) post_infraction_mock.assert_awaited_once_with( - self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + self.ctx, self.user, "voice_mute", "foobar", active=True, my_kwarg=23 ) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + async def test_voice_mute_mod_log_ignore(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() @@ -131,20 +160,20 @@ async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infrac get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) async def action_tester(self, action, reason: str) -> None: - """Helper method to test voice ban action.""" - self.assertTrue(inspect.iscoroutine(action)) - await action + """Helper method to test voice mute action.""" + self.assertTrue(inspect.iscoroutinefunction(action)) + await action() self.user.move_to.assert_called_once_with(None, reason=ANY) self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): + async def test_voice_mute_apply_infraction(self, get_active_infraction, post_infraction_mock): """Should ignore Voice Verified role removing.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() @@ -153,22 +182,22 @@ async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infr post_infraction_mock.return_value = {"foo": "bar"} reason = "foobar" - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason)) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, reason)) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): - """Should truncate reason for voice ban.""" + async def test_voice_mute_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice mute.""" self.cog.mod_log.ignore = MagicMock() self.cog.apply_infraction = AsyncMock() get_active_infraction.return_value = None post_infraction_mock.return_value = {"foo": "bar"} - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) + self.assertIsNone(await self.cog.apply_voice_mute(self.ctx, self.user, "foobar" * 3000)) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY) # Test action @@ -177,36 +206,38 @@ async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infra @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None) @autospec(Infractions, "apply_infraction") - async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): - """Should voice ban user that left the guild without throwing an error.""" + async def test_voice_mute_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _): + """Should voice mute user that left the guild without throwing an error.""" infraction = {"foo": "bar"} post_infraction_mock.return_value = {"foo": "bar"} user = MockUser() - await self.cog.voiceban(self.cog, self.ctx, user, reason=None) - post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True, expires_at=None) + await self.cog.voicemute(self.cog, self.ctx, user, reason=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, + duration_or_expiry=None) apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) # Test action action = self.cog.apply_infraction.call_args[0][-1] - self.assertTrue(inspect.iscoroutine(action)) - await action + self.assertTrue(inspect.iscoroutinefunction(action)) + await action() - async def test_voice_unban_user_not_found(self): + async def test_voice_unmute_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found") + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") - async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + async def test_voice_unmute_user_found(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "Sent" @@ -215,15 +246,99 @@ async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") - async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + async def test_voice_unmute_dm_fail(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = False format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_mute(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "**Failed**" }) notify_pardon_mock.assert_awaited_once() + + +class CleanBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for cleanban functionality.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) + self.guild = MockGuild() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + self.clean_cog = Clean(self.bot) + self.management_cog = ModManagement(self.bot) + + self.cog.apply_ban = AsyncMock(return_value={"id": 42}) + self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + self.clean_cog._clean_messages = AsyncMock(return_value=self.log_url) + + def mock_get_cog(self, enable_clean, enable_manage): + """Mock get cog factory that allows the user to specify whether clean and manage cogs are enabled.""" + def inner(name): + if name == "ModManagement": + return self.management_cog if enable_manage else None + if name == "Clean": + return self.clean_cog if enable_clean else None + return DEFAULT + return inner + + async def test_cleanban_falls_back_to_native_purge_without_clean_cog(self): + """Should fallback to native purge if the Clean cog is not available.""" + self.bot.get_cog.side_effect = self.mock_get_cog(False, False) + + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + self.cog.apply_ban.assert_awaited_once_with( + self.ctx, + self.user, + "FooBar", + purge_days=1, + duration_or_expiry=None, + ) + + async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self): + """Cleanban command should use the native purge messages if the clean cog is available.""" + self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + self.cog.apply_ban.assert_awaited_once_with( + self.ctx, + self.user, + "FooBar", + duration_or_expiry=None, + ) + + @patch("bot.exts.moderation.infraction.infractions.Age") + async def test_cleanban_uses_clean_cog_when_available(self, mocked_age_converter): + """Test cleanban uses the clean cog to clean messages if it's available.""" + self.bot.api_client.patch = AsyncMock() + self.bot.get_cog.side_effect = self.mock_get_cog(True, False) + + mocked_age_converter.return_value.convert = AsyncMock(return_value="81M") + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + + self.clean_cog._clean_messages.assert_awaited_once_with( + self.ctx, + users=[self.user], + channels="*", + first_limit="81M", + attempt_delete_invocation=False, + ) + + async def test_cleanban_edits_infraction_reason(self): + """Ensure cleanban edits the ban reason with a link to the clean log.""" + self.bot.get_cog.side_effect = self.mock_get_cog(True, True) + + self.management_cog.infraction_append = AsyncMock() + self.assertIsNone(await self.cog.cleanban(self.cog, self.ctx, self.user, None, reason="FooBar")) + + self.management_cog.infraction_append.assert_awaited_once_with( + self.ctx, + {"id": 42}, + None, + reason=f"[Clean log]({self.log_url})" + ) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index ee9ff650c0..e2a7bad9f3 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -1,11 +1,11 @@ import unittest from collections import namedtuple -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, call, patch +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch from discord import Embed, Forbidden, HTTPException, NotFound +from pydis_core.site_api import ResponseCodeError -from bot.api import ResponseCodeError from bot.constants import Colours, Icons from bot.exts.moderation.infraction import _utils as utils from tests.helpers import MockBot, MockContext, MockMember, MockUser @@ -15,7 +15,10 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """Tests Moderation utils.""" def setUp(self): - self.bot = MockBot() + patcher = patch("bot.instance", new=MockBot()) + self.bot = patcher.start() + self.addCleanup(patcher.stop) + self.member = MockMember(id=1234) self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) @@ -94,8 +97,8 @@ async def test_get_active_infraction(self): test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"]) test_cases = [ test_case([], None, None, True), - test_case([{"id": 123987}], {"id": 123987}, "123987", False), - test_case([{"id": 123987}], {"id": 123987}, "123987", True) + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", False), + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", True) ] for case in test_cases: @@ -123,8 +126,9 @@ async def test_get_active_infraction(self): else: self.ctx.send.assert_not_awaited() + @unittest.skip("Current time needs to be patched so infraction duration is correct.") @patch("bot.exts.moderation.infraction._utils.send_private_embed") - async def test_notify_infraction(self, send_private_embed_mock): + async def test_send_infraction_embed(self, send_private_embed_mock): """ Should send an embed of a certain format as a DM and return `True` if DM successful. @@ -132,95 +136,101 @@ async def test_notify_infraction(self, send_private_embed_mock): """ test_cases = [ { - "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), + "args": ( + dict(id=0, type="ban", reason=None, expires_at=datetime(2020, 2, 26, 9, 20, tzinfo=UTC)), + self.user, + ), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." - ), + ) + utils.INFRACTION_APPEAL_SERVER_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, - icon_url=Icons.token_removed - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + icon_url=Icons.user_ban + ), "send_result": True }, { - "args": (self.user, "warning", None, "Test reason."), + "args": (dict(id=0, type="warning", reason="Test reason.", expires_at=None), self.user), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( type="Warning", expires="N/A", reason="Test reason." - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, - icon_url=Icons.token_removed - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + icon_url=Icons.user_warn + ), "send_result": False }, # Note that this test case asserts that the DM that *would* get sent to the user is formatted # correctly, even though that message is deliberately never sent. { - "args": (self.user, "note", None, None, Icons.defcon_denied), + "args": (dict(id=0, type="note", reason=None, expires_at=None), self.user), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( type="Note", expires="N/A", reason="No reason provided." - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, - icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + icon_url=Icons.user_warn + ), "send_result": False }, { - "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), + "args": ( + dict(id=0, type="mute", reason="Test", expires_at=datetime(2020, 2, 26, 9, 20, tzinfo=UTC)), + self.user, + ), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, - icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + icon_url=Icons.user_mute + ), "send_result": False }, { - "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), + "args": (dict(id=0, type="mute", reason="foo bar" * 4000, expires_at=None), self.user), "expected_output": Embed( title=utils.INFRACTION_TITLE, - description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( + description=utils.INFRACTION_DESCRIPTION_NOT_WARNING_TEMPLATE.format( type="Mute", expires="N/A", reason="foo bar" * 4000 - )[:2045] + "...", + )[:4093-utils.LONGEST_EXTRAS] + "..." + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, - icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + icon_url=Icons.user_mute + ), "send_result": True } ] @@ -238,7 +248,7 @@ async def test_notify_infraction(self, send_private_embed_mock): self.assertEqual(embed.to_dict(), case["expected_output"].to_dict()) - send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) + send_private_embed_mock.assert_awaited_once_with(case["args"][1], embed) @patch("bot.exts.moderation.infraction._utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): @@ -305,22 +315,26 @@ def setUp(self): async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" - now = datetime.now() - payload = { + now = datetime.now(UTC) + expected = { "actor": self.ctx.author.id, "hidden": True, "reason": "Test reason", "type": "ban", "user": self.member.id, "active": False, - "expires_at": now.isoformat() + "expires_at": now.isoformat(), + "dm_sent": False, } self.ctx.bot.api_client.post.return_value = "foo" actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.ctx.bot.api_client.post.assert_awaited_once() + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + payload: dict = self.ctx.bot.api_client.post.await_args_list[0].kwargs["json"] + self.assertEqual(payload, payload | expected) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" @@ -344,18 +358,25 @@ async def test_user_not_found_none_post_infraction(self, post_user_mock): @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" - payload = { + expected = { "actor": self.ctx.author.id, "hidden": False, "reason": "Test reason", "type": "mute", "user": self.user.id, - "active": True + "active": True, + "dm_sent": False, } self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + await_args = self.bot.api_client.post.await_args_list + self.assertEqual(len(await_args), 2, "Expected 2 awaits") + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + for args in await_args: + payload: dict = args.kwargs["json"] + self.assertEqual(payload, payload | expected) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) diff --git a/tests/bot/exts/moderation/test_clean.py b/tests/bot/exts/moderation/test_clean.py new file mode 100644 index 0000000000..d7647fa48d --- /dev/null +++ b/tests/bot/exts/moderation/test_clean.py @@ -0,0 +1,104 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from bot.exts.moderation.clean import Clean +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockMessage, MockRole, MockTextChannel + + +class CleanTests(unittest.IsolatedAsyncioTestCase): + """Tests for clean cog functionality.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) + self.guild = MockGuild() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Clean(self.bot) + + self.log_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + self.cog._modlog_cleaned_messages = AsyncMock(return_value=self.log_url) + + self.cog._use_cache = MagicMock(return_value=True) + self.cog._delete_found = AsyncMock(return_value=[42, 84]) + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_deletes_invocation_in_non_mod_channel(self, mod_channel_check): + """Clean command should delete the invocation message if ran in a non mod channel.""" + mod_channel_check.return_value = False + self.ctx.message.delete = AsyncMock() + + self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + + self.ctx.message.delete.assert_awaited_once() + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_doesnt_delete_invocation_in_mod_channel(self, mod_channel_check): + """Clean command should not delete the invocation message if ran in a mod channel.""" + mod_channel_check.return_value = True + self.ctx.message.delete = AsyncMock() + + self.assertIsNone(await self.cog._delete_invocation(self.ctx)) + + self.ctx.message.delete.assert_not_awaited() + + async def test_clean_doesnt_attempt_deletion_when_attempt_delete_invocation_is_false(self): + """Clean command should not attempt to delete the invocation message if attempt_delete_invocation is false.""" + self.cog._delete_invocation = AsyncMock() + self.bot.get_channel = MagicMock(return_value=False) + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + self.cog._delete_invocation.assert_not_awaited() + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_replies_with_success_message_when_ran_in_mod_channel(self, mod_channel_check): + """Clean command should reply to the message with a confirmation message if invoked in a mod channel.""" + mod_channel_check.return_value = True + self.ctx.reply = AsyncMock() + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + self.ctx.reply.assert_awaited_once() + sent_message = self.ctx.reply.await_args[0][0] + self.assertIn(self.log_url, sent_message) + self.assertIn("2 messages", sent_message) + + @patch("bot.exts.moderation.clean.is_mod_channel") + async def test_clean_send_success_message_to_mods_when_ran_in_non_mod_channel(self, mod_channel_check): + """Clean command should send a confirmation message to #mods if invoked in a non-mod channel.""" + mod_channel_check.return_value = False + mocked_mods = MockTextChannel(id=1234567) + mocked_mods.send = AsyncMock() + self.bot.get_channel = MagicMock(return_value=mocked_mods) + + self.assertEqual( + await self.cog._clean_messages( + self.ctx, + None, + first_limit=MockMessage(), + attempt_delete_invocation=False, + ), + self.log_url, + ) + + mocked_mods.send.assert_awaited_once() + sent_message = mocked_mods.send.await_args[0][0] + self.assertIn(self.log_url, sent_message) + self.assertIn("2 messages", sent_message) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf8..239240251b 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -1,15 +1,20 @@ import asyncio +import datetime import enum import logging import typing as t import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest import mock +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch import aiohttp import discord from bot.constants import Colours from bot.exts.moderation import incidents +from bot.utils.messages import format_user +from bot.utils.time import TimestampFormats, discord_timestamp +from tests.base import RedisTestCase from tests.helpers import ( MockAsyncWebhook, MockAttachment, @@ -20,8 +25,11 @@ MockRole, MockTextChannel, MockUser, + no_create_task, ) +CURRENT_TIME = datetime.datetime(2022, 1, 1, tzinfo=datetime.UTC) + class MockAsyncIterable: """ @@ -104,30 +112,45 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): async def test_make_embed_actioned(self): """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + embed, _file = await incidents.make_embed( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=incidents.Signal.ACTIONED, + actioned_by=MockMember() + ) self.assertEqual(embed.colour.value, Colours.soft_green) self.assertIn("Actioned", embed.footer.text) async def test_make_embed_not_actioned(self): """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + embed, _file = await incidents.make_embed( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=incidents.Signal.NOT_ACTIONED, + actioned_by=MockMember() + ) self.assertEqual(embed.colour.value, Colours.soft_red) self.assertIn("Rejected", embed.footer.text) async def test_make_embed_content(self): """Incident content appears as embed description.""" - incident = MockMessage(content="this is an incident") - embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + incident = MockMessage(content="this is an incident", created_at=CURRENT_TIME) + + reported_timestamp = discord_timestamp(CURRENT_TIME) + relative_timestamp = discord_timestamp(CURRENT_TIME, TimestampFormats.RELATIVE) - self.assertEqual(incident.content, embed.description) + embed, _file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual( + f"{incident.content}\n\n*Reported {reported_timestamp} ({relative_timestamp}).*", + embed.description + ) async def test_make_embed_with_attachment_succeeds(self): """Incident's attachment is downloaded and displayed in the embed's image field.""" file = MagicMock(discord.File, filename="bigbadjoe.jpg") attachment = MockAttachment(filename="bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) + incident = MockMessage(content="this is an incident", attachments=[attachment], created_at=CURRENT_TIME) # Patch `download_file` to return our `file` with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)): @@ -139,7 +162,7 @@ async def test_make_embed_with_attachment_succeeds(self): async def test_make_embed_with_attachment_fails(self): """Incident's attachment fails to download, proxy url is linked instead.""" attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") - incident = MockMessage(content="this is an incident", attachments=[attachment]) + incident = MockMessage(content="this is an incident", attachments=[attachment], created_at=CURRENT_TIME) # Patch `download_file` to return None as if the download failed with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)): @@ -170,6 +193,7 @@ def setUp(self) -> None: content="this is an incident", author=MockUser(bot=False), pinned=False, + reference=None, ) def test_is_incident_true(self): @@ -274,7 +298,7 @@ async def test_add_signals_present(self): self.incident.add_reaction.assert_not_called() -class TestIncidents(unittest.IsolatedAsyncioTestCase): +class TestIncidents(RedisTestCase): """ Tests for bound methods of the `Incidents` cog. @@ -290,7 +314,8 @@ def setUp(self): Note that this will not schedule `crawl_incidents` in the background, as everything is being mocked. The `crawl_task` attribute will end up being None. """ - self.cog_instance = incidents.Incidents(MockBot()) + with no_create_task(): + self.cog_instance = incidents.Incidents(MockBot()) @patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test @@ -353,7 +378,6 @@ async def test_crawl_incidents_add_signals_called(self): class TestArchive(TestIncidents): """Tests for the `Incidents.archive` coroutine.""" - async def test_archive_webhook_not_found(self): """ Method recovers and returns False when the webhook is not found. @@ -363,7 +387,11 @@ async def test_archive_webhook_not_found(self): """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) self.assertFalse( - await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + await self.cog_instance.archive( + incident=MockMessage(created_at=CURRENT_TIME), + outcome=MagicMock(), + actioned_by=MockMember() + ) ) async def test_archive_relays_incident(self): @@ -379,7 +407,7 @@ async def test_archive_relays_incident(self): # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", - author=MockUser(name="author_name", avatar_url="author_avatar"), + author=MockUser(display_name="author_name", display_avatar=Mock(url="author_avatar")), id=123, ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this @@ -410,7 +438,7 @@ async def test_archive_clyde_username(self): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + message_from_clyde = MockMessage(author=MockUser(display_name="clyde the great"), created_at=CURRENT_TIME) await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) @@ -439,7 +467,8 @@ def test_make_confirmation_task_check(self): If this function begins to fail, first check that `created_check` is being retrieved correctly. It should be the function that is built locally in the tested method. """ - self.cog_instance.make_confirmation_task(MockMessage(id=123)) + with no_create_task(): + self.cog_instance.make_confirmation_task(MockMessage(id=123)) self.cog_instance.bot.wait_for.assert_called_once() created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] @@ -509,12 +538,13 @@ async def test_process_event_no_delete_if_archive_fails(self): async def test_process_event_confirmation_task_is_awaited(self): """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" mock_task = AsyncMock() + mock_member = MockMember(display_name="Bobby Johnson", roles=[MockRole(id=1)]) with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), - member=MockMember(roles=[MockRole(id=1)]) + incident=MockMessage(author=mock_member, id=123, created_at=CURRENT_TIME), + member=mock_member ) mock_task.assert_awaited() @@ -527,16 +557,16 @@ async def test_process_event_confirmation_task_timeout_is_handled(self): exception should it propagate out of `process_event`. This is so that we can then manually fail the test with a more informative message than just the plain traceback. """ - mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + mock_task = AsyncMock(side_effect=TimeoutError()) try: with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), + incident=MockMessage(id=123, created_at=CURRENT_TIME), member=MockMember(roles=[MockRole(id=1)]) ) - except asyncio.TimeoutError: + except TimeoutError: self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") @@ -644,7 +674,7 @@ def setUp(self): emoji="reaction", ) - async def asyncSetUp(self): # noqa: N802 + async def asyncSetUp(self): """ Prepare an empty task and assign it as `crawl_task`. @@ -768,3 +798,74 @@ async def test_on_message_non_incident(self): await self.cog_instance.on_message(MockMessage()) mock_add_signals.assert_not_called() + + +class TestMessageLinkEmbeds(TestIncidents): + """Tests for `extract_message_links` coroutine.""" + + async def test_shorten_text(self): + """Test all cases of text shortening by mocking messages.""" + tests = { + "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...", + + "\n".join(["Lets", "make", "a", "new", "line", "test"]): "Lets\nmake\na...", + + "Hello, World!" * 300: ( + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!H..." + ) + } + + for content, expected_conversion in tests.items(): + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = incidents.shorten_text(content) + self.assertEqual(conversion, expected_conversion) + + async def extract_and_form_message_link_embeds(self): + """ + Extract message links from a mocked message and form the message link embed. + + Considers all types of message links, discord supports. + """ + self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + msg = MockMessage(id=555, content="Hello, World!" * 3000) + msg.channel.mention = "#lemonade-stand" + + msg_links = [ + # Valid Message links + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + + # Invalid Message links + f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}", + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}", + ] + + incident_msg = MockMessage( + id=777, + content=( + f"I would like to report the following messages, " + f"as they break our rules: \n{', '.join(msg_links)}" + ) + ) + + with patch( + "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() + ) as mock_extract_message_links: + embeds = mock_extract_message_links(incident_msg) + description = ( + f"**Author:** {format_user(msg.author)}\n" + f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" + f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" + ) + + # Check number of embeds returned with number of valid links + self.assertEqual(len(embeds), 2) + + # Check for the embed descriptions + for embed in embeds: + self.assertEqual(embed.description, description) diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py index f8f142484c..f2b02bd1bd 100644 --- a/tests/bot/exts/moderation/test_modlog.py +++ b/tests/bot/exts/moderation/test_modlog.py @@ -3,6 +3,7 @@ import discord from bot.exts.moderation.modlog import ModLog +from bot.utils.modlog import send_log_message from tests.helpers import MockBot, MockTextChannel @@ -17,7 +18,8 @@ def setUp(self): async def test_log_entry_description_truncation(self): """Test that embed description for ModLog entry is truncated.""" self.bot.get_channel.return_value = self.channel - await self.cog.send_log_message( + await send_log_message( + self.bot, icon_url="foo", colour=discord.Colour.blue(), title="bar", @@ -25,5 +27,5 @@ async def test_log_entry_description_truncation(self): ) embed = self.channel.send.call_args[1]["embed"] self.assertEqual( - embed.description, ("foo bar" * 3000)[:2045] + "..." + embed.description, ("foo bar" * 3000)[:4093] + "..." ) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index fa5fc9e81e..86d396afda 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -1,31 +1,24 @@ -import asyncio +import itertools import unittest -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest import mock -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock -from async_rediscache import RedisSession from discord import PermissionOverwrite -from bot.constants import Channels, Guild, Roles +from bot.constants import Channels, Guild, MODERATION_ROLES, Roles from bot.exts.moderation import silence -from tests.helpers import MockBot, MockContext, MockTextChannel, autospec - -redis_session = None -redis_loop = asyncio.get_event_loop() - - -def setUpModule(): # noqa: N802 - """Create and connect to the fakeredis session.""" - global redis_session - redis_session = RedisSession(use_fakeredis=True) - redis_loop.run_until_complete(redis_session.connect()) - - -def tearDownModule(): # noqa: N802 - """Close the fakeredis session.""" - if redis_session: - redis_loop.run_until_complete(redis_session.close()) +from tests.base import RedisTestCase +from tests.helpers import ( + MockBot, + MockContext, + MockGuild, + MockMember, + MockRole, + MockTextChannel, + MockVoiceChannel, + autospec, +) # Have to subclass it because builtins can't be patched. @@ -35,6 +28,20 @@ class PatchedDatetime(datetime): now = mock.create_autospec(datetime, "now") +class SilenceTest(RedisTestCase): + """A base class for Silence tests that correctly sets up the cog and redis.""" + + @autospec(silence, "Scheduler", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot(get_channel=lambda _id: MockTextChannel(id=_id)) + self.cog = silence.Silence(self.bot) + + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + await self.cog.cog_load() # Populate instance attributes. + + class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -45,32 +52,36 @@ def setUp(self) -> None: def test_add_channel_adds_channel(self): """Channel is added to `_silenced_channels` with the current loop.""" channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.add_channel(channel) - silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) + self.notifier.add_channel(channel) + self.assertDictEqual(self.notifier._silenced_channels, {channel: self.notifier._current_loop}) + + def test_add_channel_loop_called_correctly(self): + """Loop is called only in correct scenarios.""" - def test_add_channel_starts_loop(self): - """Loop is started if `_silenced_channels` was empty.""" + # Loop is started if `_silenced_channels` was empty. self.notifier.add_channel(Mock()) self.notifier_start_mock.assert_called_once() - def test_add_channel_skips_start_with_channels(self): - """Loop start is not called when `_silenced_channels` is not empty.""" - with mock.patch.object(self.notifier, "_silenced_channels"): - self.notifier.add_channel(Mock()) + self.notifier_start_mock.reset_mock() + + # Loop start is not called when `_silenced_channels` is not empty. + self.notifier.add_channel(Mock()) self.notifier_start_mock.assert_not_called() def test_remove_channel_removes_channel(self): """Channel is removed from `_silenced_channels`.""" channel = Mock() - with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: - self.notifier.remove_channel(channel) - silenced_channels.__delitem__.assert_called_with(channel) + self.notifier.add_channel(channel) + self.notifier.remove_channel(channel) + self.assertDictEqual(self.notifier._silenced_channels, {}) def test_remove_channel_stops_loop(self): """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" - with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): - self.notifier.remove_channel(Mock()) + channel = Mock() + self.notifier.add_channel(channel) + self.notifier_stop_mock.assert_not_called() + + self.notifier.remove_channel(channel) self.notifier_stop_mock.assert_called_once() def test_remove_channel_skips_stop_with_channels(self): @@ -94,62 +105,41 @@ async def test_notifier_skips_alert(self): """Alert is skipped on first loop or not an increment of 900.""" test_cases = (0, 15, 5000) for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - with mock.patch.object(self.notifier, "_current_loop", new=current_loop): - await self.notifier._notifier() - self.alert_channel.send.assert_not_called() + with ( + self.subTest(current_loop=current_loop), + mock.patch.object(self.notifier, "_current_loop", new=current_loop), + ): + await self.notifier._notifier() + self.alert_channel.send.assert_not_called() -@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) -class SilenceCogTests(unittest.IsolatedAsyncioTestCase): +class SilenceCogTests(SilenceTest): """Tests for the general functionality of the Silence cog.""" - @autospec(silence, "Scheduler", pass_mocks=False) - def setUp(self) -> None: - self.bot = MockBot() - self.cog = silence.Silence(self.bot) - - @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_async_init_got_guild(self): + async def test_cog_load_got_guild(self): """Bot got guild after it became available.""" - await self.cog._async_init() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_async_init_got_channels(self): + async def test_cog_load_got_channels(self): """Got channels from bot.""" - self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) - - await self.cog._async_init() self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts) - @autospec(silence, "SilenceNotifier") - async def test_async_init_got_notifier(self, notifier): + async def test_cog_load_got_notifier(self): """Notifier was started with channel.""" - self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) - - await self.cog._async_init() + with mock.patch.object(silence, "SilenceNotifier") as notifier: + await self.cog.cog_load() notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log)) self.assertEqual(self.cog.notifier, notifier.return_value) - @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_async_init_rescheduled(self): + async def testcog_load_rescheduled(self): """`_reschedule_` coroutine was awaited.""" - self.cog._reschedule = mock.create_autospec(self.cog._reschedule) - await self.cog._async_init() + self.cog._reschedule = AsyncMock() + await self.cog.cog_load() self.cog._reschedule.assert_awaited_once_with() - def test_cog_unload_cancelled_tasks(self): - """The init task was cancelled.""" - self.cog._init_task = asyncio.Future() - self.cog.cog_unload() - - # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda. - self.assertTrue(self.cog._init_task.cancelled()) - @autospec("discord.ext.commands", "has_any_role") - @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) + @mock.patch.object(silence.constants, "MODERATION_ROLES", new=(1, 2, 3)) async def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" ctx = MockContext() @@ -159,23 +149,185 @@ async def test_cog_check(self, role_check): role_check.assert_called_once_with(*(1, 2, 3)) role_check.return_value.predicate.assert_awaited_once_with(ctx) + async def test_force_voice_sync(self): + """Tests the _force_voice_sync helper function.""" + await self.cog.cog_load() + + # Create a regular member, and one member for each of the moderation roles + moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES] + members = [MockMember(), *moderation_members] + + channel = MockVoiceChannel(members=members) + + await self.cog._force_voice_sync(channel) + for member in members: + if member in moderation_members: + member.move_to.assert_not_called() + else: + self.assertEqual(member.move_to.call_count, 2) + calls = member.move_to.call_args_list + + # Tests that the member was moved to the afk channel, and back. + self.assertEqual((channel.guild.afk_channel,), calls[0].args) + self.assertEqual((channel,), calls[1].args) + + async def test_force_voice_sync_no_channel(self): + """Test to ensure _force_voice_sync can create its own voice channel if one is not available.""" + await self.cog.cog_load() + + channel = MockVoiceChannel(guild=MockGuild(afk_channel=None)) + new_channel = MockVoiceChannel(delete=AsyncMock()) + channel.guild.create_voice_channel.return_value = new_channel + + await self.cog._force_voice_sync(channel) + + # Check channel creation + overwrites = { + channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + channel.guild.create_voice_channel.assert_awaited_once_with("mute-temp", overwrites=overwrites) + + # Check bot deleted channel + new_channel.delete.assert_awaited_once() -@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) -class RescheduleTests(unittest.IsolatedAsyncioTestCase): + async def test_voice_kick(self): + """Test to ensure kick function can remove all members from a voice channel.""" + await self.cog.cog_load() + + # Create a regular member, and one member for each of the moderation roles + moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES] + members = [MockMember(), *moderation_members] + + channel = MockVoiceChannel(members=members) + await self.cog._kick_voice_members(channel) + + for member in members: + if member in moderation_members: + member.move_to.assert_not_called() + else: + self.assertEqual((None,), member.move_to.call_args_list[0].args) + + @staticmethod + def create_erroneous_members() -> tuple[list[MockMember], list[MockMember]]: + """ + Helper method to generate a list of members that error out on move_to call. + + Returns the list of erroneous members, + as well as a list of regular and erroneous members combined, in that order. + """ + erroneous_member = MockMember(move_to=AsyncMock(side_effect=Exception())) + members = [MockMember(), erroneous_member] + + return erroneous_member, members + + async def test_kick_move_to_error(self): + """Test to ensure move_to gets called on all members during kick, even if some fail.""" + await self.cog.cog_load() + _, members = self.create_erroneous_members() + + await self.cog._kick_voice_members(MockVoiceChannel(members=members)) + for member in members: + member.move_to.assert_awaited_once() + + async def test_sync_move_to_error(self): + """Test to ensure move_to gets called on all members during sync, even if some fail.""" + await self.cog.cog_load() + failing_member, members = self.create_erroneous_members() + + await self.cog._force_voice_sync(MockVoiceChannel(members=members)) + for member in members: + self.assertEqual(member.move_to.call_count, 1 if member == failing_member else 2) + + +class SilenceArgumentParserTests(unittest.IsolatedAsyncioTestCase): + """Tests for the silence argument parser utility function.""" + + @autospec(silence.Silence, "send_message", pass_mocks=False) + @autospec(silence.Silence, "_set_silence_overwrites", return_value=False, pass_mocks=False) + @autospec(silence.Silence, "parse_silence_args") + async def test_command(self, parser_mock): + """Test that the command passes in the correct arguments for different calls.""" + bot = MockBot() + cog = silence.Silence(bot) + + test_cases = ( + (), + (15, ), + (MockTextChannel(),), + (MockTextChannel(), 15), + ) + + ctx = MockContext() + parser_mock.return_value = (ctx.channel, 10) + + for case in test_cases: + with self.subTest("Test command converters", args=case): + await cog.silence.callback(cog, ctx, *case) + + try: + first_arg = case[0] + except IndexError: + # Default value when the first argument is not passed + first_arg = None + + try: + second_arg = case[1] + except IndexError: + # Default value when the second argument is not passed + second_arg = 10 + + parser_mock.assert_called_with(ctx, first_arg, second_arg) + + async def test_no_arguments(self): + """Test the parser when no arguments are passed to the command.""" + ctx = MockContext() + channel, duration = silence.Silence.parse_silence_args(ctx, None, 10) + + self.assertEqual(ctx.channel, channel) + self.assertEqual(10, duration) + + async def test_channel_only(self): + """Test the parser when just the channel argument is passed.""" + expected_channel = MockTextChannel() + actual_channel, duration = silence.Silence.parse_silence_args(MockContext(), expected_channel, 10) + + self.assertEqual(expected_channel, actual_channel) + self.assertEqual(10, duration) + + async def test_duration_only(self): + """Test the parser when just the duration argument is passed.""" + ctx = MockContext() + channel, duration = silence.Silence.parse_silence_args(ctx, 15, 10) + + self.assertEqual(ctx.channel, channel) + self.assertEqual(15, duration) + + async def test_all_args(self): + """Test the parser when both channel and duration are passed.""" + expected_channel = MockTextChannel() + actual_channel, duration = silence.Silence.parse_silence_args(MockContext(), expected_channel, 15) + + self.assertEqual(expected_channel, actual_channel) + self.assertEqual(15, duration) + + +class RescheduleTests(RedisTestCase): """Tests for the rescheduling of cached unsilences.""" - @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) - def setUp(self): + @autospec(silence, "Scheduler", pass_mocks=False) + def setUp(self) -> None: self.bot = MockBot() self.cog = silence.Silence(self.bot) self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) - with mock.patch.object(self.cog, "_reschedule", autospec=True): - asyncio.run(self.cog._async_init()) # Populate instance attributes. + @autospec(silence, "SilenceNotifier", pass_mocks=False) + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + await self.cog.cog_load() # Populate instance attributes. async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)] + await self.cog.unsilence_timestamps.set(123, -1) self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -188,8 +340,8 @@ async def test_added_permanent_to_notifier(self): """Permanently silenced channels were added to the notifier.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)] - + await self.cog.unsilence_timestamps.set(123, -1) + await self.cog.unsilence_timestamps.set(456, -1) await self.cog._reschedule() self.cog.notifier.add_channel.assert_any_call(channels[0]) @@ -202,7 +354,8 @@ async def test_unsilenced_expired(self): """Unsilenced expired silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)] + await self.cog.unsilence_timestamps.set(123, 100) + await self.cog.unsilence_timestamps.set(456, 200) await self.cog._reschedule() @@ -217,8 +370,9 @@ async def test_rescheduled_active(self): """Rescheduled active silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)] - silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) + await self.cog.unsilence_timestamps.set(123, 2000) + await self.cog.unsilence_timestamps.set(456, 3000) + silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=UTC) self.cog._unsilence_wrapper = mock.MagicMock() unsilence_return = self.cog._unsilence_wrapper.return_value @@ -235,105 +389,246 @@ async def test_rescheduled_active(self): self.cog.notifier.add_channel.assert_not_called() -@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) -class SilenceTests(unittest.IsolatedAsyncioTestCase): +def voice_sync_helper(function): + """Helper wrapper to test the sync and kick functions for voice channels.""" + @autospec(silence.Silence, "_force_voice_sync", "_kick_voice_members", "_set_silence_overwrites") + async def inner(self, sync, kick, overwrites): + overwrites.return_value = True + await function(self, MockContext(), sync, kick) + + return inner + + +class SilenceTests(SilenceTest): """Tests for the silence command and its related helper methods.""" - @autospec(silence.Silence, "_reschedule", pass_mocks=False) - @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot() - self.cog = silence.Silence(self.bot) - self.cog._init_task = asyncio.Future() - self.cog._init_task.set_result(None) + super().setUp() # Avoid unawaited coroutine warnings. self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close() + self.text_channel = MockTextChannel() + self.text_overwrite = PermissionOverwrite( + send_messages=True, + add_reactions=False, + create_private_threads=True, + create_public_threads=False, + send_messages_in_threads=True + ) + self.text_channel.overwrites_for.return_value = self.text_overwrite - asyncio.run(self.cog._async_init()) # Populate instance attributes. - - self.channel = MockTextChannel() - self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) - self.channel.overwrites_for.return_value = self.overwrite + self.voice_channel = MockVoiceChannel() + self.voice_overwrite = PermissionOverwrite(connect=True, speak=True) + self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): - """Appropriate failure/success message was sent by the command.""" + """Appropriate failure/success message was sent by the command to the correct channel.""" + # The following test tuples are made up of: + # duration, expected message, and the success of the _set_silence_overwrites function test_cases = ( (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), (None, silence.MSG_SILENCE_PERMANENT, True,), (5, silence.MSG_SILENCE_FAIL, False,), ) - for duration, message, was_silenced in test_cases: - ctx = MockContext() - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): - with self.subTest(was_silenced=was_silenced, message=message, duration=duration): - await self.cog.silence.callback(self.cog, ctx, duration) - ctx.send.assert_called_once_with(message) + + targets = (MockTextChannel(), MockVoiceChannel(), None) + + for (duration, message, was_silenced), target in itertools.product(test_cases, targets): + with ( + mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced), + self.subTest(was_silenced=was_silenced, target=target, message=message), + mock.patch.object(self.cog, "send_message") as send_message + ): + ctx = MockContext() + await self.cog.silence.callback(self.cog, ctx, target, duration) + send_message.assert_called_once_with( + message, + ctx.channel, + target or ctx.channel, + alert_target=was_silenced + ) + + @voice_sync_helper + async def test_sync_called(self, ctx, sync, kick): + """Tests if silence command calls sync on a voice channel.""" + channel = MockVoiceChannel() + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False) + + sync.assert_awaited_once_with(self.cog, channel) + kick.assert_not_called() + + @voice_sync_helper + async def test_kick_called(self, ctx, sync, kick): + """Tests if silence command calls kick on a voice channel.""" + channel = MockVoiceChannel() + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True) + + kick.assert_awaited_once_with(channel) + sync.assert_not_called() + + @voice_sync_helper + async def test_sync_not_called(self, ctx, sync, kick): + """Tests that silence command does not call sync on a text channel.""" + channel = MockTextChannel() + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False) + + sync.assert_not_called() + kick.assert_not_called() + + @voice_sync_helper + async def test_kick_not_called(self, ctx, sync, kick): + """Tests that silence command does not call kick on a text channel.""" + channel = MockTextChannel() + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True) + + sync.assert_not_called() + kick.assert_not_called() async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( - (False, PermissionOverwrite(send_messages=False, add_reactions=False)), - (True, PermissionOverwrite(send_messages=True, add_reactions=True)), - (True, PermissionOverwrite(send_messages=False, add_reactions=False)), + ( + False, + MockTextChannel(), + PermissionOverwrite( + send_messages=False, + add_reactions=False, + create_private_threads=False, + create_public_threads=False, + send_messages_in_threads=False + ) + ), + ( + True, + MockTextChannel(), + PermissionOverwrite( + send_messages=True, + add_reactions=True, + create_private_threads=True, + create_public_threads=True, + send_messages_in_threads=True + ) + ), + ( + True, + MockTextChannel(), + PermissionOverwrite( + send_messages=False, + add_reactions=False, + create_private_threads=False, + create_public_threads=False, + send_messages_in_threads=False + ) + ), + (False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), + (True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)), + (True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), ) - for contains, overwrite in subtests: - with self.subTest(contains=contains, overwrite=overwrite): + for contains, channel, overwrite in subtests: + with self.subTest(contains=contains, is_text=isinstance(channel, MockTextChannel), overwrite=overwrite): self.cog.scheduler.__contains__.return_value = contains - channel = MockTextChannel() channel.overwrites_for.return_value = overwrite self.assertFalse(await self.cog._set_silence_overwrites(channel)) channel.set_permissions.assert_not_called() - async def test_silenced_channel(self): + async def test_silenced_text_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) - self.assertFalse(self.overwrite.send_messages) - self.assertFalse(self.overwrite.add_reactions) - self.channel.set_permissions.assert_awaited_once_with( + self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) + self.assertFalse(self.text_overwrite.send_messages) + self.assertFalse(self.text_overwrite.add_reactions) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._everyone_role, - overwrite=self.overwrite + overwrite=self.text_overwrite ) - async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed.""" - prev_overwrite_dict = dict(self.overwrite) - await self.cog._set_silence_overwrites(self.channel) - new_overwrite_dict = dict(self.overwrite) + async def test_silenced_voice_channel_speak(self): + """Channel had `speak` permissions revoked for verified role.""" + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) + self.assertFalse(self.voice_overwrite.speak) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite + ) + + async def test_silenced_voice_channel_full(self): + """Channel had `speak` and `connect` permissions revoked for verified role.""" + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, kick=True)) + self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite + ) - # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] + async def test_preserved_other_overwrites_text(self): + """Channel's other unrelated overwrites were not changed for a text channel mute.""" + prev_overwrite_dict = dict(self.text_overwrite) + await self.cog._set_silence_overwrites(self.text_channel) + new_overwrite_dict = dict(self.text_overwrite) + + # Remove related permission keys because they were changed by the method. + for perm_name in ( + "send_messages", + "add_reactions", + "create_private_threads", + "create_public_threads", + "send_messages_in_threads" + ): + del prev_overwrite_dict[perm_name] + del new_overwrite_dict[perm_name] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + + async def test_preserved_other_overwrites_voice(self): + """Channel's other unrelated overwrites were not changed for a voice channel mute.""" + prev_overwrite_dict = dict(self.voice_overwrite) + await self.cog._set_silence_overwrites(self.voice_channel) + new_overwrite_dict = dict(self.voice_overwrite) + + # Remove 'connect' & 'speak' keys because they were changed by the method. + del prev_overwrite_dict["connect"] + del prev_overwrite_dict["speak"] + del new_overwrite_dict["connect"] + del new_overwrite_dict["speak"] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) async def test_temp_not_added_to_notifier(self): """Channel was not added to notifier if a duration was set for the silence.""" - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): + with ( + mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True), + mock.patch.object(self.cog.notifier, "add_channel") + ): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_indefinite_added_to_notifier(self): """Channel was added to notifier if a duration was not set for the silence.""" - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): - await self.cog.silence.callback(self.cog, MockContext(), None) + with ( + mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True), + mock.patch.object(self.cog.notifier, "add_channel") + ): + await self.cog.silence.callback(self.cog, MockContext(), None, None) self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False): + with ( + mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False), + mock.patch.object(self.cog.notifier, "add_channel") + ): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" - overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._set_silence_overwrites(self.channel) - self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) + overwrite_json = ( + '{"send_messages": true, "add_reactions": false, "create_private_threads": true, ' + '"create_public_threads": false, "send_messages_in_threads": true}' + ) + await self.cog._set_silence_overwrites(self.text_channel) + self.assertEqual(await self.cog.previous_overwrites.get(self.text_channel.id), overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -341,59 +636,58 @@ async def test_cached_unsilence_time(self, datetime_mock): now_timestamp = 100 duration = 15 timestamp = now_timestamp + duration * 60 - datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) + datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=UTC) - ctx = MockContext(channel=self.channel) + ctx = MockContext(channel=self.text_channel) await self.cog.silence.callback(self.cog, ctx, duration) - self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) - datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + self.assertEqual(await self.cog.unsilence_timestamps.get(ctx.channel.id), timestamp) + datetime_mock.now.assert_called_once_with(tz=UTC) # Ensure it's using an aware dt. async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" - ctx = MockContext(channel=self.channel) - await self.cog.silence.callback(self.cog, ctx, None) - self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) + ctx = MockContext(channel=self.text_channel) + await self.cog.silence.callback(self.cog, ctx, None, None) + self.assertEqual(await self.cog.unsilence_timestamps.get(ctx.channel.id), -1) async def test_scheduled_task(self): """An unsilence task was scheduled.""" - ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + ctx = MockContext(channel=self.text_channel, invoke=mock.MagicMock()) await self.cog.silence.callback(self.cog, ctx, 5) args = (300, ctx.channel.id, ctx.invoke.return_value) self.cog.scheduler.schedule_later.assert_called_once_with(*args) - ctx.invoke.assert_called_once_with(self.cog.unsilence) + ctx.invoke.assert_called_once_with(self.cog.unsilence, channel=ctx.channel) async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" - ctx = MockContext(channel=self.channel) - await self.cog.silence.callback(self.cog, ctx, None) + ctx = MockContext(channel=self.text_channel) + await self.cog.silence.callback(self.cog, ctx, None, None) self.cog.scheduler.schedule_later.assert_not_called() + async def test_indefinite_silence(self): + """Test silencing a channel forever.""" + with mock.patch.object(self.cog, "_schedule_unsilence") as unsilence: + ctx = MockContext(channel=self.text_channel) + await self.cog.silence.callback(self.cog, ctx, -1) + unsilence.assert_awaited_once_with(ctx, ctx.channel, None) + -@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) -class UnsilenceTests(unittest.IsolatedAsyncioTestCase): +class UnsilenceTests(SilenceTest): """Tests for the unsilence command and its related helper methods.""" - @autospec(silence.Silence, "_reschedule", pass_mocks=False) - @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot(get_channel=lambda _: MockTextChannel()) - self.cog = silence.Silence(self.bot) - self.cog._init_task = asyncio.Future() - self.cog._init_task.set_result(None) - - overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) - self.cog.previous_overwrites = overwrites_cache - - asyncio.run(self.cog._async_init()) # Populate instance attributes. + super().setUp() self.cog.scheduler.__contains__.return_value = True - overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' - self.channel = MockTextChannel() - self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - self.channel.overwrites_for.return_value = self.overwrite + self.text_channel = MockTextChannel() + self.text_overwrite = PermissionOverwrite(send_messages=False, add_reactions=False) + self.text_channel.overwrites_for.return_value = self.text_overwrite + + self.voice_channel = MockVoiceChannel() + self.voice_overwrite = PermissionOverwrite(connect=True, speak=True) + self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -401,93 +695,251 @@ async def test_sent_correct_message(self): test_cases = ( (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), - (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, self.text_overwrite), (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) - for was_unsilenced, message, overwrite in test_cases: + + targets = (None, MockTextChannel()) + + for (was_unsilenced, message, overwrite), target in itertools.product(test_cases, targets): ctx = MockContext() - with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - ctx.channel.overwrites_for.return_value = overwrite - await self.cog.unsilence.callback(self.cog, ctx) - ctx.channel.send.assert_called_once_with(message) + ctx.channel.overwrites_for.return_value = overwrite + if target: + target.overwrites_for.return_value = overwrite + + with ( + mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced), + mock.patch.object(self.cog, "send_message") as send_message, + self.subTest(was_unsilenced=was_unsilenced, overwrite=overwrite, target=target), + ): + await self.cog.unsilence.callback(self.cog, ctx, channel=target) + + call_args = (message, ctx.channel, target or ctx.channel) + send_message.assert_awaited_once_with(*call_args, alert_target=was_unsilenced) async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False - self.cog.previous_overwrites.get.return_value = None - channel = MockTextChannel() - self.assertFalse(await self.cog._unsilence(channel)) - channel.set_permissions.assert_not_called() + for channel in (MockVoiceChannel(), MockTextChannel()): + with self.subTest(channel=channel): + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() - async def test_restored_overwrites(self): - """Channel's `send_message` and `add_reactions` overwrites were restored.""" - await self.cog._unsilence(self.channel) - self.channel.set_permissions.assert_awaited_once_with( + async def test_restored_overwrites_text(self): + """Text channel's `send_message` and `add_reactions` overwrites were restored.""" + await self.cog.previous_overwrites.set(self.text_channel.id, '{"send_messages": true, "add_reactions": false}') + await self.cog._unsilence(self.text_channel) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._everyone_role, - overwrite=self.overwrite, + overwrite=self.text_overwrite, ) # Recall that these values are determined by the fixture. - self.assertTrue(self.overwrite.send_messages) - self.assertFalse(self.overwrite.add_reactions) + self.assertTrue(self.text_overwrite.send_messages) + self.assertFalse(self.text_overwrite.add_reactions) + + async def test_restored_overwrites_voice(self): + """Voice channel's `connect` and `speak` overwrites were restored.""" + await self.cog.previous_overwrites.set(self.voice_channel.id, '{"connect": true, "speak": true}') + await self.cog._unsilence(self.voice_channel) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite, + ) - async def test_cache_miss_used_default_overwrites(self): - """Both overwrites were set to None due previous values not being found in the cache.""" - self.cog.previous_overwrites.get.return_value = None + self.assertTrue(self.voice_overwrite.connect) + self.assertTrue(self.voice_overwrite.speak) - await self.cog._unsilence(self.channel) - self.channel.set_permissions.assert_awaited_once_with( + async def test_cache_miss_used_default_overwrites_text(self): + """Text overwrites were set to None due previous values not being found in the cache.""" + + await self.cog._unsilence(self.text_channel) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._everyone_role, - overwrite=self.overwrite, + overwrite=self.text_overwrite, + ) + + self.assertIsNone(self.text_overwrite.send_messages) + self.assertIsNone(self.text_overwrite.add_reactions) + + async def test_cache_miss_used_default_overwrites_voice(self): + """Voice overwrites were set to None due previous values not being found in the cache.""" + + await self.cog._unsilence(self.voice_channel) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite, ) - self.assertIsNone(self.overwrite.send_messages) - self.assertIsNone(self.overwrite.add_reactions) + self.assertIsNone(self.voice_overwrite.connect) + self.assertIsNone(self.voice_overwrite.speak) - async def test_cache_miss_sent_mod_alert(self): - """A message was sent to the mod alerts channel.""" - self.cog.previous_overwrites.get.return_value = None + async def test_cache_miss_sent_mod_alert_text(self): + """A message was sent to the mod alerts channel upon muting a text channel.""" + await self.cog._unsilence(self.text_channel) + self.cog._mod_alerts_channel.send.assert_awaited_once() - await self.cog._unsilence(self.channel) + async def test_cache_miss_sent_mod_alert_voice(self): + """A message was sent to the mod alerts channel upon muting a voice channel.""" + await self.cog._unsilence(MockVoiceChannel()) self.cog._mod_alerts_channel.send.assert_awaited_once() async def test_removed_notifier(self): """Channel was removed from `notifier`.""" - await self.cog._unsilence(self.channel) - self.cog.notifier.remove_channel.assert_called_once_with(self.channel) + with mock.patch.object(silence.SilenceNotifier, "remove_channel"): + await self.cog._unsilence(self.text_channel) + self.cog.notifier.remove_channel.assert_called_once_with(self.text_channel) async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" - await self.cog._unsilence(self.channel) - self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) + await self.cog.previous_overwrites.set(self.text_channel.id, '{"send_messages": true, "add_reactions": false}') + await self.cog._unsilence(self.text_channel) + self.assertEqual(await self.cog.previous_overwrites.get(self.text_channel.id), None) async def test_deleted_cached_time(self): """Channel was deleted from the timestamp cache.""" - await self.cog._unsilence(self.channel) - self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) + await self.cog.unsilence_timestamps.set(self.text_channel.id, 100) + await self.cog._unsilence(self.text_channel) + self.assertEqual(await self.cog.unsilence_timestamps.get(self.text_channel.id), None) async def test_cancelled_task(self): """The scheduled unsilence task should be cancelled.""" - await self.cog._unsilence(self.channel) - self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + await self.cog._unsilence(self.text_channel) + self.cog.scheduler.cancel.assert_called_once_with(self.text_channel.id) - async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed, including cache misses.""" + async def test_preserved_other_overwrites_text(self): + """Text channel's other unrelated overwrites were not changed, including cache misses.""" for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): with self.subTest(overwrite_json=overwrite_json): - self.cog.previous_overwrites.get.return_value = overwrite_json + if overwrite_json is None: + await self.cog.previous_overwrites.delete(self.text_channel.id) + else: + await self.cog.previous_overwrites.set(self.text_channel.id, overwrite_json) - prev_overwrite_dict = dict(self.overwrite) - await self.cog._unsilence(self.channel) - new_overwrite_dict = dict(self.overwrite) + prev_overwrite_dict = dict(self.text_overwrite) + await self.cog._unsilence(self.text_channel) + new_overwrite_dict = dict(self.text_overwrite) # Remove these keys because they were modified by the unsilence. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] + del prev_overwrite_dict["send_messages"] + del prev_overwrite_dict["add_reactions"] + del new_overwrite_dict["send_messages"] + del new_overwrite_dict["add_reactions"] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + + async def test_preserved_other_overwrites_voice(self): + """Voice channel's other unrelated overwrites were not changed, including cache misses.""" + for overwrite_json in ('{"connect": true, "speak": true}', None): + with self.subTest(overwrite_json=overwrite_json): + if overwrite_json is None: + await self.cog.previous_overwrites.delete(self.voice_channel.id) + else: + await self.cog.previous_overwrites.set(self.voice_channel.id, overwrite_json) + + prev_overwrite_dict = dict(self.voice_overwrite) + await self.cog._unsilence(self.voice_channel) + new_overwrite_dict = dict(self.voice_overwrite) + + # Remove these keys because they were modified by the unsilence. + del prev_overwrite_dict["connect"] + del prev_overwrite_dict["speak"] + del new_overwrite_dict["connect"] + del new_overwrite_dict["speak"] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + + async def test_unsilence_role(self): + """Tests unsilence_wrapper applies permission to the correct role.""" + test_cases = ( + (MockTextChannel(), self.cog.bot.get_guild(Guild.id).default_role), + (MockVoiceChannel(), self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified)) + ) + + for channel, role in test_cases: + with self.subTest(channel=channel, role=role): + await self.cog._unsilence_wrapper(channel, MockContext()) + channel.overwrites_for.assert_called_with(role) + + +class SendMessageTests(unittest.IsolatedAsyncioTestCase): + """Unittests for the send message helper function.""" + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + + self.text_channels = [MockTextChannel() for _ in range(2)] + self.bot.get_channel.return_value = self.text_channels[1] + + self.voice_channel = MockVoiceChannel() + + async def test_send_to_channel(self): + """Tests a basic case for the send function.""" + message = "Test basic message." + await self.cog.send_message(message, *self.text_channels, alert_target=False) + + self.text_channels[0].send.assert_awaited_once_with(message) + self.text_channels[1].send.assert_not_called() + + async def test_send_to_multiple_channels(self): + """Tests sending messages to two channels.""" + message = "Test basic message." + await self.cog.send_message(message, *self.text_channels, alert_target=True) + + self.text_channels[0].send.assert_awaited_once_with(message) + self.text_channels[1].send.assert_awaited_once_with(message) + + async def test_duration_replacement(self): + """Tests that the channel name was set correctly for one target channel.""" + message = "Current. The following should be replaced: {channel}." + await self.cog.send_message(message, *self.text_channels, alert_target=False) + + updated_message = message.format(channel=self.text_channels[0].mention) + self.text_channels[0].send.assert_awaited_once_with(updated_message) + self.text_channels[1].send.assert_not_called() + + async def test_name_replacement_multiple_channels(self): + """Tests that the channel name was set correctly for two channels.""" + message = "Current. The following should be replaced: {channel}." + await self.cog.send_message(message, *self.text_channels, alert_target=True) + + self.text_channels[0].send.assert_awaited_once_with(message.format(channel=self.text_channels[0].mention)) + self.text_channels[1].send.assert_awaited_once_with(message.format(channel="current channel")) + + async def test_silence_voice(self): + """Tests that the correct message was sent when a voice channel is muted without alerting.""" + message = "This should show up just here." + await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=False) + self.text_channels[0].send.assert_awaited_once_with(message) + self.text_channels[1].send.assert_not_called() + + async def test_silence_voice_alert(self): + """Tests that the correct message was sent when a voice channel is muted with alerts.""" + with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: + mock_voice_channels.get.return_value = self.text_channels[1].id + + message = "This should show up as {channel}." + await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True) + + updated_message = message.format(channel=self.voice_channel.mention) + self.text_channels[0].send.assert_awaited_once_with(updated_message) + self.text_channels[1].send.assert_awaited_once_with(updated_message) + + mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) + + async def test_silence_voice_sibling_channel(self): + """Tests silencing a voice channel from the related text channel.""" + with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: + mock_voice_channels.get.return_value = self.text_channels[1].id + + message = "This should show up as {channel}." + await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True) + + updated_message = message.format(channel=self.voice_channel.mention) + self.text_channels[1].send.assert_awaited_once_with(updated_message) + + mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) + self.bot.get_channel.assert_called_once_with(self.text_channels[1].id) diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py index 5483b7a64a..d75fcd2f11 100644 --- a/tests/bot/exts/moderation/test_slowmode.py +++ b/tests/bot/exts/moderation/test_slowmode.py @@ -1,14 +1,16 @@ -import unittest +import asyncio +import datetime from unittest import mock from dateutil.relativedelta import relativedelta from bot.constants import Emojis from bot.exts.moderation.slowmode import Slowmode +from tests.base import RedisTestCase from tests.helpers import MockBot, MockContext, MockTextChannel -class SlowmodeTests(unittest.IsolatedAsyncioTestCase): +class SlowmodeTests(RedisTestCase): def setUp(self) -> None: self.bot = MockBot() @@ -17,24 +19,24 @@ def setUp(self) -> None: async def test_get_slowmode_no_channel(self) -> None: """Get slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) + self.ctx.channel = MockTextChannel(name="python-general", slowmode_delay=5) await self.cog.get_slowmode(self.cog, self.ctx, None) self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") async def test_get_slowmode_with_channel(self) -> None: """Get slowmode with a given channel.""" - text_channel = MockTextChannel(name='python-language', slowmode_delay=2) + text_channel = MockTextChannel(name="python-language", slowmode_delay=2) await self.cog.get_slowmode(self.cog, self.ctx, text_channel) - self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + self.ctx.send.assert_called_once_with("The slowmode delay for #python-language is 2 seconds.") async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" test_cases = ( - ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ("helpers", 23, True, f"{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds."), + ("mods", 76526, False, f"{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours."), + ("admins", 97, True, f"{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.") ) for channel_name, seconds, edited, result_msg in test_cases: @@ -60,9 +62,9 @@ async def test_set_slowmode_no_channel(self) -> None: async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" test_cases = ( - ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ("bot-commands", 12, True, f"{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds."), + ("mod-spam", 21, True, f"{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds."), + ("admin-spam", 4323598, False, f"{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.") ) for channel_name, seconds, edited, result_msg in test_cases: @@ -87,7 +89,7 @@ async def test_set_slowmode_with_channel(self) -> None: async def test_reset_slowmode_sets_delay_to_zero(self) -> None: """Reset slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta', slowmode_delay=1) + text_channel = MockTextChannel(name="meta", slowmode_delay=1) self.cog.set_slowmode = mock.AsyncMock() await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) @@ -95,6 +97,119 @@ async def test_reset_slowmode_sets_delay_to_zero(self) -> None: self.ctx, text_channel, relativedelta(seconds=0) ) + @mock.patch("bot.exts.moderation.slowmode.datetime") + async def test_set_slowmode_with_expiry(self, mock_datetime) -> None: + """Set slowmode with an expiry""" + fixed_datetime = datetime.datetime(2025, 6, 2, 12, 0, 0, tzinfo=datetime.UTC) + mock_datetime.now.return_value = fixed_datetime + + test_cases = ( + ("python-general", 6, 6000, f"{Emojis.check_mark} The slowmode delay for #python-general is now 6 seconds " + "and will revert to 0 seconds ."), + ("mod-spam", 5, 600, f"{Emojis.check_mark} The slowmode delay for #mod-spam is now 5 seconds and will " + "revert to 0 seconds ."), + ("changelog", 12, 7200, f"{Emojis.check_mark} The slowmode delay for #changelog is now 12 seconds and will " + "revert to 0 seconds .") + ) + for channel_name, seconds, expiry, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + expiry=expiry, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name, slowmode_delay=0) + await self.cog.set_slowmode( + self.cog, + self.ctx, + text_channel, + relativedelta(seconds=seconds), + fixed_datetime + relativedelta(seconds=expiry) + ) + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + self.ctx.send.assert_called_once_with(result_msg) + self.ctx.reset_mock() + + async def test_callback_scheduled(self): + """Schedule slowmode to be reverted""" + self.cog.scheduler=mock.MagicMock(wraps=self.cog.scheduler) + + text_channel = MockTextChannel(name="python-general", slowmode_delay=2, id=123) + expiry = datetime.datetime.now(tz=datetime.UTC) + relativedelta(seconds=10) + await self.cog.set_slowmode( + self.cog, + self.ctx, + text_channel, + relativedelta(seconds=4), + expiry + ) + + args = (expiry, text_channel.id, mock.ANY) + self.cog.scheduler.schedule_at.assert_called_once_with(*args) + + @mock.patch("bot.exts.moderation.slowmode.get_or_fetch_channel") + async def test_revert_slowmode_callback(self, mock_get_or_fetch_channel) -> None: + """Check that the slowmode is reverted""" + text_channel = MockTextChannel(name="python-general", slowmode_delay=2, id=123, jump_url="#python-general") + mod_channel = MockTextChannel(name="mods", id=999, ) + mock_get_or_fetch_channel.side_effect = [text_channel, mod_channel] + + await self.cog.set_slowmode( + self.cog, + self.ctx, + text_channel, + relativedelta(seconds=4), + datetime.datetime.now(tz=datetime.UTC) + relativedelta(seconds=10) + ) + await self.cog._revert_slowmode(text_channel.id) + text_channel.edit.assert_awaited_with(slowmode_delay=2) + mod_channel.send.assert_called_once_with( + f"{Emojis.check_mark} A previously applied slowmode in {text_channel.jump_url} ({text_channel.id}) " + "has expired and has been reverted to 2 seconds." + ) + + async def test_reschedule_slowmodes(self) -> None: + """Does not reschedule if cache is empty""" + self.cog.scheduler.schedule_at = mock.MagicMock() + self.cog._reschedule = mock.AsyncMock() + await self.cog.cog_unload() + await self.cog.cog_load() + + self.cog._reschedule.assert_called() + self.cog.scheduler.schedule_at.assert_not_called() + + async def test_reschedule_upon_reload(self) -> None: + """ Check that method `_reschedule` is called upon cog reload""" + self.cog._reschedule = mock.AsyncMock(wraps=self.cog._reschedule) + await self.cog.cog_unload() + await self.cog.cog_load() + + self.cog._reschedule.assert_called() + + async def test_reschedules_slowmodes(self) -> None: + """Slowmodes are loaded from cache at cog reload and scheduled to be reverted.""" + + now = datetime.datetime.now(tz=datetime.UTC) + channels = {} + slowmodes = ( + (123, (now - datetime.timedelta(minutes=10)), 2), # expiration in the past + (456, (now + datetime.timedelta(minutes=20)), 4), # expiration in the future + ) + for channel_id, expiration_datetime, delay in slowmodes: + channel = MockTextChannel(slowmode_delay=delay, id=channel_id) + channels[channel_id] = channel + await self.cog.slowmode_cache.set(channel_id, f"{delay}, {expiration_datetime}") + + self.bot.get_channel = mock.MagicMock(side_effect=lambda channel_id: channels.get(channel_id)) + await self.cog.cog_unload() + await self.cog.cog_load() + for channel_id in channels: + self.assertIn(channel_id, self.cog.scheduler) + + await asyncio.sleep(1) # give scheduled task time to execute + channels[123].edit.assert_awaited_once_with(slowmode_delay=channels[123].slowmode_delay) + channels[456].edit.assert_not_called() + @mock.patch("bot.exts.moderation.slowmode.has_any_role") @mock.patch("bot.exts.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) async def test_cog_check(self, role_check): diff --git a/tests/bot/exts/recruitment/__init__.py b/tests/bot/exts/recruitment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bot/exts/recruitment/talentpool/test_review.py b/tests/bot/exts/recruitment/talentpool/test_review.py new file mode 100644 index 0000000000..8ec384bb28 --- /dev/null +++ b/tests/bot/exts/recruitment/talentpool/test_review.py @@ -0,0 +1,263 @@ +import unittest +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, Mock, patch + +from bot.exts.recruitment.talentpool import _review +from tests.helpers import MockBot, MockMember, MockMessage, MockReaction, MockTextChannel + + +class AsyncIterator: + """Normal->Async iterator helper.""" + + def __init__(self, seq): + self.iter = iter(seq) + + def __aiter__(self): + return self + + # Allows it to be used to mock the discord TextChannel.history function + def __call__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + +def nomination( + inserted_at: datetime, + num_entries: int, + reviewed: bool = False, + id: int | None = None, + msg_count: int = 1000, +) -> Mock: + id = id or MockMember().id + return Mock( + id=id or MockMember().id, + user_id=id, + inserted_at=inserted_at, + entries=[Mock() for _ in range(num_entries)], + reviewed=reviewed, + _msg_count=msg_count, + ) + + +class ReviewerTests(unittest.IsolatedAsyncioTestCase): + """Tests for the talentpool reviewer.""" + + def setUp(self): + self.bot_user = MockMember(bot=True) + self.bot = MockBot(user=self.bot_user) + + self.voting_channel = MockTextChannel() + self.bot.get_channel = Mock(return_value=self.voting_channel) + + self.nomination_api = Mock(name="MockNominationAPI") + self.reviewer = _review.Reviewer(self.bot, self.nomination_api) + + @patch("bot.exts.recruitment.talentpool._review.MAX_ONGOING_REVIEWS", 3) + @patch("bot.exts.recruitment.talentpool._review.MIN_REVIEW_INTERVAL", timedelta(days=1)) + async def test_is_ready_for_review(self): + """Tests for the `is_ready_for_review` function.""" + too_recent = datetime.now(UTC) - timedelta(hours=1) + not_too_recent = datetime.now(UTC) - timedelta(days=7) + ticket_reaction = MockReaction(users=[self.bot_user], emoji="\N{TICKET}") + + cases = ( + # Only one active review, and not too recent, so ready. + ( + [ + MockMessage(author=self.bot_user, content="wookie for Helper!", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), + ], + not_too_recent.timestamp(), + True, + ), + + # Three active reviews, so not ready. + ( + [ + MockMessage(author=self.bot_user, content="Chrisjl for Helper!", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="Zig for Helper!", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="Scaleios for Helper!", created_at=not_too_recent), + ], + not_too_recent.timestamp(), + False, + ), + + # Only one active review, but too recent, so not ready. + ( + [ + MockMessage(author=self.bot_user, content="Chrisjl for Helper!", created_at=too_recent), + ], + too_recent.timestamp(), + False, + ), + + # Only two active reviews, and not too recent, so ready. + ( + [ + MockMessage(author=self.bot_user, content="Not a review", created_at=too_recent), + MockMessage(author=self.bot_user, content="wookie for Helper!", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="wookie for Helper!", created_at=not_too_recent), + MockMessage(author=self.bot_user, content="Not a review", created_at=not_too_recent), + ], + not_too_recent.timestamp(), + True, + ), + + # Over the active threshold, but below the total threshold + ( + [ + MockMessage( + author=self.bot_user, + content="joe for Helper!", + created_at=not_too_recent, + reactions=[ticket_reaction] + ) + ] * 6, + not_too_recent.timestamp(), + True + ), + + # Over the total threshold + ( + [ + MockMessage( + author=self.bot_user, + content="joe for Helper!", + created_at=not_too_recent, + reactions=[ticket_reaction] + ) + ] * 11, + not_too_recent.timestamp(), + False + ), + + # No messages, so ready. + ([], None, True), + ) + + for messages, last_review_timestamp, expected in cases: + with self.subTest(messages=messages, expected=expected): + self.voting_channel.history = AsyncIterator(messages) + + cache_get_mock = AsyncMock(return_value=last_review_timestamp) + self.reviewer.status_cache.get = cache_get_mock + + res = await self.reviewer.is_ready_for_review() + + self.assertIs(res, expected) + cache_get_mock.assert_called_with("last_vote_date") + + @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=7)) + async def test_get_nomination_to_review(self): + """Test get_nomination_to_review function.""" + now = datetime.now(UTC) + + # Each case contains a list of nominations, followed by the index in that list + # of the one that should be selected, or None if None should be returned + cases = [ + # Don't send if too recent, already reviewed, or no recent messages. + ( + [ + nomination(now - timedelta(days=1), 5), + nomination(now - timedelta(days=10), 5, reviewed=True), + nomination(now - timedelta(days=10), 5, msg_count=0), + + ], + None, + ), + + # First one has most entries so should be returned. + ( + [ + nomination(now - timedelta(days=10), 6), + nomination(now - timedelta(days=10), 5), + nomination(now - timedelta(days=9), 5), + nomination(now - timedelta(days=11), 5), + ], + 0, + ), + + # Same number of entries so oldest (second) should be returned. + ( + [ + nomination(now - timedelta(days=1), 2), + nomination(now - timedelta(days=80), 2), + nomination(now - timedelta(days=79), 2), + ], + 1, + ), + ] + + for (case_num, (nominations, expected)) in enumerate(cases, 1): + with self.subTest(case_num=case_num): + get_nominations_mock = AsyncMock(return_value=nominations) + self.nomination_api.get_nominations = get_nominations_mock + + activity = {nomination.id: nomination._msg_count for nomination in nominations} + get_activity_mock = AsyncMock(return_value=activity) + self.nomination_api.get_activity = get_activity_mock + + res = await self.reviewer.get_nomination_to_review() + + if expected is None: + self.assertIsNone(res) + else: + self.assertEqual(res, nominations[expected]) + get_nominations_mock.assert_called_once_with(active=True) + get_activity_mock.assert_called_once() + + @patch("bot.exts.recruitment.talentpool._review.MIN_NOMINATION_TIME", timedelta(days=0)) + async def test_get_nomination_to_review_order(self): + now = datetime.now(UTC) + + # Each case in cases is a list of nominations in the order they should be chosen from first to last + cases = [ + [ + nomination(now - timedelta(days=10), 3, id=1), + nomination(now - timedelta(days=50), 2, id=2), + nomination(now - timedelta(days=100), 1, id=3), + ], + [ + nomination(now - timedelta(days=100), 2, id=1), + nomination(now - timedelta(days=10), 3, id=2), + nomination(now - timedelta(days=80), 1, id=3), + nomination(now - timedelta(days=79), 1, id=4), + nomination(now - timedelta(days=10), 2, id=5), + ], + [ + nomination(now - timedelta(days=200), 8, id=1), + nomination(now - timedelta(days=359), 4, id=2), + nomination(now - timedelta(days=230), 5, id=3), + nomination(now - timedelta(days=331), 3, id=4), + nomination(now - timedelta(days=113), 5, id=5), + nomination(now - timedelta(days=186), 3, id=6), + nomination(now - timedelta(days=272), 2, id=7), + nomination(now - timedelta(days=30), 4, id=8), + nomination(now - timedelta(days=198), 2, id=9), + nomination(now - timedelta(days=270), 1, id=10), + nomination(now - timedelta(days=140), 1, id=11), + nomination(now - timedelta(days=19), 2, id=12), + nomination(now - timedelta(days=30), 1, id=13), + ] + ] + + for case_num, case in enumerate(cases, 1): + with self.subTest(case_num=case_num): + for i in range(len(case)): + with self.subTest(nomination_num=i+1): + get_nominations_mock = AsyncMock(return_value=case[i:]) + self.nomination_api.get_nominations = get_nominations_mock + + activity = {nomination.id: nomination._msg_count for nomination in case} + get_activity_mock = AsyncMock(return_value=activity) + self.nomination_api.get_activity = get_activity_mock + + res = await self.reviewer.get_nomination_to_review() + self.assertEqual(res, case[i]) + get_nominations_mock.assert_called_once_with(active=True) diff --git a/tests/bot/exts/test_cogs.py b/tests/bot/exts/test_cogs.py index f8e1202620..0c117b3e66 100644 --- a/tests/bot/exts/test_cogs.py +++ b/tests/bot/exts/test_cogs.py @@ -50,7 +50,7 @@ def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: yield obj @staticmethod - def get_qualified_names(command: commands.Command) -> t.List[str]: + def get_qualified_names(command: commands.Command) -> list[str]: """Return a list of all qualified names, including aliases, for the `command`.""" names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) @@ -62,8 +62,7 @@ def get_all_commands(self) -> t.Iterator[commands.Command]: """Yield all commands for all cogs in all extensions.""" for module in self.walk_modules(): for cog in self.walk_cogs(module): - for cmd in self.walk_commands(cog): - yield cmd + yield from self.walk_commands(cog) def test_names_dont_shadow(self): """Names and aliases of commands should be unique.""" diff --git a/tests/bot/exts/utils/snekbox/__init__.py b/tests/bot/exts/utils/snekbox/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/bot/exts/utils/snekbox/test_io.py b/tests/bot/exts/utils/snekbox/test_io.py new file mode 100644 index 0000000000..4f7f49a5e9 --- /dev/null +++ b/tests/bot/exts/utils/snekbox/test_io.py @@ -0,0 +1,34 @@ +from unittest import TestCase + +# noinspection PyProtectedMember +from bot.exts.utils.snekbox import _io + + +class SnekboxIOTests(TestCase): + # noinspection SpellCheckingInspection + def test_normalize_file_name(self): + """Invalid file names should be normalized.""" + cases = [ + # ANSI escape sequences -> underscore + (r"\u001b[31mText", "_Text"), + # (Multiple consecutive should be collapsed to one underscore) + (r"a\u001b[35m\u001b[37mb", "a_b"), + # Backslash escaped chars -> underscore + (r"\n", "_"), + (r"\r", "_"), + (r"A\0\tB", "A__B"), + # Any other disallowed chars -> underscore + (r"\\.txt", "_.txt"), + (r"A!@#$%^&*B, C()[]{}+=D.txt", "A_B_C_D.txt"), + (" ", "_"), + # Normal file names should be unchanged + ("legal_file-name.txt", "legal_file-name.txt"), + ("_-.", "_-."), + ] + for name, expected in cases: + with self.subTest(name=name, expected=expected): + # Test function directly + self.assertEqual(_io.normalize_discord_file_name(name), expected) + # Test FileAttachment.to_file() + obj = _io.FileAttachment(name, b"") + self.assertEqual(obj.to_file().filename, expected) diff --git a/tests/bot/exts/utils/snekbox/test_snekbox.py b/tests/bot/exts/utils/snekbox/test_snekbox.py new file mode 100644 index 0000000000..7a0a703858 --- /dev/null +++ b/tests/bot/exts/utils/snekbox/test_snekbox.py @@ -0,0 +1,535 @@ +import asyncio +import unittest +from base64 import b64encode +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch + +from discord import AllowedMentions +from discord.ext import commands +from pydis_core.utils.paste_service import MAX_PASTE_SIZE + +from bot import constants +from bot.errors import LockedResourceError +from bot.exts.utils import snekbox +from bot.exts.utils.snekbox import EvalJob, EvalResult, Snekbox +from bot.exts.utils.snekbox._constants import DEFAULT_PYTHON_VERSION +from bot.exts.utils.snekbox._io import FileAttachment +from tests.helpers import MockBot, MockContext, MockMember, MockMessage, MockReaction, MockUser + + +class SnekboxTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + """Add mocked bot and cog to the instance.""" + self.bot = MockBot() + self.cog = Snekbox(bot=self.bot) + self.job = EvalJob.from_code("import random") + self.default_version = DEFAULT_PYTHON_VERSION + + @staticmethod + def code_args(code: str) -> tuple[EvalJob]: + """Converts code to a tuple of arguments expected.""" + return EvalJob.from_code(code), + + async def test_post_job(self): + """Post the eval code to the URLs.snekbox_eval_api endpoint.""" + resp = MagicMock() + resp.json = AsyncMock(return_value={"stdout": "Hi", "returncode": 137, "files": []}) + + context_manager = MagicMock() + context_manager.__aenter__.return_value = resp + self.bot.http_session.post.return_value = context_manager + py_version = self.default_version + job = EvalJob.from_code("import random").as_version(py_version) + self.assertEqual(await self.cog.post_job(job), EvalResult("Hi", 137)) + + expected = { + "args": ["main.py"], + "files": [ + { + "path": "main.py", + "content": b64encode(b"import random").decode(), + } + ], + "executable_path": f"/snekbin/python/{py_version}/bin/python", + } + self.bot.http_session.post.assert_called_with( + constants.URLs.snekbox_eval_api, + json=expected, + raise_for_status=True + ) + resp.json.assert_awaited_once() + + @patch( + "bot.exts.utils.snekbox._cog.paste_service._lexers_supported_by_pastebin", + {"https://paste.pythondiscord.com": ["text"]}, + ) + async def test_upload_output_reject_too_long(self): + """Reject output longer than MAX_PASTE_LENGTH.""" + result = await self.cog.upload_output("-" * (MAX_PASTE_SIZE + 1)) + self.assertEqual(result, "too long to upload") + + async def test_codeblock_converter(self): + ctx = MockContext() + cases = ( + ('print("Hello world!")', 'print("Hello world!")', "non-formatted"), + ('`print("Hello world!")`', 'print("Hello world!")', "one line code block"), + ('```\nprint("Hello world!")```', 'print("Hello world!")', "multiline code block"), + ('```py\nprint("Hello world!")```', 'print("Hello world!")', "multiline python code block"), + ('text```print("Hello world!")```text', 'print("Hello world!")', "code block surrounded by text"), + ('```print("Hello world!")```\ntext\n```py\nprint("Hello world!")```', + 'print("Hello world!")\nprint("Hello world!")', "two code blocks with text in-between"), + ('`print("Hello world!")`\ntext\n```print("How\'s it going?")```', + 'print("How\'s it going?")', "code block preceded by inline code"), + ('`print("Hello world!")`\ntext\n`print("Hello world!")`', + 'print("Hello world!")', "one inline code block of two") + ) + for case, expected, testname in cases: + with self.subTest(msg=f"Extract code from {testname}."): + self.assertEqual( + "\n".join(await snekbox.CodeblockConverter.convert(ctx, case)), expected + ) + + def test_prepare_timeit_input(self): + """Test the prepare_timeit_input codeblock detection.""" + base_args = ("-m", "timeit", "-s") + cases = ( + (['print("Hello World")'], "", "single block of code"), + (["x = 1", "print(x)"], "x = 1", "two blocks of code"), + (["x = 1", "print(x)", 'print("Some other code.")'], "x = 1", "three blocks of code") + ) + + for case, setup_code, test_name in cases: + setup = snekbox._cog.TIMEIT_SETUP_WRAPPER.format(setup=setup_code) + expected = [*base_args, setup, "\n".join(case[1:] if setup_code else case)] + with self.subTest(msg=f"Test with {test_name} and expected return {expected}"): + self.assertEqual(self.cog.prepare_timeit_input(case), expected) + + def test_eval_result_message(self): + """EvalResult.get_message(), should return message.""" + cases = ( + ("ERROR", None, (f"Your {self.default_version} eval job has failed", "ERROR", "")), + ( + "", + 128 + snekbox._eval.SIGKILL, + (f"Your {self.default_version} eval job timed out or ran out of memory", "", "") + ), + ("", 255, (f"Your {self.default_version} eval job has failed", "A fatal NsJail error occurred", "")) + ) + for stdout, returncode, expected in cases: + exp_msg, exp_err, exp_files_err = expected + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + result = EvalResult(stdout=stdout, returncode=returncode) + job = EvalJob([]) + # Check all 3 message types + msg = result.get_status_message(job) + self.assertEqual(msg, exp_msg) + error = result.error_message + self.assertEqual(error, exp_err) + files_error = result.files_error_message + self.assertEqual(files_error, exp_files_err) + + @patch("bot.exts.utils.snekbox._eval.FILE_COUNT_LIMIT", 2) + def test_eval_result_files_error_message(self): + """EvalResult.files_error_message, should return files error message.""" + cases = [ + ([], ["abc"], ( + "1 file upload (abc) failed because its file size exceeds 8 MiB." + )), + ([], ["file1.bin", "f2.bin"], ( + "2 file uploads (file1.bin, f2.bin) failed because each file's size exceeds 8 MiB." + )), + (["a", "b"], ["c"], ( + "1 file upload (c) failed as it exceeded the 2 file limit." + )), + (["a"], ["b", "c"], ( + "2 file uploads (b, c) failed as they exceeded the 2 file limit." + )), + ] + for files, failed_files, expected_msg in cases: + with self.subTest(files=files, failed_files=failed_files, expected_msg=expected_msg): + result = EvalResult("", 0, files, failed_files) + msg = result.files_error_message + self.assertIn(expected_msg, msg) + + @patch("bot.exts.utils.snekbox._eval.FILE_COUNT_LIMIT", 2) + def test_eval_result_files_error_str(self): + """EvalResult.files_error_message, should return files error message.""" + cases = [ + # Normal + (["x.ini"], "x.ini"), + (["123456", "879"], "123456, 879"), + # Break on whole name if less than 3 characters remaining + (["12345678", "9"], "12345678, ..."), + # Otherwise break on max chars + (["123", "345", "67890000"], "123, 345, 6789..."), + (["abcdefg1234567"], "abcdefg123..."), + ] + for failed_files, expected in cases: + with self.subTest(failed_files=failed_files, expected=expected): + result = EvalResult("", 0, [], failed_files) + msg = result.get_failed_files_str(char_max=10) + self.assertEqual(msg, expected) + + @patch("bot.exts.utils.snekbox._eval.Signals", side_effect=ValueError) + def test_eval_result_message_invalid_signal(self, _mock_signals: Mock): + result = EvalResult(stdout="", returncode=127) + self.assertEqual( + result.get_status_message(EvalJob([], version="3.10")), + "Your 3.10 eval job has completed with return code 127" + ) + self.assertEqual(result.error_message, "") + self.assertEqual(result.files_error_message, "") + + @patch("bot.exts.utils.snekbox._eval.Signals") + def test_eval_result_message_valid_signal(self, mock_signals: Mock): + mock_signals.return_value.name = "SIGTEST" + result = EvalResult(stdout="", returncode=127) + self.assertEqual( + result.get_status_message(EvalJob([])), + f"Your {self.default_version} eval job has completed with return code 127 (SIGTEST)" + ) + + def test_eval_result_status_emoji(self): + """Return emoji according to the eval result.""" + cases = ( + (" ", -1, ":warning:"), + ("Hello world!", 0, ":white_check_mark:"), + ("Invalid beard size", -1, ":x:") + ) + for stdout, returncode, expected in cases: + with self.subTest(stdout=stdout, returncode=returncode, expected=expected): + result = EvalResult(stdout=stdout, returncode=returncode) + self.assertEqual(result.status_emoji, expected) + + async def test_format_output(self): + """Test output formatting.""" + self.cog.upload_output = AsyncMock(return_value="https://testificate.com/") + + too_many_lines = ( + "001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n" + "007 | n\n008 | g\n009 | b\n010 | e\n... (truncated - too many lines)" + ) + too_long_too_many_lines = ( + "\n".join( + f"{i:03d} | {line}" for i, line in enumerate(["verylongbeard" * 10] * 15, 1) + )[:1000] + "\n... (truncated - too long, too many lines)" + ) + + cases = ( + ("", ("[No output]", None), "No output"), + ("My awesome output", ("My awesome output", None), "One line output"), + ("<@", ("<@\u200B", None), r"Convert <@ to <@\u200B"), + (" dict: + """Delay the post_job call to ensure the job runs long enough to conflict.""" + await asyncio.sleep(1) + return {"stdout": "", "returncode": 0} + + self.cog.post_job = AsyncMock(side_effect=delay_with_side_effect) + with self.assertRaises(LockedResourceError): + await asyncio.gather( + self.cog.send_job(ctx, EvalJob.from_code("MyAwesomeCode")), + self.cog.send_job(ctx, EvalJob.from_code("MyAwesomeCode")), + ) + + async def test_send_job(self): + """Test the send_job function.""" + ctx = MockContext() + ctx.send = AsyncMock() + ctx.author = MockUser(mention="@LemonLemonishBeard#0042") + + eval_result = EvalResult("", 0) + self.cog.post_job = AsyncMock(return_value=eval_result) + self.cog.format_output = AsyncMock(return_value=("[No output]", None)) + self.cog.upload_output = AsyncMock() # Should not be called + + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, [])) + self.bot.get_cog.return_value = mocked_filter_cog + + job = EvalJob.from_code("MyAwesomeCode") + await self.cog.send_job(ctx, job), + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], + f":warning: Your {self.default_version} eval job has completed " + "with return code 0.\n\n```ansi\n[No output]\n```" + ) + allowed_mentions = ctx.send.call_args.kwargs["allowed_mentions"] + expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict()) + + self.cog.post_job.assert_called_once_with(job) + self.cog.format_output.assert_called_once_with("") + self.cog.upload_output.assert_not_called() + + async def test_send_job_with_paste_link(self): + """Test the send_job function with a too long output that generate a paste link.""" + ctx = MockContext() + ctx.send = AsyncMock() + + eval_result = EvalResult("Way too long beard", 0) + self.cog.post_job = AsyncMock(return_value=eval_result) + self.cog.format_output = AsyncMock(return_value=("Way too long beard", "lookatmybeard.com")) + + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, [])) + self.bot.get_cog.return_value = mocked_filter_cog + + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) + await self.cog.send_job(ctx, job), + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], + f":white_check_mark: Your {self.default_version} eval job " + "has completed with return code 0." + "\n\n```ansi\nWay too long beard\n```\nFull output: lookatmybeard.com" + ) + + self.cog.post_job.assert_called_once_with(job) + self.cog.format_output.assert_called_once_with("Way too long beard") + + async def test_send_job_with_non_zero_eval(self): + """Test the send_job function with a code returning a non-zero code.""" + ctx = MockContext() + ctx.send = AsyncMock() + + eval_result = EvalResult("ERROR", 127) + self.cog.post_job = AsyncMock(return_value=eval_result) + self.cog.upload_output = AsyncMock() # This function isn't called + + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, [])) + self.bot.get_cog.return_value = mocked_filter_cog + + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) + await self.cog.send_job(ctx, job), + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], + f":x: Your {self.default_version} eval job has completed with return code 127." + "\n\n```ansi\nERROR\n```" + ) + + self.cog.post_job.assert_called_once_with(job) + self.cog.upload_output.assert_not_called() + + async def test_send_job_with_disallowed_file_ext(self): + """Test send_job with disallowed file extensions.""" + ctx = MockContext() + ctx.send = AsyncMock() + + files = [ + FileAttachment("test.disallowed2", b"test"), + FileAttachment("test.disallowed", b"test"), + FileAttachment("test.allowed", b"test"), + FileAttachment("test." + ("a" * 100), b"test") + ] + eval_result = EvalResult("", 0, files=files) + self.cog.post_job = AsyncMock(return_value=eval_result) + self.cog.upload_output = AsyncMock() # This function isn't called + + disallowed_exts = [".disallowed", "." + ("a" * 100), ".disallowed2"] + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_snekbox_output = AsyncMock(return_value=(False, disallowed_exts)) + self.bot.get_cog.return_value = mocked_filter_cog + + job = EvalJob.from_code("MyAwesomeCode").as_version(self.default_version) + await self.cog.send_job(ctx, job), + + ctx.send.assert_called_once() + res = ctx.send.call_args.args[0] + self.assertTrue( + res.startswith(f":white_check_mark: Your {self.default_version} eval job has completed with return code 0.") + ) + self.assertIn("Files with disallowed extensions can't be uploaded: **.disallowed, .disallowed2, ...**", res) + + self.cog.post_job.assert_called_once_with(job) + self.cog.upload_output.assert_not_called() + + @patch("bot.exts.utils.snekbox._cog.partial") + async def test_continue_job_does_continue(self, partial_mock): + """Test that the continue_job function does continue if required conditions are met.""" + ctx = MockContext( + message=MockMessage( + id=4, + add_reaction=AsyncMock(), + clear_reactions=AsyncMock() + ), + author=MockMember(id=14) + ) + response = MockMessage(id=42, delete=AsyncMock()) + new_msg = MockMessage() + self.cog.jobs = {4: 42} + self.bot.wait_for.side_effect = ((None, new_msg), None) + expected = "NewCode" + self.cog.get_code = create_autospec(self.cog.get_code, spec_set=True, return_value=expected) + + actual = await self.cog.continue_job(ctx, response, self.cog.eval_command) + self.cog.get_code.assert_awaited_once_with(new_msg, ctx.command) + self.assertEqual(actual, EvalJob.from_code(expected)) + self.bot.wait_for.assert_has_awaits( + ( + call( + "message_edit", + check=partial_mock(snekbox._cog.predicate_message_edit, ctx), + timeout=snekbox._cog.REDO_TIMEOUT, + ), + call("reaction_add", check=partial_mock(snekbox._cog.predicate_emoji_reaction, ctx), timeout=10) + ) + ) + ctx.message.add_reaction.assert_called_once_with(snekbox._cog.REDO_EMOJI) + ctx.message.clear_reaction.assert_called_once_with(snekbox._cog.REDO_EMOJI) + response.delete.assert_called_once() + + async def test_continue_job_does_not_continue(self): + ctx = MockContext(message=MockMessage(clear_reactions=AsyncMock())) + self.bot.wait_for.side_effect = asyncio.TimeoutError + + actual = await self.cog.continue_job(ctx, MockMessage(), self.cog.eval_command) + self.assertEqual(actual, None) + ctx.message.clear_reaction.assert_called_once_with(snekbox._cog.REDO_EMOJI) + + async def test_get_code(self): + """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" + prefix = constants.Bot.prefix + subtests = ( + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name} print(1)", "print(1)"), + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name}", None), + (MagicMock(spec=commands.Command), f"{prefix}tags get foo"), + (None, "print(123)") + ) + + for command, content, *expected_code in subtests: + if not expected_code: + expected_code = content + else: + [expected_code] = expected_code + + with self.subTest(content=content, expected_code=expected_code): + self.bot.get_context.reset_mock() + self.bot.get_context.return_value = MockContext(command=command) + message = MockMessage(content=content) + + actual_code = await self.cog.get_code(message, self.cog.eval_command) + + self.bot.get_context.assert_awaited_once_with(message) + self.assertEqual(actual_code, expected_code) + + def test_predicate_message_edit(self): + """Test the predicate_message_edit function.""" + msg0 = MockMessage(id=1, content="abc") + msg1 = MockMessage(id=2, content="abcdef") + msg2 = MockMessage(id=1, content="abcdef") + + cases = ( + (msg0, msg0, False, "same ID, same content"), + (msg0, msg1, False, "different ID, different content"), + (msg0, msg2, True, "same ID, different content") + ) + for ctx_msg, new_msg, expected, testname in cases: + with self.subTest(msg=f"Messages with {testname} return {expected}"): + ctx = MockContext(message=ctx_msg) + actual = snekbox._cog.predicate_message_edit(ctx, ctx_msg, new_msg) + self.assertEqual(actual, expected) + + def test_predicate_emoji_reaction(self): + """Test the predicate_emoji_reaction function.""" + valid_reaction = MockReaction(message=MockMessage(id=1)) + valid_reaction.__str__.return_value = snekbox._cog.REDO_EMOJI + valid_ctx = MockContext(message=MockMessage(id=1), author=MockUser(id=2)) + valid_user = MockUser(id=2) + + invalid_reaction_id = MockReaction(message=MockMessage(id=42)) + invalid_reaction_id.__str__.return_value = snekbox._cog.REDO_EMOJI + invalid_user_id = MockUser(id=42) + invalid_reaction_str = MockReaction(message=MockMessage(id=1)) + invalid_reaction_str.__str__.return_value = ":longbeard:" + + cases = ( + (invalid_reaction_id, valid_user, False, "invalid reaction ID"), + (valid_reaction, invalid_user_id, False, "invalid user ID"), + (invalid_reaction_str, valid_user, False, "invalid reaction __str__"), + (valid_reaction, valid_user, True, "matching attributes") + ) + for reaction, user, expected, testname in cases: + with self.subTest(msg=f"Test with {testname} and expected return {expected}"): + actual = snekbox._cog.predicate_emoji_reaction(valid_ctx, reaction, user) + self.assertEqual(actual, expected) + + +class SnekboxSetupTests(unittest.IsolatedAsyncioTestCase): + """Tests setup of the `Snekbox` cog.""" + + async def test_setup(self): + """Setup of the extension should call add_cog.""" + bot = MockBot() + await snekbox.setup(bot) + bot.add_cog.assert_awaited_once() diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py deleted file mode 100644 index 85d6a1173c..0000000000 --- a/tests/bot/exts/utils/test_jams.py +++ /dev/null @@ -1,171 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel - -from bot.constants import Roles -from bot.exts.utils import jams -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) - - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) - - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - for categories in subtests: - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: - self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) - - async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) - - self.assertEqual("foobar-channel", actual) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - self.guild.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] - - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) - - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py deleted file mode 100644 index 321a924457..0000000000 --- a/tests/bot/exts/utils/test_snekbox.py +++ /dev/null @@ -1,384 +0,0 @@ -import asyncio -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch - -from discord.ext import commands - -from bot import constants -from bot.exts.utils import snekbox -from bot.exts.utils.snekbox import Snekbox -from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser - - -class SnekboxTests(unittest.IsolatedAsyncioTestCase): - def setUp(self): - """Add mocked bot and cog to the instance.""" - self.bot = MockBot() - self.cog = Snekbox(bot=self.bot) - - async def test_post_eval(self): - """Post the eval code to the URLs.snekbox_eval_api endpoint.""" - resp = MagicMock() - resp.json = AsyncMock(return_value="return") - - context_manager = MagicMock() - context_manager.__aenter__.return_value = resp - self.bot.http_session.post.return_value = context_manager - - self.assertEqual(await self.cog.post_eval("import random"), "return") - self.bot.http_session.post.assert_called_with( - constants.URLs.snekbox_eval_api, - json={"input": "import random"}, - raise_for_status=True - ) - resp.json.assert_awaited_once() - - async def test_upload_output_reject_too_long(self): - """Reject output longer than MAX_PASTE_LEN.""" - result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1)) - self.assertEqual(result, "too long to upload") - - @patch("bot.exts.utils.snekbox.send_to_paste_service") - async def test_upload_output(self, mock_paste_util): - """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" - await self.cog.upload_output("Test output.") - mock_paste_util.assert_called_once_with("Test output.", extension="txt") - - def test_prepare_input(self): - cases = ( - ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), - ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), - ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), - ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), - ('text```print("Hello world!")```text', 'print("Hello world!")', 'code block surrounded by text'), - ('```print("Hello world!")```\ntext\n```py\nprint("Hello world!")```', - 'print("Hello world!")\nprint("Hello world!")', 'two code blocks with text in-between'), - ('`print("Hello world!")`\ntext\n```print("How\'s it going?")```', - 'print("How\'s it going?")', 'code block preceded by inline code'), - ('`print("Hello world!")`\ntext\n`print("Hello world!")`', - 'print("Hello world!")', 'one inline code block of two') - ) - for case, expected, testname in cases: - with self.subTest(msg=f'Extract code from {testname}.'): - self.assertEqual(self.cog.prepare_input(case), expected) - - def test_get_results_message(self): - """Return error and message according to the eval result.""" - cases = ( - ('ERROR', None, ('Your eval job has failed', 'ERROR')), - ('', 128 + snekbox.SIGKILL, ('Your eval job timed out or ran out of memory', '')), - ('', 255, ('Your eval job has failed', 'A fatal NsJail error occurred')) - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - @patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError) - def test_get_results_message_invalid_signal(self, mock_signals: Mock): - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127', '') - ) - - @patch('bot.exts.utils.snekbox.Signals') - def test_get_results_message_valid_signal(self, mock_signals: Mock): - mock_signals.return_value.name = 'SIGTEST' - self.assertEqual( - self.cog.get_results_message({'stdout': '', 'returncode': 127}), - ('Your eval job has completed with return code 127 (SIGTEST)', '') - ) - - def test_get_status_emoji(self): - """Return emoji according to the eval result.""" - cases = ( - (' ', -1, ':warning:'), - ('Hello world!', 0, ':white_check_mark:'), - ('Invalid beard size', -1, ':x:') - ) - for stdout, returncode, expected in cases: - with self.subTest(stdout=stdout, returncode=returncode, expected=expected): - actual = self.cog.get_status_emoji({'stdout': stdout, 'returncode': returncode}) - self.assertEqual(actual, expected) - - async def test_format_output(self): - """Test output formatting.""" - self.cog.upload_output = AsyncMock(return_value='https://testificate.com/') - - too_many_lines = ( - '001 | v\n002 | e\n003 | r\n004 | y\n005 | l\n006 | o\n' - '007 | n\n008 | g\n009 | b\n010 | e\n011 | a\n... (truncated - too many lines)' - ) - too_long_too_many_lines = ( - "\n".join( - f"{i:03d} | {line}" for i, line in enumerate(['verylongbeard' * 10] * 15, 1) - )[:1000] + "\n... (truncated - too long, too many lines)" - ) - - cases = ( - ('', ('[No output]', None), 'No output'), - ('My awesome output', ('My awesome output', None), 'One line output'), - ('<@', ("<@\u200B", None), r'Convert <@ to <@\u200B'), - (' None: - """Run all `cases` against `self.apply` expecting them to pass.""" - for recent_messages in cases: - last_message = recent_messages[0] - - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - config=self.config, - ): - self.assertIsNone( - await self.apply(last_message, recent_messages, self.config) - ) - - async def run_disallowed(self, cases: Tuple[DisallowedCase, ...]) -> None: - """Run all `cases` against `self.apply` expecting them to fail.""" - for case in cases: - recent_messages, culprits, n_violations = case - last_message = recent_messages[0] - relevant_messages = self.relevant_messages(case) - desired_output = ( - self.get_report(case), - culprits, - relevant_messages, - ) - - with self.subTest( - last_message=last_message, - recent_messages=recent_messages, - relevant_messages=relevant_messages, - n_violations=n_violations, - config=self.config, - ): - self.assertTupleEqual( - await self.apply(last_message, recent_messages, self.config), - desired_output, - ) - - @abstractmethod - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - """Give expected relevant messages for `case`.""" - raise NotImplementedError # pragma: no cover - - @abstractmethod - def get_report(self, case: DisallowedCase) -> str: - """Give expected error report for `case`.""" - raise NotImplementedError # pragma: no cover diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py deleted file mode 100644 index d7e7792214..0000000000 --- a/tests/bot/rules/test_attachments.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Iterable - -from bot.rules import attachments -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, total_attachments: int) -> MockMessage: - """Builds a message with `total_attachments` attachments.""" - return MockMessage(author=author, attachments=list(range(total_attachments))) - - -class AttachmentRuleTests(RuleTest): - """Tests applying the `attachments` antispam rule.""" - - def setUp(self): - self.apply = attachments.apply - self.config = {"max": 5, "interval": 10} - - async def test_allows_messages_without_too_many_attachments(self): - """Messages without too many attachments are allowed as-is.""" - cases = ( - [make_msg("bob", 0), make_msg("bob", 0), make_msg("bob", 0)], - [make_msg("bob", 2), make_msg("bob", 2)], - [make_msg("bob", 2), make_msg("alice", 2), make_msg("bob", 2)], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_with_too_many_attachments(self): - """Messages with too many attachments trigger the rule.""" - cases = ( - DisallowedCase( - [make_msg("bob", 4), make_msg("bob", 0), make_msg("bob", 6)], - ("bob",), - 10, - ), - DisallowedCase( - [make_msg("bob", 4), make_msg("alice", 6), make_msg("bob", 2)], - ("bob",), - 6, - ), - DisallowedCase( - [make_msg("alice", 6)], - ("alice",), - 6, - ), - DisallowedCase( - [make_msg("alice", 1) for _ in range(6)], - ("alice",), - 6, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if ( - msg.author == last_message.author - and len(msg.attachments) > 0 - ) - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} attachments in {self.config['interval']}s" diff --git a/tests/bot/rules/test_burst.py b/tests/bot/rules/test_burst.py deleted file mode 100644 index 03682966b8..0000000000 --- a/tests/bot/rules/test_burst.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Iterable - -from bot.rules import burst -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str) -> MockMessage: - """ - Init a MockMessage instance with author set to `author`. - - This serves as a shorthand / alias to keep the test cases visually clean. - """ - return MockMessage(author=author) - - -class BurstRuleTests(RuleTest): - """Tests the `burst` antispam rule.""" - - def setUp(self): - self.apply = burst.apply - self.config = {"max": 2, "interval": 10} - - async def test_allows_messages_within_limit(self): - """Cases which do not violate the rule.""" - cases = ( - [make_msg("bob"), make_msg("bob")], - [make_msg("bob"), make_msg("alice"), make_msg("bob")], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases where the amount of messages exceeds the limit, triggering the rule.""" - cases = ( - DisallowedCase( - [make_msg("bob"), make_msg("bob"), make_msg("bob")], - ("bob",), - 3, - ), - DisallowedCase( - [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], - ("bob",), - 3, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - return tuple(msg for msg in case.recent_messages if msg.author in case.culprits) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} messages in {self.config['interval']}s" diff --git a/tests/bot/rules/test_burst_shared.py b/tests/bot/rules/test_burst_shared.py deleted file mode 100644 index 3275143d5b..0000000000 --- a/tests/bot/rules/test_burst_shared.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Iterable - -from bot.rules import burst_shared -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str) -> MockMessage: - """ - Init a MockMessage instance with the passed arg. - - This serves as a shorthand / alias to keep the test cases visually clean. - """ - return MockMessage(author=author) - - -class BurstSharedRuleTests(RuleTest): - """Tests the `burst_shared` antispam rule.""" - - def setUp(self): - self.apply = burst_shared.apply - self.config = {"max": 2, "interval": 10} - - async def test_allows_messages_within_limit(self): - """ - Cases that do not violate the rule. - - There really isn't more to test here than a single case. - """ - cases = ( - [make_msg("spongebob"), make_msg("patrick")], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases where the amount of messages exceeds the limit, triggering the rule.""" - cases = ( - DisallowedCase( - [make_msg("bob"), make_msg("bob"), make_msg("bob")], - {"bob"}, - 3, - ), - DisallowedCase( - [make_msg("bob"), make_msg("bob"), make_msg("alice"), make_msg("bob")], - {"bob", "alice"}, - 4, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - return case.recent_messages - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} messages in {self.config['interval']}s" diff --git a/tests/bot/rules/test_chars.py b/tests/bot/rules/test_chars.py deleted file mode 100644 index f1e3c76a77..0000000000 --- a/tests/bot/rules/test_chars.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Iterable - -from bot.rules import chars -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, n_chars: int) -> MockMessage: - """Build a message with arbitrary content of `n_chars` length.""" - return MockMessage(author=author, content="A" * n_chars) - - -class CharsRuleTests(RuleTest): - """Tests the `chars` antispam rule.""" - - def setUp(self): - self.apply = chars.apply - self.config = { - "max": 20, # Max allowed sum of chars per user - "interval": 10, - } - - async def test_allows_messages_within_limit(self): - """Cases with a total amount of chars within limit.""" - cases = ( - [make_msg("bob", 0)], - [make_msg("bob", 20)], - [make_msg("bob", 15), make_msg("alice", 15)], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases where the total amount of chars exceeds the limit, triggering the rule.""" - cases = ( - DisallowedCase( - [make_msg("bob", 21)], - ("bob",), - 21, - ), - DisallowedCase( - [make_msg("bob", 15), make_msg("bob", 15)], - ("bob",), - 30, - ), - DisallowedCase( - [make_msg("alice", 15), make_msg("bob", 20), make_msg("alice", 15)], - ("alice",), - 30, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if msg.author == last_message.author - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} characters in {self.config['interval']}s" diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py deleted file mode 100644 index 66c2d9f92d..0000000000 --- a/tests/bot/rules/test_discord_emojis.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Iterable - -from bot.rules import discord_emojis -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - -discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id> -unicode_emoji = "🧪" - - -def make_msg(author: str, n_emojis: int, emoji: str = discord_emoji) -> MockMessage: - """Build a MockMessage instance with content containing `n_emojis` arbitrary emojis.""" - return MockMessage(author=author, content=emoji * n_emojis) - - -class DiscordEmojisRuleTests(RuleTest): - """Tests for the `discord_emojis` antispam rule.""" - - def setUp(self): - self.apply = discord_emojis.apply - self.config = {"max": 2, "interval": 10} - - async def test_allows_messages_within_limit(self): - """Cases with a total amount of discord and unicode emojis within limit.""" - cases = ( - [make_msg("bob", 2)], - [make_msg("alice", 1), make_msg("bob", 2), make_msg("alice", 1)], - [make_msg("bob", 2, unicode_emoji)], - [ - make_msg("alice", 1, unicode_emoji), - make_msg("bob", 2, unicode_emoji), - make_msg("alice", 1, unicode_emoji) - ], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases with more than the allowed amount of discord and unicode emojis.""" - cases = ( - DisallowedCase( - [make_msg("bob", 3)], - ("bob",), - 3, - ), - DisallowedCase( - [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], - ("alice",), - 4, - ), - DisallowedCase( - [make_msg("bob", 3, unicode_emoji)], - ("bob",), - 3, - ), - DisallowedCase( - [ - make_msg("alice", 2, unicode_emoji), - make_msg("bob", 2, unicode_emoji), - make_msg("alice", 2, unicode_emoji) - ], - ("alice",), - 4 - ) - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - return tuple(msg for msg in case.recent_messages if msg.author in case.culprits) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} emojis in {self.config['interval']}s" diff --git a/tests/bot/rules/test_duplicates.py b/tests/bot/rules/test_duplicates.py deleted file mode 100644 index 9bd886a779..0000000000 --- a/tests/bot/rules/test_duplicates.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Iterable - -from bot.rules import duplicates -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, content: str) -> MockMessage: - """Give a MockMessage instance with `author` and `content` attrs.""" - return MockMessage(author=author, content=content) - - -class DuplicatesRuleTests(RuleTest): - """Tests the `duplicates` antispam rule.""" - - def setUp(self): - self.apply = duplicates.apply - self.config = {"max": 2, "interval": 10} - - async def test_allows_messages_within_limit(self): - """Cases which do not violate the rule.""" - cases = ( - [make_msg("alice", "A"), make_msg("alice", "A")], - [make_msg("alice", "A"), make_msg("alice", "B"), make_msg("alice", "C")], # Non-duplicate - [make_msg("alice", "A"), make_msg("bob", "A"), make_msg("alice", "A")], # Different author - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases with too many duplicate messages from the same author.""" - cases = ( - DisallowedCase( - [make_msg("alice", "A"), make_msg("alice", "A"), make_msg("alice", "A")], - ("alice",), - 3, - ), - DisallowedCase( - [make_msg("bob", "A"), make_msg("alice", "A"), make_msg("bob", "A"), make_msg("bob", "A")], - ("bob",), - 3, # 4 duplicate messages, but only 3 from bob - ), - DisallowedCase( - [make_msg("bob", "A"), make_msg("bob", "B"), make_msg("bob", "A"), make_msg("bob", "A")], - ("bob",), - 3, # 4 message from bob, but only 3 duplicates - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if ( - msg.author == last_message.author - and msg.content == last_message.content - ) - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} duplicated messages in {self.config['interval']}s" diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py deleted file mode 100644 index b091bd9d72..0000000000 --- a/tests/bot/rules/test_links.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Iterable - -from bot.rules import links -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, total_links: int) -> MockMessage: - """Makes a message with `total_links` links.""" - content = " ".join(["https://pydis.com"] * total_links) - return MockMessage(author=author, content=content) - - -class LinksTests(RuleTest): - """Tests applying the `links` rule.""" - - def setUp(self): - self.apply = links.apply - self.config = { - "max": 2, - "interval": 10 - } - - async def test_links_within_limit(self): - """Messages with an allowed amount of links.""" - cases = ( - [make_msg("bob", 0)], - [make_msg("bob", 2)], - [make_msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1 - [make_msg("bob", 1), make_msg("bob", 1)], - [make_msg("bob", 2), make_msg("alice", 2)] # Only messages from latest author count - ) - - await self.run_allowed(cases) - - async def test_links_exceeding_limit(self): - """Messages with a a higher than allowed amount of links.""" - cases = ( - DisallowedCase( - [make_msg("bob", 1), make_msg("bob", 2)], - ("bob",), - 3 - ), - DisallowedCase( - [make_msg("alice", 1), make_msg("alice", 1), make_msg("alice", 1)], - ("alice",), - 3 - ), - DisallowedCase( - [make_msg("alice", 2), make_msg("bob", 3), make_msg("alice", 1)], - ("alice",), - 3 - ) - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if msg.author == last_message.author - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} links in {self.config['interval']}s" diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py deleted file mode 100644 index 6444532f24..0000000000 --- a/tests/bot/rules/test_mentions.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Iterable - -from bot.rules import mentions -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, total_mentions: int) -> MockMessage: - """Makes a message with `total_mentions` mentions.""" - return MockMessage(author=author, mentions=list(range(total_mentions))) - - -class TestMentions(RuleTest): - """Tests applying the `mentions` antispam rule.""" - - def setUp(self): - self.apply = mentions.apply - self.config = { - "max": 2, - "interval": 10, - } - - async def test_mentions_within_limit(self): - """Messages with an allowed amount of mentions.""" - cases = ( - [make_msg("bob", 0)], - [make_msg("bob", 2)], - [make_msg("bob", 1), make_msg("bob", 1)], - [make_msg("bob", 1), make_msg("alice", 2)], - ) - - await self.run_allowed(cases) - - async def test_mentions_exceeding_limit(self): - """Messages with a higher than allowed amount of mentions.""" - cases = ( - DisallowedCase( - [make_msg("bob", 3)], - ("bob",), - 3, - ), - DisallowedCase( - [make_msg("alice", 2), make_msg("alice", 0), make_msg("alice", 1)], - ("alice",), - 3, - ), - DisallowedCase( - [make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)], - ("bob",), - 4, - ) - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if msg.author == last_message.author - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} mentions in {self.config['interval']}s" diff --git a/tests/bot/rules/test_newlines.py b/tests/bot/rules/test_newlines.py deleted file mode 100644 index e35377773e..0000000000 --- a/tests/bot/rules/test_newlines.py +++ /dev/null @@ -1,102 +0,0 @@ -from typing import Iterable, List - -from bot.rules import newlines -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, newline_groups: List[int]) -> MockMessage: - """Init a MockMessage instance with `author` and content configured by `newline_groups". - - Configure content by passing a list of ints, where each int `n` will generate - a separate group of `n` newlines. - - Example: - newline_groups=[3, 1, 2] -> content="\n\n\n \n \n\n" - """ - content = " ".join("\n" * n for n in newline_groups) - return MockMessage(author=author, content=content) - - -class TotalNewlinesRuleTests(RuleTest): - """Tests the `newlines` antispam rule against allowed cases and total newline count violations.""" - - def setUp(self): - self.apply = newlines.apply - self.config = { - "max": 5, # Max sum of newlines in relevant messages - "max_consecutive": 3, # Max newlines in one group, in one message - "interval": 10, - } - - async def test_allows_messages_within_limit(self): - """Cases which do not violate the rule.""" - cases = ( - [make_msg("alice", [])], # Single message with no newlines - [make_msg("alice", [1, 2]), make_msg("alice", [1, 1])], # 5 newlines in 2 messages - [make_msg("alice", [2, 2, 1]), make_msg("bob", [2, 3])], # 5 newlines from each author - [make_msg("bob", [1]), make_msg("alice", [5])], # Alice breaks the rule, but only bob is relevant - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_total(self): - """Cases which violate the rule by having too many newlines in total.""" - cases = ( - DisallowedCase( # Alice sends a total of 6 newlines (disallowed) - [make_msg("alice", [2, 2]), make_msg("alice", [2])], - ("alice",), - 6, - ), - DisallowedCase( # Here we test that only alice's newlines count in the sum - [make_msg("alice", [2, 2]), make_msg("bob", [3]), make_msg("alice", [3])], - ("alice",), - 7, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_author = case.recent_messages[0].author - return tuple(msg for msg in case.recent_messages if msg.author == last_author) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} newlines in {self.config['interval']}s" - - -class GroupNewlinesRuleTests(RuleTest): - """ - Tests the `newlines` antispam rule against max consecutive newline violations. - - As these violations yield a different error report, they require a different - `get_report` implementation. - """ - - def setUp(self): - self.apply = newlines.apply - self.config = {"max": 5, "max_consecutive": 3, "interval": 10} - - async def test_disallows_messages_consecutive(self): - """Cases which violate the rule due to having too many consecutive newlines.""" - cases = ( - DisallowedCase( # Bob sends a group of newlines too large - [make_msg("bob", [4])], - ("bob",), - 4, - ), - DisallowedCase( # Alice sends 5 in total (allowed), but 4 in one group (disallowed) - [make_msg("alice", [1]), make_msg("alice", [4])], - ("alice",), - 4, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_author = case.recent_messages[0].author - return tuple(msg for msg in case.recent_messages if msg.author == last_author) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} consecutive newlines in {self.config['interval']}s" diff --git a/tests/bot/rules/test_role_mentions.py b/tests/bot/rules/test_role_mentions.py deleted file mode 100644 index 26c05d5277..0000000000 --- a/tests/bot/rules/test_role_mentions.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Iterable - -from bot.rules import role_mentions -from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage - - -def make_msg(author: str, n_mentions: int) -> MockMessage: - """Build a MockMessage instance with `n_mentions` role mentions.""" - return MockMessage(author=author, role_mentions=[None] * n_mentions) - - -class RoleMentionsRuleTests(RuleTest): - """Tests for the `role_mentions` antispam rule.""" - - def setUp(self): - self.apply = role_mentions.apply - self.config = {"max": 2, "interval": 10} - - async def test_allows_messages_within_limit(self): - """Cases with a total amount of role mentions within limit.""" - cases = ( - [make_msg("bob", 2)], - [make_msg("bob", 1), make_msg("alice", 1), make_msg("bob", 1)], - ) - - await self.run_allowed(cases) - - async def test_disallows_messages_beyond_limit(self): - """Cases with more than the allowed amount of role mentions.""" - cases = ( - DisallowedCase( - [make_msg("bob", 3)], - ("bob",), - 3, - ), - DisallowedCase( - [make_msg("alice", 2), make_msg("bob", 2), make_msg("alice", 2)], - ("alice",), - 4, - ), - ) - - await self.run_disallowed(cases) - - def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: - last_message = case.recent_messages[0] - return tuple( - msg - for msg in case.recent_messages - if msg.author == last_message.author - ) - - def get_report(self, case: DisallowedCase) -> str: - return f"sent {case.n_violations} role mentions in {self.config['interval']}s" diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py deleted file mode 100644 index 76bcb481d3..0000000000 --- a/tests/bot/test_api.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -from bot import api - - -class APIClientTests(unittest.IsolatedAsyncioTestCase): - """Tests for the bot's API client.""" - - @classmethod - def setUpClass(cls): - """Sets up the shared fixtures for the tests.""" - cls.error_api_response = MagicMock() - cls.error_api_response.status = 999 - - def test_response_code_error_default_initialization(self): - """Test the default initialization of `ResponseCodeError` without `text` or `json`""" - error = api.ResponseCodeError(response=self.error_api_response) - - self.assertIs(error.status, self.error_api_response.status) - self.assertEqual(error.response_json, {}) - self.assertEqual(error.response_text, "") - self.assertIs(error.response, self.error_api_response) - - def test_response_code_error_string_representation_default_initialization(self): - """Test the string representation of `ResponseCodeError` initialized without text or json.""" - error = api.ResponseCodeError(response=self.error_api_response) - self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: ") - - def test_response_code_error_initialization_with_json(self): - """Test the initialization of `ResponseCodeError` with json.""" - json_data = {'hello': 'world'} - error = api.ResponseCodeError( - response=self.error_api_response, - response_json=json_data, - ) - self.assertEqual(error.response_json, json_data) - self.assertEqual(error.response_text, "") - - def test_response_code_error_string_representation_with_nonempty_response_json(self): - """Test the string representation of `ResponseCodeError` initialized with json.""" - json_data = {'hello': 'world'} - error = api.ResponseCodeError( - response=self.error_api_response, - response_json=json_data - ) - self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {json_data}") - - def test_response_code_error_initialization_with_text(self): - """Test the initialization of `ResponseCodeError` with text.""" - text_data = 'Lemon will eat your soul' - error = api.ResponseCodeError( - response=self.error_api_response, - response_text=text_data, - ) - self.assertEqual(error.response_text, text_data) - self.assertEqual(error.response_json, {}) - - def test_response_code_error_string_representation_with_nonempty_response_text(self): - """Test the string representation of `ResponseCodeError` initialized with text.""" - text_data = 'Lemon will eat your soul' - error = api.ResponseCodeError( - response=self.error_api_response, - response_text=text_data - ) - self.assertEqual(str(error), f"Status: {self.error_api_response.status} Response: {text_data}") diff --git a/tests/bot/test_constants.py b/tests/bot/test_constants.py index f10d6fbe82..916e1d5bb1 100644 --- a/tests/bot/test_constants.py +++ b/tests/bot/test_constants.py @@ -1,57 +1,41 @@ -import inspect -import typing -import unittest +import os +from pathlib import Path +from unittest import TestCase, mock -from bot import constants +from pydantic import BaseModel +from bot.constants import EnvConfig -def is_annotation_instance(value: typing.Any, annotation: typing.Any) -> bool: - """ - Return True if `value` is an instance of the type represented by `annotation`. +current_path = Path(__file__) +env_file_path = current_path.parent / ".testenv" - This doesn't account for things like Unions or checking for homogenous types in collections. - """ - origin = typing.get_origin(annotation) - # This is done in case a bare e.g. `typing.List` is used. - # In such case, for the assertion to pass, the type needs to be normalised to e.g. `list`. - # `get_origin()` does this normalisation for us. - type_ = annotation if origin is None else origin +class _TestEnvConfig( + EnvConfig, + env_file=env_file_path, +): + """Our default configuration for models that should load from .env files.""" - return isinstance(value, type_) +class NestedModel(BaseModel): + server_name: str -def is_any_instance(value: typing.Any, types: typing.Collection) -> bool: - """Return True if `value` is an instance of any type in `types`.""" - for type_ in types: - if is_annotation_instance(value, type_): - return True - return False +class _TestConfig(_TestEnvConfig, env_prefix="unittests_"): + goat: str + execution_env: str = "local" + nested: NestedModel -class ConstantsTests(unittest.TestCase): + +class ConstantsTests(TestCase): """Tests for our constants.""" + @mock.patch.dict(os.environ, {"UNITTESTS_EXECUTION_ENV": "production"}) def test_section_configuration_matches_type_specification(self): """"The section annotations should match the actual types of the sections.""" - sections = ( - cls - for (name, cls) in inspect.getmembers(constants) - if hasattr(cls, 'section') and isinstance(cls, type) - ) - for section in sections: - for name, annotation in section.__annotations__.items(): - with self.subTest(section=section.__name__, name=name, annotation=annotation): - value = getattr(section, name) - origin = typing.get_origin(annotation) - annotation_args = typing.get_args(annotation) - failure_msg = f"{value} is not an instance of {annotation}" - - if origin is typing.Union: - is_instance = is_any_instance(value, annotation_args) - self.assertTrue(is_instance, failure_msg) - else: - is_instance = is_annotation_instance(value, annotation) - self.assertTrue(is_instance, failure_msg) + testconfig = _TestConfig() + self.assertEqual("volcyy", testconfig.goat) + self.assertEqual("pydis", testconfig.nested.server_name) + self.assertEqual("production", testconfig.execution_env) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 4af84dde52..e5ccf27f78 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -1,19 +1,12 @@ -import datetime import re import unittest +from datetime import MAXYEAR, UTC, datetime from unittest.mock import MagicMock, patch from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument -from bot.converters import ( - Duration, - HushDurationConverter, - ISODateTime, - PackageName, - TagContentConverter, - TagNameConverter, -) +from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName class ConverterTests(unittest.IsolatedAsyncioTestCase): @@ -22,65 +15,13 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): cls.context = MagicMock - cls.context.author = 'bob' + cls.context.author = "bob" - cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - - async def test_tag_content_converter_for_valid(self): - """TagContentConverter should return correct values for valid input.""" - test_values = ( - ('hello', 'hello'), - (' h ello ', 'h ello'), - ) - - for content, expected_conversion in test_values: - with self.subTest(content=content, expected_conversion=expected_conversion): - conversion = await TagContentConverter.convert(self.context, content) - self.assertEqual(conversion, expected_conversion) - - async def test_tag_content_converter_for_invalid(self): - """TagContentConverter should raise the proper exception for invalid input.""" - test_values = ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace."), - ) - - for value, exception_message in test_values: - with self.subTest(tag_content=value, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await TagContentConverter.convert(self.context, value) - - async def test_tag_name_converter_for_valid(self): - """TagNameConverter should return the correct values for valid tag names.""" - test_values = ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) - - for name, expected_conversion in test_values: - with self.subTest(name=name, expected_conversion=expected_conversion): - conversion = await TagNameConverter.convert(self.context, name) - self.assertEqual(conversion, expected_conversion) - - async def test_tag_name_converter_for_invalid(self): - """TagNameConverter should raise the correct exception for invalid tag names.""" - test_values = ( - ('👋', "Don't be ridiculous, you can't use that character!"), - ('', "Tag names should not be empty, or filled with whitespace."), - (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names must contain at least one letter."), - ('x' * 128, "Are you insane? That's way too long!"), - ) - - for invalid_name, exception_message in test_values: - with self.subTest(invalid_name=invalid_name, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await TagNameConverter.convert(self.context, invalid_name) + cls.fixed_utc_now = datetime.fromisoformat("2019-01-01T00:00:00+00:00") async def test_package_name_for_valid(self): """PackageName returns valid package names unchanged.""" - test_values = ('foo', 'le_mon', 'num83r') + test_values = ("foo", "le_mon", "num83r") for name in test_values: with self.subTest(identifier=name): @@ -89,47 +30,46 @@ async def test_package_name_for_valid(self): async def test_package_name_for_invalid(self): """PackageName raises the proper exception for invalid package names.""" - test_values = ('text_with_a_dot.', 'UpperCaseName', 'dashed-name') + test_values = ("text_with_a_dot.", "UpperCaseName", "dashed-name") for name in test_values: - with self.subTest(identifier=name): - with self.assertRaises(BadArgument): - await PackageName.convert(self.context, name) + with self.subTest(identifier=name), self.assertRaises(BadArgument): + await PackageName.convert(self.context, name) async def test_duration_converter_for_valid(self): """Duration returns the correct `datetime` for valid duration strings.""" test_values = ( # Simple duration strings - ('1Y', {"years": 1}), - ('1y', {"years": 1}), - ('1year', {"years": 1}), - ('1years', {"years": 1}), - ('1m', {"months": 1}), - ('1month', {"months": 1}), - ('1months', {"months": 1}), - ('1w', {"weeks": 1}), - ('1W', {"weeks": 1}), - ('1week', {"weeks": 1}), - ('1weeks', {"weeks": 1}), - ('1d', {"days": 1}), - ('1D', {"days": 1}), - ('1day', {"days": 1}), - ('1days', {"days": 1}), - ('1h', {"hours": 1}), - ('1H', {"hours": 1}), - ('1hour', {"hours": 1}), - ('1hours', {"hours": 1}), - ('1M', {"minutes": 1}), - ('1minute', {"minutes": 1}), - ('1minutes', {"minutes": 1}), - ('1s', {"seconds": 1}), - ('1S', {"seconds": 1}), - ('1second', {"seconds": 1}), - ('1seconds', {"seconds": 1}), + ("1Y", {"years": 1}), + ("1y", {"years": 1}), + ("1year", {"years": 1}), + ("1years", {"years": 1}), + ("1m", {"months": 1}), + ("1month", {"months": 1}), + ("1months", {"months": 1}), + ("1w", {"weeks": 1}), + ("1W", {"weeks": 1}), + ("1week", {"weeks": 1}), + ("1weeks", {"weeks": 1}), + ("1d", {"days": 1}), + ("1D", {"days": 1}), + ("1day", {"days": 1}), + ("1days", {"days": 1}), + ("1h", {"hours": 1}), + ("1H", {"hours": 1}), + ("1hour", {"hours": 1}), + ("1hours", {"hours": 1}), + ("1M", {"minutes": 1}), + ("1minute", {"minutes": 1}), + ("1minutes", {"minutes": 1}), + ("1s", {"seconds": 1}), + ("1S", {"seconds": 1}), + ("1second", {"seconds": 1}), + ("1seconds", {"seconds": 1}), # Complex duration strings ( - '1y1m1w1d1H1M1S', + "1y1m1w1d1H1M1S", { "years": 1, "months": 1, @@ -140,13 +80,13 @@ async def test_duration_converter_for_valid(self): "seconds": 1 } ), - ('5y100S', {"years": 5, "seconds": 100}), - ('2w28H', {"weeks": 2, "hours": 28}), + ("5y100S", {"years": 5, "seconds": 100}), + ("2w28H", {"weeks": 2, "hours": 28}), # Duration strings with spaces - ('1 year 2 months', {"years": 1, "months": 2}), - ('1d 2H', {"days": 1, "hours": 2}), - ('1 week2 days', {"weeks": 1, "days": 2}), + ("1 year 2 months", {"years": 1, "months": 2}), + ("1d 2H", {"days": 1, "hours": 2}), + ("1 week2 days", {"weeks": 1, "days": 2}), ) converter = Duration() @@ -154,8 +94,8 @@ async def test_duration_converter_for_valid(self): for duration, duration_dict in test_values: expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict) - with patch('bot.converters.datetime') as mock_datetime: - mock_datetime.utcnow.return_value = self.fixed_utc_now + with patch("bot.converters.datetime") as mock_datetime: + mock_datetime.now.return_value = self.fixed_utc_now with self.subTest(duration=duration, duration_dict=duration_dict): converted_datetime = await converter.convert(self.context, duration) @@ -165,19 +105,19 @@ async def test_duration_converter_for_invalid(self): """Duration raises the right exception for invalid duration strings.""" test_values = ( # Units in wrong order - '1d1w', - '1s1y', + "1d1w", + "1s1y", # Duplicated units - '1 year 2 years', - '1 M 10 minutes', + "1 year 2 years", + "1 M 10 minutes", # Unknown substrings - '1MVes', - '1y3breads', + "1MVes", + "1y3breads", # Missing amount - 'ym', + "ym", # Incorrect whitespace " 1y", @@ -185,15 +125,15 @@ async def test_duration_converter_for_invalid(self): "1y 1m", # Garbage - 'Guido van Rossum', - 'lemon lemon lemon lemon lemon lemon lemon', + "Guido van Rossum", + "lemon lemon lemon lemon lemon lemon lemon", ) converter = Duration() for invalid_duration in test_values: with self.subTest(invalid_duration=invalid_duration): - exception_message = f'`{invalid_duration}` is not a valid duration string.' + exception_message = f"`{invalid_duration}` is not a valid duration string." with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await converter.convert(self.context, invalid_duration) @@ -201,9 +141,9 @@ async def test_duration_converter_for_invalid(self): async def test_duration_converter_out_of_range(self, mock_datetime): """Duration converter should raise BadArgument if datetime raises a ValueError.""" mock_datetime.__add__.side_effect = ValueError - mock_datetime.utcnow.return_value = mock_datetime + mock_datetime.now.return_value = mock_datetime - duration = f"{datetime.MAXYEAR}y" + duration = f"{MAXYEAR}y" exception_message = f"`{duration}` results in a datetime outside the supported range." with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await Duration().convert(self.context, duration) @@ -212,41 +152,41 @@ async def test_isodatetime_converter_for_valid(self): """ISODateTime converter returns correct datetime for valid datetime string.""" test_values = ( # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` - ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ("2019-09-02T02:03:05Z", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 02:03:05Z", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` - ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ("2019-09-02T03:18:05+01:15", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 03:18:05+01:15", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02T00:48:05-01:15", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 00:48:05-01:15", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` - ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ("2019-09-02T03:18:05+0115", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 03:18:05+0115", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02T00:48:05-0115", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 00:48:05-0115", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ("2019-09-02 03:03:05+01", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02T01:03:05-01", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ("2019-09-02T02:03:05", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), + ("2019-09-02 02:03:05", datetime(2019, 9, 2, 2, 3, 5, tzinfo=UTC)), # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), - ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ("2019-11-12T09:15", datetime(2019, 11, 12, 9, 15, tzinfo=UTC)), + ("2019-11-12 09:15", datetime(2019, 11, 12, 9, 15, tzinfo=UTC)), # `YYYY-mm-dd` - ('2019-04-01', datetime.datetime(2019, 4, 1)), + ("2019-04-01", datetime(2019, 4, 1, tzinfo=UTC)), # `YYYY-mm` - ('2019-02-01', datetime.datetime(2019, 2, 1)), + ("2019-02-01", datetime(2019, 2, 1, tzinfo=UTC)), # `YYYY` - ('2025', datetime.datetime(2025, 1, 1)), + ("2025", datetime(2025, 1, 1, tzinfo=UTC)), ) converter = ISODateTime() @@ -254,26 +194,25 @@ async def test_isodatetime_converter_for_valid(self): for datetime_string, expected_dt in test_values: with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): converted_dt = await converter.convert(self.context, datetime_string) - self.assertIsNone(converted_dt.tzinfo) self.assertEqual(converted_dt, expected_dt) async def test_isodatetime_converter_for_invalid(self): """ISODateTime converter raises the correct exception for invalid datetime strings.""" test_values = ( # Make sure it doesn't interfere with the Duration converter - '1Y', - '1d', - '1H', + "1Y", + "1d", + "1H", # Check if it fails when only providing the optional time part - '10:10:10', - '10:00', + "10:10:10", + "10:00", # Invalid date format - '19-01-01', + "19-01-01", # Other non-valid strings - 'fisk the tag master', + "fisk the tag master", ) converter = ISODateTime() @@ -291,7 +230,7 @@ async def test_hush_duration_converter_for_valid(self): ("10", 10), ("5m", 5), ("5M", 5), - ("forever", None), + ("forever", -1), ) converter = HushDurationConverter() for minutes_string, expected_minutes in test_values: @@ -308,6 +247,8 @@ async def test_hush_duration_converter_for_invalid(self): ) converter = HushDurationConverter() for invalid_minutes_string, exception_message in test_values: - with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await converter.convert(self.context, invalid_minutes_string) + with ( + self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message), + self.assertRaisesRegex(BadArgument, re.escape(exception_message)), + ): + await converter.convert(self.context, invalid_minutes_string) diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index 3d450caa0a..6a04123a78 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -142,6 +142,8 @@ def test_predicate_raises_exception_for_non_whitelisted_context(self): with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) - with self.subTest(test_description=test_case.description): - with self.assertRaisesRegex(InWhitelistCheckFailure, exception_message): - predicate(test_case.ctx) + with ( + self.subTest(test_description=test_case.description), + self.assertRaisesRegex(InWhitelistCheckFailure, exception_message), + ): + predicate(test_case.ctx) diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py deleted file mode 100644 index 630f2516df..0000000000 --- a/tests/bot/test_pagination.py +++ /dev/null @@ -1,46 +0,0 @@ -from unittest import TestCase - -from bot import pagination - - -class LinePaginatorTests(TestCase): - """Tests functionality of the `LinePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30, - scale_to_size=50) - - def test_add_line_works_on_small_lines(self): - """`add_line` should allow small lines to be added.""" - self.paginator.add_line('x' * (self.paginator.max_size - 3)) - # Note that the page isn't added to _pages until it's full. - self.assertEqual(len(self.paginator._pages), 0) - - def test_add_line_works_on_long_lines(self): - """After additional lines after `max_size` is exceeded should go on the next page.""" - self.paginator.add_line('x' * self.paginator.max_size) - self.assertEqual(len(self.paginator._pages), 0) - - # Any additional lines should start a new page after `max_size` is exceeded. - self.paginator.add_line('x') - self.assertEqual(len(self.paginator._pages), 1) - - def test_add_line_continuation(self): - """When `scale_to_size` is exceeded, remaining words should be split onto the next page.""" - self.paginator.add_line('zyz ' * (self.paginator.scale_to_size//4 + 1)) - self.assertEqual(len(self.paginator._pages), 1) - - def test_add_line_no_continuation(self): - """If adding a new line to an existing page would exceed `max_size`, it should start a new - page rather than using continuation. - """ - self.paginator.add_line('z' * (self.paginator.max_size - 3)) - self.paginator.add_line('z') - self.assertEqual(len(self.paginator._pages), 1) - - def test_add_line_truncates_very_long_words(self): - """`add_line` should truncate if a single long word exceeds `scale_to_size`.""" - self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) - # Note: item at index 1 is the truncated line, index 0 is prefix - self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size) diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 883465e0b6..4ae11d5d39 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -32,6 +32,7 @@ async def test_has_any_role_check_with_guild_and_required_role(self): async def test_has_no_roles_check_without_guild(self): """`has_no_roles_check` should return `False` when `Context.guild` is None.""" self.ctx.channel = MagicMock(DMChannel) + self.ctx.guild = None self.assertFalse(await checks.has_no_roles_check(self.ctx)) async def test_has_no_roles_check_returns_false_with_unwanted_role(self): diff --git a/tests/bot/utils/test_helpers.py b/tests/bot/utils/test_helpers.py new file mode 100644 index 0000000000..e8ab6ba804 --- /dev/null +++ b/tests/bot/utils/test_helpers.py @@ -0,0 +1,113 @@ +import unittest + +from bot.utils import helpers + + +class TestHelpers(unittest.TestCase): + """Tests for the helper functions in the `bot.utils.helpers` module.""" + + def test_find_nth_occurrence_returns_index(self): + """Test if `find_nth_occurrence` returns the index correctly when substring is found.""" + test_values = ( + ("hello", "l", 1, 2), + ("hello", "l", 2, 3), + ("hello world", "world", 1, 6), + ("hello world", " ", 1, 5), + ("hello world", "o w", 1, 4) + ) + + for string, substring, n, expected_index in test_values: + with self.subTest(string=string, substring=substring, n=n): + index = helpers.find_nth_occurrence(string, substring, n) + self.assertEqual(index, expected_index) + + def test_find_nth_occurrence_returns_none(self): + """Test if `find_nth_occurrence` returns None when substring is not found.""" + test_values = ( + ("hello", "w", 1, None), + ("hello", "w", 2, None), + ("hello world", "world", 2, None), + ("hello world", " ", 2, None), + ("hello world", "o w", 2, None) + ) + + for string, substring, n, expected_index in test_values: + with self.subTest(string=string, substring=substring, n=n): + index = helpers.find_nth_occurrence(string, substring, n) + self.assertEqual(index, expected_index) + + def test_has_lines_handles_normal_cases(self): + """Test if `has_lines` returns True for strings with at least `count` lines.""" + test_values = ( + ("hello\nworld", 1, True), + ("hello\nworld", 2, True), + ("hello\nworld", 3, False), + ) + + for string, count, expected in test_values: + with self.subTest(string=string, count=count): + result = helpers.has_lines(string, count) + self.assertEqual(result, expected) + + def test_has_lines_handles_empty_string(self): + """Test if `has_lines` returns False for empty strings.""" + test_values = ( + ("", 0, False), + ("", 1, False), + ) + + for string, count, expected in test_values: + with self.subTest(string=string, count=count): + result = helpers.has_lines(string, count) + self.assertEqual(result, expected) + + def test_has_lines_handles_newline_at_end(self): + """Test if `has_lines` ignores one newline at the end.""" + test_values = ( + ("hello\nworld\n", 2, True), + ("hello\nworld\n", 3, False), + ("hello\nworld\n\n", 3, True), + ) + + for string, count, expected in test_values: + with self.subTest(string=string, count=count): + result = helpers.has_lines(string, count) + self.assertEqual(result, expected) + + def test_pad_base64_correctly(self): + """Test if `pad_base64` correctly pads a base64 string.""" + test_values = ( + ("", ""), + ("a", "a==="), + ("aa", "aa=="), + ("aaa", "aaa="), + ("aaaa", "aaaa"), + ("aaaaa", "aaaaa==="), + ("aaaaaa", "aaaaaa=="), + ("aaaaaaa", "aaaaaaa=") + ) + + for data, expected in test_values: + with self.subTest(data=data): + result = helpers.pad_base64(data) + self.assertEqual(result, expected) + + def test_remove_subdomain_from_url_correctly(self): + """Test if `remove_subdomain_from_url` correctly removes subdomains from URLs.""" + test_values = ( + ("https://example.com", "https://example.com"), + ("https://www.example.com", "https://example.com"), + ("https://sub.example.com", "https://example.com"), + ("https://sub.sub.example.com", "https://example.com"), + ("https://sub.example.co.uk", "https://example.co.uk"), + ("https://sub.sub.example.co.uk", "https://example.co.uk"), + ("https://sub.example.co.uk/path", "https://example.co.uk/path"), + ("https://sub.sub.example.co.uk/path", "https://example.co.uk/path"), + ("https://sub.example.co.uk/path?query", "https://example.co.uk/path?query"), + ("https://sub.sub.example.co.uk/path?query", "https://example.co.uk/path?query"), + ) + + for url, expected in test_values: + with self.subTest(url=url): + result = helpers.remove_subdomain_from_url(url) + self.assertEqual(result, expected) diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py new file mode 100644 index 0000000000..ad3f4e8b66 --- /dev/null +++ b/tests/bot/utils/test_message_cache.py @@ -0,0 +1,213 @@ +import unittest + +from bot.utils.message_cache import MessageCache +from tests.helpers import MockMessage + + +# noinspection SpellCheckingInspection +class TestMessageCache(unittest.TestCase): + """Tests for the MessageCache class in the `bot.utils.caching` module.""" + + def test_first_append_sets_the_first_value(self): + """Test if the first append adds the message to the first cell.""" + cache = MessageCache(maxlen=10) + message = MockMessage() + + cache.append(message) + + self.assertEqual(cache[0], message) + + def test_append_adds_in_the_right_order(self): + """Test if two appends are added in the same order if newest_first is False, or in reverse order otherwise.""" + messages = [MockMessage(), MockMessage()] + + cache = MessageCache(maxlen=10, newest_first=False) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages, list(cache)) + + cache = MessageCache(maxlen=10, newest_first=True) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages[::-1], list(cache)) + + def test_appending_over_maxlen_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages.""" + cache = MessageCache(maxlen=2) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[1:], list(cache)) + + def test_appending_over_maxlen_with_newest_first_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages if newest_first is True.""" + cache = MessageCache(maxlen=2, newest_first=True) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[:0:-1], list(cache)) + + def test_pop_removes_from_the_end(self): + """Test if a pop removes the right-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.pop() + + self.assertEqual(msg, messages[-1]) + self.assertListEqual(messages[:-1], list(cache)) + + def test_popleft_removes_from_the_beginning(self): + """Test if a popleft removes the left-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.popleft() + + self.assertEqual(msg, messages[0]) + self.assertListEqual(messages[1:], list(cache)) + + def test_clear(self): + """Test if a clear makes the cache empty.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + cache.clear() + + self.assertListEqual(list(cache), []) + self.assertEqual(len(cache), 0) + + def test_get_message_returns_the_message(self): + """Test if get_message returns the cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertEqual(cache.get_message(1234), message) + + def test_get_message_returns_none(self): + """Test if get_message returns None for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIsNone(cache.get_message(4321)) + + def test_update_replaces_old_element(self): + """Test if an update replaced the old message with the same ID.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + message = MockMessage(id=1234) + cache.update(message) + + self.assertIs(cache.get_message(1234), message) + self.assertEqual(len(cache), 1) + + def test_contains_returns_true_for_cached_message(self): + """Test if contains returns True for an ID of a cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIn(1234, cache) + + def test_contains_returns_false_for_non_cached_message(self): + """Test if contains returns False for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertNotIn(4321, cache) + + def test_indexing(self): + """Test if the cache returns the correct messages by index.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(5)] + + for msg in messages: + cache.append(msg) + + for current_loop in range(-5, 5): + with self.subTest(current_loop=current_loop): + self.assertEqual(cache[current_loop], messages[current_loop]) + + def test_bad_index_raises_index_error(self): + """Test if the cache raises IndexError for invalid indices.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + test_cases = (-10, -4, 3, 4, 5) + + for msg in messages: + cache.append(msg) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop), self.assertRaises(IndexError): + cache[current_loop] + + def test_slicing_with_unfilled_cache(self): + """Test if slicing returns the correct messages if the cache is not yet fully filled.""" + sizes = (5, 10, 55, 101) + + slices = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) + ) + + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size // 3 * 2)] + + for msg in messages: + cache.append(msg) + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) + + def test_slicing_with_overfilled_cache(self): + """Test if slicing returns the correct messages if the cache was appended with more messages it can contain.""" + sizes = (5, 10, 55, 101) + + slices = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) + ) + + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size * 3 // 2)] + + for msg in messages: + cache.append(msg) + messages = messages[size // 2:] + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) + + def test_length(self): + """Test if len returns the correct number of items in the cache.""" + cache = MessageCache(maxlen=5) + + for current_loop in range(10): + with self.subTest(current_loop=current_loop): + self.assertEqual(len(cache), min(current_loop, 5)) + cache.append(MockMessage()) diff --git a/tests/bot/utils/test_messages.py b/tests/bot/utils/test_messages.py index 9c22c97510..48a62ed1c7 100644 --- a/tests/bot/utils/test_messages.py +++ b/tests/bot/utils/test_messages.py @@ -9,7 +9,8 @@ class TestMessages(unittest.TestCase): def test_sub_clyde(self): """Uppercase E's and lowercase e's are substituted with their cyrillic counterparts.""" sub_e = "\u0435" - sub_E = "\u0415" # noqa: N806: Uppercase E in variable name + # N806 fires for upper-case E in variable name. + sub_E = "\u0415" # noqa: N806 test_cases = ( (None, None), diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py deleted file mode 100644 index 3b71022dbb..0000000000 --- a/tests/bot/utils/test_services.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging -import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from aiohttp import ClientConnectorError - -from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service -from tests.helpers import MockBot - - -class PasteTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - patcher = patch("bot.instance", new=MockBot()) - self.bot = patcher.start() - self.addCleanup(patcher.stop) - - @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") - async def test_url_and_sent_contents(self): - """Correct url was used and post was called with expected data.""" - response = MagicMock( - json=AsyncMock(return_value={"key": ""}) - ) - self.bot.http_session.post.return_value.__aenter__.return_value = response - self.bot.http_session.post.reset_mock() - await send_to_paste_service("Content") - self.bot.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") - - @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") - async def test_paste_returns_correct_url_on_success(self): - """Url with specified extension is returned on successful requests.""" - key = "paste_key" - test_cases = ( - (f"https://paste_service.com/{key}.txt?noredirect", "txt"), - (f"https://paste_service.com/{key}.py", "py"), - (f"https://paste_service.com/{key}?noredirect", ""), - ) - response = MagicMock( - json=AsyncMock(return_value={"key": key}) - ) - self.bot.http_session.post.return_value.__aenter__.return_value = response - - for expected_output, extension in test_cases: - with self.subTest(msg=f"Send contents with extension {repr(extension)}"): - self.assertEqual( - await send_to_paste_service("", extension=extension), - expected_output - ) - - async def test_request_repeated_on_json_errors(self): - """Json with error message and invalid json are handled as errors and requests repeated.""" - test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) - self.bot.http_session.post.return_value.__aenter__.return_value = response = MagicMock() - self.bot.http_session.post.reset_mock() - - for error_json in test_cases: - with self.subTest(error_json=error_json): - response.json = AsyncMock(return_value=error_json) - result = await send_to_paste_service("") - self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - self.bot.http_session.post.reset_mock() - - async def test_request_repeated_on_connection_errors(self): - """Requests are repeated in the case of connection errors.""" - self.bot.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) - result = await send_to_paste_service("") - self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertIsNone(result) - - async def test_general_error_handled_and_request_repeated(self): - """All `Exception`s are handled, logged and request repeated.""" - self.bot.http_session.post = MagicMock(side_effect=Exception) - result = await send_to_paste_service("") - self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) - self.assertLogs("bot.utils", logging.ERROR) - self.assertIsNone(result) diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 115ddfb0d2..6244a35484 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -1,5 +1,5 @@ import unittest -from datetime import datetime, timezone +from datetime import UTC, datetime from dateutil.relativedelta import relativedelta @@ -13,26 +13,29 @@ def test_humanize_delta_handle_unknown_units(self): """humanize_delta should be able to handle unknown units, and will not abort.""" # Does not abort for unknown units, as the unit name is checked # against the attribute of the relativedelta instance. - self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours') + actual = time.humanize_delta(relativedelta(days=2, hours=2), precision="elephants", max_units=2) + self.assertEqual(actual, "2 days and 2 hours") def test_humanize_delta_handle_high_units(self): """humanize_delta should be able to handle very high units.""" # Very high maximum units, but it only ever iterates over # each value the relativedelta might have. - self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours') + actual = time.humanize_delta(relativedelta(days=2, hours=2), precision="hours", max_units=20) + self.assertEqual(actual, "2 days and 2 hours") def test_humanize_delta_should_normal_usage(self): """Testing humanize delta.""" test_cases = ( - (relativedelta(days=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'), - (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'), - (relativedelta(days=2, hours=2), 'days', 2, '2 days'), + (relativedelta(days=2), "seconds", 1, "2 days"), + (relativedelta(days=2, hours=2), "seconds", 2, "2 days and 2 hours"), + (relativedelta(days=2, hours=2), "seconds", 1, "2 days"), + (relativedelta(days=2, hours=2), "days", 2, "2 days"), ) for delta, precision, max_units, expected in test_cases: with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected): - self.assertEqual(time.humanize_delta(delta, precision, max_units), expected) + actual = time.humanize_delta(delta, precision=precision, max_units=max_units) + self.assertEqual(actual, expected) def test_humanize_delta_raises_for_invalid_max_units(self): """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units.""" @@ -40,109 +43,86 @@ def test_humanize_delta_raises_for_invalid_max_units(self): for max_units in test_cases: with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error: - time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units) - self.assertEqual(str(error.exception), 'max_units must be positive') - - def test_parse_rfc1123(self): - """Testing parse_rfc1123.""" - self.assertEqual( - time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'), - datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc) - ) - - def test_format_infraction(self): - """Testing format_infraction.""" - self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') + time.humanize_delta(relativedelta(days=2, hours=2), precision="hours", max_units=max_units) + self.assertEqual(str(error.exception), "max_units must be positive.") - def test_format_infraction_with_duration_none_expiry(self): - """format_infraction_with_duration should work for None expiry.""" + def test_format_with_duration_none_expiry(self): + """format_with_duration should work for None expiry.""" test_cases = ( (None, None, None, None), # To make sure that date_from and max_units are not touched - (None, 'Why hello there!', None, None), - (None, None, float('inf'), None), - (None, 'Why hello there!', float('inf'), None), + (None, "Why hello there!", None, None), + (None, None, float("inf"), None), + (None, "Why hello there!", float("inf"), None), ) for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) - def test_format_infraction_with_duration_custom_units(self): - """format_infraction_with_duration should work for custom max_units.""" + def test_format_with_duration_custom_units(self): + """format_with_duration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') + ("3000-12-12T00:01:00Z", datetime(3000, 12, 11, 12, 5, 5, tzinfo=UTC), 6, + " (11 hours, 55 minutes and 55 seconds)"), + ("3000-11-23T20:09:00Z", datetime(3000, 4, 25, 20, 15, tzinfo=UTC), 20, + " (6 months, 28 days, 23 hours and 54 minutes)") ) for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) - def test_format_infraction_with_duration_normal_usage(self): - """format_infraction_with_duration should work for normal usage, across various durations.""" + def test_format_with_duration_normal_usage(self): + """format_with_duration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, - '2019-11-23 23:59 (9 minutes and 55 seconds)'), - (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ("2019-12-12T00:01:00Z", datetime(2019, 12, 11, 12, 0, 5, tzinfo=UTC), 2, + " (12 hours and 55 seconds)"), + ("2019-12-12T00:01:00Z", datetime(2019, 12, 11, 12, 0, 5, tzinfo=UTC), 1, " (12 hours)"), + ("2019-12-12T00:00:00Z", datetime(2019, 12, 11, 23, 59, tzinfo=UTC), 2, " (1 minute)"), + ("2019-11-23T20:09:00Z", datetime(2019, 11, 15, 20, 15, tzinfo=UTC), 2, + " (7 days and 23 hours)"), + ("2019-11-23T20:09:00Z", datetime(2019, 4, 25, 20, 15, tzinfo=UTC), 2, + " (6 months and 28 days)"), + ("2019-11-23T20:58:00Z", datetime(2019, 11, 23, 20, 53, tzinfo=UTC), 2, " (5 minutes)"), + ("2019-11-24T00:00:00Z", datetime(2019, 11, 23, 23, 59, 0, tzinfo=UTC), 2, " (1 minute)"), + ("2019-11-23T23:59:00Z", datetime(2017, 7, 21, 23, 0, tzinfo=UTC), 2, + " (2 years and 4 months)"), + ("2019-11-23T23:59:00Z", datetime(2019, 11, 23, 23, 49, 5, tzinfo=UTC), 2, + " (9 minutes and 55 seconds)"), + (None, datetime(2019, 11, 23, 23, 49, 5, tzinfo=UTC), 2, None), ) for expiry, date_from, max_units, expected in test_cases: with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected): - self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected) + self.assertEqual(time.format_with_duration(expiry, date_from, max_units), expected) def test_until_expiration_with_duration_none_expiry(self): - """until_expiration should work for None expiry.""" - test_cases = ( - (None, None, None, None), - - # To make sure that now and max_units are not touched - (None, 'Why hello there!', None, None), - (None, None, float('inf'), None), - (None, 'Why hello there!', float('inf'), None), - ) - - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + """until_expiration should return "Permanent" is expiry is None.""" + self.assertEqual(time.until_expiration(None), "Permanent") def test_until_expiration_with_duration_custom_units(self): """until_expiration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') + ("3000-12-12T00:01:00Z", ""), + ("3000-11-23T20:09:00Z", "") ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry,), expected) def test_until_expiration_normal_usage(self): """until_expiration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), - (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ("3000-12-12T00:01:00Z", ""), + ("3000-12-12T00:01:00Z", ""), + ("3000-12-12T00:00:00Z", ""), + ("3000-11-23T20:09:00Z", ""), + ("3000-11-23T20:09:00Z", ""), ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry), expected) diff --git a/tests/helpers.py b/tests/helpers.py index e3dc5fe5bd..28212e4ab0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,22 +1,21 @@ -from __future__ import annotations - import collections import itertools import logging import unittest.mock from asyncio import AbstractEventLoop -from typing import Iterable, Optional +from collections.abc import Iterable +from contextlib import contextmanager +from functools import cached_property import discord from aiohttp import ClientSession from discord.ext.commands import Context +from pydis_core.async_stats import AsyncStatsClient +from pydis_core.site_api import APIClient -from bot.api import APIClient -from bot.async_stats import AsyncStatsClient from bot.bot import Bot from tests._autospec import autospec # noqa: F401 other modules import it via this module - for logger in logging.Logger.manager.loggerDict.values(): # Set all loggers to CRITICAL by default to prevent screen clutter during testing @@ -40,7 +39,7 @@ def __hash__(self): class ColourMixin: - """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" + """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py does.""" @property def color(self) -> discord.Colour: @@ -50,6 +49,14 @@ def color(self) -> discord.Colour: def color(self, color: discord.Colour) -> None: self.colour = color + @property + def accent_color(self) -> discord.Colour: + return self.accent_colour + + @accent_color.setter + def accent_color(self, color: discord.Colour) -> None: + self.accent_colour = color + class CustomMockMixin: """ @@ -72,7 +79,7 @@ class method `spec_set` can be overwritten with the object that should be uses a additional_spec_asyncs = None def __init__(self, **kwargs): - name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually. + name = kwargs.pop("name", None) # `name` has special meaning for Mock classes, so we need to set it manually. super().__init__(spec_set=self.spec_set, **kwargs) if self.additional_spec_asyncs: @@ -94,7 +101,7 @@ def _get_child_mock(self, **kw): This override will look for an attribute called `child_mock_type` and use that as the type of the child mock. """ _new_name = kw.get("_new_name") - if _new_name in self.__dict__['_spec_asyncs']: + if _new_name in self.__dict__["_spec_asyncs"]: return unittest.mock.AsyncMock(**kw) _type = type(self) @@ -114,23 +121,23 @@ def _get_child_mock(self, **kw): # Create a guild instance to get a realistic Mock of `discord.Guild` guild_data = { - 'id': 1, - 'name': 'guild', - 'region': 'Europe', - 'verification_level': 2, - 'default_notications': 1, - 'afk_timeout': 100, - 'icon': "icon.png", - 'banner': 'banner.png', - 'mfa_level': 1, - 'splash': 'splash.png', - 'system_channel_id': 464033278631084042, - 'description': 'mocking is fun', - 'max_presences': 10_000, - 'max_members': 100_000, - 'preferred_locale': 'UTC', - 'owner_id': 1, - 'afk_channel_id': 464033278631084042, + "id": 1, + "name": "guild", + "region": "Europe", + "verification_level": 2, + "default_notications": 1, + "afk_timeout": 100, + "icon": "icon.png", + "banner": "banner.png", + "mfa_level": 1, + "splash": "splash.png", + "system_channel_id": 464033278631084042, + "description": "mocking is fun", + "max_presences": 10_000, + "max_members": 100_000, + "preferred_locale": "UTC", + "owner_id": 1, + "afk_channel_id": 464033278631084042, } guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock()) @@ -163,17 +170,24 @@ class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ spec_set = guild_instance - def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: - default_kwargs = {'id': next(self.discord_id), 'members': []} + def __init__(self, roles: Iterable[MockRole] | None = None, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "members": [], "chunked": True} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) - self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: - self.roles.extend(roles) + self.roles = [ + MockRole(name="@everyone", position=1, id=0), + *roles + ] + + @cached_property + def roles(self) -> list[MockRole]: + """Cached roles property.""" + return [MockRole(name="@everyone", position=1, id=0)] # Create a Role instance to get a realistic Mock of `discord.Role` -role_data = {'name': 'role', 'id': 1} +role_data = {"name": "role", "id": 1} role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data) @@ -188,11 +202,11 @@ class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): def __init__(self, **kwargs) -> None: default_kwargs = { - 'id': next(self.discord_id), - 'name': 'role', - 'position': 1, - 'colour': discord.Colour(0xdeadbf), - 'permissions': discord.Permissions(), + "id": next(self.discord_id), + "name": "role", + "position": 1, + "colour": discord.Colour(0xdeadbf), + "permissions": discord.Permissions(), } super().__init__(**collections.ChainMap(kwargs, default_kwargs)) @@ -202,8 +216,8 @@ def __init__(self, **kwargs) -> None: if isinstance(self.permissions, int): self.permissions = discord.Permissions(self.permissions) - if 'mention' not in kwargs: - self.mention = f'&{self.name}' + if "mention" not in kwargs: + self.mention = f"&{self.name}" def __lt__(self, other): """Simplified position-based comparisons similar to those of `discord.Role`.""" @@ -215,7 +229,7 @@ def __ge__(self, other): # Create a Member instance to get a realistic Mock of `discord.Member` -member_data = {'user': 'lemon', 'roles': [1]} +member_data = {"user": "lemon", "roles": [1], "flags": 2} state_mock = unittest.mock.MagicMock() member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock) @@ -229,20 +243,30 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin """ spec_set = member_instance - def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None: - default_kwargs = {'name': 'member', 'id': next(self.discord_id), 'bot': False, "pending": False} + def __init__(self, roles: Iterable[MockRole] | None = None, **kwargs) -> None: + default_kwargs = {"name": "member", "id": next(self.discord_id), "bot": False, "pending": False} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) + self.top_role = max(self.roles) - if 'mention' not in kwargs: + if "mention" not in kwargs: self.mention = f"@{self.name}" + def get_role(self, role_id: int) -> MockRole | None: + return discord.utils.get(self.roles, id=role_id) + # Create a User instance to get a realistic Mock of `discord.User` -user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock()) +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, { + "accent_color": 0 +}) +user_instance = discord.User( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock() +) class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): @@ -255,10 +279,10 @@ class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): spec_set = user_instance def __init__(self, **kwargs) -> None: - default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False} + default_kwargs = {"name": "user", "id": next(self.discord_id), "bot": False} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) - if 'mention' not in kwargs: + if "mention" not in kwargs: self.mention = f"@{self.name}" @@ -279,7 +303,10 @@ def _get_mock_loop() -> unittest.mock.Mock: # Since calling `create_task` on our MockBot does not actually schedule the coroutine object # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object # to prevent "has not been awaited"-warnings. - loop.create_task.side_effect = lambda coroutine: coroutine.close() + def mock_create_task(coroutine, **kwargs): + coroutine.close() + return unittest.mock.Mock() + loop.create_task.side_effect = mock_create_task return loop @@ -295,32 +322,58 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): command_prefix=unittest.mock.MagicMock(), loop=_get_mock_loop(), redis_session=unittest.mock.MagicMock(), + http_session=unittest.mock.MagicMock(), + allowed_roles=[1], + guild_id=1, + intents=discord.Intents.all(), ) - additional_spec_asyncs = ("wait_for", "redis_ready") + additional_spec_asyncs = ("wait_for",) def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.loop = _get_mock_loop() - self.api_client = MockAPIClient(loop=self.loop) - self.http_session = unittest.mock.create_autospec(spec=ClientSession, spec_set=True) - self.stats = unittest.mock.create_autospec(spec=AsyncStatsClient, spec_set=True) + self.add_cog = unittest.mock.AsyncMock() + + @cached_property + def loop(self) -> unittest.mock.Mock: + """Cached loop property.""" + return _get_mock_loop() + + @cached_property + def api_client(self) -> MockAPIClient: + """Cached api_client property.""" + return MockAPIClient() + + @cached_property + def http_session(self) -> unittest.mock.Mock: + """Cached http_session property.""" + return unittest.mock.create_autospec(spec=ClientSession, spec_set=True) + + @cached_property + def stats(self) -> unittest.mock.Mock: + """Cached stats property.""" + return unittest.mock.create_autospec(spec=AsyncStatsClient, spec_set=True) # Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel` channel_data = { - 'id': 1, - 'type': 'TextChannel', - 'name': 'channel', - 'parent_id': 1234567890, - 'topic': 'topic', - 'position': 1, - 'nsfw': False, - 'last_message_id': 1, + "id": 1, + "type": "TextChannel", + "name": "channel", + "parent_id": 1234567890, + "topic": "topic", + "position": 1, + "nsfw": False, + "last_message_id": 1, + "bitrate": 1337, + "user_limit": 25, } state = unittest.mock.MagicMock() guild = unittest.mock.MagicMock() -channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) +text_channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + +channel_data["type"] = "VoiceChannel" +voice_channel_instance = discord.VoiceChannel(state=state, guild=guild, data=channel_data) class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -330,15 +383,42 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - spec_set = channel_instance + spec_set = text_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id), "name": "channel"} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if "mention" not in kwargs: + self.mention = f"#{self.name}" + + @cached_property + def guild(self) -> MockGuild: + """Cached guild property.""" + return MockGuild() + + +class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock VoiceChannel objects. + + Instances of this class will follow the specifications of `discord.VoiceChannel` instances. For + more information, see the `MockGuild` docstring. + """ + spec_set = voice_channel_instance def __init__(self, **kwargs) -> None: - default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} + default_kwargs = {"id": next(self.discord_id), "name": "channel"} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) - if 'mention' not in kwargs: + if "mention" not in kwargs: self.mention = f"#{self.name}" + @cached_property + def guild(self) -> MockGuild: + """Cached guild property.""" + return MockGuild() + # Create data for the DMChannel instance state = unittest.mock.MagicMock() @@ -349,42 +429,69 @@ def __init__(self, **kwargs) -> None: class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): """ - A MagicMock subclass to mock TextChannel objects. + A MagicMock subclass to mock DMChannel objects. - Instances of this class will follow the specifications of `discord.TextChannel` instances. For + Instances of this class will follow the specifications of `discord.DMChannel` instances. For more information, see the `MockGuild` docstring. """ spec_set = dm_channel_instance def __init__(self, **kwargs) -> None: - default_kwargs = {'id': next(self.discord_id), 'recipient': MockUser(), "me": MockUser()} + default_kwargs = {"id": next(self.discord_id), "recipient": MockUser(), "me": MockUser(), "guild": None} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + "id": 1, + "type": discord.ChannelType.category, + "name": "category", + "position": 1, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +category_channel_instance = discord.CategoryChannel( + state=state, guild=guild, data=category_channel_data +) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + def __init__(self, **kwargs) -> None: + default_kwargs = {"id": next(self.discord_id)} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { - 'id': 1, - 'webhook_id': 431341013479718912, - 'attachments': [], - 'embeds': [], - 'application': 'Python Discord', - 'activity': 'mocking', - 'channel': unittest.mock.MagicMock(), - 'edited_timestamp': '2019-10-14T15:33:48+00:00', - 'type': 'message', - 'pinned': False, - 'mention_everyone': False, - 'tts': None, - 'content': 'content', - 'nonce': None, + "id": 1, + "webhook_id": 431341013479718912, + "attachments": [], + "embeds": [], + "application": {"id": 4, "description": "A Python Bot", "name": "Python Discord", "icon": None}, + "activity": "mocking", + "channel": unittest.mock.MagicMock(), + "edited_timestamp": "2019-10-14T15:33:48+00:00", + "type": "message", + "pinned": False, + "mention_everyone": False, + "tts": None, + "content": "content", + "nonce": None, } state = unittest.mock.MagicMock() channel = unittest.mock.MagicMock() +channel.type = discord.ChannelType.text message_instance = discord.Message(state=state, channel=channel, data=message_data) # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) +context_instance = Context( + message=unittest.mock.MagicMock(), + prefix="$", + bot=MockBot(), + view=None +) context_instance.invoked_from_error_handler = None @@ -399,14 +506,50 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.bot = kwargs.get('bot', MockBot()) - self.guild = kwargs.get('guild', MockGuild()) - self.author = kwargs.get('author', MockMember()) - self.channel = kwargs.get('channel', MockTextChannel()) - self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) + self.me = kwargs.get("me", MockMember()) + self.bot = kwargs.get("bot", MockBot()) + + self.message = kwargs.get("message", MockMessage(guild=self.guild)) + self.author = kwargs.get("author", self.message.author) + self.channel = kwargs.get("channel", self.message.channel) + self.guild = kwargs.get("guild", self.channel.guild) + + self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) + + +class MockInteraction(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock Interaction objects. + + Instances of this class will follow the specifications of `discord.Interaction` + instances. For more information, see the `MockGuild` docstring. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.me = kwargs.get("me", MockMember()) + self.client = kwargs.get("client", MockBot()) + self.message = kwargs.get("message", MockMessage(guild=self.guild)) + self.user = kwargs.get("user", self.message.author) + self.channel = kwargs.get("channel", self.message.channel) + self.guild = kwargs.get("guild", self.channel.guild) -attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock()) + self.invoked_from_error_handler = kwargs.get("invoked_from_error_handler", False) + + +attachment_data = { + "id": 1, + "size": 14, + "filename": "jchrist.png", + "url": "https://google.com", + "proxy_url": "https://google.com", + "waveform": None, +} +attachment_instance = discord.Attachment( + data=attachment_data, + state=unittest.mock.MagicMock(), +) class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): @@ -419,6 +562,28 @@ class MockAttachment(CustomMockMixin, unittest.mock.MagicMock): spec_set = attachment_instance +message_reference_instance = discord.MessageReference( + message_id=unittest.mock.MagicMock(id=1), + channel_id=unittest.mock.MagicMock(id=2), + guild_id=unittest.mock.MagicMock(id=3) +) + + +class MockMessageReference(CustomMockMixin, unittest.mock.MagicMock): + """ + A MagicMock subclass to mock MessageReference objects. + + Instances of this class will follow the specification of `discord.MessageReference` instances. + For more information, see the `MockGuild` docstring. + """ + spec_set = message_reference_instance + + def __init__(self, *, reference_author_is_bot: bool = False, **kwargs): + super().__init__(**kwargs) + referenced_msg_author = MockMember(name="bob", bot=reference_author_is_bot) + self.resolved = MockMessage(author=referenced_msg_author) + + class MockMessage(CustomMockMixin, unittest.mock.MagicMock): """ A MagicMock subclass to mock Message objects. @@ -429,13 +594,22 @@ class MockMessage(CustomMockMixin, unittest.mock.MagicMock): spec_set = message_instance def __init__(self, **kwargs) -> None: - default_kwargs = {'attachments': []} + default_kwargs = {"attachments": []} super().__init__(**collections.ChainMap(kwargs, default_kwargs)) - self.author = kwargs.get('author', MockMember()) - self.channel = kwargs.get('channel', MockTextChannel()) + self.author = kwargs.get("author", MockMember()) + self.channel = kwargs.get("channel", MockTextChannel()) -emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'} +class MockInteractionMessage(MockMessage): + """ + A MagicMock subclass to mock InteractionMessage objects. + + Instances of this class will follow the specifications of `discord.InteractionMessage` instances. For more + information, see the `MockGuild` docstring. + """ + + +emoji_data = {"require_colons": True, "managed": True, "id": 1, "name": "hyperlemon"} emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data) @@ -450,10 +624,10 @@ class MockEmoji(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) - self.guild = kwargs.get('guild', MockGuild()) + self.guild = kwargs.get("guild", MockGuild()) -partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido') +partial_emoji_instance = discord.PartialEmoji(animated=False, name="guido") class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): @@ -466,7 +640,7 @@ class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock): spec_set = partial_emoji_instance -reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji()) +reaction_instance = discord.Reaction(message=MockMessage(), data={"me": True}, emoji=MockEmoji()) class MockReaction(CustomMockMixin, unittest.mock.MagicMock): @@ -481,8 +655,8 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: _users = kwargs.pop("users", []) super().__init__(**kwargs) - self.emoji = kwargs.get('emoji', MockEmoji()) - self.message = kwargs.get('message', MockMessage()) + self.emoji = kwargs.get("emoji", MockEmoji()) + self.message = kwargs.get("message", MockMessage()) user_iterator = unittest.mock.AsyncMock() user_iterator.__aiter__.return_value = _users @@ -491,7 +665,7 @@ def __init__(self, **kwargs) -> None: self.__str__.return_value = str(self.emoji) -webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): @@ -503,3 +677,12 @@ class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): """ spec_set = webhook_instance additional_spec_asyncs = ("send", "edit", "delete", "execute") + +@contextmanager +def no_create_task(): + def side_effect(coro, *_, **__): + coro.close() + + with unittest.mock.patch("pydis_core.utils.scheduling.create_task") as create_task: + create_task.side_effect = side_effect + yield diff --git a/tests/test_base.py b/tests/test_base.py index a7db4bf3e6..f1fb1a514d 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,8 +1,7 @@ import logging -import unittest import unittest.mock - +from bot.log import get_logger from tests.base import LoggingTestsMixin, _CaptureLogHandler @@ -15,7 +14,7 @@ class LoggingTestCaseTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.log = logging.getLogger(__name__) + cls.log = get_logger(__name__) def test_assert_not_logs_does_not_raise_with_no_logs(self): """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" @@ -31,15 +30,19 @@ def test_assert_not_logs_raises_correct_assertion_error_when_logs_are_emitted(se r"1 logs of DEBUG or higher were triggered on root:\n" r'' ) - with self.assertRaisesRegex(AssertionError, msg_regex): - with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): - self.log.debug("Log!") + with ( + self.assertRaisesRegex(AssertionError, msg_regex), + LoggingTestCase.assertNotLogs(self, level=logging.DEBUG), + ): + self.log.debug("Log!") def test_assert_not_logs_reraises_unexpected_exception_in_managed_context(self): """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" - with self.assertRaises(ValueError, msg="test exception"): - with LoggingTestCase.assertNotLogs(self, level=logging.DEBUG): - raise ValueError("test exception") + with ( + self.assertRaises(ValueError, msg="test exception"), + LoggingTestCase.assertNotLogs(self, level=logging.DEBUG), + ): + raise ValueError("test exception") def test_assert_not_logs_restores_old_logging_settings(self): """Test if LoggingTestCase.assertNotLogs reraises an unexpected exception.""" @@ -56,15 +59,14 @@ def test_assert_not_logs_restores_old_logging_settings(self): def test_logging_test_case_works_with_logger_instance(self): """Test if the LoggingTestCase captures logging for provided logger.""" - log = logging.getLogger("new_logger") - with self.assertRaises(AssertionError): - with LoggingTestCase.assertNotLogs(self, logger=log): - log.info("Hello, this should raise an AssertionError") + log = get_logger("new_logger") + with self.assertRaises(AssertionError), LoggingTestCase.assertNotLogs(self, logger=log): + log.info("Hello, this should raise an AssertionError") def test_logging_test_case_respects_alternative_logger(self): """Test if LoggingTestCase only checks the provided logger.""" - log_one = logging.getLogger("log one") - log_two = logging.getLogger("log two") + log_one = get_logger("log one") + log_two = get_logger("log two") with LoggingTestCase.assertNotLogs(self, logger=log_one): log_two.info("Hello, this should not raise an AssertionError") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 81285e0091..3d4cb09e77 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -14,7 +14,7 @@ def test_mock_role_default_initialization(self): """Test if the default initialization of MockRole results in the correct object.""" role = helpers.MockRole() - # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass self.assertIsInstance(role, discord.Role) self.assertEqual(role.name, "role") @@ -144,13 +144,13 @@ def test_mock_context_default_initialization(self): def test_mocks_allows_access_to_attributes_part_of_spec(self): """Accessing attributes that are valid for the objects they mock should succeed.""" mocks = ( - (helpers.MockGuild(), 'name'), - (helpers.MockRole(), 'hoist'), - (helpers.MockMember(), 'display_name'), - (helpers.MockBot(), 'user'), - (helpers.MockContext(), 'invoked_with'), - (helpers.MockTextChannel(), 'last_message'), - (helpers.MockMessage(), 'mention_everyone'), + (helpers.MockGuild(), "name"), + (helpers.MockRole(), "hoist"), + (helpers.MockMember(), "display_name"), + (helpers.MockBot(), "user"), + (helpers.MockContext(), "invoked_with"), + (helpers.MockTextChannel(), "last_message"), + (helpers.MockMessage(), "mention_everyone"), ) for mock, valid_attribute in mocks: @@ -161,8 +161,8 @@ def test_mocks_allows_access_to_attributes_part_of_spec(self): msg = f"accessing valid attribute `{valid_attribute}` raised an AttributeError" self.fail(msg) - @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest') - @unittest.mock.patch(f'{__name__}.getattr') + @unittest.mock.patch(f"{__name__}.DiscordMocksTests.subTest") + @unittest.mock.patch(f"{__name__}.getattr") def test_mock_allows_access_to_attributes_test(self, mock_getattr, mock_subtest): """The valid attribute test should raise an AssertionError after an AttributeError.""" mock_getattr.side_effect = AttributeError @@ -184,9 +184,8 @@ def test_mocks_rejects_access_to_attributes_not_part_of_spec(self): ) for mock in mocks: - with self.subTest(mock=mock): - with self.assertRaises(AttributeError): - mock.the_cake_is_a_lie + with self.subTest(mock=mock), self.assertRaises(AttributeError): + mock.the_cake_is_a_lie # noqa: B018 def test_mocks_use_mention_when_provided_as_kwarg(self): """The mock should use the passed `mention` instead of the default one if present.""" @@ -317,7 +316,6 @@ def test_custom_mock_mixin_accepts_mock_seal(self): class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): child_mock_type = unittest.mock.MagicMock - pass mock = MyMock() unittest.mock.seal(mock) @@ -327,15 +325,15 @@ class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock): def test_spec_propagation_of_mock_subclasses(self): """Test if the `spec` does not propagate to attributes of the mock object.""" test_values = ( - (helpers.MockGuild, "region"), + (helpers.MockGuild, "features"), (helpers.MockRole, "mentionable"), (helpers.MockMember, "display_name"), (helpers.MockBot, "owner_id"), (helpers.MockContext, "command_failed"), (helpers.MockMessage, "mention_everyone"), - (helpers.MockEmoji, 'managed'), - (helpers.MockPartialEmoji, 'url'), - (helpers.MockReaction, 'me'), + (helpers.MockEmoji, "managed"), + (helpers.MockPartialEmoji, "url"), + (helpers.MockReaction, "me"), ) for mock_type, valid_attribute in test_values: diff --git a/tox.ini b/tox.ini deleted file mode 100644 index b8293a3b61..0000000000 --- a/tox.ini +++ /dev/null @@ -1,19 +0,0 @@ -[flake8] -max-line-length=120 -docstring-convention=all -import-order-style=pycharm -application_import_names=bot,tests -exclude=.cache,.venv,.git,constants.py -ignore= - B311,W503,E226,S311,T000 - # Missing Docstrings - D100,D104,D105,D107, - # Docstring Whitespace - D203,D212,D214,D215, - # Docstring Quotes - D301,D302, - # Docstring Content - D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 - # Type Annotations - ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 -per-file-ignores=tests/*:D,ANN diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..7e47fa3c3d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1465 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[options] +prerelease-mode = "allow" + +[[package]] +name = "aiodns" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycares" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/0a/163e5260cecc12de6abc259d158d9da3b8ec062ab863107dcdb1166cdcef/aiodns-3.5.0.tar.gz", hash = "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310", size = 14380, upload-time = "2025-06-13T16:21:53.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/2c/711076e5f5d0707b8ec55a233c8bfb193e0981a800cd1b3b123e8ff61ca1/aiodns-3.5.0-py3-none-any.whl", hash = "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5", size = 8068, upload-time = "2025-06-13T16:21:52.45Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "async-rediscache" +version = "1.0.0rc2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b3/4498ba00daa98576b0c6f94b5a24313705ca59d25d623bc2a3cda312eba5/async-rediscache-1.0.0rc2.tar.gz", hash = "sha256:65b1f67df0bd92defe37a3e645ea4c868da29eb41bfa493643a3b4ae7c0e109c", size = 16903, upload-time = "2022-07-25T21:32:50.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/03/49f4e8590df63c74a073e2a6d94576c7de7693ec4c5290d77333cd163aa6/async_rediscache-1.0.0rc2-py3-none-any.whl", hash = "sha256:b156cc42b3285e1bd620487c594d7238552f95e48dc07b4e5d0b1c095c3acc86", size = 17852, upload-time = "2022-07-25T21:32:49.132Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "bot" +version = "1.0.1" +source = { virtual = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "arrow" }, + { name = "beautifulsoup4" }, + { name = "deepdiff" }, + { name = "emoji" }, + { name = "feedparser" }, + { name = "lxml" }, + { name = "markdownify" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pydis-core", extra = ["all"] }, + { name = "python-dateutil" }, + { name = "python-frontmatter" }, + { name = "rapidfuzz" }, + { name = "regex" }, + { name = "sentry-sdk" }, + { name = "tenacity" }, + { name = "tldextract" }, + { name = "yarl" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "httpx" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-subtests" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "taskipy" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = "==3.13.4" }, + { name = "arrow", specifier = "==1.4.0" }, + { name = "beautifulsoup4", specifier = "==4.14.2" }, + { name = "deepdiff", specifier = "==9.0.0" }, + { name = "emoji", specifier = "==2.15.0" }, + { name = "feedparser", specifier = "==6.0.12" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdownify", specifier = "==1.2.0" }, + { name = "pydantic", specifier = "==2.12.3" }, + { name = "pydantic-settings", specifier = "==2.11.0" }, + { name = "pydis-core", extras = ["all"], specifier = "==11.8.0" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "python-frontmatter", specifier = "==1.1.0" }, + { name = "rapidfuzz", specifier = "==3.14.1" }, + { name = "regex", specifier = "==2026.3.32" }, + { name = "sentry-sdk", specifier = "==2.43.0" }, + { name = "tenacity", specifier = "==9.1.2" }, + { name = "tldextract", specifier = "==5.3.0" }, + { name = "yarl", specifier = "==1.22.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = "==7.11.0" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "pre-commit", specifier = "==4.3.0" }, + { name = "pytest", specifier = "==8.4.2" }, + { name = "pytest-cov", specifier = "==7.0.0" }, + { name = "pytest-subtests", specifier = "==0.14.1" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "ruff", specifier = "==0.14.2" }, + { name = "taskipy", specifier = "==1.14.1" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + +[[package]] +name = "deepdiff" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/20/63dd34163ed07393968128dc8c7ab948c96e47c4ce76976ea533de64909d/deepdiff-9.0.0.tar.gz", hash = "sha256:4872005306237b5b50829803feff58a1dfd20b2b357a55de22e7ded65b2008a7", size = 151952, upload-time = "2026-03-30T05:52:23.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/c4/da7089cd7aa4ab554f56e18a7fb08dcfed8fd2ae91fa528f5b1be207a148/deepdiff-9.0.0-py3-none-any.whl", hash = "sha256:b1ae0dd86290d86a03de5fbee728fde43095c1472ae4974bdab23ab4656305bd", size = 170540, upload-time = "2026-03-30T05:52:22.008Z" }, +] + +[[package]] +name = "discord-py" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "audioop-lts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/2e/94ca3f2ff35f086d7d3eeb924054e328b2ac851f0a20302d942c8d29726c/fakeredis-2.32.0.tar.gz", hash = "sha256:63d745b40eb6c8be4899cf2a53187c097ccca3afbca04fdbc5edc8b936cd1d59", size = 171097, upload-time = "2025-10-07T10:46:58.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/1b/84ab7fd197eba5243b6625c78fbcffaa4cf6ac7dda42f95d22165f52187e/fakeredis-2.32.0-py3-none-any.whl", hash = "sha256:c9da8228de84060cfdb72c3cf4555c18c59ba7a5ae4d273f75e4822d6f01ecf8", size = 118422, upload-time = "2025-10-07T10:46:57.643Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lupa" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/a0/327c40cdd59f0ce9dc6faaf73bf6e52b0b22188f1ee2366ef36d0e5c1b85/lupa-2.7.tar.gz", hash = "sha256:73a64ce5dc8cd95b75a330c1513e46e098d40fceed3fea516c09f6595eade889", size = 8121014, upload-time = "2026-04-07T08:54:54.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/b7/18f7e66df0245cee685b22f458e9c614fdff9406aaf77a2e81b452a56da6/lupa-2.7-cp310-abi3-win32.whl", hash = "sha256:9992c5afa5adddabb953685b205cfd42cb6cdaf44316f4e4e2cf1c5dc4815f74", size = 1594773, upload-time = "2026-04-07T08:52:36.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/390470a09b8972772f547b51c29baafd1708b20c46f92ac8c251d1db2fbf/lupa-2.7-cp310-abi3-win_arm64.whl", hash = "sha256:e353bad751b55d48d1b909a6b48fb5b306a2d6cd8404981a1554b356da2cf050", size = 1371622, upload-time = "2026-04-07T08:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/58/b3/83836d5f3d4af2c135b12dd4483592c4064339b075158cbcc8fbfe5e239b/lupa-2.7-cp312-abi3-macosx_10_13_x86_64.whl", hash = "sha256:5bb4461f6127293c866819a22f39a3878d49314f4463b4adf45d3bf968d15c92", size = 1193921, upload-time = "2026-04-07T08:52:59.073Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/9b3db3535ec25f3e091ffd111b39e25a16bd17bcddea5e5a269075ec104d/lupa-2.7-cp312-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:9de6c9263a82bc4f0db73d0c8534bdb8a3a1fd13f448c967d7f3e085dbc50c05", size = 1434166, upload-time = "2026-04-07T08:53:01.119Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b7/a7b8561ceefca78c6b38fcd2edd624dbbd66818d6a7cacdb49155745f44c/lupa-2.7-cp312-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23915808a2475705582e7c8adc17fac5ca9f3a803be184798e06cbd8c7350580", size = 1149951, upload-time = "2026-04-07T08:53:02.814Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1d/ae4f7cc90eb3e42021e0ca7ef5f4ce5e9894a3f46b47d3dfa40d9b749c94/lupa-2.7-cp312-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f720ce493fa9049917954ae1270a5d3c41987f792e34a633f725fa0fc85781e", size = 1409419, upload-time = "2026-04-07T08:53:04.706Z" }, + { url = "https://files.pythonhosted.org/packages/40/17/bd577543997b3e0b9f49525b4a9966817808048e34a30ca3e15939d9de40/lupa-2.7-cp312-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8046dedc75cbd93ee4ec01d3088511079425d9438ffddd7c0de8bac54db6701d", size = 1242571, upload-time = "2026-04-07T08:53:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/3e3c32342dfa906c9c3ab2a88084688c1e22f77be1cc4da44669faa071d3/lupa-2.7-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319c02f8e4ec2dbdccb47bb3676a139f8634b255efafbb433a2c705e315fbb76", size = 1855925, upload-time = "2026-04-07T08:53:08.573Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/c8a7a80dc6f31216fdce524089cea475af7ae1e6b28952fecba076d8a166/lupa-2.7-cp312-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9545a538067f517330faa56d59ff08b0c74910711597dcccc24ad71a97c9cb8c", size = 1128866, upload-time = "2026-04-07T08:53:10.848Z" }, + { url = "https://files.pythonhosted.org/packages/64/fa/a9fe2aaf0605b5556ebb8539c19c023fdd3bc78a0d07811ceba27d3f18c8/lupa-2.7-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:90c0adf6519cac6f2ab1fc094e6ea40650198571e9aace52b8a7e69e316a2b89", size = 1457480, upload-time = "2026-04-07T08:53:13.228Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/dd451cb97c52bcb69b8ecc1e92c87426dd88cb51e79d128accfda2b66949/lupa-2.7-cp312-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:ab47477a37c562c6512a222c0acaddb11d9d69ec16e50913214bc15b2ab3d8bc", size = 1425606, upload-time = "2026-04-07T08:53:14.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/67/af8600ce0268e675f7d23e970bb2a5f6c68a3686884ef2518c9d24c75379/lupa-2.7-cp312-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:4615785072c1185b0ed1caf0dea188abd7890667e730c6d6717d84b317d10e78", size = 1253143, upload-time = "2026-04-07T08:53:17.078Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ec/5ba9bad40d46baebc2f85d22c21b44c1afa38363e00cf8f75ca2add07267/lupa-2.7-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d0bf6bd82d639d6cd527983ecff76450bc8878a75e4434c0fbde5edf16aa2bb4", size = 2395157, upload-time = "2026-04-07T08:53:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/69bbd0cba804e33761709775b769db54187003f98000350ae2807793821a/lupa-2.7-cp312-abi3-win32.whl", hash = "sha256:093e03d520d294a372f2521e5948b5660874c889f189f23b538d59fa1841c365", size = 1606024, upload-time = "2026-04-07T08:53:22.219Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a1/4eee20d28e7170fb50e7b2389946e9b385b9e23c079391648ef7a7b3db10/lupa-2.7-cp312-abi3-win_arm64.whl", hash = "sha256:5b4630d86f3d97613f08cb0cb302ecb2a5266e67fbb2d50eb69ba69f259b5ee0", size = 1364378, upload-time = "2026-04-07T08:53:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/198d43e8abee9febca7bbed5cbd2bbe471f4a08fd20860f5544fb5fcf782/lupa-2.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1d832452975b2251bda891a12e5fc239151f347802fd2f6e2224b3adf5caf49", size = 1209249, upload-time = "2026-04-07T08:53:48.153Z" }, + { url = "https://files.pythonhosted.org/packages/32/c4/433da832d0d1b551f8d699a23f63ee02203b13522e9956a18b19a4770b89/lupa-2.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fbc5dfabab4ef6da94284db04132da1702696cbdb39b57e83e7e577c30d12e3", size = 1826708, upload-time = "2026-04-07T08:53:50.641Z" }, + { url = "https://files.pythonhosted.org/packages/c6/96/060b5e32c70af7a8b47c1ac8b497ade1817fada345f904edaafb28928894/lupa-2.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8b7012f1389e7b8399edf7520e6f3aba1ff99d192b269ccd0a1c452d1fc2064", size = 2366778, upload-time = "2026-04-07T08:53:53.493Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/05a4169973a4e9498268f66fcc778a56331b4fce94feb15b9b41a3f831ed/lupa-2.7-cp314-cp314-win_amd64.whl", hash = "sha256:a2de0c293cb0c67c38963b676017ed2e38c5b76254316e956451ca21589b44fe", size = 1994579, upload-time = "2026-04-07T08:54:05.849Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/651916ffd568ac4261298fdcb64dd8213603ed22ebaab8541bb8114c73d9/lupa-2.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e0fd2c9895e8a456f583ac05b210b406924cf25921a675c42b00806cb14a8f4", size = 1251117, upload-time = "2026-04-07T08:53:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/3b9aea6bad4141c9379f0612c94e7c465204d64738eca25fb03c688fab35/lupa-2.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4abb197d3adb7607651df03853c0f7656d23a8f6d91f43b72d8d7a1032c6e82", size = 1814587, upload-time = "2026-04-07T08:53:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5d/6c31a442643ad5b3fb0fd74236fc4bdf835162e22686213ead92600c560e/lupa-2.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dc4956154e0fb556f1b4d37f97ee83f8ab4288151172ebb869209fb1732070e", size = 2348299, upload-time = "2026-04-07T08:54:00.782Z" }, + { url = "https://files.pythonhosted.org/packages/41/b2/2a18c34abf3e63daa539c29dcbf595de4c1fa482aa632b0045555df333c1/lupa-2.7-cp314-cp314t-win_amd64.whl", hash = "sha256:8e01cefd60857ab39a9fd648c594d9a77872f768d9411e3ba0d84373dafae8fc", size = 2209127, upload-time = "2026-04-07T08:54:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/78/c1/9976b81671b339196a69f4ee96a885864b96cf9541c6c1c76ce00cb08df4/lupa-2.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:10d679932e759d628cd9df56590d6e90dd81040d10552172d01b2419e8f16565", size = 1185876, upload-time = "2026-04-07T08:54:18.172Z" }, + { url = "https://files.pythonhosted.org/packages/08/3e/17109c4f7e333205a08f01c2d3d4222c76ce6f8b6c72791a739b11c6f43a/lupa-2.7-cp39-abi3-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:aa5d3f540fed4534cac696d0a5ee6cb267180b45241501a43db5ca1699d46746", size = 1468828, upload-time = "2026-04-07T08:54:20.621Z" }, + { url = "https://files.pythonhosted.org/packages/4e/25/9bcbd18a5742d0b1b80b783548007dcc57d4159075ff35fd833bf53548a5/lupa-2.7-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:07f2cd100f7278888f23f79d7ad49f3544835501543fa65be74bc5bfd2369aea", size = 1172884, upload-time = "2026-04-07T08:54:22.544Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3f/7e0a6660a84ae3b6d8f9834ff183d0522b123b3b2329f0343f52d469fd1a/lupa-2.7-cp39-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c98d4d085e438d5fcee10e555baf67bc84553417efbe1d163fcc152466696a69", size = 1449860, upload-time = "2026-04-07T08:54:24.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/66e5289a6d086235723fa7516049313eb994a5d6ead9eb8f7f7eb8523172/lupa-2.7-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd273b4fa9c0fcdaed0a541e37ced22117c7aeab8af14d75ece213ce4c37506", size = 1281828, upload-time = "2026-04-07T08:54:27.633Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/5499f13dac1329a9759fd9344622656b3f00c52ea70f9be94de9fb273256/lupa-2.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:14b26aaa7f600c670eec2afeb5cf312cc398c0a1bd958f97f5328e8162d11f78", size = 1910337, upload-time = "2026-04-07T08:54:30.047Z" }, + { url = "https://files.pythonhosted.org/packages/d1/de/cb2dca1f39ada99f311e54f4ede0235ec47398b712482528fc64737df407/lupa-2.7-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:02e24aaf1bc55242fdd4dd6c6b556f927f1c2a7a669deb902d4d1e73d2c9d1d6", size = 1155434, upload-time = "2026-04-07T08:54:31.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2a/8cfc3ae8ec1d474beaa07839b3e200ef0710f0439959cc91a97ef4e432f9/lupa-2.7-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:f810b920787dfb46b3bf3f54d370f2e3c78ea37db214954fc025d2d1e760eff7", size = 1489117, upload-time = "2026-04-07T08:54:33.901Z" }, + { url = "https://files.pythonhosted.org/packages/0e/28/4e4cbc45a36000a37a1109ce4c375a8621e9fa195e86ec1e228138e88a39/lupa-2.7-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:1129d12951c221941c471251ea478db6faa34175ffafadd3dca1f75c9d83e84a", size = 1466206, upload-time = "2026-04-07T08:54:35.865Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/6ed7cb2441213569d5732b9d2b955c4fb0484c94dbb875f3af502e083650/lupa-2.7-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:6dcf4cba4bb5936859a675bb22f0d9291914f3e2840366653eb829b7d8b3035d", size = 1288464, upload-time = "2026-04-07T08:54:37.763Z" }, + { url = "https://files.pythonhosted.org/packages/14/77/5df2e2296eac345c6d391632b50138615cabc4834742acf82d318fe2f89b/lupa-2.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8f899ceacf2d38bdd4c54aa777cec4deafcc7a6b02a0aa65dbca96e57e11d260", size = 2444753, upload-time = "2026-04-07T08:54:39.785Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, +] + +[[package]] +name = "markdownify" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/1b/6f2697b51eaca81f08852fd2734745af15718fea10222a1d40f8a239c4ea/markdownify-1.2.0.tar.gz", hash = "sha256:f6c367c54eb24ee953921804dfe6d6575c5e5b42c643955e7242034435de634c", size = 18771, upload-time = "2025-08-09T17:44:15.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/e2/7af643acb4cae0741dffffaa7f3f7c9e7ab4046724543ba1777c401d821c/markdownify-1.2.0-py3-none-any.whl", hash = "sha256:48e150a1c4993d4d50f282f725c0111bd9eb25645d41fa2f543708fd44161351", size = 15561, upload-time = "2025-08-09T17:44:14.074Z" }, +] + +[[package]] +name = "mslex" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "psutil" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502, upload-time = "2024-12-19T18:21:20.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511, upload-time = "2024-12-19T18:21:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985, upload-time = "2024-12-19T18:21:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488, upload-time = "2024-12-19T18:21:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477, upload-time = "2024-12-19T18:21:55.306Z" }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017, upload-time = "2024-12-19T18:21:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602, upload-time = "2024-12-19T18:22:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, +] + +[[package]] +name = "pycares" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/ad/9d1e96486d2eb5a2672c4d9a2dd372d015b8d7a332c6ac2722c4c8e6bbbf/pycares-4.11.0.tar.gz", hash = "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557", size = 654473, upload-time = "2025-09-09T15:18:21.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/70/a723bc79bdcac60361b40184b649282ac0ab433b90e9cc0975370c2ff9c9/pycares-4.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35", size = 145910, upload-time = "2025-09-09T15:17:26.774Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/46311ef5a384b5f0bb206851135dde8f86b3def38fdbee9e3c03475d35ae/pycares-4.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00", size = 142053, upload-time = "2025-09-09T15:17:27.956Z" }, + { url = "https://files.pythonhosted.org/packages/74/23/d236fc4f134d6311e4ad6445571e8285e84a3e155be36422ff20c0fbe471/pycares-4.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766", size = 637878, upload-time = "2025-09-09T15:17:29.173Z" }, + { url = "https://files.pythonhosted.org/packages/f7/92/6edd41282b3f0e3d9defaba7b05c39730d51c37c165d9d3b319349c975aa/pycares-4.11.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197", size = 687865, upload-time = "2025-09-09T15:17:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a9/4d7cf4d72600fd47d9518f9ce99703a3e8711fb08d2ef63d198056cdc9a9/pycares-4.11.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078", size = 678396, upload-time = "2025-09-09T15:17:32.304Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/e546eeb1d8ff6559e2e3bef31a6ea0c6e57ec826191941f83a3ce900ca89/pycares-4.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134", size = 640786, upload-time = "2025-09-09T15:17:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f5/b4572d9ee9c26de1f8d1dc80730df756276b9243a6794fa3101bbe56613d/pycares-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf", size = 621857, upload-time = "2025-09-09T15:17:34.74Z" }, + { url = "https://files.pythonhosted.org/packages/17/f2/639090376198bcaeff86562b25e1bce05a481cfb1e605f82ce62285230cd/pycares-4.11.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae", size = 670130, upload-time = "2025-09-09T15:17:35.982Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c4/cf40773cd9c36a12cebbe1e9b6fb120f9160dc9bfe0398d81a20b6c69972/pycares-4.11.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a", size = 653133, upload-time = "2025-09-09T15:17:37.179Z" }, + { url = "https://files.pythonhosted.org/packages/32/6b/06054d977b0a9643821043b59f523f3db5e7684c4b1b4f5821994d5fa780/pycares-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d", size = 629344, upload-time = "2025-09-09T15:17:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6f/14bb0c2171a286d512e3f02d6168e608ffe5f6eceab78bf63e3073091ae3/pycares-4.11.0-cp314-cp314-win32.whl", hash = "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01", size = 121804, upload-time = "2025-09-09T15:17:39.388Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/6822f9ad6941027f70e1cf161d8631456531a87061588ed3b1dcad07d49d/pycares-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266", size = 148005, upload-time = "2025-09-09T15:17:40.44Z" }, + { url = "https://files.pythonhosted.org/packages/ea/24/24ff3a80aa8471fbb62785c821a8e90f397ca842e0489f83ebf7ee274397/pycares-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80", size = 119239, upload-time = "2025-09-09T15:17:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/2f3558d298ff8db31d5c83369001ab72af3b86a0374d9b0d40dc63314187/pycares-4.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed", size = 146408, upload-time = "2025-09-09T15:17:43.74Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c8/516901e46a1a73b3a75e87a35f3a3a4fe085f1214f37d954c9d7e782bd6d/pycares-4.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8", size = 142371, upload-time = "2025-09-09T15:17:45.186Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/c3fba0aa575f331ebed91f87ba960ffbe0849211cdf103ab275bc0107ac6/pycares-4.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232", size = 647504, upload-time = "2025-09-09T15:17:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e4/1cdc3ec9c92f8069ec18c58b016b2df7c44a088e2849f37ed457554961aa/pycares-4.11.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7", size = 697122, upload-time = "2025-09-09T15:17:47.772Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d5/bd8f370b97bb73e5bdd55dc2a78e18d6f49181cf77e88af0599d16f5c073/pycares-4.11.0-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c", size = 687543, upload-time = "2025-09-09T15:17:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/33/38/49b77b9cf5dffc0b1fdd86656975c3bc1a58b79bdc883a9ef749b17a013c/pycares-4.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759", size = 649565, upload-time = "2025-09-09T15:17:51.03Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/f6d57bfb99d00a6a7363f95c8d3a930fe82a868d9de24c64c8048d66f16a/pycares-4.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401", size = 631242, upload-time = "2025-09-09T15:17:52.298Z" }, + { url = "https://files.pythonhosted.org/packages/33/a2/7b9121c71cfe06a8474e221593f83a78176fae3b79e5853d2dfd13ab01cc/pycares-4.11.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad", size = 680304, upload-time = "2025-09-09T15:17:53.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/07/dfe76807f637d8b80e1a59dfc4a1bceabdd0205a45b2ebf78b415ae72af3/pycares-4.11.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e", size = 661039, upload-time = "2025-09-09T15:17:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/55d50c5acd46cbe95d0da27740a83e721d89c0ce7e42bff9891a9f29a855/pycares-4.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502", size = 637560, upload-time = "2025-09-09T15:17:56.492Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/2b2e723d1b929dbe7f99e80a56abb29a4f86988c1f73195d960d706b1629/pycares-4.11.0-cp314-cp314t-win32.whl", hash = "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461", size = 122235, upload-time = "2025-09-09T15:17:57.576Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/bf3b3ed9345a38092e72cd9890a5df5c2349fc27846a714d823a41f0ee27/pycares-4.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9", size = 148575, upload-time = "2025-09-09T15:17:58.699Z" }, + { url = "https://files.pythonhosted.org/packages/ce/20/c0c5cfcf89725fe533b27bc5f714dc4efa8e782bf697c36f9ddf04ba975d/pycares-4.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", size = 119690, upload-time = "2025-09-09T15:17:59.809Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pydis-core" +version = "11.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiodns" }, + { name = "discord-py" }, + { name = "pydantic" }, + { name = "statsd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/ed/21ac5a50474576de03491ee230f7d58ab1b14ae73e4e36a7f57a1a325b88/pydis_core-11.8.0.tar.gz", hash = "sha256:8a2579638622bb49e04059100c147b594f2ed23dfd8fbeb9660cb35013cf2099", size = 33570, upload-time = "2025-10-17T18:06:30.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/d9/6c606d5f46ebc353afb113e5a95f0d8581d726e288bcfde276b230b18b3c/pydis_core-11.8.0-py3-none-any.whl", hash = "sha256:f0e49a4a4e1ccecb00ce85c963c3b1f0b4047b214b4e445ecea6889ae5212910", size = 44018, upload-time = "2025-10-17T18:06:29.859Z" }, +] + +[package.optional-dependencies] +all = [ + { name = "async-rediscache" }, + { name = "fakeredis", extra = ["lua"] }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-subtests" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/4c/ba9eab21a2250c2d46c06c0e3cd316850fde9a90da0ac8d0202f074c6817/pytest_subtests-0.14.1.tar.gz", hash = "sha256:350c00adc36c3aff676a66135c81aed9e2182e15f6c3ec8721366918bbbf7580", size = 17632, upload-time = "2024-12-10T00:21:04.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b7/7ca948d35642ae72500efda6ba6fa61dcb6683feb596d19c4747c63c0789/pytest_subtests-0.14.1-py3-none-any.whl", hash = "sha256:e92a780d98b43118c28a16044ad9b841727bd7cb6a417073b38fd2d7ccdf052d", size = 8833, upload-time = "2024-12-10T00:20:58.873Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/fc/a98b616db9a42dcdda7c78c76bdfdf6fe290ac4c5ffbb186f73ec981ad5b/rapidfuzz-3.14.1.tar.gz", hash = "sha256:b02850e7f7152bd1edff27e9d584505b84968cacedee7a734ec4050c655a803c", size = 57869570, upload-time = "2025-09-08T21:08:15.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/36/53debca45fbe693bd6181fb05b6a2fd561c87669edb82ec0d7c1961a43f0/rapidfuzz-3.14.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e84d9a844dc2e4d5c4cabd14c096374ead006583304333c14a6fbde51f612a44", size = 1926336, upload-time = "2025-09-08T21:07:18.809Z" }, + { url = "https://files.pythonhosted.org/packages/ae/32/b874f48609665fcfeaf16cbaeb2bbc210deef2b88e996c51cfc36c3eb7c3/rapidfuzz-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:40301b93b99350edcd02dbb22e37ca5f2a75d0db822e9b3c522da451a93d6f27", size = 1389653, upload-time = "2025-09-08T21:07:20.667Z" }, + { url = "https://files.pythonhosted.org/packages/97/25/f6c5a1ff4ec11edadacb270e70b8415f51fa2f0d5730c2c552b81651fbe3/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fedd5097a44808dddf341466866e5c57a18a19a336565b4ff50aa8f09eb528f6", size = 1380911, upload-time = "2025-09-08T21:07:22.584Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/d322202ef8fab463759b51ebfaa33228100510c82e6153bd7a922e150270/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e3e61c9e80d8c26709d8aa5c51fdd25139c81a4ab463895f8a567f8347b0548", size = 1673515, upload-time = "2025-09-08T21:07:24.417Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/6b2a97f4c6be96cac3749f32301b8cdf751ce5617b1c8934c96586a0662b/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da011a373722fac6e64687297a1d17dc8461b82cb12c437845d5a5b161bc24b9", size = 2219394, upload-time = "2025-09-08T21:07:26.402Z" }, + { url = "https://files.pythonhosted.org/packages/11/bf/afb76adffe4406e6250f14ce48e60a7eb05d4624945bd3c044cfda575fbc/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5967d571243cfb9ad3710e6e628ab68c421a237b76e24a67ac22ee0ff12784d6", size = 3163582, upload-time = "2025-09-08T21:07:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e6405227560f61e956cb4c5de653b0f874751c5ada658d3532d6c1df328e/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:474f416cbb9099676de54aa41944c154ba8d25033ee460f87bb23e54af6d01c9", size = 1221116, upload-time = "2025-09-08T21:07:30.8Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/5b757e2e18de384b11d1daf59608453f0baf5d5d8d1c43e1a964af4dc19a/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae2d57464b59297f727c4e201ea99ec7b13935f1f056c753e8103da3f2fc2404", size = 2402670, upload-time = "2025-09-08T21:07:32.702Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/d753a415fe54531aa882e288db5ed77daaa72e05c1a39e1cbac00d23024f/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:57047493a1f62f11354c7143c380b02f1b355c52733e6b03adb1cb0fe8fb8816", size = 2521659, upload-time = "2025-09-08T21:07:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/d4e7fe1515430db98f42deb794c7586a026d302fe70f0216b638d89cf10f/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4acc20776f225ee37d69517a237c090b9fa7e0836a0b8bc58868e9168ba6ef6f", size = 2788552, upload-time = "2025-09-08T21:07:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/eab05473af7a2cafb4f3994bc6bf408126b8eec99a569aac6254ac757db4/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4373f914ff524ee0146919dea96a40a8200ab157e5a15e777a74a769f73d8a4a", size = 3306261, upload-time = "2025-09-08T21:07:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/2feb8dfcfcff6508230cd2ccfdde7a8bf988c6fda142fe9ce5d3eb15704d/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:37017b84953927807847016620d61251fe236bd4bcb25e27b6133d955bb9cafb", size = 4269522, upload-time = "2025-09-08T21:07:41.663Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/250538d73c8fbab60597c3d131a11ef2a634d38b44296ca11922794491ac/rapidfuzz-3.14.1-cp314-cp314-win32.whl", hash = "sha256:c8d1dd1146539e093b84d0805e8951475644af794ace81d957ca612e3eb31598", size = 1745018, upload-time = "2025-09-08T21:07:44.313Z" }, + { url = "https://files.pythonhosted.org/packages/c5/15/d50839d20ad0743aded25b08a98ffb872f4bfda4e310bac6c111fcf6ea1f/rapidfuzz-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:f51c7571295ea97387bac4f048d73cecce51222be78ed808263b45c79c40a440", size = 1587666, upload-time = "2025-09-08T21:07:46.917Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ff/d73fec989213fb6f0b6f15ee4bbdf2d88b0686197951a06b036111cd1c7d/rapidfuzz-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:01eab10ec90912d7d28b3f08f6c91adbaf93458a53f849ff70776ecd70dd7a7a", size = 835780, upload-time = "2025-09-08T21:07:49.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e7/f0a242687143cebd33a1fb165226b73bd9496d47c5acfad93de820a18fa8/rapidfuzz-3.14.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:60879fcae2f7618403c4c746a9a3eec89327d73148fb6e89a933b78442ff0669", size = 1945182, upload-time = "2025-09-08T21:07:51.84Z" }, + { url = "https://files.pythonhosted.org/packages/96/29/ca8a3f8525e3d0e7ab49cb927b5fb4a54855f794c9ecd0a0b60a6c96a05f/rapidfuzz-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f94d61e44db3fc95a74006a394257af90fa6e826c900a501d749979ff495d702", size = 1413946, upload-time = "2025-09-08T21:07:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ef/6fd10aa028db19c05b4ac7fe77f5613e4719377f630c709d89d7a538eea2/rapidfuzz-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:93b6294a3ffab32a9b5f9b5ca048fa0474998e7e8bb0f2d2b5e819c64cb71ec7", size = 1795851, upload-time = "2025-09-08T21:07:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/acd29ebd906a50f9e0f27d5f82a48cf5e8854637b21489bd81a2459985cf/rapidfuzz-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6cb56b695421538fdbe2c0c85888b991d833b8637d2f2b41faa79cea7234c000", size = 1626748, upload-time = "2025-09-08T21:07:58.166Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f4/dfc7b8c46b1044a47f7ca55deceb5965985cff3193906cb32913121e6652/rapidfuzz-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7cd312c380d3ce9d35c3ec9726b75eee9da50e8a38e89e229a03db2262d3d96b", size = 853771, upload-time = "2025-09-08T21:08:00.816Z" }, +] + +[[package]] +name = "redis" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/88/63d802c2b18dd9eaa5b846cbf18917c6b2882f20efda398cc16a7500b02c/redis-4.6.0.tar.gz", hash = "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d", size = 4561721, upload-time = "2023-06-25T13:13:57.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/2e/409703d645363352a20c944f5d119bdae3eb3034051a53724a7c5fee12b8/redis-4.6.0-py3-none-any.whl", hash = "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c", size = 241149, upload-time = "2023-06-25T13:13:54.563Z" }, +] + +[[package]] +name = "regex" +version = "2026.3.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/93/5ab3e899c47fa7994e524447135a71cd121685a35c8fe35029005f8b236f/regex-2026.3.32.tar.gz", hash = "sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16", size = 415605, upload-time = "2026-03-28T21:49:22.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, + { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, + { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/18/09875b4323b03ca9025bae7e6539797b27e4fc032998a466b4b9c3d24653/sentry_sdk-2.43.0.tar.gz", hash = "sha256:52ed6e251c5d2c084224d73efee56b007ef5c2d408a4a071270e82131d336e20", size = 368953, upload-time = "2025-10-29T11:26:08.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/31/8228fa962f7fd8814d634e4ebece8780e2cdcfbdf0cd2e14d4a6861a7cd5/sentry_sdk-2.43.0-py2.py3-none-any.whl", hash = "sha256:4aacafcf1756ef066d359ae35030881917160ba7f6fc3ae11e0e58b09edc2d5d", size = 400997, upload-time = "2025-10-29T11:26:05.77Z" }, +] + +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "statsd" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/29/05e9f50946f4cf2ed182726c60d9c0ae523bb3f180588c574dd9746de557/statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128", size = 27814, upload-time = "2022-11-06T14:17:36.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d0/c9543b52c067a390ae6ae632d7fd1b97a35cdc8d69d40c0b7d334b326410/statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093", size = 13118, upload-time = "2022-11-06T14:17:34.258Z" }, +] + +[[package]] +name = "taskipy" +version = "1.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mslex", marker = "sys_platform == 'win32'" }, + { name = "psutil" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/44/572261df3db9c6c3332f8618fafeb07a578fd18b06673c73f000f3586749/taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed", size = 14475, upload-time = "2024-11-26T16:37:46.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/97/4e4cfb1391c81e926bebe3d68d5231b5dbc3bb41c6ba48349e68a881462d/taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1", size = 13052, upload-time = "2024-11-26T16:37:44.546Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tldextract" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "idna" }, + { name = "requests" }, + { name = "requests-file" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/78/182641ea38e3cfd56e9c7b3c0d48a53d432eea755003aa544af96403d4ac/tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609", size = 128502, upload-time = "2025-04-22T06:19:37.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/7c/ea488ef48f2f544566947ced88541bc45fae9e0e422b2edbf165ee07da99/tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2", size = 107384, upload-time = "2025-04-22T06:19:36.304Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]