aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Numerlor <[email protected]>2020-12-11 11:03:21 +0100
committerGravatar Numerlor <[email protected]>2020-12-11 11:03:21 +0100
commitbdbe29adce68a05bd78c2cca7785302ed974b171 (patch)
tree4a27c45ff2755d9a48d30f35efed71d328a059f6
parentRestructure doc cache to handle caches of whole pages (diff)
parentMake sure PR build artifact is always uploaded (diff)
Merge remote-tracking branch 'upstream/master' into doc-imp
-rw-r--r--.github/CODEOWNERS36
-rw-r--r--.github/review-policy.yml3
-rw-r--r--.github/workflows/build.yml57
-rw-r--r--.github/workflows/codeql-analysis.yml32
-rw-r--r--.github/workflows/deploy.yml42
-rw-r--r--.github/workflows/lint-test.yml137
-rw-r--r--.github/workflows/status_embed.yaml78
-rw-r--r--.gitignore1
-rw-r--r--Pipfile7
-rw-r--r--Pipfile.lock317
-rw-r--r--README.md18
-rw-r--r--azure-pipelines.yml108
-rw-r--r--bot/__init__.py67
-rw-r--r--bot/__main__.py78
-rw-r--r--bot/bot.py89
-rw-r--r--bot/constants.py16
-rw-r--r--bot/converters.py29
-rw-r--r--bot/exts/backend/sync/_cog.py9
-rw-r--r--bot/exts/backend/sync/_syncers.py66
-rw-r--r--bot/exts/help_channels.py934
-rw-r--r--bot/exts/help_channels/__init__.py41
-rw-r--r--bot/exts/help_channels/_caches.py19
-rw-r--r--bot/exts/help_channels/_channel.py57
-rw-r--r--bot/exts/help_channels/_cog.py520
-rw-r--r--bot/exts/help_channels/_cooldown.py95
-rw-r--r--bot/exts/help_channels/_message.py217
-rw-r--r--bot/exts/help_channels/_name.py69
-rw-r--r--bot/exts/info/codeblock/_cog.py2
-rw-r--r--bot/exts/info/doc/_cog.py9
-rw-r--r--bot/exts/info/help.py6
-rw-r--r--bot/exts/info/tags.py2
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py22
-rw-r--r--bot/exts/moderation/infraction/infractions.py46
-rw-r--r--bot/exts/moderation/infraction/management.py76
-rw-r--r--bot/exts/moderation/infraction/superstarify.py92
-rw-r--r--bot/exts/moderation/verification.py18
-rw-r--r--bot/exts/moderation/voice_gate.py100
-rw-r--r--bot/exts/utils/internal.py4
-rw-r--r--bot/exts/utils/snekbox.py4
-rw-r--r--bot/exts/utils/utils.py18
-rw-r--r--bot/interpreter.py6
-rw-r--r--bot/log.py86
-rw-r--r--bot/resources/tags/microsoft-build-tools.md15
-rw-r--r--bot/rules/discord_emojis.py8
-rw-r--r--bot/utils/channel.py7
-rw-r--r--bot/utils/messages.py4
-rw-r--r--bot/utils/services.py8
-rw-r--r--config-default.yml25
-rw-r--r--docker-compose.yml4
-rw-r--r--tests/bot/exts/backend/sync/test_base.py29
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py34
-rw-r--r--tests/bot/exts/backend/sync/test_roles.py26
-rw-r--r--tests/bot/exts/backend/sync/test_users.py29
-rw-r--r--tests/bot/exts/utils/test_snekbox.py4
-rw-r--r--tests/bot/rules/test_discord_emojis.py29
-rw-r--r--tests/bot/utils/test_services.py39
56 files changed, 2259 insertions, 1635 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cf5f1590d..73e303325 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,35 @@
-* @python-discord/core-developers
+# 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
+bot/exts/help_channels/** @MarkKoz @Akarys42
+bot/exts/moderation/** @Akarys42 @mbaruh @Den4200
+bot/exts/info/** @Akarys42 @mbaruh @Den4200
+bot/exts/filters/** @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
+Dockerfile @MarkKoz @Akarys42 @Den4200
+docker-compose.yml @MarkKoz @Akarys42 @Den4200
+
+# Tools
+Pipfile* @Akarys42
+
+# Statistics
+bot/async_stats.py @jb3
+bot/exts/info/stats.py @jb3
diff --git a/.github/review-policy.yml b/.github/review-policy.yml
new file mode 100644
index 000000000..421b30f8a
--- /dev/null
+++ b/.github/review-policy.yml
@@ -0,0 +1,3 @@
+remote: python-discord/.github
+path: review-policies/core-developers.yml
+ref: main
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..6152f1543
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,57 @@
+name: Build
+
+on:
+ workflow_run:
+ workflows: ["Lint & Test"]
+ branches:
+ - master
+ 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.GHCR_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 }}
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 8760b35ec..000000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: "Code scanning - action"
-
-on:
- push:
- pull_request:
- schedule:
- - cron: '0 12 * * *'
-
-jobs:
- CodeQL-Build:
-
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v2
- with:
- fetch-depth: 2
-
- - run: git checkout HEAD^2
- if: ${{ github.event_name == 'pull_request' }}
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v1
- with:
- languages: python
-
- - name: Autobuild
- uses: github/codeql-action/autobuild@v1
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..5a4aede30
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,42 @@
+name: Deploy
+
+on:
+ workflow_run:
+ workflows: ["Build"]
+ branches:
+ - master
+ types:
+ - completed
+
+jobs:
+ build:
+ 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
new file mode 100644
index 000000000..6fa8e8333
--- /dev/null
+++ b/.github/workflows/lint-test.yml
@@ -0,0 +1,137 @@
+name: Lint & Test
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+
+jobs:
+ lint-test:
+ runs-on: ubuntu-latest
+ env:
+ # Dummy values for required bot environment variables
+ BOT_API_KEY: foo
+ BOT_SENTRY_DSN: blah
+ BOT_TOKEN: bar
+ REDDIT_CLIENT_ID: spam
+ REDDIT_SECRET: ham
+ REDIS_PASSWORD: ''
+
+ # Configure pip to cache dependencies and do a user install
+ PIP_NO_CACHE_DIR: false
+ PIP_USER: 1
+
+ # Hide the graphical elements from pipenv's output
+ PIPENV_HIDE_EMOJIS: 1
+ PIPENV_NOSPIN: 1
+
+ # Make sure pipenv does not try reuse an environment it's running in
+ PIPENV_IGNORE_VIRTUALENVS: 1
+
+ # Specify explicit paths for python dependencies and the pre-commit
+ # environment so we know which directories to cache
+ 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
+
+ - name: Setup python
+ id: python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+
+ # 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('./Pipfile', './Pipfile.lock') }}"
+
+ # Install our dependencies if we did not restore a dependency cache
+ - name: Install dependencies using pipenv
+ if: steps.python_cache.outputs.cache-hit != 'true'
+ run: |
+ pip install pipenv
+ pipenv install --dev --deploy --system
+
+ # 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 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'"
+
+ # 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
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: coveralls
+
+ # Prepare the Pull Request Payload artifact. If this fails, we
+ # we fail silently using the `continue-on-error` option. It's
+ # nice if this succeeds, but if it fails for any reason, it
+ # does not mean that our lint-test checks failed.
+ - name: Prepare Pull Request Payload artifact
+ id: prepare-artifact
+ if: always() && github.event_name == 'pull_request'
+ continue-on-error: true
+ run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json
+
+ # This only makes sense if the previous step succeeded. To
+ # get the original outcome of the previous step before the
+ # `continue-on-error` conclusion is applied, we use the
+ # `.outcome` value. This step also fails silently.
+ - name: Upload a Build Artifact
+ if: always() && steps.prepare-artifact.outcome == 'success'
+ continue-on-error: true
+ uses: actions/upload-artifact@v2
+ with:
+ name: pull-request-payload
+ path: pull_request_payload.json
diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml
new file mode 100644
index 000000000..b6a71b887
--- /dev/null
+++ b/.github/workflows/status_embed.yaml
@@ -0,0 +1,78 @@
+name: Status Embed
+
+on:
+ workflow_run:
+ workflows:
+ - Lint & Test
+ - Build
+ - Deploy
+ types:
+ - completed
+
+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
+
+ steps:
+ # A workflow_run event does not contain all the information
+ # we need for a PR embed. That's why we upload an artifact
+ # with that information in the Lint workflow.
+ - name: Get Pull Request Information
+ id: pr_info
+ if: github.event.workflow_run.event == 'pull_request'
+ run: |
+ 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
+ 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)"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Send an informational status embed to Discord instead of the
+ # standard embeds that Discord sends. This embed will contain
+ # more information and we can fine tune when we actually want
+ # to send an embed.
+ - name: GitHub Actions Status Embed for Discord
+ uses: SebastiaanZ/[email protected]
+ with:
+ # Our GitHub Actions webhook
+ webhook_id: '784184528997842985'
+ webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }}
+
+ # Workflow information
+ 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 }}
+ pr_number: ${{ steps.pr_info.outputs.pr_number }}
+ pr_title: ${{ steps.pr_info.outputs.pr_title }}
+ pr_source: ${{ steps.pr_info.outputs.pr_source }}
diff --git a/.gitignore b/.gitignore
index 2074887ad..9186dbe06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,6 +111,7 @@ ENV/
# Logfiles
log.*
*.log.*
+!log.py
# Custom user configuration
config.yml
diff --git a/Pipfile b/Pipfile
index 70e7d67e7..4ca651c92 100644
--- a/Pipfile
+++ b/Pipfile
@@ -24,6 +24,7 @@ python-dateutil = "~=2.8"
pyyaml = "~=5.1"
sentry-sdk = "~=0.14"
statsd = "~=3.3"
+emoji = "~=0.6"
[dev-packages]
coverage = "~=5.0"
@@ -37,7 +38,7 @@ flake8-tidy-imports = "~=4.0"
flake8-todo = "~=0.7"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
-unittest-xml-reporting = "~=3.0"
+coveralls = "~=2.1"
[requires]
python_version = "3.8"
@@ -46,8 +47,8 @@ python_version = "3.8"
start = "python -m bot"
lint = "pre-commit run --all-files"
precommit = "pre-commit install"
-build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."
-push = "docker push pythondiscord/bot:latest"
+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"
html = "coverage html"
report = "coverage report"
diff --git a/Pipfile.lock b/Pipfile.lock
index f622d9e01..927786f93 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "35130d225126e341941fe36e4193fe53aa253e193a50505054a87f48ab7f7c8c"
+ "sha256": "a033c8f3593c5429469fde8790d26d3acd85c18bb6eddd06ee30d7d282191432"
},
"pipfile-spec": 6,
"requires": {
@@ -114,51 +114,51 @@
},
"certifi": {
"hashes": [
- "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
- "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
+ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
+ "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
],
- "version": "==2020.11.8"
+ "version": "==2020.12.5"
},
"cffi": {
"hashes": [
- "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d",
- "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b",
- "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4",
- "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f",
- "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3",
- "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579",
- "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537",
- "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e",
- "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05",
- "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171",
- "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca",
- "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522",
- "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c",
- "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc",
- "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d",
- "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808",
- "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828",
- "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869",
- "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d",
- "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9",
- "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0",
- "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc",
- "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15",
- "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c",
- "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a",
- "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3",
- "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1",
- "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768",
- "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d",
- "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b",
- "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e",
- "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d",
- "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730",
- "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394",
- "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1",
- "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591"
- ],
- "version": "==1.14.3"
+ "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e",
+ "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d",
+ "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a",
+ "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec",
+ "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362",
+ "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668",
+ "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c",
+ "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b",
+ "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06",
+ "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698",
+ "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2",
+ "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c",
+ "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7",
+ "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
+ "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
+ "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
+ "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
+ "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
+ "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
+ "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26",
+ "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b",
+ "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01",
+ "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb",
+ "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293",
+ "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd",
+ "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d",
+ "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3",
+ "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d",
+ "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e",
+ "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca",
+ "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d",
+ "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775",
+ "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375",
+ "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b",
+ "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b",
+ "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"
+ ],
+ "version": "==1.14.4"
},
"chardet": {
"hashes": [
@@ -178,11 +178,11 @@
},
"coloredlogs": {
"hashes": [
- "sha256:346f58aad6afd48444c2468618623638dadab76e4e70d5e10822676f2d32226a",
- "sha256:a1fab193d2053aa6c0a97608c4342d031f1f93a3d1218432c59322441d31a505"
+ "sha256:7ef1a7219870c7f02c218a2f2877ce68f2f8e087bb3a55bd6fbaa2a4362b4d52",
+ "sha256:e244a892f9d97ffd2c60f15bf1d2582ef7f9ac0f848d132249004184785702b3"
],
"index": "pypi",
- "version": "==14.0"
+ "version": "==14.3"
},
"deepdiff": {
"hashes": [
@@ -200,12 +200,19 @@
"index": "pypi",
"version": "==1.5.1"
},
+ "emoji": {
+ "hashes": [
+ "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"
+ ],
+ "index": "pypi",
+ "version": "==0.6.0"
+ },
"fakeredis": {
"hashes": [
- "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5",
- "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271"
+ "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a",
+ "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73"
],
- "version": "==1.4.4"
+ "version": "==1.4.5"
},
"feedparser": {
"hashes": [
@@ -278,11 +285,11 @@
},
"humanfriendly": {
"hashes": [
- "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12",
- "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080"
+ "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d",
+ "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
- "version": "==8.2"
+ "version": "==9.1"
},
"idna": {
"hashes": [
@@ -294,46 +301,46 @@
},
"lxml": {
"hashes": [
- "sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab",
- "sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b",
- "sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5",
- "sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301",
- "sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b",
- "sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d",
- "sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b",
- "sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9",
- "sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b",
- "sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311",
- "sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891",
- "sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a",
- "sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1",
- "sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856",
- "sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810",
- "sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51",
- "sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360",
- "sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4",
- "sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f",
- "sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230",
- "sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a",
- "sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f",
- "sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174",
- "sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf",
- "sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd",
- "sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3",
- "sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a",
- "sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5",
- "sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367",
- "sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c",
- "sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1",
- "sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8",
- "sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f",
- "sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc",
- "sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d",
- "sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9",
- "sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f"
- ],
- "index": "pypi",
- "version": "==4.6.1"
+ "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d",
+ "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37",
+ "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01",
+ "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2",
+ "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644",
+ "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75",
+ "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80",
+ "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2",
+ "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780",
+ "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98",
+ "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308",
+ "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf",
+ "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388",
+ "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d",
+ "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3",
+ "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8",
+ "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af",
+ "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2",
+ "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e",
+ "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939",
+ "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03",
+ "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d",
+ "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a",
+ "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5",
+ "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a",
+ "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711",
+ "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf",
+ "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089",
+ "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505",
+ "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b",
+ "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f",
+ "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc",
+ "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e",
+ "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931",
+ "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc",
+ "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe",
+ "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e"
+ ],
+ "index": "pypi",
+ "version": "==4.6.2"
},
"markdownify": {
"hashes": [
@@ -449,17 +456,19 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
- "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
- "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
+ "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a",
+ "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
+ "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
- "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
- "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
+ "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"
],
"index": "pypi",
"version": "==5.3.1"
@@ -474,11 +483,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:17b725df2258354ccb39618ae4ead29651aa92c01a92acf72f98efe06ee2e45a",
- "sha256:9040539485226708b5cad0401d76628fba4eed9154bf301c50579767afe344fd"
+ "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0",
+ "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f"
],
"index": "pypi",
- "version": "==0.19.2"
+ "version": "==0.19.5"
},
"six": {
"hashes": [
@@ -497,11 +506,11 @@
},
"soupsieve": {
"hashes": [
- "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55",
- "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232"
+ "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851",
+ "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"
],
"markers": "python_version >= '3.0'",
- "version": "==2.0.1"
+ "version": "==2.1"
},
"statsd": {
"hashes": [
@@ -513,11 +522,11 @@
},
"urllib3": {
"hashes": [
- "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
- "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
- "version": "==1.25.11"
+ "version": "==1.26.2"
},
"yarl": {
"hashes": [
@@ -559,6 +568,13 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0"
},
+ "certifi": {
+ "hashes": [
+ "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
+ "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
+ ],
+ "version": "==2020.12.5"
+ },
"cfgv": {
"hashes": [
"sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
@@ -567,6 +583,13 @@
"markers": "python_full_version >= '3.6.1'",
"version": "==3.2.0"
},
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
"coverage": {
"hashes": [
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
@@ -607,6 +630,14 @@
"index": "pypi",
"version": "==5.3"
},
+ "coveralls": {
+ "hashes": [
+ "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc",
+ "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"
+ ],
+ "index": "pypi",
+ "version": "==2.2.0"
+ },
"distlib": {
"hashes": [
"sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
@@ -614,6 +645,12 @@
],
"version": "==0.3.1"
},
+ "docopt": {
+ "hashes": [
+ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
+ ],
+ "version": "==0.6.2"
+ },
"filelock": {
"hashes": [
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
@@ -639,11 +676,11 @@
},
"flake8-bugbear": {
"hashes": [
- "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
- "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
+ "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538",
+ "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"
],
"index": "pypi",
- "version": "==20.1.4"
+ "version": "==20.11.1"
},
"flake8-docstrings": {
"hashes": [
@@ -693,11 +730,19 @@
},
"identify": {
"hashes": [
- "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12",
- "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513"
+ "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5",
+ "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.9"
+ "version": "==1.5.10"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
},
"mccabe": {
"hashes": [
@@ -723,11 +768,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315",
- "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6"
+ "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0",
+ "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"
],
"index": "pypi",
- "version": "==2.8.2"
+ "version": "==2.9.3"
},
"pycodestyle": {
"hashes": [
@@ -755,21 +800,31 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
- "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
- "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
+ "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a",
+ "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
+ "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
- "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
- "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
+ "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"
],
"index": "pypi",
"version": "==5.3.1"
},
+ "requests": {
+ "hashes": [
+ "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
+ "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==2.25.0"
+ },
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@@ -793,21 +848,21 @@
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
- "unittest-xml-reporting": {
+ "urllib3": {
"hashes": [
- "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
- "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
- "index": "pypi",
- "version": "==3.0.4"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==1.26.2"
},
"virtualenv": {
"hashes": [
- "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2",
- "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"
+ "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c",
+ "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.1.0"
+ "version": "==20.2.2"
}
}
}
diff --git a/README.md b/README.md
index b37ece296..c813997e7 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
# Python Utility Bot
-[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
-[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)
-[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
-[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
+[![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)
[![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE)
[![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com)
@@ -11,3 +12,12 @@ This project is a Discord bot specifically for use with the Python Discord serve
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=master
+[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amaster
+[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=master
+[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster
+[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master
+[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster
+[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white
+[8]: https://discord.gg/2B963hn
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index 9f58e38c8..000000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,108 +0,0 @@
-# https://aka.ms/yaml
-
-variables:
- PIP_NO_CACHE_DIR: false
- PIP_USER: 1
- PIPENV_HIDE_EMOJIS: 1
- PIPENV_IGNORE_VIRTUALENVS: 1
- PIPENV_NOSPIN: 1
- PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache
- PYTHONUSERBASE: $(Pipeline.Workspace)/py-user-base
-
-jobs:
- - job: test
- displayName: 'Lint & Test'
- pool:
- vmImage: ubuntu-18.04
-
- variables:
- BOT_API_KEY: foo
- BOT_SENTRY_DSN: blah
- BOT_TOKEN: bar
- REDDIT_CLIENT_ID: spam
- REDDIT_SECRET: ham
- REDIS_PASSWORD: ''
-
- steps:
- - task: UsePythonVersion@0
- displayName: 'Set Python version'
- name: python
- inputs:
- versionSpec: '3.8.x'
- addToPath: true
-
- - task: Cache@2
- displayName: 'Restore Python environment'
- inputs:
- key: python | $(Agent.OS) | "$(python.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock
- cacheHitVar: PY_ENV_RESTORED
- path: $(PYTHONUSERBASE)
- continueOnError: true
-
- - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin'
- displayName: 'Prepend PATH'
-
- - script: pip install pipenv
- displayName: 'Install pipenv'
- condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true'))
-
- - script: pipenv install --dev --deploy --system
- displayName: 'Install project using pipenv'
- condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true'))
-
- # Create an executable shell script which replaces the original pipenv binary.
- # The shell script ignores the first argument and executes the rest of the args as a command.
- # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing
- # pipenv entirely, which is too dumb to know it should use the system interpreter rather than
- # creating a new venv.
- - script: |
- printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(python.pythonLocation)/bin/pipenv \
- && chmod +x $(python.pythonLocation)/bin/pipenv
- displayName: 'Mock pipenv binary'
-
- - task: Cache@2
- displayName: 'Restore pre-commit environment'
- inputs:
- key: pre-commit | "$(python.pythonLocation)" | 0 | .pre-commit-config.yaml
- path: $(PRE_COMMIT_HOME)
- continueOnError: true
-
- # pre-commit's venv doesn't allow user installs - not that they're really needed anyway.
- - script: export PIP_USER=0; pre-commit run --all-files
- displayName: 'Run pre-commit hooks'
-
- - script: coverage run -m xmlrunner
- displayName: Run tests
-
- - script: coverage report -m && coverage xml -o coverage.xml
- displayName: Generate test coverage report
-
- - task: PublishCodeCoverageResults@1
- displayName: 'Publish Coverage Results'
- condition: succeededOrFailed()
- inputs:
- codeCoverageTool: Cobertura
- summaryFileLocation: coverage.xml
-
- - task: PublishTestResults@2
- condition: succeededOrFailed()
- displayName: 'Publish Test Results'
- inputs:
- testResultsFiles: '**/TEST-*.xml'
- testRunTitle: 'Bot Test Results'
-
- - job: build
- displayName: 'Build & Push Container'
- dependsOn: 'test'
- condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
-
- steps:
- - task: Docker@2
- displayName: 'Build & Push Container'
- inputs:
- containerRegistry: 'DockerHub'
- repository: 'pythondiscord/bot'
- command: 'buildAndPush'
- Dockerfile: 'Dockerfile'
- buildContext: '.'
- tags: 'latest'
diff --git a/bot/__init__.py b/bot/__init__.py
index 4fce04532..8f880b8e6 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,78 +1,25 @@
import asyncio
-import logging
import os
-import sys
from functools import partial, partialmethod
-from logging import Logger, handlers
-from pathlib import Path
+from typing import TYPE_CHECKING
-import coloredlogs
from discord.ext import commands
+from bot import log
from bot.command import Command
-TRACE_LEVEL = logging.TRACE = 5
-logging.addLevelName(TRACE_LEVEL, "TRACE")
-
-
-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)
-
-
-Logger.trace = monkeypatch_trace
-
-DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local")
-
-log_level = TRACE_LEVEL if DEBUG_MODE else logging.INFO
-format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
-log_format = logging.Formatter(format_string)
-
-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.setLevel(log_level)
-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
-
-if "COLOREDLOGS_LOG_LEVEL" not in os.environ:
- coloredlogs.DEFAULT_LOG_LEVEL = log_level
-
-coloredlogs.install(logger=root_log, stream=sys.stdout)
-
-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)
+if TYPE_CHECKING:
+ from bot.bot import Bot
+log.setup()
# On Windows, the selector event loop is required for aiodns.
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)
+
+instance: "Bot" = None # Global Bot instance.
diff --git a/bot/__main__.py b/bot/__main__.py
index 367be1300..257216fa7 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,76 +1,10 @@
-import asyncio
-import logging
-
-import discord
-import sentry_sdk
-from async_rediscache import RedisSession
-from discord.ext.commands import when_mentioned_or
-from sentry_sdk.integrations.aiohttp import AioHttpIntegration
-from sentry_sdk.integrations.logging import LoggingIntegration
-from sentry_sdk.integrations.redis import RedisIntegration
-
+import bot
from bot import constants
from bot.bot import Bot
-from bot.utils.extensions import EXTENSIONS
-
-# Set up Sentry.
-sentry_logging = LoggingIntegration(
- level=logging.DEBUG,
- event_level=logging.WARNING
-)
-
-sentry_sdk.init(
- dsn=constants.Bot.sentry_dsn,
- integrations=[
- sentry_logging,
- AioHttpIntegration(),
- RedisIntegration(),
- ]
-)
-
-# Create the redis session instance.
-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",
-)
-
-# Connect redis session to ensure it's connected before we try to access Redis
-# from somewhere within the bot. We create the event loop in the same way
-# discord.py normally does and pass it to the bot's __init__.
-loop = asyncio.get_event_loop()
-loop.run_until_complete(redis_session.connect())
-
-
-# Instantiate the bot.
-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
-bot = Bot(
- redis_session=redis_session,
- loop=loop,
- command_prefix=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,
-)
-
-# Load extensions.
-extensions = set(EXTENSIONS) # Create a mutable copy.
-if not constants.HelpChannels.enable:
- extensions.remove("bot.exts.help_channels")
+from bot.log import setup_sentry
-for extension in extensions:
- bot.load_extension(extension)
+setup_sentry()
-bot.run(constants.Bot.token)
+bot.instance = Bot.create()
+bot.instance.load_extensions()
+bot.instance.run(constants.Bot.token)
diff --git a/bot/bot.py b/bot/bot.py
index b2e5237fe..f71f5d1fb 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -11,10 +11,11 @@ from async_rediscache import RedisSession
from discord.ext import commands
from sentry_sdk import push_scope
-from bot import DEBUG_MODE, api, constants
+from bot import api, constants
from bot.async_stats import AsyncStatsClient
log = logging.getLogger('bot')
+LOCALHOST = "127.0.0.1"
class Bot(commands.Bot):
@@ -36,17 +37,38 @@ class Bot(commands.Bot):
self._connector = None
self._resolver = None
+ self._statsd_timerhandle: asyncio.TimerHandle = None
self._guild_available = asyncio.Event()
statsd_url = constants.Stats.statsd_host
- if DEBUG_MODE:
+ 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 = "127.0.0.1"
+ statsd_url = LOCALHOST
- self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot")
+ 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
+ )
async def cache_filter_list_data(self) -> None:
"""Cache all the data in the FilterList on the site."""
@@ -95,6 +117,43 @@ class Bot(commands.Bot):
# Build the FilterList cache
self.loop.create_task(self.cache_filter_list_data())
+ @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)
@@ -152,6 +211,9 @@ class Bot(commands.Bot):
if self.redis_session:
await self.redis_session.close()
+ if self._statsd_timerhandle:
+ self._statsd_timerhandle.cancel()
+
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"]
@@ -243,3 +305,22 @@ class Bot(commands.Bot):
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",
+ )
+ loop.run_until_complete(redis_session.connect())
+ return redis_session
diff --git a/bot/constants.py b/bot/constants.py
index 4d41f4eb2..08ae0d52f 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -248,6 +248,7 @@ class Colours(metaclass=YAMLGetter):
soft_red: int
soft_green: int
soft_orange: int
+ bright_green: int
class DuckPond(metaclass=YAMLGetter):
@@ -354,6 +355,8 @@ class Icons(metaclass=YAMLGetter):
voice_state_green: str
voice_state_red: str
+ green_checkmark: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -361,6 +364,7 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+
class Stats(metaclass=YAMLGetter):
section = "bot"
subsection = "stats"
@@ -387,12 +391,15 @@ class Channels(metaclass=YAMLGetter):
admin_announcements: int
admin_spam: int
admins: int
+ admins_voice: int
announcements: int
attachment_log: int
big_brother_logs: int
bot_commands: int
change_log: int
- code_help_voice: int
+ code_help_chat_1: int
+ code_help_chat_2: int
+ code_help_voice_1: int
code_help_voice_2: int
cooldown: int
defcon: int
@@ -401,8 +408,8 @@ class Channels(metaclass=YAMLGetter):
dev_log: int
dm_log: int
esoteric: int
+ general_voice: int
helpers: int
- how_to_get_help: int
incidents: int
incidents_archive: int
mailing_lists: int
@@ -422,6 +429,8 @@ class Channels(metaclass=YAMLGetter):
python_news: int
reddit: int
staff_announcements: int
+ staff_voice: int
+ staff_voice_chat: int
talent_pool: int
user_event_announcements: int
user_log: int
@@ -601,6 +610,7 @@ class VoiceGate(metaclass=YAMLGetter):
minimum_messages: int
bot_message_delete_delay: int
minimum_activity_blocks: int
+ voice_ping_delete_delay: int
class Event(Enum):
@@ -630,7 +640,7 @@ class Event(Enum):
# Debug mode
-DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
+DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local")
# Paths
BOT_DIR = os.path.dirname(__file__)
diff --git a/bot/converters.py b/bot/converters.py
index 901ba1cca..d44b675a7 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -567,6 +567,35 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
return int(match.group(1))
+class Infraction(Converter):
+ """
+ Attempts to convert a given infraction ID into an infraction.
+
+ Alternatively, `l`, `last`, or `recent` can be passed in order to
+ obtain the most recent infraction by the actor.
+ """
+
+ async def convert(self, ctx: Context, arg: str) -> t.Optional[dict]:
+ """Attempts to convert `arg` into an infraction `dict`."""
+ if arg in ("l", "last", "recent"):
+ params = {
+ "actor__id": ctx.author.id,
+ "ordering": "-inserted_at"
+ }
+
+ infractions = await ctx.bot.api_client.get("bot/infractions", params=params)
+
+ if not infractions:
+ raise BadArgument(
+ "Couldn't find most recent infraction; you have never given an infraction."
+ )
+ else:
+ 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)
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
index 6e85e2b7d..48d2b6f02 100644
--- a/bot/exts/backend/sync/_cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -18,9 +18,6 @@ class Sync(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- self.role_syncer = _syncers.RoleSyncer(self.bot)
- self.user_syncer = _syncers.UserSyncer(self.bot)
-
self.bot.loop.create_task(self.sync_guild())
async def sync_guild(self) -> None:
@@ -31,7 +28,7 @@ class Sync(Cog):
if guild is None:
return
- for syncer in (self.role_syncer, self.user_syncer):
+ for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer):
await syncer.sync(guild)
async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None:
@@ -171,10 +168,10 @@ class Sync(Cog):
@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 self.role_syncer.sync(ctx.guild, ctx)
+ await _syncers.RoleSyncer.sync(ctx.guild, ctx)
@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."""
- await self.user_syncer.sync(ctx.guild, ctx)
+ await _syncers.UserSyncer.sync(ctx.guild, ctx)
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index 38468c2b1..2eb9f9971 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -6,8 +6,8 @@ from collections import namedtuple
from discord import Guild
from discord.ext.commands import Context
+import bot
from bot.api import ResponseCodeError
-from bot.bot import Bot
log = logging.getLogger(__name__)
@@ -17,57 +17,60 @@ _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.
+# However, methods are kept abstract to at least symbolise that they should be abstract.
class Syncer(abc.ABC):
"""Base class for synchronising the database with objects in the Discord cache."""
- def __init__(self, bot: Bot) -> None:
- self.bot = bot
-
+ @staticmethod
@property
@abc.abstractmethod
- def name(self) -> str:
+ def name() -> str:
"""The name of the syncer; used in output messages and logging."""
raise NotImplementedError # pragma: no cover
+ @staticmethod
@abc.abstractmethod
- async def _get_diff(self, guild: Guild) -> _Diff:
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference between the cache of `guild` and the database."""
raise NotImplementedError # pragma: no cover
+ @staticmethod
@abc.abstractmethod
- async def _sync(self, diff: _Diff) -> None:
+ async def _sync(diff: _Diff) -> None:
"""Perform the API calls for synchronisation."""
raise NotImplementedError # pragma: no cover
- async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:
+ @classmethod
+ async def sync(cls, guild: Guild, ctx: t.Optional[Context] = None) -> None:
"""
Synchronise the database with the cache of `guild`.
If `ctx` is given, send a message with the results.
"""
- log.info(f"Starting {self.name} syncer.")
+ log.info(f"Starting {cls.name} syncer.")
if ctx:
- message = await ctx.send(f"📊 Synchronising {self.name}s.")
+ message = await ctx.send(f"📊 Synchronising {cls.name}s.")
else:
message = None
- diff = await self._get_diff(guild)
+ diff = await cls._get_diff(guild)
try:
- await self._sync(diff)
+ await cls._sync(diff)
except ResponseCodeError as e:
- log.exception(f"{self.name} syncer failed!")
+ log.exception(f"{cls.name} syncer failed!")
# Don't show response text because it's probably some really long HTML.
results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```"
- content = f":x: Synchronisation of {self.name}s failed: {results}"
+ content = f":x: Synchronisation of {cls.name}s failed: {results}"
else:
diff_dict = diff._asdict()
results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None)
results = ", ".join(results)
- log.info(f"{self.name} syncer finished: {results}.")
- content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"
+ log.info(f"{cls.name} syncer finished: {results}.")
+ content = f":ok_hand: Synchronisation of {cls.name}s complete: {results}"
if message:
await message.edit(content=content)
@@ -78,10 +81,11 @@ class RoleSyncer(Syncer):
name = "role"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ 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 self.bot.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.
@@ -110,19 +114,20 @@ class RoleSyncer(Syncer):
return _Diff(roles_to_create, roles_to_update, roles_to_delete)
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ 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 self.bot.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 self.bot.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 self.bot.api_client.delete(f'bot/roles/{role.id}')
+ await bot.instance.api_client.delete(f'bot/roles/{role.id}')
class UserSyncer(Syncer):
@@ -130,7 +135,8 @@ class UserSyncer(Syncer):
name = "user"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference of users between the cache of `guild` and the database."""
log.trace("Getting the diff for users.")
@@ -138,7 +144,7 @@ class UserSyncer(Syncer):
users_to_update = []
seen_guild_users = set()
- async for db_user in self._get_users():
+ async for db_user in UserSyncer._get_users():
# Store user fields which are to be updated.
updated_fields = {}
@@ -185,24 +191,26 @@ class UserSyncer(Syncer):
return _Diff(users_to_create, users_to_update, None)
- async def _get_users(self) -> t.AsyncIterable:
+ @staticmethod
+ async def _get_users() -> t.AsyncIterable:
"""GET users from database."""
query_params = {
"page": 1
}
while query_params["page"]:
- res = await self.bot.api_client.get("bot/users", params=query_params)
+ res = await bot.instance.api_client.get("bot/users", params=query_params)
for user in res["results"]:
yield user
query_params["page"] = res["next_page_no"]
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ async def _sync(diff: _Diff) -> None:
"""Synchronise the database with the user cache of `guild`."""
log.trace("Syncing created users...")
if diff.created:
- await self.bot.api_client.post("bot/users", json=diff.created)
+ await bot.instance.api_client.post("bot/users", json=diff.created)
log.trace("Syncing updated users...")
if diff.updated:
- await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated)
+ await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated)
diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py
deleted file mode 100644
index 062d4fcfe..000000000
--- a/bot/exts/help_channels.py
+++ /dev/null
@@ -1,934 +0,0 @@
-import asyncio
-import json
-import logging
-import random
-import typing as t
-from collections import deque
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-
-import discord
-import discord.abc
-from async_rediscache import RedisCache
-from discord.ext import commands
-
-from bot import constants
-from bot.bot import Bot
-from bot.utils import channel as channel_utils
-from bot.utils.scheduling import Scheduler
-
-log = logging.getLogger(__name__)
-
-ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
-MAX_CHANNELS_PER_CATEGORY = 50
-EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown)
-
-HELP_CHANNEL_TOPIC = """
-This is a Python help channel. You can claim your own help channel in the Python Help: Available category.
-"""
-
-AVAILABLE_MSG = f"""
-This help channel is now **available**, which means that you can claim it by simply typing your \
-question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \
-and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \
-is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \
-the **Help: Dormant** category.
-
-Try to write the best question you can by providing a detailed description and telling us what \
-you've tried already. For more information on asking a good question, \
-check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
-"""
-
-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})**.
-"""
-
-CoroutineFunc = t.Callable[..., t.Coroutine]
-
-
-class HelpChannels(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
-
- * Contains all channels which are occupied by someone needing help
- * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
- * 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
-
- Dormant Category
-
- * Contains channels which aren't in use
- * Channels are used to refill the Available category
-
- Help channels are named after the chemical elements in `bot/resources/elements.json`.
- """
-
- # This cache tracks which channels are claimed by which members.
- # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]]
- help_channel_claimants = RedisCache()
-
- # This cache maps a help channel to whether it has had any
- # activity other than the original claimant. True being no other
- # activity and False being other activity.
- # RedisCache[discord.TextChannel.id, bool]
- unanswered = RedisCache()
-
- # This dictionary maps a help channel to the time it was claimed
- # RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
- claim_times = RedisCache()
-
- # This cache maps a help channel to original question message in same channel.
- # RedisCache[discord.TextChannel.id, discord.Message.id]
- question_messages = RedisCache()
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
-
- # 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.name_positions = self.get_names()
- self.last_notification: t.Optional[datetime] = None
-
- # Asyncio stuff
- self.queue_tasks: t.List[asyncio.Task] = []
- self.ready = asyncio.Event()
- self.on_message_lock = asyncio.Lock()
- 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()
-
- self.scheduler.cancel_all()
-
- 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(self.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)
-
- def create_name_queue(self) -> deque:
- """Return a queue of element names to use for creating new channels."""
- log.trace("Creating the chemical element name queue.")
-
- used_names = self.get_used_names()
-
- log.trace("Determining the available names.")
- available_names = (name for name in self.name_positions if name not in used_names)
-
- log.trace("Populating the name queue with names.")
- return deque(available_names)
-
- async def dormant_check(self, ctx: commands.Context) -> bool:
- """Return True if the user is the help channel claimant or passes the role check."""
- if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.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)
- async def close_command(self, ctx: commands.Context) -> None:
- """
- Make the current in-use help channel dormant.
-
- Make the channel dormant if the user passes the `dormant_check`,
- delete the message that invoked this,
- and reset the send permissions cooldown for the user who started the session.
- """
- log.trace("close command invoked; checking if the channel is in-use.")
- if ctx.channel.category == self.in_use_category:
- if await self.dormant_check(ctx):
- await self.remove_cooldown_role(ctx.author)
-
- # Ignore missing task when cooldown has passed but the channel still isn't dormant.
- if ctx.author.id in self.scheduler:
- self.scheduler.cancel(ctx.author.id)
-
- await self.move_to_dormant(ctx.channel, "command")
- self.scheduler.cancel(ctx.channel.id)
- else:
- log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel")
-
- 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.")
- await self.notify()
- channel = await self.wait_for_dormant_channel()
-
- return channel
-
- @staticmethod
- def get_clean_channel_name(channel: discord.TextChannel) -> str:
- """Return a clean channel name without status emojis prefix."""
- prefix = constants.HelpChannels.name_prefix
- try:
- # Try to remove the status prefix using the index of the channel prefix
- name = channel.name[channel.name.index(prefix):]
- log.trace(f"The clean name for `{channel}` is `{name}`")
- except ValueError:
- # If, for some reason, the channel name does not contain "help-" fall back gracefully
- log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.")
- name = channel.name
-
- return name
-
- @staticmethod
- 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
-
- def get_category_channels(self, 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 self.bot.get_guild(constants.Guild.id).channels:
- if channel.category_id == category.id and not self.is_excluded_channel(channel):
- yield channel
-
- async def get_in_use_time(self, 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 self.claim_times.get(channel_id)
- if claimed_timestamp:
- claimed = datetime.utcfromtimestamp(claimed_timestamp)
- return datetime.utcnow() - claimed
-
- @staticmethod
- def get_names() -> t.List[str]:
- """
- Return a truncated list of prefixed element 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} element names from JSON.")
-
- with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file:
- all_names = json.load(elements_file)
-
- if prefix:
- return [prefix + name for name in all_names[:count]]
- else:
- return all_names[:count]
-
- def get_used_names(self) -> t.Set[str]:
- """Return channel names which are already being used."""
- log.trace("Getting channel names which are already being used.")
-
- names = set()
- for cat in (self.available_category, self.in_use_category, self.dormant_category):
- for channel in self.get_category_channels(cat):
- names.add(self.get_clean_channel_name(channel))
-
- 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
-
- @classmethod
- async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]:
- """
- Return the time elapsed, in seconds, since the last message sent in the `channel`.
-
- Return None if the channel has no messages.
- """
- log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
-
- msg = await cls.get_last_message(channel)
- if not msg:
- log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
- return None
-
- idle_time = (datetime.utcnow() - msg.created_at).seconds
-
- log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
- return idle_time
-
- @staticmethod
- 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 init_available(self) -> None:
- """Initialise the Available category with channels."""
- log.trace("Initialising the Available category with channels.")
-
- channels = list(self.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.move_to_dormant(channel, "auto")
-
- 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.bot
- )
- self.in_use_category = await channel_utils.try_get_channel(
- constants.Categories.help_in_use,
- self.bot
- )
- self.dormant_category = await channel_utils.try_get_channel(
- constants.Categories.help_dormant,
- self.bot
- )
- 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()
- await self.check_cooldowns()
-
- self.channel_queue = self.create_channel_queue()
- self.name_queue = self.create_name_queue()
-
- log.trace("Moving or rescheduling in-use channels.")
- for channel in self.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
-
- await self.init_available()
-
- log.info("Cog is ready!")
- self.ready.set()
-
- self.report_stats()
-
- def report_stats(self) -> None:
- """Report the channel count stats."""
- total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category))
- total_available = sum(1 for _ in self.get_category_channels(self.available_category))
- total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category))
-
- self.bot.stats.gauge("help.total.in_use", total_in_use)
- self.bot.stats.gauge("help.total.available", total_available)
- self.bot.stats.gauge("help.total.dormant", total_dormant)
-
- @staticmethod
- def is_claimant(member: discord.Member) -> bool:
- """Return True if `member` has the 'Help Cooldown' role."""
- return any(constants.Roles.help_cooldown == role.id for role in member.roles)
-
- def match_bot_embed(self, 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 == self.bot.user and bot_msg_desc.strip() == description.strip()
-
- 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}).")
-
- if not await self.is_empty(channel):
- idle_seconds = constants.HelpChannels.idle_minutes * 60
- else:
- idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60
-
- time_elapsed = await self.get_idle_time(channel)
-
- if time_elapsed is None or time_elapsed >= idle_seconds:
- log.info(
- f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
- f"and will be made dormant."
- )
-
- await self.move_to_dormant(channel, "auto")
- else:
- # Cancel the existing task, if any.
- if has_task:
- self.scheduler.cancel(channel.id)
-
- delay = idle_seconds - time_elapsed
- 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_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None:
- """
- Move the `channel` to the bottom position of `category` and edit channel attributes.
-
- 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.
-
- 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.
- """
- # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had.
- category = await channel_utils.try_get_channel(category_id, self.bot)
-
- 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 self.bot.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)
-
- 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 self.send_available_message(channel)
-
- log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
-
- await self.move_to_bottom_position(
- channel=channel,
- category_id=constants.Categories.help_available,
- )
-
- self.report_stats()
-
- async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None:
- """
- Make the `channel` dormant.
-
- A caller argument is provided for metrics.
- """
- log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
-
- await self.help_channel_claimants.delete(channel.id)
- await self.move_to_bottom_position(
- channel=channel,
- category_id=constants.Categories.help_dormant,
- )
-
- self.bot.stats.incr(f"help.dormant_calls.{caller}")
-
- in_use_time = await self.get_in_use_time(channel.id)
- if in_use_time:
- self.bot.stats.timing("help.in_use_time", in_use_time)
-
- unanswered = await self.unanswered.get(channel.id)
- if unanswered:
- self.bot.stats.incr("help.sessions.unanswered")
- elif unanswered is not None:
- self.bot.stats.incr("help.sessions.answered")
-
- log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
- log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
- embed = discord.Embed(description=DORMANT_MSG)
- await channel.send(embed=embed)
-
- await self.unpin(channel)
-
- log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
- self.channel_queue.put_nowait(channel)
- self.report_stats()
-
- 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 self.move_to_bottom_position(
- channel=channel,
- category_id=constants.Categories.help_in_use,
- )
-
- timeout = constants.HelpChannels.idle_minutes * 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))
- self.report_stats()
-
- async def notify(self) -> None:
- """
- Send a message notifying about a lack of available help channels.
-
- Configuration:
-
- * `HelpChannels.notify` - toggle notifications
- * `HelpChannels.notify_channel` - destination channel for 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 self.last_notification:
- elapsed = (datetime.utcnow() - self.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.")
-
- channel = self.bot.get_channel(constants.HelpChannels.notify_channel)
- 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)
- )
-
- self.bot.stats.incr("help.out_of_channel_alerts")
-
- self.last_notification = 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 check_for_answer(self, message: discord.Message) -> None:
- """Checks for whether new content in a help channel comes from non-claimants."""
- channel = message.channel
-
- # Confirm the channel is an in use help channel
- if channel_utils.is_in_category(channel, constants.Categories.help_in_use):
- log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
-
- # Check if there is an entry in unanswered
- if await self.unanswered.contains(channel.id):
- claimant_id = await self.help_channel_claimants.get(channel.id)
- if not claimant_id:
- # The mapping for this channel doesn't exist, we can't do anything.
- return
-
- # Check the message did not come from the claimant
- if claimant_id != message.author.id:
- # Mark the channel as answered
- await self.unanswered.set(channel.id, False)
-
- @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.
-
- channel = message.channel
-
- await self.check_for_answer(message)
-
- is_available = channel_utils.is_in_category(channel, constants.Categories.help_available)
- if not is_available or self.is_excluded_channel(channel):
- return # Ignore messages outside the Available category or in excluded channels.
-
- log.trace("Waiting for the cog to be ready before processing messages.")
- await self.ready.wait()
-
- log.trace("Acquiring lock to prevent a channel from being processed twice...")
- async with self.on_message_lock:
- log.trace(f"on_message lock acquired for {message.id}.")
-
- if not channel_utils.is_in_category(channel, constants.Categories.help_available):
- log.debug(
- f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
- f"because another message in the channel already triggered that."
- )
- return
-
- log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")
- await self.move_to_in_use(channel)
- await self.revoke_send_permissions(message.author)
-
- await self.pin(message)
-
- # Add user with channel for dormant check.
- await self.help_channel_claimants.set(channel.id, message.author.id)
-
- self.bot.stats.incr("help.claimed")
-
- # Must use a timezone-aware datetime to ensure a correct POSIX timestamp.
- timestamp = datetime.now(timezone.utc).timestamp()
- await self.claim_times.set(channel.id, timestamp)
-
- await self.unanswered.set(channel.id, True)
-
- log.trace(f"Releasing on_message lock for {message.id}.")
-
- # Move a dormant channel to the Available category to fill in the gap.
- # This is done last and outside the lock because it may wait indefinitely for a channel to
- # be put in the queue.
- await self.move_to_available()
-
- @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`.
- """
- if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):
- return
-
- if not await self.is_empty(msg.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 is_empty(self, 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 self.match_bot_embed(msg, AVAILABLE_MSG):
- log.trace(f"#{channel} ({channel.id}) has the available message embed.")
- return True
-
- return False
-
- async def check_cooldowns(self) -> None:
- """Remove expired cooldowns and re-schedule active ones."""
- log.trace("Checking all cooldowns to remove or re-schedule them.")
- guild = self.bot.get_guild(constants.Guild.id)
- cooldown = constants.HelpChannels.claim_minutes * 60
-
- for channel_id, member_id in await self.help_channel_claimants.items():
- member = guild.get_member(member_id)
- if not member:
- continue # Member probably left the guild.
-
- in_use_time = await self.get_in_use_time(channel_id)
-
- if not in_use_time or in_use_time.seconds > cooldown:
- # Remove the role if no claim time could be retrieved or if the cooldown expired.
- # Since the channel is in the claimants cache, it is definitely strange for a time
- # to not exist. However, it isn't a reason to keep the user stuck with a cooldown.
- await self.remove_cooldown_role(member)
- else:
- # The member is still on a cooldown; re-schedule it for the remaining time.
- delay = cooldown - in_use_time.seconds
- self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member))
-
- async def add_cooldown_role(self, member: discord.Member) -> None:
- """Add the help cooldown role to `member`."""
- log.trace(f"Adding cooldown role for {member} ({member.id}).")
- await self._change_cooldown_role(member, member.add_roles)
-
- async def remove_cooldown_role(self, member: discord.Member) -> None:
- """Remove the help cooldown role from `member`."""
- log.trace(f"Removing cooldown role for {member} ({member.id}).")
- await self._change_cooldown_role(member, member.remove_roles)
-
- async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None:
- """
- Change `member`'s cooldown role via awaiting `coro_func` and handle errors.
-
- `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
- """
- guild = self.bot.get_guild(constants.Guild.id)
- role = guild.get_role(constants.Roles.help_cooldown)
- if role is None:
- log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")
- return
-
- try:
- await coro_func(role)
- 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}")
-
- async def revoke_send_permissions(self, member: discord.Member) -> None:
- """
- Disallow `member` to send messages in the Available category for a certain time.
-
- The time until permissions are reinstated can be configured with
- `HelpChannels.claim_minutes`.
- """
- log.trace(
- f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
- )
-
- await self.add_cooldown_role(member)
-
- # Cancel the existing task, if any.
- # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
- if member.id in self.scheduler:
- self.scheduler.cancel(member.id)
-
- delay = constants.HelpChannels.claim_minutes * 60
- self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member))
-
- async def send_available_message(self, 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(description=AVAILABLE_MSG)
-
- msg = await self.get_last_message(channel)
- if self.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 pin_wrapper(self, 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 = self.bot.http.pin_message
- verb = "pin"
- else:
- func = self.bot.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
-
- async def pin(self, message: discord.Message) -> None:
- """Pin an initial question `message` and store it in a cache."""
- if await self.pin_wrapper(message.id, message.channel, pin=True):
- await self.question_messages.set(message.channel.id, message.id)
-
- async def unpin(self, channel: discord.TextChannel) -> None:
- """Unpin the initial question message sent in `channel`."""
- msg_id = await self.question_messages.pop(channel.id)
- if msg_id is None:
- log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.")
- else:
- await self.pin_wrapper(msg_id, channel, pin=False)
-
- 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)
-
- return channel
-
-
-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."""
- 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))
diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py
new file mode 100644
index 000000000..781f40449
--- /dev/null
+++ b/bot/exts/help_channels/__init__.py
@@ -0,0 +1,41 @@
+import logging
+
+from bot import constants
+from bot.bot import Bot
+from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY
+
+log = logging.getLogger(__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))
diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py
new file mode 100644
index 000000000..4cea385b7
--- /dev/null
+++ b/bot/exts/help_channels/_caches.py
@@ -0,0 +1,19 @@
+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")
+
+# 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 maps a help channel to whether it has had any
+# activity other than the original claimant. True being no other
+# activity and False being other activity.
+# RedisCache[discord.TextChannel.id, bool]
+unanswered = RedisCache(namespace="HelpChannels.unanswered")
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
new file mode 100644
index 000000000..e717d7af8
--- /dev/null
+++ b/bot/exts/help_channels/_channel.py
@@ -0,0 +1,57 @@
+import logging
+import typing as t
+from datetime import datetime, timedelta
+
+import discord
+
+from bot import constants
+from bot.exts.help_channels import _caches, _message
+
+log = logging.getLogger(__name__)
+
+MAX_CHANNELS_PER_CATEGORY = 50
+EXCLUDED_CHANNELS = (constants.Channels.cooldown,)
+
+
+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_idle_time(channel: discord.TextChannel) -> t.Optional[int]:
+ """
+ Return the time elapsed, in seconds, since the last message sent in the `channel`.
+
+ Return None if the channel has no messages.
+ """
+ log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
+
+ msg = await _message.get_last_message(channel)
+ if not msg:
+ log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
+ return None
+
+ idle_time = (datetime.utcnow() - msg.created_at).seconds
+
+ log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
+ return idle_time
+
+
+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 = datetime.utcfromtimestamp(claimed_timestamp)
+ return datetime.utcnow() - claimed
+
+
+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
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
new file mode 100644
index 000000000..983c5d183
--- /dev/null
+++ b/bot/exts/help_channels/_cog.py
@@ -0,0 +1,520 @@
+import asyncio
+import logging
+import random
+import typing as t
+from datetime import datetime, timezone
+
+import discord
+import discord.abc
+from discord.ext import commands
+
+from bot import constants
+from bot.bot import Bot
+from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name
+from bot.utils import channel as channel_utils
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+HELP_CHANNEL_TOPIC = """
+This is a Python help channel. You can claim your own help channel in the Python Help: Available category.
+"""
+
+
+class HelpChannels(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
+
+ * Contains all channels which are occupied by someone needing help
+ * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
+ * 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
+
+ Dormant Category
+
+ * Contains channels which aren't in use
+ * Channels are used to refill the Available category
+
+ Help channels are named after the chemical elements in `bot/resources/elements.json`.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.scheduler = Scheduler(self.__class__.__name__)
+
+ # 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[datetime] = None
+
+ # Asyncio stuff
+ self.queue_tasks: t.List[asyncio.Task] = []
+ self.on_message_lock = asyncio.Lock()
+ 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()
+
+ self.scheduler.cancel_all()
+
+ 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 dormant_check(self, ctx: commands.Context) -> bool:
+ """Return True if the user is the help channel claimant or passes the role check."""
+ if await _caches.claimants.get(ctx.channel.id) == ctx.author.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)
+ async def close_command(self, ctx: commands.Context) -> None:
+ """
+ Make the current in-use help channel dormant.
+
+ Make the channel dormant if the user passes the `dormant_check`,
+ delete the message that invoked this.
+ """
+ log.trace("close command invoked; checking if the channel is in-use.")
+
+ if ctx.channel.category != self.in_use_category:
+ log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel")
+ return
+
+ if await self.dormant_check(ctx):
+ await self.move_to_dormant(ctx.channel, "command")
+ self.scheduler.cancel(ctx.channel.id)
+
+ 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.move_to_dormant(channel, "auto")
+
+ 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()
+ await _cooldown.check_cooldowns(self.scheduler)
+
+ self.channel_queue = self.create_channel_queue()
+ self.name_queue = _name.create_name_queue(
+ self.available_category,
+ self.in_use_category,
+ self.dormant_category,
+ )
+
+ 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
+
+ await self.init_available()
+ self.report_stats()
+
+ log.info("Cog is ready!")
+
+ def report_stats(self) -> None:
+ """Report the channel count stats."""
+ total_in_use = sum(1 for _ in _channel.get_category_channels(self.in_use_category))
+ total_available = sum(1 for _ in _channel.get_category_channels(self.available_category))
+ total_dormant = sum(1 for _ in _channel.get_category_channels(self.dormant_category))
+
+ self.bot.stats.gauge("help.total.in_use", total_in_use)
+ self.bot.stats.gauge("help.total.available", total_available)
+ self.bot.stats.gauge("help.total.dormant", total_dormant)
+
+ 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}).")
+
+ if not await _message.is_empty(channel):
+ idle_seconds = constants.HelpChannels.idle_minutes * 60
+ else:
+ idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60
+
+ time_elapsed = await _channel.get_idle_time(channel)
+
+ if time_elapsed is None or time_elapsed >= idle_seconds:
+ log.info(
+ f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
+ f"and will be made dormant."
+ )
+
+ await self.move_to_dormant(channel, "auto")
+ else:
+ # Cancel the existing task, if any.
+ if has_task:
+ self.scheduler.cancel(channel.id)
+
+ delay = idle_seconds - time_elapsed
+ 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_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None:
+ """
+ Move the `channel` to the bottom position of `category` and edit channel attributes.
+
+ 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.
+
+ 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.
+ """
+ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had.
+ category = await channel_utils.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 self.bot.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)
+
+ 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 self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_available,
+ )
+
+ self.report_stats()
+
+ async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None:
+ """
+ Make the `channel` dormant.
+
+ A caller argument is provided for metrics.
+ """
+ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
+
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_dormant,
+ )
+
+ await self.unclaim_channel(channel)
+
+ self.bot.stats.incr(f"help.dormant_calls.{caller}")
+
+ in_use_time = await _channel.get_in_use_time(channel.id)
+ if in_use_time:
+ self.bot.stats.timing("help.in_use_time", in_use_time)
+
+ unanswered = await _caches.unanswered.get(channel.id)
+ if unanswered:
+ self.bot.stats.incr("help.sessions.unanswered")
+ elif unanswered is not None:
+ self.bot.stats.incr("help.sessions.answered")
+
+ log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
+ log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
+ embed = discord.Embed(description=_message.DORMANT_MSG)
+ await channel.send(embed=embed)
+
+ await _message.unpin(channel)
+
+ log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
+ self.channel_queue.put_nowait(channel)
+ self.report_stats()
+
+ async def unclaim_channel(self, channel: discord.TextChannel) -> None:
+ """
+ Mark the channel as unclaimed and remove the cooldown role from the claimant if needed.
+
+ The role is only removed if they have no claimed channels left once the current one is unclaimed.
+ This method also handles canceling the automatic removal of the cooldown role.
+ """
+ claimant_id = await _caches.claimants.pop(channel.id)
+
+ # Ignore missing task when cooldown has passed but the channel still isn't dormant.
+ if claimant_id in self.scheduler:
+ self.scheduler.cancel(claimant_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")
+ return
+
+ # Remove the cooldown role if the claimant has no other channels left
+ if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()):
+ await _cooldown.remove_cooldown_role(claimant)
+
+ 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 self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_in_use,
+ )
+
+ timeout = constants.HelpChannels.idle_minutes * 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))
+ self.report_stats()
+
+ @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.
+
+ channel = message.channel
+
+ await _message.check_for_answer(message)
+
+ is_available = channel_utils.is_in_category(channel, constants.Categories.help_available)
+ if not is_available or _channel.is_excluded_channel(channel):
+ return # Ignore messages outside the Available category or in excluded channels.
+
+ log.trace("Waiting for the cog to be ready before processing messages.")
+ await self.init_task
+
+ log.trace("Acquiring lock to prevent a channel from being processed twice...")
+ async with self.on_message_lock:
+ log.trace(f"on_message lock acquired for {message.id}.")
+
+ if not channel_utils.is_in_category(channel, constants.Categories.help_available):
+ log.debug(
+ f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
+ f"because another message in the channel already triggered that."
+ )
+ return
+
+ log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")
+ await self.move_to_in_use(channel)
+ await _cooldown.revoke_send_permissions(message.author, self.scheduler)
+
+ await _message.pin(message)
+
+ # Add user with channel for dormant check.
+ await _caches.claimants.set(channel.id, message.author.id)
+
+ self.bot.stats.incr("help.claimed")
+
+ # Must use a timezone-aware datetime to ensure a correct POSIX timestamp.
+ timestamp = datetime.now(timezone.utc).timestamp()
+ await _caches.claim_times.set(channel.id, timestamp)
+
+ await _caches.unanswered.set(channel.id, True)
+
+ log.trace(f"Releasing on_message lock for {message.id}.")
+
+ # Move a dormant channel to the Available category to fill in the gap.
+ # This is done last and outside the lock because it may wait indefinitely for a channel to
+ # be put in the queue.
+ await self.move_to_available()
+
+ @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`.
+ """
+ if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):
+ return
+
+ if not await _message.is_empty(msg.channel):
+ return
+
+ log.trace("Waiting for the cog to be ready before processing deleted messages.")
+ await self.init_task
+
+ 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)
+
+ return channel
diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py
new file mode 100644
index 000000000..c5c39297f
--- /dev/null
+++ b/bot/exts/help_channels/_cooldown.py
@@ -0,0 +1,95 @@
+import logging
+from typing import Callable, Coroutine
+
+import discord
+
+import bot
+from bot import constants
+from bot.exts.help_channels import _caches, _channel
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+CoroutineFunc = Callable[..., Coroutine]
+
+
+async def add_cooldown_role(member: discord.Member) -> None:
+ """Add the help cooldown role to `member`."""
+ log.trace(f"Adding cooldown role for {member} ({member.id}).")
+ await _change_cooldown_role(member, member.add_roles)
+
+
+async def check_cooldowns(scheduler: Scheduler) -> None:
+ """Remove expired cooldowns and re-schedule active ones."""
+ log.trace("Checking all cooldowns to remove or re-schedule them.")
+ guild = bot.instance.get_guild(constants.Guild.id)
+ cooldown = constants.HelpChannels.claim_minutes * 60
+
+ for channel_id, member_id in await _caches.claimants.items():
+ member = guild.get_member(member_id)
+ if not member:
+ continue # Member probably left the guild.
+
+ in_use_time = await _channel.get_in_use_time(channel_id)
+
+ if not in_use_time or in_use_time.seconds > cooldown:
+ # Remove the role if no claim time could be retrieved or if the cooldown expired.
+ # Since the channel is in the claimants cache, it is definitely strange for a time
+ # to not exist. However, it isn't a reason to keep the user stuck with a cooldown.
+ await remove_cooldown_role(member)
+ else:
+ # The member is still on a cooldown; re-schedule it for the remaining time.
+ delay = cooldown - in_use_time.seconds
+ scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
+
+
+async def remove_cooldown_role(member: discord.Member) -> None:
+ """Remove the help cooldown role from `member`."""
+ log.trace(f"Removing cooldown role for {member} ({member.id}).")
+ await _change_cooldown_role(member, member.remove_roles)
+
+
+async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None:
+ """
+ Disallow `member` to send messages in the Available category for a certain time.
+
+ The time until permissions are reinstated can be configured with
+ `HelpChannels.claim_minutes`.
+ """
+ log.trace(
+ f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
+ )
+
+ await add_cooldown_role(member)
+
+ # Cancel the existing task, if any.
+ # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
+ if member.id in scheduler:
+ scheduler.cancel(member.id)
+
+ delay = constants.HelpChannels.claim_minutes * 60
+ scheduler.schedule_later(delay, member.id, remove_cooldown_role(member))
+
+
+async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro_func` and handle errors.
+
+ `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ guild = bot.instance.get_guild(constants.Guild.id)
+ role = guild.get_role(constants.Roles.help_cooldown)
+ if role is None:
+ log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")
+ return
+
+ try:
+ await coro_func(role)
+ 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}")
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
new file mode 100644
index 000000000..2bbd4bdd6
--- /dev/null
+++ b/bot/exts/help_channels/_message.py
@@ -0,0 +1,217 @@
+import logging
+import typing as t
+from datetime import datetime
+
+import discord
+
+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 = f"Closes after {constants.HelpChannels.idle_minutes} minutes 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 check_for_answer(message: discord.Message) -> None:
+ """Checks for whether new content in a help channel comes from non-claimants."""
+ 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 been answered.")
+
+ # Check if there is an entry in unanswered
+ if await _caches.unanswered.contains(channel.id):
+ 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
+
+ # Check the message did not come from the claimant
+ if claimant_id != message.author.id:
+ # Mark the channel as answered
+ await _caches.unanswered.set(channel.id, False)
+
+
+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 notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]:
+ """
+ Send a message in `channel` notifying about a lack of available help channels.
+
+ If a notification was sent, return the `datetime` 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 = (datetime.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 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
new file mode 100644
index 000000000..728234b1e
--- /dev/null
+++ b/bot/exts/help_channels/_name.py
@@ -0,0 +1,69 @@
+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 element names to use for creating new channels.
+
+ Skip names that are already in use by channels in `categories`.
+ """
+ log.trace("Creating the chemical element 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 element 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} element names from JSON.")
+
+ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file:
+ all_names = json.load(elements_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/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
index 1e0feab0d..9094d9d15 100644
--- a/bot/exts/info/codeblock/_cog.py
+++ b/bot/exts/info/codeblock/_cog.py
@@ -114,7 +114,7 @@ class CodeBlockCog(Cog, name="Code Block"):
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,), self.bot))
+ self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,)))
# Increase amount of codeblock correction in stats
self.bot.stats.incr("codeblock_corrections")
diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py
index d1518f69d..524dcc829 100644
--- a/bot/exts/info/doc/_cog.py
+++ b/bot/exts/info/doc/_cog.py
@@ -366,19 +366,14 @@ class DocCog(commands.Cog):
colour=discord.Colour.red()
)
error_message = await ctx.send(embed=error_embed)
- await wait_for_deletion(
- error_message,
- (ctx.author.id,),
- timeout=NOT_FOUND_DELETE_DELAY,
- client=self.bot
- )
+ 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()
else:
msg = await ctx.send(embed=doc_embed)
- await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)
+ await wait_for_deletion(msg, (ctx.author.id,))
@docs_group.command(name='setdoc', aliases=('s',))
@commands.has_any_role(*MODERATION_ROLES)
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 599c5d5c0..461ff82fd 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -186,7 +186,7 @@ class CustomHelpCommand(HelpCommand):
"""Send help for a single command."""
embed = await self.command_formatting(command)
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ 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]:
@@ -225,7 +225,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ await wait_for_deletion(message, (self.context.author.id,))
async def send_cog_help(self, cog: Cog) -> None:
"""Send help for a cog."""
@@ -241,7 +241,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n\n**Commands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
def _category_key(command: Command) -> str:
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index ae95ac1ef..8f15f932b 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -236,7 +236,6 @@ class Tags(Cog):
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
- self.bot
)
elif founds and len(tag_name) >= 3:
await wait_for_deletion(
@@ -247,7 +246,6 @@ class Tags(Cog):
)
),
[ctx.author.id],
- self.bot
)
else:
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index bebade0ae..c062ae7f8 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -82,15 +82,27 @@ class InfractionScheduler:
ctx: Context,
infraction: _utils.Infraction,
user: UserSnowflake,
- action_coro: t.Optional[t.Awaitable] = None
- ) -> None:
- """Apply an infraction to the user, log the infraction, and optionally notify the user."""
+ action_coro: t.Optional[t.Awaitable] = None,
+ user_reason: t.Optional[str] = None,
+ additional_info: str = "",
+ ) -> bool:
+ """
+ Apply an infraction to the user, log the infraction, and optionally notify the user.
+
+ `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.
+
+ 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']
+ if user_reason is None:
+ user_reason = reason
+
log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")
# Default values for the confirmation message and mod log.
@@ -126,7 +138,7 @@ class InfractionScheduler:
log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
else:
# Accordingly display whether the user was successfully notified via DM.
- if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon):
+ if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
@@ -198,12 +210,14 @@ class InfractionScheduler:
Member: {messages.format_user(user)}
Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}
Reason: {reason}
+ {additional_info}
"""),
content=log_content,
footer=f"ID {infraction['id']}"
)
log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
+ return not failed
async def pardon_infraction(
self,
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 746d4e154..18e937e87 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -10,7 +10,7 @@ from discord.ext.commands import Context, command
from bot import constants
from bot.bot import Bot
from bot.constants import Event
-from bot.converters import Expiry, FetchedMember
+from bot.converters import Duration, Expiry, FetchedMember
from bot.decorators import respect_role_hierarchy
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
@@ -27,7 +27,7 @@ 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"})
+ super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning", "voice_ban"})
self.category = "Moderation"
self._muted_role = discord.Object(constants.Roles.muted)
@@ -98,7 +98,13 @@ class Infractions(InfractionScheduler, commands.Cog):
# region: Temporary infractions
@command(aliases=["mute"])
- async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None:
+ async def tempmute(
+ self, ctx: Context,
+ user: Member,
+ duration: t.Optional[Expiry] = None,
+ *,
+ reason: t.Optional[str] = None
+ ) -> None:
"""
Temporarily mute a user for the given reason and duration.
@@ -113,7 +119,11 @@ class Infractions(InfractionScheduler, commands.Cog):
\u2003`s` - seconds
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 duration is None:
+ duration = await Duration().convert(ctx, "1h")
await self.apply_mute(ctx, user, reason, expires_at=duration)
@command()
@@ -180,11 +190,6 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user)
- @command(hidden=True, aliases=['shadowkick', 'skick'])
- async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
- """Kick a user for the given reason without notifying the user."""
- await self.apply_kick(ctx, user, reason, hidden=True)
-
@command(hidden=True, aliases=['shadowban', 'sban'])
async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Permanently ban a user for the given reason without notifying the user."""
@@ -193,31 +198,6 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Temporary shadow infractions
- @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"])
- async def shadow_tempmute(
- self, ctx: Context,
- user: Member,
- duration: Expiry,
- *,
- reason: t.Optional[str] = None
- ) -> None:
- """
- Temporarily mute a user for the given reason and duration without notifying the user.
-
- A unit of time should be appended to the duration.
- Units (∗case-sensitive):
- \u2003`y` - years
- \u2003`m` - months∗
- \u2003`w` - weeks
- \u2003`d` - days
- \u2003`h` - hours
- \u2003`M` - minutes∗
- \u2003`s` - seconds
-
- Alternatively, an ISO 8601 timestamp can be provided for the duration.
- """
- await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True)
-
@command(hidden=True, aliases=["shadowtempban, stempban"])
async def shadow_tempban(
self,
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index 394f63da3..b3783cd60 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -10,7 +10,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user
+from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user
from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
@@ -40,16 +40,55 @@ class ModManagement(commands.Cog):
# region: Edit infraction commands
- @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
+ @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)
- @infraction_group.command(name='edit')
+ @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
+ *,
+ reason: str = None
+ ) -> None:
+ """
+ Append text and/or edit the duration of an infraction.
+
+ Durations are relative to the time of updating and should be appended with a unit of time.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction
+ authored by the command invoker should be edited.
+
+ Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601
+ timestamp can be provided for the duration.
+
+ If a previous infraction reason does not end with an ending punctuation mark, this automatically
+ adds a period before the amended reason.
+ """
+ old_reason = infraction["reason"]
+
+ if old_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',))
async def infraction_edit(
self,
ctx: Context,
- infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821
+ infraction: Infraction,
duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821
*,
reason: str = None
@@ -77,30 +116,13 @@ class ModManagement(commands.Cog):
# 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.")
- # Retrieve the previous infraction for its information.
- if isinstance(infraction_id, str):
- params = {
- "actor__id": ctx.author.id,
- "ordering": "-inserted_at"
- }
- infractions = await self.bot.api_client.get("bot/infractions", params=params)
-
- if infractions:
- old_infraction = infractions[0]
- infraction_id = old_infraction["id"]
- else:
- await ctx.send(
- ":x: Couldn't find most recent infraction; you have never given an infraction."
- )
- return
- else:
- old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}")
+ infraction_id = infraction["id"]
request_data = {}
confirm_messages = []
log_text = ""
- if duration is not None and not old_infraction['active']:
+ if duration is not None and not infraction['active']:
if reason is None:
await ctx.send(":x: Cannot edit the expiration of an expired infraction.")
return
@@ -119,7 +141,7 @@ class ModManagement(commands.Cog):
request_data['reason'] = reason
confirm_messages.append("set a new reason")
log_text += f"""
- Previous reason: {old_infraction['reason']}
+ Previous reason: {infraction['reason']}
New reason: {reason}
""".rstrip()
else:
@@ -134,7 +156,7 @@ class ModManagement(commands.Cog):
# Re-schedule infraction if the expiration has been updated
if 'expires_at' in request_data:
# A scheduled task should only exist if the old infraction wasn't permanent
- if old_infraction['expires_at']:
+ if infraction['expires_at']:
self.infractions_cog.scheduler.cancel(new_infraction['id'])
# If the infraction was not marked as permanent, schedule a new expiration task
@@ -142,7 +164,7 @@ class ModManagement(commands.Cog):
self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
- Previous expiry: {old_infraction['expires_at'] or "Permanent"}
+ Previous expiry: {infraction['expires_at'] or "Permanent"}
New expiry: {new_infraction['expires_at'] or "Permanent"}
""".rstrip()
@@ -175,7 +197,7 @@ class ModManagement(commands.Cog):
# endregion
# region: Search infractions
- @infraction_group.group(name="search", invoke_without_command=True)
+ @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:
"""Searches for infractions in the database."""
if isinstance(query, int):
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index adfe42fcd..ffc470c54 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -5,7 +5,7 @@ import textwrap
import typing as t
from pathlib import Path
-from discord import Colour, Embed, Member
+from discord import Embed, Member
from discord.ext.commands import Cog, Context, command, has_any_role
from discord.utils import escape_markdown
@@ -104,14 +104,14 @@ class Superstarify(InfractionScheduler, Cog):
await self.reapply_infraction(infraction, action)
- @command(name="superstarify", aliases=("force_nick", "star"))
+ @command(name="superstarify", aliases=("force_nick", "star", "starify"))
async def superstarify(
self,
ctx: Context,
member: Member,
duration: Expiry,
*,
- reason: str = None,
+ reason: str = '',
) -> None:
"""
Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname.
@@ -128,74 +128,62 @@ class Superstarify(InfractionScheduler, Cog):
Alternatively, an ISO 8601 timestamp can be provided for the duration.
- An optional reason can be provided. If no reason is given, the original name will be shown
- in a generated reason.
+ An optional reason can be provided, which would be added to a message stating their old nickname
+ and linking to the nickname policy.
"""
if await _utils.get_active_infraction(ctx, member, "superstar"):
return
# Post the infraction to the API
old_nick = member.display_name
- reason = reason or f"old nick: {old_nick}"
- infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
+ 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"])
- # Apply the infraction and schedule the expiration task.
- log.debug(f"Changing nickname of {member} to {forced_nick}.")
- self.mod_log.ignore(constants.Event.member_update, member.id)
- await member.edit(nick=forced_nick, reason=reason)
- self.schedule_expiration(infraction)
+ # Apply the infraction
+ async def action() -> None:
+ log.debug(f"Changing nickname of {member} to {forced_nick}.")
+ self.mod_log.ignore(constants.Event.member_update, member.id)
+ await member.edit(nick=forced_nick, reason=reason)
old_nick = escape_markdown(old_nick)
forced_nick = escape_markdown(forced_nick)
- # Send a DM to the user to notify them of their new infraction.
- await _utils.notify_infraction(
- user=member,
- infr_type="Superstarify",
- expires_at=expiry_str,
- icon_url=_utils.INFRACTION_ICONS["superstar"][0],
- reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
+ nickname_info = textwrap.dedent(f"""
+ Old nickname: `{old_nick}`
+ New nickname: `{forced_nick}`
+ """).strip()
+
+ user_message = (
+ f"Your previous nickname, **{old_nick}**, "
+ f"was so bad that we have decided to change it. "
+ f"Your new nickname will be **{forced_nick}**.\n\n"
+ "{reason}"
+ f"You will be unable to change your nickname until **{expiry_str}**. "
+ "If you're confused by this, please read our "
+ f"[official nickname policy]({NICKNAME_POLICY_URL})."
+ ).format
+
+ successful = await self.apply_infraction(
+ ctx, infraction, member, action(),
+ user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''),
+ additional_info=nickname_info
)
- # Send an embed with the infraction information to the invoking context.
- log.trace(f"Sending superstar #{id_} embed.")
- embed = Embed(
- title="Congratulations!",
- colour=constants.Colours.soft_orange,
- description=(
- f"Your previous nickname, **{old_nick}**, "
- f"was so bad that we have decided to change it. "
- f"Your new nickname will be **{forced_nick}**.\n\n"
- f"You will be unable to change your nickname until **{expiry_str}**.\n\n"
- "If you're confused by this, please read our "
- f"[official nickname policy]({NICKNAME_POLICY_URL})."
+ # Send an embed with to the invoking context if superstar was successful.
+ if successful:
+ log.trace(f"Sending superstar #{id_} embed.")
+ embed = Embed(
+ title="Superstarified!",
+ colour=constants.Colours.soft_orange,
+ description=user_message(reason='')
)
- )
- await ctx.send(embed=embed)
-
- # Log to the mod log channel.
- log.trace(f"Sending apply mod log for superstar #{id_}.")
- await self.mod_log.send_log_message(
- icon_url=_utils.INFRACTION_ICONS["superstar"][0],
- colour=Colour.gold(),
- title="Member achieved superstardom",
- thumbnail=member.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {member.mention}
- Actor: {ctx.message.author.mention}
- Expires: {expiry_str}
- Old nickname: `{old_nick}`
- New nickname: `{forced_nick}`
- Reason: {reason}
- """),
- footer=f"ID {id_}"
- )
+ await ctx.send(embed=embed)
- @command(name="unsuperstarify", aliases=("release_nick", "unstar"))
+ @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify"))
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)
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index c599156d0..c42c6588f 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -756,7 +756,7 @@ class Verification(Cog):
log.trace(f"Bumping verification stats in category: {category}")
self.bot.stats.incr(f"verification.{category}")
- @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
+ @command(name='accept', aliases=('verified', 'accepted'), hidden=True)
@has_no_roles(constants.Roles.verified)
@in_whitelist(channels=(constants.Channels.verification,))
async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
@@ -848,6 +848,22 @@ class Verification(Cog):
else:
return True
+ @command(name='verify')
+ @has_any_role(*constants.MODERATION_ROLES)
+ async def apply_developer_role(self, ctx: Context, user: discord.Member) -> None:
+ """Command for moderators to apply the Developer role to any user."""
+ log.trace(f'verify command called by {ctx.author} for {user.id}.')
+ developer_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.verified)
+
+ if developer_role in user.roles:
+ log.trace(f'{user.id} is already a developer, aborting.')
+ await ctx.send(f'{constants.Emojis.cross_mark} {user} is already a developer.')
+ return
+
+ await user.add_roles(developer_role)
+ log.trace(f'Developer role successfully applied to {user.id}')
+ await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user}.')
+
# endregion
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 93d96693c..4d48d2c1b 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -4,8 +4,9 @@ from contextlib import suppress
from datetime import datetime, timedelta
import discord
+from async_rediscache import RedisCache
from dateutil import parser
-from discord import Colour
+from discord import Colour, Member, VoiceState
from discord.ext.commands import Cog, Context, command
from bot.api import ResponseCodeError
@@ -17,6 +18,12 @@ from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__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,
+# and that the notification does not need to be removed. The implementation relies on
+# this being falsey!
+NO_MSG = 0
+
FAILED_MESSAGE = (
"""You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}"""
)
@@ -28,11 +35,22 @@ MESSAGE_FIELD_MAP = {
"activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks",
}
+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!"
+)
+
class VoiceGate(Cog):
"""Voice channels verification management."""
- def __init__(self, bot: Bot):
+ # 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()
+
+ def __init__(self, bot: Bot) -> None:
self.bot = bot
@property
@@ -40,6 +58,54 @@ class VoiceGate(Cog):
"""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) -> bool:
+ """
+ See if `member` should be sent a voice verification notification, and send it if so.
+
+ Returns False 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 True is returned.
+ """
+ if await self.redis_cache.contains(member.id):
+ log.trace("User already in cache. Ignore.")
+ return False
+
+ 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
+
+ log.trace("User is unverified. Send ping.")
+ await self.bot.wait_until_guild_available()
+ voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
+
+ message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
+ await self.redis_cache.set(member.id, message.id)
+
+ return True
+
@command(aliases=('voiceverify',))
@has_no_roles(Roles.voice_verified)
@in_whitelist(channels=(Channels.voice_gate,), redirect=None)
@@ -53,6 +119,8 @@ class VoiceGate(Cog):
- 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
+
try:
data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data")
except ResponseCodeError as e:
@@ -142,8 +210,12 @@ class VoiceGate(Cog):
ctx = await self.bot.get_context(message)
is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify"
- # When it's bot sent message, delete it after some time
+ # 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
@@ -160,6 +232,28 @@ class VoiceGate(Cog):
with suppress(discord.NotFound):
await message.delete()
+ @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.")
+ 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.")
+ 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 = await self._ping_newcomer(member)
+
+ # Schedule the notification to be deleted after the configured delay, which is
+ # again delegated to an atomic helper
+ if notification_sent:
+ await asyncio.sleep(GateConf.voice_ping_delete_delay)
+ await self._delete_ping(member.id)
+
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Check for & ignore any InWhitelistCheckFailure."""
if isinstance(error, InWhitelistCheckFailure):
diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py
index 1b4900f42..3521c8fd4 100644
--- a/bot/exts/utils/internal.py
+++ b/bot/exts/utils/internal.py
@@ -30,7 +30,7 @@ class Internal(Cog):
self.ln = 0
self.stdout = StringIO()
- self.interpreter = Interpreter(bot)
+ self.interpreter = Interpreter()
self.socket_since = datetime.utcnow()
self.socket_event_total = 0
@@ -195,7 +195,7 @@ async def func(): # (None,) -> Any
truncate_index = newline_truncate_index
if len(out) > truncate_index:
- paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py")
+ 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:
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index 41cb00541..9f480c067 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -70,7 +70,7 @@ class Snekbox(Cog):
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(self.bot.http_session, output, extension="txt")
+ return await send_to_paste_service(output, extension="txt")
@staticmethod
def prepare_input(code: str) -> str:
@@ -219,7 +219,7 @@ class Snekbox(Cog):
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,), ctx.bot))
+ 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
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 6d8d98695..8e7e6ba36 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -9,13 +9,16 @@ from typing import Dict, Optional, 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, STAFF_ROLES
+from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
from bot.utils import messages
from bot.utils.cache import AsyncCache
+from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -166,6 +169,21 @@ class Utils(Cog):
embed.description = best_match
await ctx.send(embed=embed)
+ @command(aliases=("snf", "snfl", "sf"))
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
+ async def snowflake(self, ctx: Context, snowflake: Snowflake) -> None:
+ """Get Discord snowflake creation time."""
+ created_at = snowflake_time(snowflake)
+ embed = Embed(
+ description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).",
+ colour=Colour.blue()
+ )
+ embed.set_author(
+ name=f"Snowflake: {snowflake}",
+ icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true"
+ )
+ await ctx.send(embed=embed)
+
@command(aliases=("poll",))
@has_any_role(*MODERATION_ROLES)
async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:
diff --git a/bot/interpreter.py b/bot/interpreter.py
index 8b7268746..b58f7a6b0 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -4,7 +4,7 @@ from typing import Any
from discord.ext.commands import Context
-from bot.bot import Bot
+import bot
CODE_TEMPLATE = """
async def _func():
@@ -21,8 +21,8 @@ class Interpreter(InteractiveInterpreter):
write_callable = None
- def __init__(self, bot: Bot):
- locals_ = {"bot": bot}
+ def __init__(self):
+ locals_ = {"bot": bot.instance}
super().__init__(locals_)
async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any:
diff --git a/bot/log.py b/bot/log.py
new file mode 100644
index 000000000..13141de40
--- /dev/null
+++ b/bot/log.py
@@ -0,0 +1,86 @@
+import logging
+import os
+import sys
+from logging import Logger, handlers
+from pathlib import Path
+
+import coloredlogs
+import sentry_sdk
+from sentry_sdk.integrations.aiohttp import AioHttpIntegration
+from sentry_sdk.integrations.logging import LoggingIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
+
+from bot import constants
+
+TRACE_LEVEL = 5
+
+
+def setup() -> None:
+ """Set up loggers."""
+ logging.TRACE = TRACE_LEVEL
+ logging.addLevelName(TRACE_LEVEL, "TRACE")
+ Logger.trace = _monkeypatch_trace
+
+ log_level = TRACE_LEVEL if constants.DEBUG_MODE else logging.INFO
+ format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
+ log_format = logging.Formatter(format_string)
+
+ 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.setLevel(log_level)
+ 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
+
+ if "COLOREDLOGS_LOG_LEVEL" not in os.environ:
+ coloredlogs.DEFAULT_LOG_LEVEL = log_level
+
+ coloredlogs.install(logger=root_log, stream=sys.stdout)
+
+ 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)
+
+
+def setup_sentry() -> None:
+ """Set up the Sentry logging integrations."""
+ sentry_logging = LoggingIntegration(
+ level=logging.DEBUG,
+ event_level=logging.WARNING
+ )
+
+ sentry_sdk.init(
+ dsn=constants.Bot.sentry_dsn,
+ integrations=[
+ sentry_logging,
+ AioHttpIntegration(),
+ RedisIntegration(),
+ ]
+ )
+
+
+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)
diff --git a/bot/resources/tags/microsoft-build-tools.md b/bot/resources/tags/microsoft-build-tools.md
new file mode 100644
index 000000000..7c702e296
--- /dev/null
+++ b/bot/resources/tags/microsoft-build-tools.md
@@ -0,0 +1,15 @@
+**Microsoft Visual C++ Build Tools**
+
+When you install a library through `pip` on Windows, sometimes you may encounter this error:
+
+```
+error: Microsoft Visual C++ 14.0 or greater is required. Get it with "Microsoft C++ Build Tools": https://visualstudio.microsoft.com/visual-cpp-build-tools/
+```
+
+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/).
+**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.
+**5.** Try installing the library via `pip` again.
diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index 6e47f0197..41faf7ee8 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -2,16 +2,17 @@ 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+>")
+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 (excluding Unicode emojis) exceeding the limit sent by a single user."""
+ """Detects total Discord emojis exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
@@ -19,8 +20,9 @@ async def apply(
)
# 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(CODE_BLOCK_RE.sub("", msg.content)))
+ len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content))))
for msg in relevant_messages
)
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index 6bf70bfde..0c072184c 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -2,6 +2,7 @@ import logging
import discord
+import bot
from bot import constants
from bot.constants import Categories
@@ -36,14 +37,14 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
return getattr(channel, "category_id", None) == category_id
-async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel:
+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 = client.get_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 client.fetch_channel(channel_id)
+ channel = await bot.instance.fetch_channel(channel_id)
log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
return channel
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index b6c7cab50..42bde358d 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -10,6 +10,7 @@ import discord
from discord.errors import HTTPException
from discord.ext.commands import Context
+import bot
from bot.constants import Emojis, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -18,7 +19,6 @@ log = logging.getLogger(__name__)
async def wait_for_deletion(
message: discord.Message,
user_ids: Sequence[discord.abc.Snowflake],
- client: discord.Client,
deletion_emojis: Sequence[str] = (Emojis.trashcan,),
timeout: float = 60 * 5,
attach_emojis: bool = True,
@@ -49,7 +49,7 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await client.wait_for('reaction_add', check=check, timeout=timeout)
+ await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
diff --git a/bot/utils/services.py b/bot/utils/services.py
index 087b9f969..5949c9e48 100644
--- a/bot/utils/services.py
+++ b/bot/utils/services.py
@@ -1,8 +1,9 @@
import logging
from typing import Optional
-from aiohttp import ClientConnectorError, ClientSession
+from aiohttp import ClientConnectorError
+import bot
from bot.constants import URLs
log = logging.getLogger(__name__)
@@ -10,11 +11,10 @@ log = logging.getLogger(__name__)
FAILED_REQUEST_ATTEMPTS = 3
-async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]:
+async def send_to_paste_service(contents: str, *, extension: str = "") -> Optional[str]:
"""
Upload `contents` to the paste service.
- `http_session` should be the current running ClientSession from aiohttp
`extension` is added to the output URL
When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
@@ -24,7 +24,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e
paste_url = URLs.paste_service.format(key="documents")
for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
try:
- async with http_session.post(paste_url, data=contents) as response:
+ async with bot.instance.http_session.post(paste_url, data=contents) as response:
response_json = await response.json()
except ClientConnectorError:
log.warning(
diff --git a/config-default.yml b/config-default.yml
index 2afdcd594..006743342 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -4,13 +4,13 @@ bot:
sentry_dsn: !ENV "BOT_SENTRY_DSN"
redis:
- host: "redis"
+ host: "redis.default.svc.cluster.local"
port: 6379
password: !ENV "REDIS_PASSWORD"
use_fakeredis: false
stats:
- statsd_host: "graphite"
+ statsd_host: "graphite.default.svc.cluster.local"
presence_update_timeout: 300
cooldowns:
@@ -27,6 +27,7 @@ style:
soft_red: 0xcd6d6d
soft_green: 0x68c290
soft_orange: 0xf9cb54
+ bright_green: 0x01d277
emojis:
defcon_disabled: "<:defcondisabled:470326273952972810>"
@@ -119,6 +120,8 @@ style:
voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
+ green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png"
+
guild:
id: 267624335836053506
@@ -152,7 +155,6 @@ guild:
python_discussion: &PY_DISCUSSION 267624335836053506
# Python Help: Available
- how_to_get_help: 704250143020417084
cooldown: 720603994149486673
# Logs
@@ -193,13 +195,19 @@ guild:
mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225
admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370
- # Voice
- code_help_voice: 755154969761677312
- code_help_voice_2: 766330079135268884
- voice_chat: 412357430186344448
+ # 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
@@ -329,7 +337,7 @@ urls:
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
# Snekbox
- snekbox_eval_api: "http://snekbox:8060/eval"
+ snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval"
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
@@ -522,6 +530,7 @@ voice_gate:
minimum_messages: 50 # How many messages a user must have to be eligible for voice
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
+ voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate
config:
diff --git a/docker-compose.yml b/docker-compose.yml
index 8be5aac0e..0002d1d56 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,7 +18,7 @@ services:
- "127.0.0.1:6379:6379"
snekbox:
- image: pythondiscord/snekbox:latest
+ image: ghcr.io/python-discord/snekbox:latest
init: true
ipc: none
ports:
@@ -26,7 +26,7 @@ services:
privileged: true
web:
- image: pythondiscord/site:latest
+ image: ghcr.io/python-discord/site:latest
command: ["run", "--debug"]
networks:
default:
diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
index 4953550f9..3ad9db9c3 100644
--- a/tests/bot/exts/backend/sync/test_base.py
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -15,28 +15,21 @@ class TestSyncer(Syncer):
_sync = mock.AsyncMock()
-class SyncerBaseTests(unittest.TestCase):
- """Tests for the syncer base class."""
-
- def setUp(self):
- self.bot = helpers.MockBot()
-
- def test_instantiation_fails_without_abstract_methods(self):
- """The class must have abstract methods implemented."""
- with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"):
- Syncer(self.bot)
-
-
class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for main function orchestrating the sync."""
def setUp(self):
- self.bot = helpers.MockBot(user=helpers.MockMember(bot=True))
- self.syncer = TestSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot(user=helpers.MockMember(bot=True)))
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
+
self.guild = helpers.MockGuild()
+ TestSyncer._get_diff.reset_mock(return_value=True, side_effect=True)
+ TestSyncer._sync.reset_mock(return_value=True, side_effect=True)
+
# Make sure `_get_diff` returns a MagicMock, not an AsyncMock
- self.syncer._get_diff.return_value = mock.MagicMock()
+ TestSyncer._get_diff.return_value = mock.MagicMock()
async def test_sync_message_edited(self):
"""The message should be edited if one was sent, even if the sync has an API error."""
@@ -48,11 +41,11 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):
for message, side_effect, should_edit in subtests:
with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit):
- self.syncer._sync.side_effect = side_effect
+ TestSyncer._sync.side_effect = side_effect
ctx = helpers.MockContext()
ctx.send.return_value = message
- await self.syncer.sync(self.guild, ctx)
+ await TestSyncer.sync(self.guild, ctx)
if should_edit:
message.edit.assert_called_once()
@@ -67,7 +60,7 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):
for ctx, message in subtests:
with self.subTest(ctx=ctx, message=message):
- await self.syncer.sync(self.guild, ctx)
+ await TestSyncer.sync(self.guild, ctx)
if ctx is not None:
ctx.send.assert_called_once()
diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 063a82754..22a07313e 100644
--- a/tests/bot/exts/backend/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -29,24 +29,24 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.bot = helpers.MockBot()
- self.role_syncer_patcher = mock.patch(
+ role_syncer_patcher = mock.patch(
"bot.exts.backend.sync._syncers.RoleSyncer",
autospec=Syncer,
spec_set=True
)
- self.user_syncer_patcher = mock.patch(
+ user_syncer_patcher = mock.patch(
"bot.exts.backend.sync._syncers.UserSyncer",
autospec=Syncer,
spec_set=True
)
- self.RoleSyncer = self.role_syncer_patcher.start()
- self.UserSyncer = self.user_syncer_patcher.start()
- self.cog = Sync(self.bot)
+ self.RoleSyncer = role_syncer_patcher.start()
+ self.UserSyncer = user_syncer_patcher.start()
- def tearDown(self):
- self.role_syncer_patcher.stop()
- self.user_syncer_patcher.stop()
+ self.addCleanup(role_syncer_patcher.stop)
+ self.addCleanup(user_syncer_patcher.stop)
+
+ self.cog = Sync(self.bot)
@staticmethod
def response_error(status: int) -> ResponseCodeError:
@@ -73,8 +73,6 @@ class SyncCogTests(SyncCogTestCase):
Sync(self.bot)
- self.RoleSyncer.assert_called_once_with(self.bot)
- self.UserSyncer.assert_called_once_with(self.bot)
sync_guild.assert_called_once_with()
self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro)
@@ -83,8 +81,8 @@ class SyncCogTests(SyncCogTestCase):
for guild in (helpers.MockGuild(), None):
with self.subTest(guild=guild):
self.bot.reset_mock()
- self.cog.role_syncer.reset_mock()
- self.cog.user_syncer.reset_mock()
+ self.RoleSyncer.reset_mock()
+ self.UserSyncer.reset_mock()
self.bot.get_guild = mock.MagicMock(return_value=guild)
@@ -94,11 +92,11 @@ class SyncCogTests(SyncCogTestCase):
self.bot.get_guild.assert_called_once_with(constants.Guild.id)
if guild is None:
- self.cog.role_syncer.sync.assert_not_called()
- self.cog.user_syncer.sync.assert_not_called()
+ self.RoleSyncer.sync.assert_not_called()
+ self.UserSyncer.sync.assert_not_called()
else:
- self.cog.role_syncer.sync.assert_called_once_with(guild)
- self.cog.user_syncer.sync.assert_called_once_with(guild)
+ self.RoleSyncer.sync.assert_called_once_with(guild)
+ self.UserSyncer.sync.assert_called_once_with(guild)
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."""
@@ -394,14 +392,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase):
ctx = helpers.MockContext()
await self.cog.sync_roles_command(self.cog, ctx)
- self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx)
+ self.RoleSyncer.sync.assert_called_once_with(ctx.guild, ctx)
async def test_sync_users_command(self):
"""sync() should be called on the UserSyncer."""
ctx = helpers.MockContext()
await self.cog.sync_users_command(self.cog, ctx)
- self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx)
+ self.UserSyncer.sync.assert_called_once_with(ctx.guild, ctx)
async def test_commands_require_admin(self):
"""The sync commands should only run if the author has the administrator permission."""
diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py
index 7b9f40cad..541074336 100644
--- a/tests/bot/exts/backend/sync/test_roles.py
+++ b/tests/bot/exts/backend/sync/test_roles.py
@@ -22,8 +22,9 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
"""Tests for determining differences between roles in the DB and roles in the Guild cache."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = RoleSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
@staticmethod
def get_guild(*roles):
@@ -44,7 +45,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role()]
guild = self.get_guild(fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), set(), set())
self.assertEqual(actual_diff, expected_diff)
@@ -56,7 +57,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()]
guild = self.get_guild(updated_role, fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), {_Role(**updated_role)}, set())
self.assertEqual(actual_diff, expected_diff)
@@ -68,7 +69,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role()]
guild = self.get_guild(fake_role(), new_role)
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = ({_Role(**new_role)}, set(), set())
self.assertEqual(actual_diff, expected_diff)
@@ -80,7 +81,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role(), deleted_role]
guild = self.get_guild(fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), set(), {_Role(**deleted_role)})
self.assertEqual(actual_diff, expected_diff)
@@ -98,7 +99,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
]
guild = self.get_guild(fake_role(), new, updated)
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)})
self.assertEqual(actual_diff, expected_diff)
@@ -108,8 +109,9 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the API requests that sync roles."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = RoleSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
async def test_sync_created_roles(self):
"""Only POST requests should be made with the correct payload."""
@@ -117,7 +119,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(role_tuples, set(), set())
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call("bot/roles", json=role) for role in roles]
self.bot.api_client.post.assert_has_calls(calls, any_order=True)
@@ -132,7 +134,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(set(), role_tuples, set())
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles]
self.bot.api_client.put.assert_has_calls(calls, any_order=True)
@@ -147,7 +149,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(set(), set(), role_tuples)
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call(f"bot/roles/{role['id']}") for role in roles]
self.bot.api_client.delete.assert_has_calls(calls, any_order=True)
diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index 9f380a15d..61673e1bb 100644
--- a/tests/bot/exts/backend/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,4 +1,5 @@
import unittest
+from unittest import mock
from bot.exts.backend.sync._syncers import UserSyncer, _Diff
from tests import helpers
@@ -19,8 +20,9 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
"""Tests for determining differences between users in the DB and users in the Guild cache."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = UserSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
@staticmethod
def get_guild(*members):
@@ -57,7 +59,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
}
guild = self.get_guild()
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -73,7 +75,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
guild = self.get_guild(fake_user())
guild.get_member.return_value = self.get_mock_member(fake_user())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -94,7 +96,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.get_mock_member(fake_user())
]
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [{"id": 99, "name": "new"}], None)
self.assertEqual(actual_diff, expected_diff)
@@ -114,7 +116,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.get_mock_member(fake_user()),
self.get_mock_member(new_user)
]
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([new_user], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -133,7 +135,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
None
]
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [{"id": 63, "in_guild": False}], None)
self.assertEqual(actual_diff, expected_diff)
@@ -157,7 +159,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
None
]
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None)
self.assertEqual(actual_diff, expected_diff)
@@ -176,7 +178,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
None
]
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await UserSyncer._get_diff(guild)
expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -186,15 +188,16 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the API requests that sync users."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = UserSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
async def test_sync_created_users(self):
"""Only POST requests should be made with the correct payload."""
users = [fake_user(id=111), fake_user(id=222)]
diff = _Diff(users, [], None)
- await self.syncer._sync(diff)
+ await UserSyncer._sync(diff)
self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created)
@@ -206,7 +209,7 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
users = [fake_user(id=111), fake_user(id=222)]
diff = _Diff([], users, None)
- await self.syncer._sync(diff)
+ await UserSyncer._sync(diff)
self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated)
diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index 9a42d0610..321a92445 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -42,9 +42,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
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(
- self.bot.http_session, "Test output.", extension="txt"
- )
+ mock_paste_util.assert_called_once_with("Test output.", extension="txt")
def test_prepare_input(self):
cases = (
diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py
index 9a72723e2..66c2d9f92 100644
--- a/tests/bot/rules/test_discord_emojis.py
+++ b/tests/bot/rules/test_discord_emojis.py
@@ -5,11 +5,12 @@ 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) -> MockMessage:
+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=discord_emoji * n_emojis)
+ return MockMessage(author=author, content=emoji * n_emojis)
class DiscordEmojisRuleTests(RuleTest):
@@ -20,16 +21,22 @@ class DiscordEmojisRuleTests(RuleTest):
self.config = {"max": 2, "interval": 10}
async def test_allows_messages_within_limit(self):
- """Cases with a total amount of discord emojis within limit."""
+ """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 emojis."""
+ """Cases with more than the allowed amount of discord and unicode emojis."""
cases = (
DisallowedCase(
[make_msg("bob", 3)],
@@ -41,6 +48,20 @@ class DiscordEmojisRuleTests(RuleTest):
("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)
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
index 5e0855704..1b48f6560 100644
--- a/tests/bot/utils/test_services.py
+++ b/tests/bot/utils/test_services.py
@@ -5,11 +5,14 @@ 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:
- self.http_session = MagicMock()
+ 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):
@@ -17,10 +20,10 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
response = MagicMock(
json=AsyncMock(return_value={"key": ""})
)
- self.http_session.post().__aenter__.return_value = response
- self.http_session.post.reset_mock()
- await send_to_paste_service(self.http_session, "Content")
- self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content")
+ 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):
@@ -34,41 +37,41 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
response = MagicMock(
json=AsyncMock(return_value={"key": key})
)
- self.http_session.post().__aenter__.return_value = response
+ 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(self.http_session, "", extension=extension),
+ 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.http_session.post().__aenter__.return_value = response = MagicMock()
- self.http_session.post.reset_mock()
+ 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.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ result = await send_to_paste_service("")
+ self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
self.assertIsNone(result)
- self.http_session.post.reset_mock()
+ 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.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
- result = await send_to_paste_service(self.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ 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.http_session.post = MagicMock(side_effect=Exception)
- result = await send_to_paste_service(self.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ 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)