aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ks129 <[email protected]>2020-12-17 17:58:13 +0200
committerGravatar GitHub <[email protected]>2020-12-17 17:58:13 +0200
commit2ffd9e32e6c2a03bc463e22aa3f82c3d5599f760 (patch)
treef24091a341e50d781cdaaba58925f1a3898334d3
parentFix wrong import orders (diff)
parentUpdate verification.py (diff)
Merge branch 'master' into branding-manager
-rw-r--r--.github/CODEOWNERS38
-rw-r--r--.github/review-policy.yml3
-rw-r--r--.github/workflows/build.yml57
-rw-r--r--.github/workflows/deploy.yml42
-rw-r--r--.github/workflows/lint-test.yml (renamed from .github/workflows/lint-test-build.yml)86
-rw-r--r--.github/workflows/status_embed.yaml78
-rw-r--r--.gitignore1
-rw-r--r--Pipfile5
-rw-r--r--Pipfile.lock47
-rw-r--r--README.md15
-rw-r--r--bot/__init__.py67
-rw-r--r--bot/__main__.py78
-rw-r--r--bot/bot.py89
-rw-r--r--bot/constants.py13
-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.py2
-rw-r--r--bot/exts/info/help.py6
-rw-r--r--bot/exts/info/information.py49
-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.py22
-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/elements.json1
-rw-r--r--bot/resources/tags/codeblock.md4
-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.yml23
-rw-r--r--docker-compose.yml2
-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/info/test_information.py1
-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
57 files changed, 1943 insertions, 1432 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cf5f1590d..ad813d893 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1,37 @@
-* @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 @ks129
+bot/exts/info/** @Akarys42 @mbaruh @Den4200
+bot/exts/filters/** @mbaruh
+bot/exts/fun/** @ks129
+bot/exts/utils/** @ks129
+
+# 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/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-build.yml b/.github/workflows/lint-test.yml
index c63f78ff6..6fa8e8333 100644
--- a/.github/workflows/lint-test-build.yml
+++ b/.github/workflows/lint-test.yml
@@ -1,13 +1,10 @@
-name: Lint, Test, Build
+name: Lint & Test
on:
push:
branches:
- master
- # We use pull_request_target as we get PRs from
- # forks, but need to be able to add annotations
- # for our flake8 step.
- pull_request_target:
+ pull_request:
jobs:
@@ -42,12 +39,8 @@ jobs:
- name: Add custom PYTHONUSERBASE to PATH
run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
- # We don't want to persist credentials, as our GitHub Action
- # may be run when a PR is made from a fork.
- name: Checkout repository
uses: actions/checkout@v2
- with:
- persist-credentials: false
- name: Setup python
id: python
@@ -94,14 +87,18 @@ jobs:
- name: Run pre-commit hooks
run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files
- # This step requires `pull_request_target`, as adding annotations
- # requires "write" permissions to the repo.
+ # 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
- uses: julianwachholz/flake8-action@v1
- with:
- checkName: lint-test
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ 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.
@@ -117,41 +114,24 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: coveralls
- build-and-push:
- needs: lint-test
- if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master'
- 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
-
- - 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 }}
-
- # This step builds and pushed the container to the
- # Github Container Registry tagged with "latest" and
- # the short SHA of the commit.
- - name: Build and push
- uses: docker/build-push-action@v2
+ # 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:
- context: .
- file: ./Dockerfile
- push: true
- cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest
- tags: |
- ghcr.io/python-discord/bot:latest
- ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}
+ 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 38bd9013a..b691a7862 100644
--- a/Pipfile
+++ b/Pipfile
@@ -27,6 +27,7 @@ sentry-sdk = "~=0.14"
sphinx = "~=2.2"
statsd = "~=3.3"
arrow = "~=0.17"
+emoji = "~=0.6"
[dev-packages]
coverage = "~=5.0"
@@ -49,8 +50,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 79c559e30..215c64bed 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -230,6 +230,13 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.16"
},
+ "emoji": {
+ "hashes": [
+ "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"
+ ],
+ "index": "pypi",
+ "version": "==0.6.0"
+ },
"fakeredis": {
"hashes": [
"sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5",
@@ -556,16 +563,18 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
+ "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"index": "pypi",
@@ -589,11 +598,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:81d7a5d8ca0b13a16666e8280127b004565aa988bfeec6481e98a8601804b215",
- "sha256:fd48f627945511c140546939b4d73815be4860cd1d2b9149577d7f6563e7bd60"
+ "sha256:1052f0ed084e532f66cb3e4ba617960d820152aee8b93fc6c05bd53861768c1c",
+ "sha256:4c42910a55a6b1fe694d5e4790d5188d105d77b5a6346c1c64cbea8c06c0e8b7"
],
"index": "pypi",
- "version": "==0.19.3"
+ "version": "==0.19.4"
},
"six": {
"hashes": [
@@ -801,11 +810,11 @@
},
"coveralls": {
"hashes": [
- "sha256:4430b862baabb3cf090d36d84d331966615e4288d8a8c5957e0fd456d0dd8bd6",
- "sha256:b3b60c17b03a0dee61952a91aed6f131e0b2ac8bd5da909389c53137811409e1"
+ "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc",
+ "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"
],
"index": "pypi",
- "version": "==2.1.2"
+ "version": "==2.2.0"
},
"distlib": {
"hashes": [
@@ -969,16 +978,18 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
+ "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"index": "pypi",
diff --git a/README.md b/README.md
index 482ada08c..c813997e7 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,9 @@
# 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)
-![Lint, Test, Build](https://github.com/python-discord/bot/workflows/Lint,%20Test,%20Build/badge.svg?branch=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)
@@ -10,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/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 20e8c4b83..3475ee4e3 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -360,6 +360,8 @@ class Icons(metaclass=YAMLGetter):
voice_state_green: str
voice_state_red: str
+ green_checkmark: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -394,12 +396,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
@@ -408,8 +413,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
@@ -429,6 +434,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
@@ -674,7 +681,7 @@ class AssetType(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 2e118d476..d0a9731d6 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -549,6 +549,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.py b/bot/exts/info/doc.py
index 7ec8caa4b..9b5bd6504 100644
--- a/bot/exts/info/doc.py
+++ b/bot/exts/info/doc.py
@@ -365,7 +365,7 @@ class Doc(commands.Cog):
await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
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='set', 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/information.py b/bot/exts/info/information.py
index 5aaf85e5a..187950689 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,11 +6,13 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Tuple, Union
+from dateutil import parser
from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils
from discord.abc import GuildChannel
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from bot import constants
+from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.converters import FetchedMember
from bot.decorators import in_whitelist
@@ -21,7 +23,6 @@ from bot.utils.time import time_since
log = logging.getLogger(__name__)
-
STATUS_EMOTES = {
Status.offline: constants.Emojis.status_offline,
Status.dnd: constants.Emojis.status_dnd,
@@ -224,13 +225,16 @@ class Information(Cog):
if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
badges.append(emoji)
+ verified_at, activity = await self.user_verification_and_messages(user)
+
if on_server:
joined = time_since(user.joined_at, max_units=3)
roles = ", ".join(role.mention for role in user.roles[1:])
- membership = textwrap.dedent(f"""
- Joined: {joined}
- Roles: {roles or None}
- """).strip()
+ membership = {"Joined": joined, "Verified": verified_at or "False", "Roles": roles or None}
+ if not is_mod_channel(ctx.channel):
+ membership.pop("Verified")
+
+ membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()]))
else:
roles = None
membership = "The user is not a member of the server"
@@ -252,6 +256,8 @@ class Information(Cog):
# Show more verbose output in moderation channels for infractions and nominations
if is_mod_channel(ctx.channel):
+ fields.append(activity)
+
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
@@ -354,6 +360,39 @@ class Information(Cog):
return "Nominations", "\n".join(output)
+ async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]:
+ """
+ Gets the time of verification and amount of messages for `member`.
+
+ Fetches information from the metricity database that's hosted by the site.
+ If the database returns a code besides a 404, then many parts of the bot are broken including this one.
+ """
+ activity_output = []
+ verified_at = False
+
+ try:
+ user_activity = await self.bot.api_client.get(f"bot/users/{user.id}/metricity_data")
+ except ResponseCodeError as e:
+ if e.status == 404:
+ activity_output = "No activity"
+
+ else:
+ try:
+ if (verified_at := user_activity["verified_at"]) is not None:
+ verified_at = time_since(parser.isoparse(verified_at), max_units=3)
+ except ValueError:
+ log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.")
+ verified_at = None
+
+ activity_output.append(user_activity["total_messages"] or "No messages")
+ activity_output.append(user_activity["activity_blocks"] or "No activity")
+
+ activity_output = "\n".join(
+ f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output)
+ )
+
+ return verified_at, ("Activity", activity_output)
+
def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
"""Format a mapping to be readable to a human."""
# sorting is technically superfluous but nice if you want to look for a specific field
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..7aa559617 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -565,11 +565,11 @@ class Verification(Cog):
raw_member = await self.bot.http.get_member(member.guild.id, member.id)
- # If the user has the is_pending flag set, they will be using the alternate
+ # If the user has the pending flag set, they will be using the alternate
# gate and will not need a welcome DM with verification instructions.
# We will send them an alternate DM once they verify with the welcome
# video.
- if raw_member.get("is_pending"):
+ if raw_member.get("pending"):
await self.member_gating_cache.set(member.id, True)
# TODO: Temporary, remove soon after asking joe.
@@ -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/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 59c472cf9..df9d6661d 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, Keys, 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__)
@@ -171,6 +174,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/elements.json b/bot/resources/elements.json
index 2dc9b6fd6..a3ac5b99f 100644
--- a/bot/resources/elements.json
+++ b/bot/resources/elements.json
@@ -32,7 +32,6 @@
"gallium",
"germanium",
"arsenic",
- "selenium",
"bromine",
"krypton",
"rubidium",
diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md
index 8d48bdf06..ac64656e5 100644
--- a/bot/resources/tags/codeblock.md
+++ b/bot/resources/tags/codeblock.md
@@ -1,7 +1,7 @@
Here's how to format Python code on Discord:
-\```py
+\`\`\`py
print('Hello world!')
-\```
+\`\`\`
**These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key.
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 a9bbb144e..9e1c9c012 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:
@@ -125,6 +125,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
@@ -158,7 +160,6 @@ guild:
python_discussion: &PY_DISCUSSION 267624335836053506
# Python Help: Available
- how_to_get_help: 704250143020417084
cooldown: 720603994149486673
# Logs
@@ -199,13 +200,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
@@ -336,7 +343,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/"
diff --git a/docker-compose.yml b/docker-compose.yml
index dc89e8885..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:
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/info/test_information.py b/tests/bot/exts/info/test_information.py
index daede54c5..254b0a867 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -355,6 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
textwrap.dedent(f"""
Joined: {"1 year ago"}
+ Verified: {"False"}
Roles: &Moderators
""").strip(),
embed.fields[1].value
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)