aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Dennis Pham <[email protected]>2021-01-23 21:05:11 -0500
committerGravatar GitHub <[email protected]>2021-01-23 21:05:11 -0500
commit31c40502db0abc2bfa6555aae6c91ec31026835a (patch)
tree7dfc6205c208109e175858431a67ad596a410970
parentRemove incorrect point (diff)
parentMerge pull request #1346 from python-discord/bug/help/1341/race-conditions (diff)
Merge branch 'master' into new-env-tag
-rw-r--r--Pipfile3
-rw-r--r--Pipfile.lock145
-rw-r--r--README.md7
-rw-r--r--bot/constants.py29
-rw-r--r--bot/exts/backend/branding/__init__.py7
-rw-r--r--bot/exts/backend/branding/_cog.py566
-rw-r--r--bot/exts/backend/branding/_constants.py51
-rw-r--r--bot/exts/backend/branding/_decorators.py27
-rw-r--r--bot/exts/backend/branding/_errors.py2
-rw-r--r--bot/exts/backend/branding/_seasons.py175
-rw-r--r--bot/exts/backend/error_handler.py54
-rw-r--r--bot/exts/backend/sync/_syncers.py10
-rw-r--r--bot/exts/filters/filtering.py7
-rw-r--r--bot/exts/help_channels/_channel.py42
-rw-r--r--bot/exts/help_channels/_cog.py255
-rw-r--r--bot/exts/help_channels/_stats.py42
-rw-r--r--bot/exts/info/help.py17
-rw-r--r--bot/exts/info/information.py31
-rw-r--r--bot/exts/info/tags.py32
-rw-r--r--bot/exts/moderation/dm_relay.py4
-rw-r--r--bot/exts/moderation/infraction/infractions.py2
-rw-r--r--bot/exts/moderation/modlog.py3
-rw-r--r--bot/exts/moderation/silence.py2
-rw-r--r--bot/exts/moderation/slowmode.py23
-rw-r--r--bot/exts/moderation/verification.py679
-rw-r--r--bot/exts/moderation/watchchannels/talentpool.py19
-rw-r--r--bot/exts/utils/jams.py4
-rw-r--r--bot/log.py3
-rw-r--r--bot/rules/burst_shared.py11
-rw-r--r--bot/utils/lock.py62
-rw-r--r--bot/utils/scheduling.py17
-rw-r--r--config-default.yml37
-rw-r--r--tests/bot/exts/backend/sync/test_users.py29
-rw-r--r--tests/bot/exts/info/test_information.py2
-rw-r--r--tests/bot/exts/moderation/test_slowmode.py16
-rw-r--r--tests/bot/exts/utils/test_jams.py4
36 files changed, 1354 insertions, 1065 deletions
diff --git a/Pipfile b/Pipfile
index 3ff653749..efdd46522 100644
--- a/Pipfile
+++ b/Pipfile
@@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9"
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"}
+"discord.py" = "~=1.6.0"
feedparser = "~=5.2"
fuzzywuzzy = "~=0.17"
lxml = "~=4.4"
@@ -26,6 +26,7 @@ requests = "~=2.22"
sentry-sdk = "~=0.19"
sphinx = "~=2.2"
statsd = "~=3.3"
+arrow = "~=0.17"
emoji = "~=0.6"
[dev-packages]
diff --git a/Pipfile.lock b/Pipfile.lock
index 085d3d829..636d07b1a 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "1ba637e521c654a23bcc82950e155f5366219eae00bbf809170a371122961a4f"
+ "sha256": "26c8089f17d6d6bac11dbed366b1b46818b4546f243af756a106a32af5d9d8f6"
},
"pipfile-spec": 6,
"requires": {
@@ -106,6 +106,14 @@
],
"version": "==0.7.12"
},
+ "arrow": {
+ "hashes": [
+ "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5",
+ "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"
+ ],
+ "index": "pypi",
+ "version": "==0.17.0"
+ },
"async-rediscache": {
"extras": [
"fakeredis"
@@ -230,13 +238,13 @@
"index": "pypi",
"version": "==4.3.2"
},
- "discord-py": {
- "git": "https://github.com/Rapptz/discord.py.git",
- "ref": "93f102ca907af6722ee03638766afd53dfe93a7f"
- },
"discord.py": {
- "git": "https://github.com/Rapptz/discord.py.git",
- "ref": "93f102ca907af6722ee03638766afd53dfe93a7f"
+ "hashes": [
+ "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12",
+ "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"
+ ],
+ "index": "pypi",
+ "version": "==1.6.0"
},
"docutils": {
"hashes": [
@@ -568,18 +576,18 @@
},
"pygments": {
"hashes": [
- "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716",
- "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"
+ "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435",
+ "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337"
],
"markers": "python_version >= '3.5'",
- "version": "==2.7.3"
+ "version": "==2.7.4"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
- "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.4.7"
},
"python-dateutil": {
@@ -592,10 +600,10 @@
},
"pytz": {
"hashes": [
- "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
- "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
+ "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
+ "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
],
- "version": "==2020.4"
+ "version": "==2020.5"
},
"pyyaml": {
"hashes": [
@@ -645,7 +653,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.15.0"
},
"snowballstemmer": {
@@ -834,43 +842,58 @@
},
"coverage": {
"hashes": [
- "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
- "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
- "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
- "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
- "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
- "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
- "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
- "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
- "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
- "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
- "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
- "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
- "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
- "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
- "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
- "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
- "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
- "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
- "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
- "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
- "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
- "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
- "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
- "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
- "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
- "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
- "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
- "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
- "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
- "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
- "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
- "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
- "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
- "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
+ "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297",
+ "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1",
+ "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497",
+ "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606",
+ "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528",
+ "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b",
+ "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4",
+ "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830",
+ "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1",
+ "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f",
+ "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d",
+ "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3",
+ "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8",
+ "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500",
+ "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7",
+ "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb",
+ "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b",
+ "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059",
+ "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b",
+ "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72",
+ "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36",
+ "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277",
+ "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c",
+ "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631",
+ "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff",
+ "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8",
+ "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec",
+ "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b",
+ "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7",
+ "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105",
+ "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b",
+ "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c",
+ "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b",
+ "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98",
+ "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4",
+ "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879",
+ "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f",
+ "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4",
+ "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044",
+ "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e",
+ "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899",
+ "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f",
+ "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448",
+ "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714",
+ "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2",
+ "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d",
+ "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd",
+ "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7",
+ "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"
],
"index": "pypi",
- "version": "==5.3"
+ "version": "==5.3.1"
},
"coveralls": {
"hashes": [
@@ -910,11 +933,11 @@
},
"flake8-annotations": {
"hashes": [
- "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1",
- "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df"
+ "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055",
+ "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"
],
"index": "pypi",
- "version": "==2.4.1"
+ "version": "==2.5.0"
},
"flake8-bugbear": {
"hashes": [
@@ -972,11 +995,11 @@
},
"identify": {
"hashes": [
- "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5",
- "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e"
+ "sha256:18994e850ba50c37bcaed4832be8b354d6a06c8fb31f54e0e7ece76d32f69bc8",
+ "sha256:892473bf12e655884132a3a32aca737a3cbefaa34a850ff52d501773a45837bc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.10"
+ "version": "==1.5.12"
},
"idna": {
"hashes": [
@@ -1072,7 +1095,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==1.15.0"
},
"snowballstemmer": {
@@ -1087,7 +1110,7 @@
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
- "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'",
"version": "==0.10.2"
},
"urllib3": {
@@ -1100,11 +1123,11 @@
},
"virtualenv": {
"hashes": [
- "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c",
- "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b"
+ "sha256:0c111a2236b191422b37fe8c28b8c828ced39aab4bf5627fa5c331aeffb570d9",
+ "sha256:14b34341e742bdca219e10708198e704e8a7064dd32f474fc16aca68ac53a306"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.2.2"
+ "version": "==20.3.1"
}
}
}
diff --git a/README.md b/README.md
index c813997e7..ac45e6340 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,7 @@
[![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)
+[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities
and other tools to help keep the server running like a well-oiled machine.
@@ -19,5 +18,5 @@ Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/)
[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
+[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg
+[8]: https://discord.gg/python
diff --git a/bot/constants.py b/bot/constants.py
index 6bfda160b..2f5cf0e8a 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -13,7 +13,7 @@ their default values from `config-default.yml`.
import logging
import os
from collections.abc import Mapping
-from enum import Enum
+from enum import Enum, IntEnum
from pathlib import Path
from typing import Dict, List, Optional
@@ -249,6 +249,9 @@ class Colours(metaclass=YAMLGetter):
soft_green: int
soft_orange: int
bright_green: int
+ orange: int
+ pink: int
+ purple: int
class DuckPond(metaclass=YAMLGetter):
@@ -299,6 +302,8 @@ class Emojis(metaclass=YAMLGetter):
comments: str
user: str
+ ok_hand: str
+
class Icons(metaclass=YAMLGetter):
section = "style"
@@ -403,6 +408,7 @@ class Channels(metaclass=YAMLGetter):
code_help_voice_2: int
cooldown: int
defcon: int
+ discord_py: int
dev_contrib: int
dev_core: int
dev_log: int
@@ -424,7 +430,7 @@ class Channels(metaclass=YAMLGetter):
off_topic_1: int
off_topic_2: int
organisation: int
- python_discussion: int
+ python_general: int
python_events: int
python_news: int
reddit: int
@@ -434,7 +440,6 @@ class Channels(metaclass=YAMLGetter):
talent_pool: int
user_event_announcements: int
user_log: int
- verification: int
voice_chat: int
voice_gate: int
voice_log: int
@@ -471,8 +476,6 @@ class Roles(metaclass=YAMLGetter):
python_community: int
sprinters: int
team_leaders: int
- unverified: int
- verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
voice_verified: int
@@ -594,16 +597,6 @@ class PythonNews(metaclass=YAMLGetter):
webhook: int
-class Verification(metaclass=YAMLGetter):
- section = "verification"
-
- unverified_after: int
- kicked_after: int
- reminder_frequency: int
- bot_message_delete_delay: int
- kick_confirmation_threshold: float
-
-
class VoiceGate(metaclass=YAMLGetter):
section = "voice_gate"
@@ -614,6 +607,12 @@ class VoiceGate(metaclass=YAMLGetter):
voice_ping_delete_delay: int
+class Branding(metaclass=YAMLGetter):
+ section = "branding"
+
+ cycle_frequency: int
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
diff --git a/bot/exts/backend/branding/__init__.py b/bot/exts/backend/branding/__init__.py
new file mode 100644
index 000000000..81ea3bf49
--- /dev/null
+++ b/bot/exts/backend/branding/__init__.py
@@ -0,0 +1,7 @@
+from bot.bot import Bot
+from bot.exts.backend.branding._cog import BrandingManager
+
+
+def setup(bot: Bot) -> None:
+ """Loads BrandingManager cog."""
+ bot.add_cog(BrandingManager(bot))
diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py
new file mode 100644
index 000000000..20df83a89
--- /dev/null
+++ b/bot/exts/backend/branding/_cog.py
@@ -0,0 +1,566 @@
+import asyncio
+import itertools
+import logging
+import random
+import typing as t
+from datetime import datetime, time, timedelta
+
+import arrow
+import async_timeout
+import discord
+from async_rediscache import RedisCache
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Branding, Colours, Emojis, Guild, MODERATION_ROLES
+from bot.exts.backend.branding import _constants, _decorators, _errors, _seasons
+
+log = logging.getLogger(__name__)
+
+
+class GitHubFile(t.NamedTuple):
+ """
+ Represents a remote file on GitHub.
+
+ The `sha` hash is kept so that we can determine that a file has changed,
+ despite its filename remaining unchanged.
+ """
+
+ download_url: str
+ path: str
+ sha: str
+
+
+def pretty_files(files: t.Iterable[GitHubFile]) -> str:
+ """Provide a human-friendly representation of `files`."""
+ return "\n".join(file.path for file in files)
+
+
+def time_until_midnight() -> timedelta:
+ """
+ Determine amount of time until the next-up UTC midnight.
+
+ The exact `midnight` moment is actually delayed to 5 seconds after, in order
+ to avoid potential problems due to imprecise sleep.
+ """
+ now = datetime.utcnow()
+ tomorrow = now + timedelta(days=1)
+ midnight = datetime.combine(tomorrow, time(second=5))
+
+ return midnight - now
+
+
+class BrandingManager(commands.Cog):
+ """
+ Manages the guild's branding.
+
+ The purpose of this cog is to help automate the synchronization of the branding
+ repository with the guild. It is capable of discovering assets in the repository
+ via GitHub's API, resolving download urls for them, and delegating
+ to the `bot` instance to upload them to the guild.
+
+ BrandingManager is designed to be entirely autonomous. Its `daemon` background task awakens
+ once a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single
+ season. The daemon can be turned on and off via the `daemon` cmd group. The value set via
+ its `start` and `stop` commands is persisted across sessions. If turned on, the daemon will
+ automatically start on the next bot start-up. Otherwise, it will wait to be started manually.
+
+ All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can
+ also be invoked manually, via the following API:
+
+ branding list
+ - Show all available seasons
+
+ branding set <season_name>
+ - Set the cog's internal state to represent `season_name`, if it exists
+ - If no `season_name` is given, set chronologically current season
+ - This will not automatically apply the season's branding to the guild,
+ the cog's state can be detached from the guild
+ - Seasons can therefore be 'previewed' using this command
+
+ branding info
+ - View detailed information about resolved assets for current season
+
+ branding refresh
+ - Refresh internal state, i.e. synchronize with branding repository
+
+ branding apply
+ - Apply the current internal state to the guild, i.e. upload the assets
+
+ branding cycle
+ - If there are multiple available icons for current season, randomly pick
+ and apply the next one
+
+ The daemon calls these methods autonomously as appropriate. The use of this cog
+ is locked to moderation roles. As it performs media asset uploads, it is prone to
+ rate-limits - the `apply` command should be used with caution. The `set` command can,
+ however, be used freely to 'preview' seasonal branding and check whether paths have been
+ resolved as appropriate.
+
+ While the bot is in debug mode, it will 'mock' asset uploads by logging the passed
+ download urls and pretending that the upload was successful. Make use of this
+ to test this cog's behaviour.
+ """
+
+ current_season: t.Type[_seasons.SeasonBase]
+
+ banner: t.Optional[GitHubFile]
+
+ available_icons: t.List[GitHubFile]
+ remaining_icons: t.List[GitHubFile]
+
+ days_since_cycle: t.Iterator
+
+ daemon: t.Optional[asyncio.Task]
+
+ # Branding configuration
+ branding_configuration = RedisCache()
+
+ def __init__(self, bot: Bot) -> None:
+ """
+ Assign safe default values on init.
+
+ At this point, we don't have information about currently available branding.
+ Most of these attributes will be overwritten once the daemon connects, or once
+ the `refresh` command is used.
+ """
+ self.bot = bot
+ self.current_season = _seasons.get_current_season()
+
+ self.banner = None
+
+ self.available_icons = []
+ self.remaining_icons = []
+
+ self.days_since_cycle = itertools.cycle([None])
+
+ self.daemon = None
+ self._startup_task = self.bot.loop.create_task(self._initial_start_daemon())
+
+ async def _initial_start_daemon(self) -> None:
+ """Checks is daemon active and when is, start it at cog load."""
+ if await self.branding_configuration.get("daemon_active"):
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+
+ @property
+ def _daemon_running(self) -> bool:
+ """True if the daemon is currently active, False otherwise."""
+ return self.daemon is not None and not self.daemon.done()
+
+ async def _daemon_func(self) -> None:
+ """
+ Manage all automated behaviour of the BrandingManager cog.
+
+ Once a day, the daemon will perform the following tasks:
+ - Update `current_season`
+ - Poll GitHub API to see if the available branding for `current_season` has changed
+ - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname)
+ - Check whether it's time to cycle guild icons
+
+ The internal loop runs once when activated, then periodically at the time
+ given by `time_until_midnight`.
+
+ All method calls in the internal loop are considered safe, i.e. no errors propagate
+ to the daemon's loop. The daemon itself does not perform any error handling on its own.
+ """
+ await self.bot.wait_until_guild_available()
+
+ while True:
+ self.current_season = _seasons.get_current_season()
+ branding_changed = await self.refresh()
+
+ if branding_changed:
+ await self.apply()
+
+ elif next(self.days_since_cycle) == Branding.cycle_frequency:
+ await self.cycle()
+
+ until_midnight = time_until_midnight()
+ await asyncio.sleep(until_midnight.total_seconds())
+
+ async def _info_embed(self) -> discord.Embed:
+ """Make an informative embed representing current season."""
+ info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour)
+
+ # If we're in a non-evergreen season, also show active months
+ if self.current_season is not _seasons.SeasonBase:
+ title = f"{self.current_season.season_name} ({', '.join(str(m) for m in self.current_season.months)})"
+ else:
+ title = self.current_season.season_name
+
+ # Use the author field to show the season's name and avatar if available
+ info_embed.set_author(name=title)
+
+ banner = self.banner.path if self.banner is not None else "Unavailable"
+ info_embed.add_field(name="Banner", value=banner, inline=False)
+
+ icons = pretty_files(self.available_icons) or "Unavailable"
+ info_embed.add_field(name="Available icons", value=icons, inline=False)
+
+ # Only display cycle frequency if we're actually cycling
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}")
+
+ return info_embed
+
+ async def _reset_remaining_icons(self) -> None:
+ """Set `remaining_icons` to a shuffled copy of `available_icons`."""
+ self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons))
+
+ async def _reset_days_since_cycle(self) -> None:
+ """
+ Reset the `days_since_cycle` iterator based on configured frequency.
+
+ If the current season only has 1 icon, or if `Branding.cycle_frequency` is falsey,
+ the iterator will always yield None. This signals that the icon shouldn't be cycled.
+
+ Otherwise, it will yield ints in range [1, `Branding.cycle_frequency`] indefinitely.
+ When the iterator yields a value equal to `Branding.cycle_frequency`, it is time to cycle.
+ """
+ if len(self.available_icons) > 1 and Branding.cycle_frequency:
+ sequence = range(1, Branding.cycle_frequency + 1)
+ else:
+ sequence = [None]
+
+ self.days_since_cycle = itertools.cycle(sequence)
+
+ async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GitHubFile]:
+ """
+ Get files at `path` in the branding repository.
+
+ If `include_dirs` is False (default), only returns files at `path`.
+ Otherwise, will return both files and directories. Never returns symlinks.
+
+ Return dict mapping from filename to corresponding `GitHubFile` instance.
+ This may return an empty dict if the response status is non-200,
+ or if the target directory is empty.
+ """
+ url = f"{_constants.BRANDING_URL}/{path}"
+ async with self.bot.http_session.get(
+ url, headers=_constants.HEADERS, params=_constants.PARAMS
+ ) as resp:
+ # Short-circuit if we get non-200 response
+ if resp.status != _constants.STATUS_OK:
+ log.error(f"GitHub API returned non-200 response: {resp}")
+ return {}
+ directory = await resp.json() # Directory at `path`
+
+ allowed_types = {"file", "dir"} if include_dirs else {"file"}
+ return {
+ file["name"]: GitHubFile(file["download_url"], file["path"], file["sha"])
+ for file in directory
+ if file["type"] in allowed_types
+ }
+
+ async def refresh(self) -> bool:
+ """
+ Synchronize available assets with branding repository.
+
+ If the current season is not the evergreen, and lacks at least one asset,
+ we use the evergreen seasonal dir as fallback for missing assets.
+
+ Finally, if neither the seasonal nor fallback branding directories contain
+ an asset, it will simply be ignored.
+
+ Return True if the branding has changed. This will be the case when we enter
+ a new season, or when something changes in the current seasons's directory
+ in the branding repository.
+ """
+ old_branding = (self.banner, self.available_icons)
+ seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True)
+
+ # Only make a call to the fallback directory if there is something to be gained
+ branding_incomplete = any(
+ asset not in seasonal_dir
+ for asset in (_constants.FILE_BANNER, _constants.FILE_AVATAR, _constants.SERVER_ICONS)
+ )
+ if branding_incomplete and self.current_season is not _seasons.SeasonBase:
+ fallback_dir = await self._get_files(
+ _seasons.SeasonBase.branding_path, include_dirs=True
+ )
+ else:
+ fallback_dir = {}
+
+ # Resolve assets in this directory, None is a safe value
+ self.banner = (
+ seasonal_dir.get(_constants.FILE_BANNER)
+ or fallback_dir.get(_constants.FILE_BANNER)
+ )
+
+ # Now resolve server icons by making a call to the proper sub-directory
+ if _constants.SERVER_ICONS in seasonal_dir:
+ icons_dir = await self._get_files(
+ f"{self.current_season.branding_path}/{_constants.SERVER_ICONS}"
+ )
+ self.available_icons = list(icons_dir.values())
+
+ elif _constants.SERVER_ICONS in fallback_dir:
+ icons_dir = await self._get_files(
+ f"{_seasons.SeasonBase.branding_path}/{_constants.SERVER_ICONS}"
+ )
+ self.available_icons = list(icons_dir.values())
+
+ else:
+ self.available_icons = [] # This should never be the case, but an empty list is a safe value
+
+ # GitHubFile instances carry a `sha` attr so this will pick up if a file changes
+ branding_changed = old_branding != (self.banner, self.available_icons)
+
+ if branding_changed:
+ log.info(f"New branding detected (season: {self.current_season.season_name})")
+ await self._reset_remaining_icons()
+ await self._reset_days_since_cycle()
+
+ return branding_changed
+
+ async def cycle(self) -> bool:
+ """
+ Apply the next-up server icon.
+
+ Returns True if an icon is available and successfully gets applied, False otherwise.
+ """
+ if not self.available_icons:
+ log.info("Cannot cycle: no icons for this season")
+ return False
+
+ if not self.remaining_icons:
+ log.info("Reset & shuffle remaining icons")
+ await self._reset_remaining_icons()
+
+ next_up = self.remaining_icons.pop(0)
+ success = await self.set_icon(next_up.download_url)
+
+ return success
+
+ async def apply(self) -> t.List[str]:
+ """
+ Apply current branding to the guild and bot.
+
+ This delegates to the bot instance to do all the work. We only provide download urls
+ for available assets. Assets unavailable in the branding repo will be ignored.
+
+ Returns a list of names of all failed assets. An asset is considered failed
+ if it isn't found in the branding repo, or if something goes wrong while the
+ bot is trying to apply it.
+
+ An empty list denotes that all assets have been applied successfully.
+ """
+ report = {asset: False for asset in ("banner", "icon")}
+
+ if self.banner is not None:
+ report["banner"] = await self.set_banner(self.banner.download_url)
+
+ report["icon"] = await self.cycle()
+
+ failed_assets = [asset for asset, succeeded in report.items() if not succeeded]
+ return failed_assets
+
+ @commands.has_any_role(*MODERATION_ROLES)
+ @commands.group(name="branding")
+ async def branding_cmds(self, ctx: commands.Context) -> None:
+ """Manual branding control."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @branding_cmds.command(name="list", aliases=["ls"])
+ async def branding_list(self, ctx: commands.Context) -> None:
+ """List all available seasons and branding sources."""
+ embed = discord.Embed(title="Available seasons", colour=Colours.soft_green)
+
+ for season in _seasons.get_all_seasons():
+ if season is _seasons.SeasonBase:
+ active_when = "always"
+ else:
+ active_when = f"in {', '.join(str(m) for m in season.months)}"
+
+ description = (
+ f"Active {active_when}\n"
+ f"Branding: {season.branding_path}"
+ )
+ embed.add_field(name=season.season_name, value=description, inline=False)
+
+ await ctx.send(embed=embed)
+
+ @branding_cmds.command(name="set")
+ async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None:
+ """
+ Manually set season, or reset to current if none given.
+
+ Season search is a case-less comparison against both seasonal class name,
+ and its `season_name` attr.
+
+ This only pre-loads the cog's internal state to the chosen season, but does not
+ automatically apply the branding. As that is an expensive operation, the `apply`
+ command must be called explicitly after this command finishes.
+
+ This means that this command can be used to 'preview' a season gathering info
+ about its available assets, without applying them to the guild.
+
+ If the daemon is running, it will automatically reset the season to current when
+ it wakes up. The season set via this command can therefore remain 'detached' from
+ what it should be - the daemon will make sure that it's set back properly.
+ """
+ if season_name is None:
+ new_season = _seasons.get_current_season()
+ else:
+ new_season = _seasons.get_season(season_name)
+ if new_season is None:
+ raise _errors.BrandingError("No such season exists")
+
+ if self.current_season is new_season:
+ raise _errors.BrandingError(f"Season {self.current_season.season_name} already active")
+
+ self.current_season = new_season
+ await self.branding_refresh(ctx)
+
+ @branding_cmds.command(name="info", aliases=["status"])
+ async def branding_info(self, ctx: commands.Context) -> None:
+ """
+ Show available assets for current season.
+
+ This can be used to confirm that assets have been resolved properly.
+ When `apply` is used, it attempts to upload exactly the assets listed here.
+ """
+ await ctx.send(embed=await self._info_embed())
+
+ @branding_cmds.command(name="refresh")
+ async def branding_refresh(self, ctx: commands.Context) -> None:
+ """Sync currently available assets with branding repository."""
+ async with ctx.typing():
+ await self.refresh()
+ await self.branding_info(ctx)
+
+ @branding_cmds.command(name="apply")
+ async def branding_apply(self, ctx: commands.Context) -> None:
+ """
+ Apply current season's branding to the guild.
+
+ Use `info` to check which assets will be applied. Shows which assets have
+ failed to be applied, if any.
+ """
+ async with ctx.typing():
+ failed_assets = await self.apply()
+ if failed_assets:
+ raise _errors.BrandingError(
+ f"Failed to apply following assets: {', '.join(failed_assets)}"
+ )
+
+ response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.command(name="cycle")
+ async def branding_cycle(self, ctx: commands.Context) -> None:
+ """
+ Apply the next-up guild icon, if multiple are available.
+
+ The order is random.
+ """
+ async with ctx.typing():
+ success = await self.cycle()
+ if not success:
+ raise _errors.BrandingError("Failed to cycle icon")
+
+ response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @branding_cmds.group(name="daemon", aliases=["d", "task"])
+ async def daemon_group(self, ctx: commands.Context) -> None:
+ """Control the background daemon."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @daemon_group.command(name="status")
+ async def daemon_status(self, ctx: commands.Context) -> None:
+ """Check whether daemon is currently active."""
+ if self._daemon_running:
+ remaining_time = (arrow.utcnow() + time_until_midnight()).humanize()
+ response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green)
+ response.set_footer(text=f"Next refresh {remaining_time}")
+ else:
+ response = discord.Embed(description="Daemon not running", colour=Colours.soft_red)
+
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="start")
+ async def daemon_start(self, ctx: commands.Context) -> None:
+ """If the daemon isn't running, start it."""
+ if self._daemon_running:
+ raise _errors.BrandingError("Daemon already running!")
+
+ self.daemon = self.bot.loop.create_task(self._daemon_func())
+ await self.branding_configuration.set("daemon_active", True)
+
+ response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ @daemon_group.command(name="stop")
+ async def daemon_stop(self, ctx: commands.Context) -> None:
+ """If the daemon is running, stop it."""
+ if not self._daemon_running:
+ raise _errors.BrandingError("Daemon not running!")
+
+ self.daemon.cancel()
+ await self.branding_configuration.set("daemon_active", False)
+
+ response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green)
+ await ctx.send(embed=response)
+
+ async def _fetch_image(self, url: str) -> bytes:
+ """Retrieve and read image from `url`."""
+ log.debug(f"Getting image from: {url}")
+ async with self.bot.http_session.get(url) as resp:
+ return await resp.read()
+
+ async def _apply_asset(self, target: discord.Guild, asset: _constants.AssetType, url: str) -> bool:
+ """
+ Internal method for applying media assets to the guild.
+
+ This shouldn't be called directly. The purpose of this method is mainly generic
+ error handling to reduce needless code repetition.
+
+ Return True if upload was successful, False otherwise.
+ """
+ log.info(f"Attempting to set {asset.name}: {url}")
+
+ kwargs = {asset.value: await self._fetch_image(url)}
+ try:
+ async with async_timeout.timeout(5):
+ await target.edit(**kwargs)
+
+ except asyncio.TimeoutError:
+ log.info("Asset upload timed out")
+ return False
+
+ except discord.HTTPException as discord_error:
+ log.exception("Asset upload failed", exc_info=discord_error)
+ return False
+
+ else:
+ log.info("Asset successfully applied")
+ return True
+
+ @_decorators.mock_in_debug(return_value=True)
+ async def set_banner(self, url: str) -> bool:
+ """Set the guild's banner to image at `url`."""
+ guild = self.bot.get_guild(Guild.id)
+ if guild is None:
+ log.info("Failed to get guild instance, aborting asset upload")
+ return False
+
+ return await self._apply_asset(guild, _constants.AssetType.BANNER, url)
+
+ @_decorators.mock_in_debug(return_value=True)
+ async def set_icon(self, url: str) -> bool:
+ """Sets the guild's icon to image at `url`."""
+ guild = self.bot.get_guild(Guild.id)
+ if guild is None:
+ log.info("Failed to get guild instance, aborting asset upload")
+ return False
+
+ return await self._apply_asset(guild, _constants.AssetType.SERVER_ICON, url)
+
+ def cog_unload(self) -> None:
+ """Cancels startup and daemon task."""
+ self._startup_task.cancel()
+ if self.daemon is not None:
+ self.daemon.cancel()
diff --git a/bot/exts/backend/branding/_constants.py b/bot/exts/backend/branding/_constants.py
new file mode 100644
index 000000000..dbc7615f2
--- /dev/null
+++ b/bot/exts/backend/branding/_constants.py
@@ -0,0 +1,51 @@
+from enum import Enum, IntEnum
+
+from bot.constants import Keys
+
+
+class Month(IntEnum):
+ """All month constants for seasons."""
+
+ JANUARY = 1
+ FEBRUARY = 2
+ MARCH = 3
+ APRIL = 4
+ MAY = 5
+ JUNE = 6
+ JULY = 7
+ AUGUST = 8
+ SEPTEMBER = 9
+ OCTOBER = 10
+ NOVEMBER = 11
+ DECEMBER = 12
+
+ def __str__(self) -> str:
+ return self.name.title()
+
+
+class AssetType(Enum):
+ """
+ Discord media assets.
+
+ The values match exactly the kwarg keys that can be passed to `Guild.edit`.
+ """
+
+ BANNER = "banner"
+ SERVER_ICON = "icon"
+
+
+STATUS_OK = 200 # HTTP status code
+
+FILE_BANNER = "banner.png"
+FILE_AVATAR = "avatar.png"
+SERVER_ICONS = "server_icons"
+
+BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents"
+
+PARAMS = {"ref": "master"} # Target branch
+HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3
+
+# A GitHub token is not necessary for the cog to operate,
+# unauthorized requests are however limited to 60 per hour
+if Keys.github:
+ HEADERS["Authorization"] = f"token {Keys.github}"
diff --git a/bot/exts/backend/branding/_decorators.py b/bot/exts/backend/branding/_decorators.py
new file mode 100644
index 000000000..6a1e7e869
--- /dev/null
+++ b/bot/exts/backend/branding/_decorators.py
@@ -0,0 +1,27 @@
+import functools
+import logging
+import typing as t
+
+from bot.constants import DEBUG_MODE
+
+log = logging.getLogger(__name__)
+
+
+def mock_in_debug(return_value: t.Any) -> t.Callable:
+ """
+ Short-circuit function execution if in debug mode and return `return_value`.
+
+ The original function name, and the incoming args and kwargs are DEBUG level logged
+ upon each call. This is useful for expensive operations, i.e. media asset uploads
+ that are prone to rate-limits but need to be tested extensively.
+ """
+ def decorator(func: t.Callable) -> t.Callable:
+ @functools.wraps(func)
+ async def wrapped(*args, **kwargs) -> t.Any:
+ """Short-circuit and log if in debug mode."""
+ if DEBUG_MODE:
+ log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}")
+ return return_value
+ return await func(*args, **kwargs)
+ return wrapped
+ return decorator
diff --git a/bot/exts/backend/branding/_errors.py b/bot/exts/backend/branding/_errors.py
new file mode 100644
index 000000000..7cd271af3
--- /dev/null
+++ b/bot/exts/backend/branding/_errors.py
@@ -0,0 +1,2 @@
+class BrandingError(Exception):
+ """Exception raised by the BrandingManager cog."""
diff --git a/bot/exts/backend/branding/_seasons.py b/bot/exts/backend/branding/_seasons.py
new file mode 100644
index 000000000..5f6256b30
--- /dev/null
+++ b/bot/exts/backend/branding/_seasons.py
@@ -0,0 +1,175 @@
+import logging
+import typing as t
+from datetime import datetime
+
+from bot.constants import Colours
+from bot.exts.backend.branding._constants import Month
+from bot.exts.backend.branding._errors import BrandingError
+
+log = logging.getLogger(__name__)
+
+
+class SeasonBase:
+ """
+ Base for Seasonal classes.
+
+ This serves as the off-season fallback for when no specific
+ seasons are active.
+
+ Seasons are 'registered' simply by inheriting from `SeasonBase`.
+ We discover them by calling `__subclasses__`.
+ """
+
+ season_name: str = "Evergreen"
+
+ colour: str = Colours.soft_green
+ description: str = "The default season!"
+
+ branding_path: str = "seasonal/evergreen"
+
+ months: t.Set[Month] = set(Month)
+
+
+class Christmas(SeasonBase):
+ """Branding for December."""
+
+ season_name = "Festive season"
+
+ colour = Colours.soft_red
+ description = (
+ "The time is here to get into the festive spirit! No matter who you are, where you are, "
+ "or what beliefs you may follow, we hope every one of you enjoy this festive season!"
+ )
+
+ branding_path = "seasonal/christmas"
+
+ months = {Month.DECEMBER}
+
+
+class Easter(SeasonBase):
+ """Branding for April."""
+
+ season_name = "Easter"
+
+ colour = Colours.bright_green
+ description = (
+ "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate "
+ "our version of Easter during the entire month of April."
+ )
+
+ branding_path = "seasonal/easter"
+
+ months = {Month.APRIL}
+
+
+class Halloween(SeasonBase):
+ """Branding for October."""
+
+ season_name = "Halloween"
+
+ colour = Colours.orange
+ description = "Trick or treat?!"
+
+ branding_path = "seasonal/halloween"
+
+ months = {Month.OCTOBER}
+
+
+class Pride(SeasonBase):
+ """Branding for June."""
+
+ season_name = "Pride"
+
+ colour = Colours.pink
+ description = (
+ "The month of June is a special month for us at Python Discord. It is very important to us "
+ "that everyone feels welcome here, no matter their origin, identity or sexuality. During the "
+ "month of June, while some of you are participating in Pride festivals across the world, "
+ "we will be celebrating individuality and commemorating the history and challenges "
+ "of the LGBTQ+ community with a Pride event of our own!"
+ )
+
+ branding_path = "seasonal/pride"
+
+ months = {Month.JUNE}
+
+
+class Valentines(SeasonBase):
+ """Branding for February."""
+
+ season_name = "Valentines"
+
+ colour = Colours.pink
+ description = "Love is in the air!"
+
+ branding_path = "seasonal/valentines"
+
+ months = {Month.FEBRUARY}
+
+
+class Wildcard(SeasonBase):
+ """Branding for August."""
+
+ season_name = "Wildcard"
+
+ colour = Colours.purple
+ description = "A season full of surprises!"
+
+ months = {Month.AUGUST}
+
+
+def get_all_seasons() -> t.List[t.Type[SeasonBase]]:
+ """Give all available season classes."""
+ return [SeasonBase] + SeasonBase.__subclasses__()
+
+
+def get_current_season() -> t.Type[SeasonBase]:
+ """Give active season, based on current UTC month."""
+ current_month = Month(datetime.utcnow().month)
+
+ active_seasons = tuple(
+ season
+ for season in SeasonBase.__subclasses__()
+ if current_month in season.months
+ )
+
+ if not active_seasons:
+ return SeasonBase
+
+ return active_seasons[0]
+
+
+def get_season(name: str) -> t.Optional[t.Type[SeasonBase]]:
+ """
+ Give season such that its class name or its `season_name` attr match `name` (caseless).
+
+ If no such season exists, return None.
+ """
+ name = name.casefold()
+
+ for season in get_all_seasons():
+ matches = (season.__name__.casefold(), season.season_name.casefold())
+
+ if name in matches:
+ return season
+
+
+def _validate_season_overlap() -> None:
+ """
+ Raise BrandingError if there are any colliding seasons.
+
+ This serves as a local test to ensure that seasons haven't been misconfigured.
+ """
+ month_to_season = {}
+
+ for season in SeasonBase.__subclasses__():
+ for month in season.months:
+ colliding_season = month_to_season.get(month)
+
+ if colliding_season:
+ raise BrandingError(f"Season {season} collides with {colliding_season} in {month.name}")
+ else:
+ month_to_season[month] = season
+
+
+_validate_season_overlap()
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index c643d346e..b8bb3757f 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -1,5 +1,7 @@
import contextlib
+import difflib
import logging
+import random
import typing as t
from discord import Embed
@@ -8,9 +10,10 @@ from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels, Colours
+from bot.constants import Colours, ERROR_REPLIES, Icons, MODERATION_ROLES
from bot.converters import TagNameConverter
from bot.errors import LockedResourceError
+from bot.exts.backend.branding._errors import BrandingError
from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__name__)
@@ -47,7 +50,6 @@ class ErrorHandler(Cog):
* If CommandNotFound is raised when invoking the tag (determined by the presence of the
`invoked_from_error_handler` attribute), this error is treated as being unexpected
and therefore sends an error message
- * Commands in the verification channel are ignored
2. UserInputError: see `handle_user_input_error`
3. CheckFailure: see `handle_check_failure`
4. CommandOnCooldown: send an error message in the invoking context
@@ -63,10 +65,9 @@ class ErrorHandler(Cog):
if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
if await self.try_silence(ctx):
return
- if ctx.channel.id != Channels.verification:
- # Try to look for a tag with the command's name
- await self.try_get_tag(ctx)
- return # Exit early to avoid logging.
+ # Try to look for a tag with the command's name
+ await self.try_get_tag(ctx)
+ return # Exit early to avoid logging.
elif isinstance(e, errors.UserInputError):
await self.handle_user_input_error(ctx, e)
elif isinstance(e, errors.CheckFailure):
@@ -78,6 +79,9 @@ class ErrorHandler(Cog):
await self.handle_api_error(ctx, e.original)
elif isinstance(e.original, LockedResourceError):
await ctx.send(f"{e.original} Please wait for it to finish and try again later.")
+ elif isinstance(e.original, BrandingError):
+ await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original)))
+ return
else:
await self.handle_unexpected_error(ctx, e.original)
return # Exit early to avoid logging.
@@ -156,10 +160,46 @@ class ErrorHandler(Cog):
)
else:
with contextlib.suppress(ResponseCodeError):
- await ctx.invoke(tags_get_command, tag_name=tag_name)
+ if await ctx.invoke(tags_get_command, tag_name=tag_name):
+ return
+
+ if not any(role.id in MODERATION_ROLES for role in ctx.author.roles):
+ await self.send_command_suggestion(ctx, ctx.invoked_with)
+
# Return to not raise the exception
return
+ async def send_command_suggestion(self, ctx: Context, command_name: str) -> None:
+ """Sends user similar commands if any can be found."""
+ # No similar tag found, or tag on cooldown -
+ # searching for a similar command
+ raw_commands = []
+ for cmd in self.bot.walk_commands():
+ if not cmd.hidden:
+ raw_commands += (cmd.name, *cmd.aliases)
+ if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1):
+ similar_command_name = similar_command_data[0]
+ similar_command = self.bot.get_command(similar_command_name)
+
+ if not similar_command:
+ return
+
+ log_msg = "Cancelling attempt to suggest a command due to failed checks."
+ try:
+ if not await similar_command.can_run(ctx):
+ log.debug(log_msg)
+ return
+ except errors.CommandError as cmd_error:
+ log.debug(log_msg)
+ await self.on_command_error(ctx, cmd_error)
+ return
+
+ misspelled_content = ctx.message.content
+ e = Embed()
+ e.set_author(name="Did you mean:", icon_url=Icons.questionmark)
+ e.description = f"{misspelled_content.replace(command_name, similar_command_name, 1)}"
+ await ctx.send(embed=e, delete_after=10.0)
+
async def handle_user_input_error(self, ctx: Context, e: errors.UserInputError) -> None:
"""
Send an error message in `ctx` for UserInputError, sometimes invoking the help command too.
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index 2eb9f9971..c9f2d2da8 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -5,12 +5,15 @@ from collections import namedtuple
from discord import Guild
from discord.ext.commands import Context
+from more_itertools import chunked
import bot
from bot.api import ResponseCodeError
log = logging.getLogger(__name__)
+CHUNK_SIZE = 1000
+
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
@@ -207,10 +210,13 @@ class UserSyncer(Syncer):
@staticmethod
async def _sync(diff: _Diff) -> None:
"""Synchronise the database with the user cache of `guild`."""
+ # Using asyncio.gather would still consume too many resources on the site.
log.trace("Syncing created users...")
if diff.created:
- await bot.instance.api_client.post("bot/users", json=diff.created)
+ for chunk in chunked(diff.created, CHUNK_SIZE):
+ await bot.instance.api_client.post("bot/users", json=chunk)
log.trace("Syncing updated users...")
if diff.updated:
- await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated)
+ for chunk in chunked(diff.updated, CHUNK_SIZE):
+ await bot.instance.api_client.patch("bot/users/bulk_patch", json=chunk)
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 208fc9e1f..3527bf8bb 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -48,7 +48,6 @@ class Stats(NamedTuple):
message_content: str
additional_embeds: Optional[List[discord.Embed]]
- additional_embeds_msg: Optional[str]
class Filtering(Cog):
@@ -358,7 +357,6 @@ class Filtering(Cog):
channel_id=Channels.mod_alerts,
ping_everyone=ping_everyone,
additional_embeds=stats.additional_embeds,
- additional_embeds_msg=stats.additional_embeds_msg
)
def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:
@@ -375,7 +373,6 @@ class Filtering(Cog):
message_content = content
additional_embeds = None
- additional_embeds_msg = None
self.bot.stats.incr(f"filters.{name}")
@@ -392,13 +389,11 @@ class Filtering(Cog):
embed.set_thumbnail(url=data["icon"])
embed.set_footer(text=f"Guild ID: {data['id']}")
additional_embeds.append(embed)
- additional_embeds_msg = "For the following guild(s):"
elif name == "watch_rich_embeds":
additional_embeds = match
- additional_embeds_msg = "With the following embed(s):"
- return Stats(message_content, additional_embeds, additional_embeds_msg)
+ return Stats(message_content, additional_embeds)
@staticmethod
def _check_filter(msg: Message) -> bool:
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index e717d7af8..224214b00 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -4,8 +4,10 @@ from datetime import datetime, timedelta
import discord
+import bot
from bot import constants
from bot.exts.help_channels import _caches, _message
+from bot.utils.channel import try_get_channel
log = logging.getLogger(__name__)
@@ -55,3 +57,43 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:
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
+
+
+async def move_to_bottom(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 try_get_channel(category_id)
+
+ payload = [{"id": c.id, "position": c.position} for c in category.channels]
+
+ # Calculate the bottom position based on the current highest position in the category. If the
+ # category is currently empty, we simply use the current position of the channel to avoid making
+ # unnecessary changes to positions in the guild.
+ bottom_position = payload[-1]["position"] + 1 if payload else channel.position
+
+ payload.append(
+ {
+ "id": channel.id,
+ "position": bottom_position,
+ "parent_id": category.id,
+ "lock_permissions": True,
+ }
+ )
+
+ # We use d.py's method to ensure our request is processed by d.py's rate limit manager
+ await bot.instance.http.bulk_channel_update(category.guild.id, payload)
+
+ # Now that the channel is moved, we can edit the other attributes
+ if options:
+ await channel.edit(**options)
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 983c5d183..0995c8a79 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -3,6 +3,7 @@ import logging
import random
import typing as t
from datetime import datetime, timezone
+from operator import attrgetter
import discord
import discord.abc
@@ -10,12 +11,12 @@ 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
+from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats
+from bot.utils import channel as channel_utils, lock, scheduling
log = logging.getLogger(__name__)
+NAMESPACE = "help"
HELP_CHANNEL_TOPIC = """
This is a Python help channel. You can claim your own help channel in the Python Help: Available category.
"""
@@ -58,7 +59,7 @@ class HelpChannels(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
# Categories
self.available_category: discord.CategoryChannel = None
@@ -73,7 +74,6 @@ class HelpChannels(commands.Cog):
# 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:
@@ -87,6 +87,36 @@ class HelpChannels(commands.Cog):
self.scheduler.cancel_all()
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id"))
+ @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id"))
+ @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True)
+ async def claim_channel(self, message: discord.Message) -> None:
+ """
+ Claim the channel in which the question `message` was sent.
+
+ Move the channel to the In Use category and pin the `message`. Add a cooldown to the
+ claimant to prevent them from asking another question. Lastly, make a new channel available.
+ """
+ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.")
+ await self.move_to_in_use(message.channel)
+ await _cooldown.revoke_send_permissions(message.author, self.scheduler)
+
+ await _message.pin(message)
+
+ # Add user with channel for dormant check.
+ await _caches.claimants.set(message.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(message.channel.id, timestamp)
+
+ await _caches.unanswered.set(message.channel.id, True)
+
+ # Not awaited because it may indefinitely hold the lock while waiting for a channel.
+ scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}")
+
def create_channel_queue(self) -> asyncio.Queue:
"""
Return a queue of dormant channels to use for getting the next available channel.
@@ -124,8 +154,12 @@ class HelpChannels(commands.Cog):
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."""
+ async def close_check(self, ctx: commands.Context) -> bool:
+ """Return True if the channel is in use and the user is the claimant or has a whitelisted role."""
+ if ctx.channel.category != self.in_use_category:
+ log.debug(f"{ctx.author} invoked command 'close' outside an in-use help channel")
+ return False
+
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")
@@ -144,18 +178,12 @@ class HelpChannels(commands.Cog):
"""
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.
+ May only be invoked by the channel's claimant or by staff.
"""
- 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)
+ # Don't use a discord.py check because the check needs to fail silently.
+ if await self.close_check(ctx):
+ log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.")
+ await self.unclaim_channel(ctx.channel, is_auto=False)
async def get_available_candidate(self) -> discord.TextChannel:
"""
@@ -201,7 +229,7 @@ class HelpChannels(commands.Cog):
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")
+ await self.unclaim_channel(channel)
async def init_categories(self) -> None:
"""Get the help category objects. Remove the cog if retrieval fails."""
@@ -248,20 +276,10 @@ class HelpChannels(commands.Cog):
self.close_command.enabled = True
await self.init_available()
- self.report_stats()
+ _stats.report_counts()
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.
@@ -284,7 +302,7 @@ class HelpChannels(commands.Cog):
f"and will be made dormant."
)
- await self.move_to_dormant(channel, "auto")
+ await self.unclaim_channel(channel)
else:
# Cancel the existing task, if any.
if has_task:
@@ -298,45 +316,6 @@ class HelpChannels(commands.Cog):
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.")
@@ -348,78 +327,81 @@ class HelpChannels(commands.Cog):
log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
- await self.move_to_bottom_position(
+ await _channel.move_to_bottom(
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.
+ _stats.report_counts()
- A caller argument is provided for metrics.
- """
+ async def move_to_dormant(self, channel: discord.TextChannel) -> None:
+ """Make the `channel` dormant."""
log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
-
- await self.move_to_bottom_position(
+ await _channel.move_to_bottom(
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:
+ _stats.report_counts()
+
+ @lock.lock_arg(f"{NAMESPACE}.unclaim", "channel")
+ async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None:
"""
- Mark the channel as unclaimed and remove the cooldown role from the claimant if needed.
+ Unclaim an in-use help `channel` to make it dormant.
+
+ Unpin the claimant's question message and move the channel to the Dormant category.
+ Remove the cooldown role from the channel claimant if they have no other channels claimed.
+ Cancel the scheduled cooldown role removal task.
- 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.
+ Set `is_auto` to True if the channel was automatically closed or False if manually closed.
"""
- claimant_id = await _caches.claimants.pop(channel.id)
+ claimant_id = await _caches.claimants.get(channel.id)
+ _unclaim_channel = self._unclaim_channel
+
+ # It could be possible that there is no claimant cached. In such case, it'd be useless and
+ # possibly incorrect to lock on None. Therefore, the lock is applied conditionally.
+ if claimant_id is not None:
+ decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True)
+ _unclaim_channel = decorator(_unclaim_channel)
+
+ return await _unclaim_channel(channel, claimant_id, is_auto)
- # Ignore missing task when cooldown has passed but the channel still isn't dormant.
+ async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None:
+ """Actual implementation of `unclaim_channel`. See that for full documentation."""
+ await _caches.claimants.delete(channel.id)
+
+ # Ignore missing tasks because a channel may still be dormant after the cooldown expires.
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()):
+ elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()):
+ # Remove the cooldown role if the claimant has no other channels left
await _cooldown.remove_cooldown_role(claimant)
+ await _message.unpin(channel)
+ await _stats.report_complete_session(channel.id, is_auto)
+ await self.move_to_dormant(channel)
+
+ # Cancel the task that makes the channel dormant only if called by the close command.
+ # In other cases, the task is either already done or not-existent.
+ if not is_auto:
+ self.scheduler.cancel(channel.id)
+
async def move_to_in_use(self, channel: discord.TextChannel) -> None:
"""Make a channel in-use and schedule it to be made dormant."""
log.info(f"Moving #{channel} ({channel.id}) to the In Use category.")
- await self.move_to_bottom_position(
+ await _channel.move_to_bottom(
channel=channel,
category_id=constants.Categories.help_in_use,
)
@@ -428,7 +410,7 @@ class HelpChannels(commands.Cog):
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()
+ _stats.report_counts()
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
@@ -436,51 +418,13 @@ class HelpChannels(commands.Cog):
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()
+ if channel_utils.is_in_category(message.channel, constants.Categories.help_available):
+ if not _channel.is_excluded_channel(message.channel):
+ await self.claim_channel(message)
+ else:
+ await _message.check_for_answer(message)
@commands.Cog.listener()
async def on_message_delete(self, msg: discord.Message) -> None:
@@ -489,15 +433,14 @@ class HelpChannels(commands.Cog):
The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.
"""
+ await self.init_task
+
if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):
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.
diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py
new file mode 100644
index 000000000..b8778e7d9
--- /dev/null
+++ b/bot/exts/help_channels/_stats.py
@@ -0,0 +1,42 @@
+import logging
+
+from more_itertools import ilen
+
+import bot
+from bot import constants
+from bot.exts.help_channels import _caches, _channel
+
+log = logging.getLogger(__name__)
+
+
+def report_counts() -> None:
+ """Report channel count stats of each help category."""
+ for name in ("in_use", "available", "dormant"):
+ id_ = getattr(constants.Categories, f"help_{name}")
+ category = bot.instance.get_channel(id_)
+
+ if category:
+ total = ilen(_channel.get_category_channels(category))
+ bot.instance.stats.gauge(f"help.total.{name}", total)
+ else:
+ log.warning(f"Couldn't find category {name!r} to track channel count stats.")
+
+
+async def report_complete_session(channel_id: int, is_auto: bool) -> None:
+ """
+ Report stats for a completed help session channel `channel_id`.
+
+ Set `is_auto` to True if the channel was automatically closed or False if manually closed.
+ """
+ caller = "auto" if is_auto else "command"
+ bot.instance.stats.incr(f"help.dormant_calls.{caller}")
+
+ in_use_time = await _channel.get_in_use_time(channel_id)
+ if in_use_time:
+ bot.instance.stats.timing("help.in_use_time", in_use_time)
+
+ unanswered = await _caches.unanswered.get(channel_id)
+ if unanswered:
+ bot.instance.stats.incr("help.sessions.unanswered")
+ elif unanswered is not None:
+ bot.instance.stats.incr("help.sessions.answered")
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 461ff82fd..3a05b2c8a 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -5,7 +5,7 @@ from contextlib import suppress
from typing import List, Union
from discord import Colour, Embed
-from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
+from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from fuzzywuzzy.utils import full_process
@@ -20,6 +20,8 @@ log = logging.getLogger(__name__)
COMMANDS_PER_PAGE = 8
PREFIX = constants.Bot.prefix
+NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n"
+
Category = namedtuple("Category", ["name", "description", "cogs"])
@@ -173,9 +175,16 @@ class CustomHelpCommand(HelpCommand):
if aliases:
command_details += f"**Can also use:** {aliases}\n\n"
- # check if the user is allowed to run this command
- if not await command.can_run(self.context):
- command_details += "***You cannot run this command.***\n\n"
+ # when command is disabled, show message about it,
+ # when other CommandError or user is not allowed to run command,
+ # add this to help message.
+ try:
+ if not await command.can_run(self.context):
+ command_details += NOT_ALLOWED_TO_RUN_MESSAGE
+ except DisabledCommand:
+ command_details += "***This command is disabled.***\n\n"
+ except CommandError:
+ command_details += NOT_ALLOWED_TO_RUN_MESSAGE
command_details += f"*{command.help or 'No details provided.'}*\n"
embed.description = command_details
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index b2138b03f..9fb875925 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,7 +6,8 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Tuple, Union
-from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils
+import fuzzywuzzy
+from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status
from discord.abc import GuildChannel
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
@@ -106,22 +107,28 @@ class Information(Cog):
To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.
"""
- parsed_roles = []
- failed_roles = []
+ parsed_roles = set()
+ failed_roles = set()
+ all_roles = {role.id: role.name for role in ctx.guild.roles}
for role_name in roles:
if isinstance(role_name, Role):
# Role conversion has already succeeded
- parsed_roles.append(role_name)
+ parsed_roles.add(role_name)
continue
- role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
+ match = fuzzywuzzy.process.extractOne(
+ role_name, all_roles, score_cutoff=80,
+ scorer=fuzzywuzzy.fuzz.ratio
+ )
- if not role:
- failed_roles.append(role_name)
+ if not match:
+ failed_roles.add(role_name)
continue
- parsed_roles.append(role)
+ # `match` is a (role name, score, role id) tuple
+ role = ctx.guild.get_role(match[2])
+ parsed_roles.add(role)
if failed_roles:
await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}")
@@ -419,10 +426,14 @@ class Information(Cog):
return out.rstrip()
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
- @group(invoke_without_command=True, enabled=False)
+ @group(invoke_without_command=True)
@in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)
async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:
"""Shows information about the raw API response."""
+ if ctx.author not in message.channel.members:
+ await ctx.send(":x: You do not have permissions to see the channel this message is in.")
+ return
+
# I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling
# doing this extra request is also much easier than trying to convert everything back into a dictionary again
raw_data = await ctx.bot.http.get_message(message.channel.id, message.id)
@@ -454,7 +465,7 @@ class Information(Cog):
for page in paginator.pages:
await ctx.send(page)
- @raw.command(enabled=False)
+ @raw.command()
async def json(self, ctx: Context, message: Message) -> None:
"""Shows information about the raw API response in a copy-pasteable Python format."""
await ctx.invoke(self.raw, message=message, json=True)
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index 8f15f932b..00b4d1a78 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -46,7 +46,7 @@ class Tags(Cog):
"embed": {
"description": file.read_text(encoding="utf8"),
},
- "restricted_to": "developers",
+ "restricted_to": None,
"location": f"/bot/{file}"
}
@@ -63,7 +63,7 @@ class Tags(Cog):
@staticmethod
def check_accessibility(user: Member, tag: dict) -> bool:
"""Check if user can access a tag."""
- return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles]
+ return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles]
@staticmethod
def _fuzzy_search(search: str, target: str) -> float:
@@ -182,10 +182,15 @@ class Tags(Cog):
matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author)
await self._send_matching_tags(ctx, keywords, matching_tags)
- @tags_group.command(name='get', aliases=('show', 'g'))
- async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
- """Get a specified tag, or a list of all tags if no tag is specified."""
+ async def display_tag(self, ctx: Context, tag_name: str = None) -> bool:
+ """
+ If a tag is not found, display similar tag names as suggestions.
+ If a tag is not specified, display a paginated embed of all tags.
+
+ Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display
+ nothing and return False.
+ """
def _command_on_cooldown(tag_name: str) -> bool:
"""
Check if the command is currently on cooldown, on a per-tag, per-channel basis.
@@ -212,7 +217,7 @@ class Tags(Cog):
f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
f"Cooldown ends in {time_left:.1f} seconds."
)
- return
+ return False
if tag_name is not None:
temp_founds = self._get_tag(tag_name)
@@ -237,6 +242,7 @@ class Tags(Cog):
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
)
+ return True
elif founds and len(tag_name) >= 3:
await wait_for_deletion(
await ctx.send(
@@ -247,6 +253,7 @@ class Tags(Cog):
),
[ctx.author.id],
)
+ return True
else:
tags = self._cache.values()
@@ -255,6 +262,7 @@ class Tags(Cog):
description="**There are no tags in the database!**",
colour=Colour.red()
))
+ return True
else:
embed: Embed = Embed(title="**Current tags**")
await LinePaginator.paginate(
@@ -268,6 +276,18 @@ class Tags(Cog):
empty=False,
max_lines=15
)
+ return True
+
+ return False
+
+ @tags_group.command(name='get', aliases=('show', 'g'))
+ async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> bool:
+ """
+ Get a specified tag, or a list of all tags if no tag is specified.
+
+ Returns False if a tag is on cooldown, or if no matches are found.
+ """
+ return await self.display_tag(ctx, tag_name)
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 4d5142b55..6d081741c 100644
--- a/bot/exts/moderation/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -52,6 +52,10 @@ class DMRelay(Cog):
await ctx.message.add_reaction("❌")
return
+ if member.id == self.bot.user.id:
+ log.debug("Not sending message to bot user")
+ return await ctx.send("🚫 I can't send messages to myself!")
+
try:
await member.send(message)
except discord.errors.Forbidden:
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 18e937e87..b3d069b34 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Temporary shadow infractions
- @command(hidden=True, aliases=["shadowtempban, stempban"])
+ @command(hidden=True, aliases=["shadowtempban", "stempban"])
async def shadow_tempban(
self,
ctx: Context,
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index b01de0ee3..e4b119f41 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -92,7 +92,6 @@ class ModLog(Cog, name="ModLog"):
files: t.Optional[t.List[discord.File]] = None,
content: t.Optional[str] = None,
additional_embeds: t.Optional[t.List[discord.Embed]] = None,
- additional_embeds_msg: t.Optional[str] = None,
timestamp_override: t.Optional[datetime] = None,
footer: t.Optional[str] = None,
) -> Context:
@@ -133,8 +132,6 @@ class ModLog(Cog, name="ModLog"):
)
if additional_embeds:
- if additional_embeds_msg:
- await channel.send(additional_embeds_msg)
for additional_embed in additional_embeds:
await channel.send(embed=additional_embed)
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index a942d5294..2a7ca932e 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -72,7 +72,7 @@ class SilenceNotifier(tasks.Loop):
class Silence(commands.Cog):
- """Commands for stopping channel messages for `verified` role in a channel."""
+ """Commands for stopping channel messages for `everyone` role in a channel."""
# Maps muted channel IDs to their previous overwrites for send_message and add_reactions.
# Overwrites are stored as JSON.
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index efd862aa5..c449752e1 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -7,7 +7,7 @@ from discord import TextChannel
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.constants import Emojis, MODERATION_ROLES
+from bot.constants import Channels, Emojis, MODERATION_ROLES
from bot.converters import DurationDelta
from bot.utils import time
@@ -15,6 +15,12 @@ log = logging.getLogger(__name__)
SLOWMODE_MAX_DELAY = 21600 # seconds
+COMMONLY_SLOWMODED_CHANNELS = {
+ Channels.python_general: "python_general",
+ Channels.discord_py: "discordpy",
+ Channels.off_topic_0: "ot0",
+}
+
class Slowmode(Cog):
"""Commands for getting and setting slowmode delays of text channels."""
@@ -58,6 +64,10 @@ class Slowmode(Cog):
log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.')
await channel.edit(slowmode_delay=slowmode_delay)
+ if channel.id in COMMONLY_SLOWMODED_CHANNELS:
+ log.info(f'Recording slowmode change in stats for {channel.name}.')
+ self.bot.stats.gauge(f"slowmode.{COMMONLY_SLOWMODED_CHANNELS[channel.id]}", slowmode_delay)
+
await ctx.send(
f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.'
)
@@ -75,16 +85,7 @@ class Slowmode(Cog):
@slowmode_group.command(name='reset', aliases=['r'])
async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
"""Reset the slowmode delay for a text channel to 0 seconds."""
- # Use the channel this command was invoked in if one was not given
- if channel is None:
- channel = ctx.channel
-
- log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.')
-
- await channel.edit(slowmode_delay=0)
- await ctx.send(
- f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.'
- )
+ await self.set_slowmode(ctx, channel, relativedelta(seconds=0))
async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index ce91dcb15..bfe9b74b4 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -1,27 +1,18 @@
-import asyncio
import logging
import typing as t
-from contextlib import suppress
-from datetime import datetime, timedelta
import discord
-from async_rediscache import RedisCache
-from discord.ext import tasks
-from discord.ext.commands import Cog, Context, command, group, has_any_role
-from discord.utils import snowflake_time
+from discord.ext.commands import Cog, Context, command, has_any_role
from bot import constants
-from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.decorators import has_no_roles, in_whitelist
-from bot.exts.moderation.modlog import ModLog
-from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check
-from bot.utils.messages import format_user
+from bot.decorators import in_whitelist
+from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__name__)
# Sent via DMs once user joins the guild
-ON_JOIN_MESSAGE = f"""
+ON_JOIN_MESSAGE = """
Welcome to Python Discord!
To show you what kind of community we are, we've created this video:
@@ -29,32 +20,9 @@ https://youtu.be/ZH26PuX3re0
As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \
In order to see the rest of the channels and to send messages, you first have to accept our rules.
-
-Please visit <#{constants.Channels.verification}> to get started. Thank you!
"""
-# Sent via DMs once user verifies
VERIFIED_MESSAGE = f"""
-Thanks for verifying yourself!
-
-For your records, these are the documents you accepted:
-
-`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
-`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
-your information removed here as well.
-
-Feel free to review them at any point!
-
-Additionally, if you'd like to receive notifications for the announcements \
-we post in <#{constants.Channels.announcements}>
-from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \
-to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement.
-
-If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
-<#{constants.Channels.bot_commands}>.
-"""
-
-ALTERNATE_VERIFIED_MESSAGE = f"""
You are now verified!
You can find a copy of our rules for reference at <https://pythondiscord.com/pages/rules>.
@@ -71,61 +39,6 @@ To introduce you to our community, we've made the following video:
https://youtu.be/ZH26PuX3re0
"""
-# Sent via DMs to users kicked for failing to verify
-KICKED_MESSAGE = f"""
-Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \
-within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again!
-
-{constants.Guild.invite}
-"""
-
-# Sent periodically in the verification channel
-REMINDER_MESSAGE = f"""
-<@&{constants.Roles.unverified}>
-
-Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \
-to send messages in the community!
-
-You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days.
-""".strip()
-
-# An async function taking a Member param
-Request = t.Callable[[discord.Member], t.Awaitable]
-
-
-class StopExecution(Exception):
- """Signals that a task should halt immediately & alert admins."""
-
- def __init__(self, reason: discord.HTTPException) -> None:
- super().__init__()
- self.reason = reason
-
-
-class Limit(t.NamedTuple):
- """Composition over config for throttling requests."""
-
- batch_size: int # Amount of requests after which to pause
- sleep_secs: int # Sleep this many seconds after each batch
-
-
-def mention_role(role_id: int) -> discord.AllowedMentions:
- """Construct an allowed mentions instance that allows pinging `role_id`."""
- return discord.AllowedMentions(roles=[discord.Object(role_id)])
-
-
-def is_verified(member: discord.Member) -> bool:
- """
- Check whether `member` is considered verified.
-
- Members are considered verified if they have at least 1 role other than
- the default role (@everyone) and the @Unverified role.
- """
- unverified_roles = {
- member.guild.get_role(constants.Roles.unverified),
- member.guild.default_role,
- }
- return len(set(member.roles) - unverified_roles) > 0
-
async def safe_dm(coro: t.Coroutine) -> None:
"""
@@ -150,410 +63,16 @@ class Verification(Cog):
"""
User verification and role management.
- There are two internal tasks in this cog:
-
- * `update_unverified_members`
- * Unverified members are given the @Unverified role after configured `unverified_after` days
- * Unverified members are kicked after configured `kicked_after` days
- * `ping_unverified`
- * Periodically ping the @Unverified role in the verification channel
-
Statistics are collected in the 'verification.' namespace.
- Moderators+ can use the `verification` command group to start or stop both internal
- tasks, if necessary. Settings are persisted in Redis across sessions.
-
- Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands,
- and keeps the verification channel clean by deleting messages.
+ Additionally, this cog offers the !subscribe and !unsubscribe commands,
"""
- # Persist task settings & last sent `REMINDER_MESSAGE` id
- # RedisCache[
- # "tasks_running": int (0 or 1),
- # "last_reminder": int (discord.Message.id),
- # ]
- task_cache = RedisCache()
-
def __init__(self, bot: Bot) -> None:
"""Start internal tasks."""
self.bot = bot
- self.bot.loop.create_task(self._maybe_start_tasks())
-
self.pending_members = set()
- def cog_unload(self) -> None:
- """
- Cancel internal tasks.
-
- This is necessary, as tasks are not automatically cancelled on cog unload.
- """
- self._stop_tasks(gracefully=False)
-
- @property
- def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
-
- async def _maybe_start_tasks(self) -> None:
- """
- Poll Redis to check whether internal tasks should start.
-
- Redis must be interfaced with from an async function.
- """
- log.trace("Checking whether background tasks should begin")
- setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set
-
- if setting:
- log.trace("Background tasks will be started")
- self.update_unverified_members.start()
- self.ping_unverified.start()
-
- def _stop_tasks(self, *, gracefully: bool) -> None:
- """
- Stop the update users & ping @Unverified tasks.
-
- If `gracefully` is True, the tasks will be able to finish their current iteration.
- Otherwise, they are cancelled immediately.
- """
- log.info(f"Stopping internal tasks ({gracefully=})")
- if gracefully:
- self.update_unverified_members.stop()
- self.ping_unverified.stop()
- else:
- self.update_unverified_members.cancel()
- self.ping_unverified.cancel()
-
- # region: automatically update unverified users
-
- async def _verify_kick(self, n_members: int) -> bool:
- """
- Determine whether `n_members` is a reasonable amount of members to kick.
-
- First, `n_members` is checked against the size of the PyDis guild. If `n_members` are
- more than the configured `kick_confirmation_threshold` of the guild, the operation
- must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe.
- """
- log.debug(f"Checking whether {n_members} members are safe to kick")
-
- await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild
- pydis = self.bot.get_guild(constants.Guild.id)
-
- percentage = n_members / len(pydis.members)
- if percentage < constants.Verification.kick_confirmation_threshold:
- log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe")
- return True
-
- # Since `n_members` is a suspiciously large number, we will ask for confirmation
- log.debug("Amount of users is too large, requesting staff confirmation")
-
- core_dev_channel = pydis.get_channel(constants.Channels.dev_core)
- core_dev_ping = f"<@&{constants.Roles.core_developers}>"
-
- confirmation_msg = await core_dev_channel.send(
- f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't "
- f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's "
- f"population. Proceed?",
- allowed_mentions=mention_role(constants.Roles.core_developers),
- )
-
- options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned)
- for option in options:
- await confirmation_msg.add_reaction(option)
-
- core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members]
-
- def check(reaction: discord.Reaction, user: discord.User) -> bool:
- """Check whether `reaction` is a valid reaction to `confirmation_msg`."""
- return (
- reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg`
- and str(reaction.emoji) in options # With one of `options`
- and user.id in core_dev_ids # By a core developer
- )
-
- timeout = 60 * 5 # Seconds, i.e. 5 minutes
- try:
- choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout)
- except asyncio.TimeoutError:
- log.debug("Staff prompt not answered, aborting operation")
- return False
- finally:
- with suppress(discord.HTTPException):
- await confirmation_msg.clear_reactions()
-
- result = str(choice) == constants.Emojis.incident_actioned
- log.debug(f"Received answer: {choice}, result: {result}")
-
- # Edit the prompt message to reflect the final choice
- if result is True:
- result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!"
- else:
- result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!"
-
- with suppress(discord.HTTPException):
- await confirmation_msg.edit(content=result_msg)
-
- return result
-
- async def _alert_admins(self, exception: discord.HTTPException) -> None:
- """
- Ping @Admins with information about `exception`.
-
- This is used when a critical `exception` caused a verification task to abort.
- """
- await self.bot.wait_until_guild_available()
- log.info(f"Sending admin alert regarding exception: {exception}")
-
- admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins)
- ping = f"<@&{constants.Roles.admins}>"
-
- await admins_channel.send(
- f"{ping} Aborted updating unverified users due to the following exception:\n"
- f"```{exception}```\n"
- f"Internal tasks will be stopped.",
- allowed_mentions=mention_role(constants.Roles.admins),
- )
-
- async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int:
- """
- Pass `members` one by one to `request` handling Discord exceptions.
-
- This coroutine serves as a generic `request` executor for kicking members and adding
- roles, as it allows us to define the error handling logic in one place only.
-
- Any `request` has the ability to completely abort the execution by raising `StopExecution`.
- In such a case, the @Admins will be alerted of the reason attribute.
-
- To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds
- to sleep between batches.
-
- Returns the amount of successful requests. Failed requests are logged at info level.
- """
- log.trace(f"Sending {len(members)} requests")
- n_success, bad_statuses = 0, set()
-
- for progress, member in enumerate(members, start=1):
- if is_verified(member): # Member could have verified in the meantime
- continue
- try:
- await request(member)
- except StopExecution as stop_execution:
- await self._alert_admins(stop_execution.reason)
- await self.task_cache.set("tasks_running", 0)
- self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop
- break
- except discord.HTTPException as http_exc:
- bad_statuses.add(http_exc.status)
- else:
- n_success += 1
-
- if progress % limit.batch_size == 0:
- log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds")
- await asyncio.sleep(limit.sleep_secs)
-
- if bad_statuses:
- log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}")
-
- return n_success
-
- async def _add_kick_note(self, member: discord.Member) -> None:
- """
- Post a note regarding `member` being kicked to site.
-
- Allows keeping track of kicked members for auditing purposes.
- """
- payload = {
- "active": False,
- "actor": self.bot.user.id, # Bot actions this autonomously
- "expires_at": None,
- "hidden": True,
- "reason": "Verification kick",
- "type": "note",
- "user": member.id,
- }
-
- log.trace(f"Posting kick note for member {member} ({member.id})")
- try:
- await self.bot.api_client.post("bot/infractions", json=payload)
- except ResponseCodeError as api_exc:
- log.warning("Failed to post kick note", exc_info=api_exc)
-
- async def _kick_members(self, members: t.Collection[discord.Member]) -> int:
- """
- Kick `members` from the PyDis guild.
-
- Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second
- after each 2 requests to allow breathing room for other features.
-
- Note that this is a potentially destructive operation. Returns the amount of successful requests.
- """
- log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)")
-
- async def kick_request(member: discord.Member) -> None:
- """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""
- try:
- await safe_dm(member.send(KICKED_MESSAGE)) # Suppress disabled DMs
- except discord.HTTPException as suspicious_exception:
- raise StopExecution(reason=suspicious_exception)
- await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
- await self._add_kick_note(member)
-
- n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1))
- self.bot.stats.incr("verification.kicked", count=n_kicked)
-
- return n_kicked
-
- async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int:
- """
- Give `role` to all `members`.
-
- We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded.
-
- Returns the amount of successful requests.
- """
- log.info(
- f"Assigning {role} role to {len(members)} members (not verified "
- f"after {constants.Verification.unverified_after} days)"
- )
-
- async def role_request(member: discord.Member) -> None:
- """Add `role` to `member`."""
- await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days")
-
- return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1))
-
- async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]:
- """
- Check in on the verification status of PyDis members.
-
- This coroutine finds two sets of users:
- * Not verified after configured `unverified_after` days, should be given the @Unverified role
- * Not verified after configured `kicked_after` days, should be kicked from the guild
-
- These sets are always disjoint, i.e. share no common members.
- """
- await self.bot.wait_until_guild_available() # Ensure cache is ready
- pydis = self.bot.get_guild(constants.Guild.id)
-
- unverified = pydis.get_role(constants.Roles.unverified)
- current_dt = datetime.utcnow() # Discord timestamps are UTC
-
- # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint
- for_role, for_kick = set(), set()
-
- log.debug("Checking verification status of guild members")
- for member in pydis.members:
-
- # Skip verified members, bots, and members for which we do not know their join date,
- # this should be extremely rare but docs mention that it can happen
- if is_verified(member) or member.bot or member.joined_at is None:
- continue
-
- # At this point, we know that `member` is an unverified user, and we will decide what
- # to do with them based on time passed since their join date
- since_join = current_dt - member.joined_at
-
- if since_join > timedelta(days=constants.Verification.kicked_after):
- for_kick.add(member) # User should be removed from the guild
-
- elif (
- since_join > timedelta(days=constants.Verification.unverified_after)
- and unverified not in member.roles
- ):
- for_role.add(member) # User should be given the @Unverified role
-
- log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked")
- return for_role, for_kick
-
- @tasks.loop(minutes=30)
- async def update_unverified_members(self) -> None:
- """
- Periodically call `_check_members` and update unverified members accordingly.
-
- After each run, a summary will be sent to the modlog channel. If a suspiciously high
- amount of members to be kicked is found, the operation is guarded by `_verify_kick`.
- """
- log.info("Updating unverified guild members")
-
- await self.bot.wait_until_guild_available()
- unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified)
-
- for_role, for_kick = await self._check_members()
-
- if not for_role:
- role_report = f"Found no users to be assigned the {unverified.mention} role."
- else:
- n_roles = await self._give_role(for_role, unverified)
- role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members."
-
- if not for_kick:
- kick_report = "Found no users to be kicked."
- elif not await self._verify_kick(len(for_kick)):
- kick_report = f"Not authorized to kick `{len(for_kick)}` members."
- else:
- n_kicks = await self._kick_members(for_kick)
- kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild."
-
- await self.mod_log.send_log_message(
- icon_url=self.bot.user.avatar_url,
- colour=discord.Colour.blurple(),
- title="Verification system",
- text=f"{kick_report}\n{role_report}",
- )
-
- # endregion
- # region: periodically ping @Unverified
-
- @tasks.loop(hours=constants.Verification.reminder_frequency)
- async def ping_unverified(self) -> None:
- """
- Delete latest `REMINDER_MESSAGE` and send it again.
-
- This utilizes RedisCache to persist the latest reminder message id.
- """
- await self.bot.wait_until_guild_available()
- verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification)
-
- last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
-
- if last_reminder is not None:
- log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}")
-
- with suppress(discord.HTTPException): # If something goes wrong, just ignore it
- await self.bot.http.delete_message(verification.id, last_reminder)
-
- log.trace("Sending verification reminder")
- new_reminder = await verification.send(
- REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified),
- )
-
- await self.task_cache.set("last_reminder", new_reminder.id)
-
- @ping_unverified.before_loop
- async def _before_first_ping(self) -> None:
- """
- Sleep until `REMINDER_MESSAGE` should be sent again.
-
- If latest reminder is not cached, exit instantly. Otherwise, wait wait until the
- configured `reminder_frequency` has passed.
- """
- last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
-
- if last_reminder is None:
- log.trace("Latest verification reminder message not cached, task will not wait")
- return
-
- # Convert cached message id into a timestamp
- time_since = datetime.utcnow() - snowflake_time(last_reminder)
- log.trace(f"Time since latest verification reminder: {time_since}")
-
- to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since
- log.trace(f"Time to sleep until next ping: {to_sleep}")
-
- # Delta can be negative if `reminder_frequency` has already passed
- secs = max(to_sleep.total_seconds(), 0)
- await asyncio.sleep(secs)
-
- # endregion
# region: listeners
@Cog.listener()
@@ -562,13 +81,11 @@ class Verification(Cog):
if member.guild.id != constants.Guild.id:
return # Only listen for PyDis events
- raw_member = await self.bot.http.get_member(member.guild.id, member.id)
-
# 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 when they pass the gate.
- if raw_member.get("pending"):
+ if member.pending:
return
log.trace(f"Sending on join message to new member: {member.id}")
@@ -586,183 +103,12 @@ class Verification(Cog):
# and has gone through the alternate gating system we should send
# our alternate welcome DM which includes info such as our welcome
# video.
- await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE))
+ await safe_dm(after.send(VERIFIED_MESSAGE))
except discord.HTTPException:
log.exception("DM dispatch failed on unexpected error code")
- @Cog.listener()
- async def on_message(self, message: discord.Message) -> None:
- """Check new message event for messages to the checkpoint channel & process."""
- if message.channel.id != constants.Channels.verification:
- return # Only listen for #checkpoint messages
-
- if message.content == REMINDER_MESSAGE:
- return # Ignore bots own verification reminder
-
- if message.author.bot:
- # They're a bot, delete their message after the delay.
- await message.delete(delay=constants.Verification.bot_message_delete_delay)
- return
-
- # if a user mentions a role or guild member
- # alert the mods in mod-alerts channel
- if message.mentions or message.role_mentions:
- log.debug(
- f"{message.author} mentioned one or more users "
- f"and/or roles in {message.channel.name}"
- )
-
- embed_text = (
- f"{format_user(message.author)} sent a message in "
- f"{message.channel.mention} that contained user and/or role mentions."
- f"\n\n**Original message:**\n>>> {message.content}"
- )
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=constants.Icons.filtering,
- colour=discord.Colour(constants.Colours.soft_red),
- title=f"User/Role mentioned in {message.channel.name}",
- text=embed_text,
- thumbnail=message.author.avatar_url_as(static_format="png"),
- channel_id=constants.Channels.mod_alerts,
- )
-
- ctx: Context = await self.bot.get_context(message)
- if ctx.command is not None and ctx.command.name == "accept":
- return
-
- if any(r.id == constants.Roles.verified for r in ctx.author.roles):
- log.info(
- f"{ctx.author} posted '{ctx.message.content}' "
- "in the verification channel, but is already verified."
- )
- return
-
- log.debug(
- f"{ctx.author} posted '{ctx.message.content}' in the verification "
- "channel. We are providing instructions how to verify."
- )
- await ctx.send(
- f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
- f"and gain access to the rest of the server.",
- delete_after=20
- )
-
- log.trace(f"Deleting the message posted by {ctx.author}")
- with suppress(discord.NotFound):
- await ctx.message.delete()
-
- # endregion
- # region: task management commands
-
- @has_any_role(*constants.MODERATION_ROLES)
- @group(name="verification")
- async def verification_group(self, ctx: Context) -> None:
- """Manage internal verification tasks."""
- if ctx.invoked_subcommand is None:
- await ctx.send_help(ctx.command)
-
- @verification_group.command(name="status")
- async def status_cmd(self, ctx: Context) -> None:
- """Check whether verification tasks are running."""
- log.trace("Checking status of verification tasks")
-
- if self.update_unverified_members.is_running():
- update_status = f"{constants.Emojis.incident_actioned} Member update task is running."
- else:
- update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running."
-
- mention = f"<@&{constants.Roles.unverified}>"
- if self.ping_unverified.is_running():
- ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running."
- else:
- ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running."
-
- embed = discord.Embed(
- title="Verification system",
- description=f"{update_status}\n{ping_status}",
- colour=discord.Colour.blurple(),
- )
- await ctx.send(embed=embed)
-
- @verification_group.command(name="start")
- async def start_cmd(self, ctx: Context) -> None:
- """Start verification tasks if they are not already running."""
- log.info("Starting verification tasks")
-
- if not self.update_unverified_members.is_running():
- self.update_unverified_members.start()
-
- if not self.ping_unverified.is_running():
- self.ping_unverified.start()
-
- await self.task_cache.set("tasks_running", 1)
-
- colour = discord.Colour.blurple()
- await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour))
-
- @verification_group.command(name="stop", aliases=["kill"])
- async def stop_cmd(self, ctx: Context) -> None:
- """Stop verification tasks."""
- log.info("Stopping verification tasks")
-
- self._stop_tasks(gracefully=False)
- await self.task_cache.set("tasks_running", 0)
-
- colour = discord.Colour.blurple()
- await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour))
-
# endregion
- # region: accept and subscribe commands
-
- def _bump_verified_stats(self, verified_member: discord.Member) -> None:
- """
- Increment verification stats for `verified_member`.
-
- Each member falls into one of the three categories:
- * Verified within 24 hours after joining
- * Does not have @Unverified role yet
- * Does have @Unverified role
-
- Stats for member kicking are handled separately.
- """
- if verified_member.joined_at is None: # Docs mention this can happen
- return
-
- if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24):
- category = "accepted_on_day_one"
- elif constants.Roles.unverified not in [role.id for role in verified_member.roles]:
- category = "accepted_before_unverified"
- else:
- category = "accepted_after_unverified"
-
- log.trace(f"Bumping verification stats in category: {category}")
- self.bot.stats.incr(f"verification.{category}")
-
- @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
- """Accept our rules and gain access to the rest of the server."""
- log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
- await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules")
-
- self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed
-
- if constants.Roles.unverified in [role.id for role in ctx.author.roles]:
- log.debug(f"Removing Unverified role from: {ctx.author}")
- await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))
-
- try:
- await safe_dm(ctx.author.send(VERIFIED_MESSAGE))
- except discord.HTTPException:
- log.exception(f"Sending welcome message failed for {ctx.author}.")
- finally:
- log.trace(f"Deleting accept message by {ctx.author}.")
- with suppress(discord.NotFound):
- self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)
- await ctx.message.delete()
+ # region: subscribe commands
@command(name='subscribe')
@in_whitelist(channels=(constants.Channels.bot_commands,))
@@ -823,15 +169,6 @@ class Verification(Cog):
if isinstance(error, InWhitelistCheckFailure):
error.handled = True
- @staticmethod
- async def bot_check(ctx: Context) -> bool:
- """Block any command within the verification channel that is not !accept."""
- is_verification = ctx.channel.id == constants.Channels.verification
- if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
- return ctx.command.name == "accept"
- else:
- return True
-
@command(name='verify')
@has_any_role(*constants.MODERATION_ROLES)
async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None:
diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py
index a77dbe156..dd3349c3a 100644
--- a/bot/exts/moderation/watchchannels/talentpool.py
+++ b/bot/exts/moderation/watchchannels/talentpool.py
@@ -64,12 +64,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
@has_any_role(*STAFF_ROLES)
- async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
+ async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:
"""
Relay messages sent by the given `user` to the `#talent-pool` channel.
- A `reason` for adding the user to the talent pool is required and will be displayed
- in the header when relaying messages of this user to the channel.
+ A `reason` for adding the user to the talent pool is optional.
+ If given, it will be displayed in the header when relaying messages of this user to the channel.
"""
if user.bot:
await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.")
@@ -122,8 +122,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
if history:
total = f"({len(history)} previous nominations in total)"
start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}"
- end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}"
- msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
+ msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}```"
await ctx.send(msg)
@@ -202,7 +201,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
f"{self.api_endpoint}/{nomination_id}",
json={field: reason}
)
-
+ await self.fetch_user_cache() # Update cache.
await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
@Cog.listener()
@@ -243,8 +242,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
actor = guild.get_member(actor_id)
active = nomination_object["active"]
- log.debug(active)
- log.debug(type(nomination_object["inserted_at"]))
+
+ reason = nomination_object["reason"] or "*None*"
start_date = time.format_infraction(nomination_object["inserted_at"])
if active:
@@ -254,7 +253,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: **Active**
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {nomination_object["reason"]}
+ Reason: {reason}
Nomination ID: `{nomination_object["id"]}`
===============
"""
@@ -267,7 +266,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Status: Inactive
Date: {start_date}
Actor: {actor.mention if actor else actor_id}
- Reason: {nomination_object["reason"]}
+ Reason: {reason}
End date: {end_date}
Unwatch reason: {nomination_object["end_reason"]}
diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py
index 1c0988343..98fbcb303 100644
--- a/bot/exts/utils/jams.py
+++ b/bot/exts/utils/jams.py
@@ -93,10 +93,6 @@ class CodeJams(commands.Cog):
connect=True
),
guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
- guild.get_role(Roles.verified): PermissionOverwrite(
- read_messages=False,
- connect=False
- )
}
# Rest of members should just have read_messages
diff --git a/bot/log.py b/bot/log.py
index 0935666d1..e92233a33 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -54,6 +54,9 @@ def setup() -> None:
logging.getLogger("chardet").setLevel(logging.WARNING)
logging.getLogger("async_rediscache").setLevel(logging.WARNING)
+ # Set back to the default of INFO even if asyncio's debug mode is enabled.
+ logging.getLogger("asyncio").setLevel(logging.INFO)
+
def setup_sentry() -> None:
"""Set up the Sentry logging integrations."""
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index 0e66df69c..bbe9271b3 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -2,20 +2,11 @@ from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
-from bot.constants import Channels
-
async def apply(
last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """
- Detects repeated messages sent by multiple users.
-
- This filter never triggers in the verification channel.
- """
- if last_message.channel.id == Channels.verification:
- return
-
+ """Detects repeated messages sent by multiple users."""
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/utils/lock.py b/bot/utils/lock.py
index 7aaafbc88..e44776340 100644
--- a/bot/utils/lock.py
+++ b/bot/utils/lock.py
@@ -1,3 +1,4 @@
+import asyncio
import inspect
import logging
from collections import defaultdict
@@ -16,39 +17,21 @@ _IdCallable = Callable[[function.BoundArgs], _IdCallableReturn]
ResourceId = Union[Hashable, _IdCallable]
-class LockGuard:
- """
- A context manager which acquires and releases a lock (mutex).
-
- Raise RuntimeError if trying to acquire a locked lock.
- """
-
- def __init__(self):
- self._locked = False
-
- @property
- def locked(self) -> bool:
- """Return True if currently locked or False if unlocked."""
- return self._locked
-
- def __enter__(self):
- if self._locked:
- raise RuntimeError("Cannot acquire a locked lock.")
-
- self._locked = True
-
- def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001
- self._locked = False
- return False # Indicate any raised exception shouldn't be suppressed.
-
-
-def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable:
+def lock(
+ namespace: Hashable,
+ resource_id: ResourceId,
+ *,
+ raise_error: bool = False,
+ wait: bool = False,
+) -> Callable:
"""
Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`.
- If any other mutually exclusive function currently holds the lock for a resource, do not run the
- decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if
- the lock cannot be acquired.
+ If `wait` is True, wait until the lock becomes available. Otherwise, if any other mutually
+ exclusive function currently holds the lock for a resource, do not run the decorated function
+ and return None.
+
+ If `raise_error` is True, raise `LockedResourceError` if the lock cannot be acquired.
`namespace` is an identifier used to prevent collisions among resource IDs.
@@ -78,15 +61,19 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa
else:
id_ = resource_id
- log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}")
+ log.trace(f"{name}: getting the lock object for resource {namespace!r}:{id_!r}")
# Get the lock for the ID. Create a lock if one doesn't exist yet.
locks = __lock_dicts[namespace]
- lock_guard = locks.setdefault(id_, LockGuard())
-
- if not lock_guard.locked:
- log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...")
- with lock_guard:
+ lock_ = locks.setdefault(id_, asyncio.Lock())
+
+ # It's safe to check an asyncio.Lock is free before acquiring it because:
+ # 1. Synchronous code like `if not lock_.locked()` does not yield execution
+ # 2. `asyncio.Lock.acquire()` does not internally await anything if the lock is free
+ # 3. awaits only yield execution to the event loop at actual I/O boundaries
+ if wait or not lock_.locked():
+ log.debug(f"{name}: acquiring lock for resource {namespace!r}:{id_!r}...")
+ async with lock_:
return await func(*args, **kwargs)
else:
log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked")
@@ -103,6 +90,7 @@ def lock_arg(
func: Callable[[Any], _IdCallableReturn] = None,
*,
raise_error: bool = False,
+ wait: bool = False,
) -> Callable:
"""
Apply the `lock` decorator using the value of the arg at the given name/position as the ID.
@@ -110,5 +98,5 @@ def lock_arg(
`func` is an optional callable or awaitable which will return the ID given the argument value.
See `lock` docs for more information.
"""
- decorator_func = partial(lock, namespace, raise_error=raise_error)
+ decorator_func = partial(lock, namespace, raise_error=raise_error, wait=wait)
return function.get_arg_value_wrapper(decorator_func, name_or_pos, func)
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 03f31d78f..4dd036e4f 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -155,3 +155,20 @@ class Scheduler:
# Log the exception if one exists.
if exception:
self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception)
+
+
+def create_task(*args, **kwargs) -> asyncio.Task:
+ """Wrapper for `asyncio.create_task` which logs exceptions raised in the task."""
+ task = asyncio.create_task(*args, **kwargs)
+ task.add_done_callback(_log_task_exception)
+ return task
+
+
+def _log_task_exception(task: asyncio.Task) -> None:
+ """Retrieve and log the exception raised in `task` if one exists."""
+ with contextlib.suppress(asyncio.CancelledError):
+ exception = task.exception()
+ # Log the exception if one exists.
+ if exception:
+ log = logging.getLogger(__name__)
+ log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception)
diff --git a/config-default.yml b/config-default.yml
index ca89bb639..6695cffed 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -28,6 +28,9 @@ style:
soft_green: 0x68c290
soft_orange: 0xf9cb54
bright_green: 0x01d277
+ orange: 0xe67e22
+ pink: 0xcf84e0
+ purple: 0xb734eb
emojis:
defcon_disabled: "<:defcondisabled:470326273952972810>"
@@ -68,6 +71,8 @@ style:
comments: "<:reddit_comments:755845255001014384>"
user: "<:reddit_users:755845303822974997>"
+ ok_hand: ":ok_hand:"
+
icons:
crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png"
@@ -152,11 +157,14 @@ guild:
# Discussion
meta: 429409067623251969
- python_discussion: &PY_DISCUSSION 267624335836053506
+ python_general: &PY_GENERAL 267624335836053506
# Python Help: Available
cooldown: 720603994149486673
+ # Topical
+ discord_py: 343944376055103488
+
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
message_log: &MESSAGE_LOG 467752170159079424
@@ -173,7 +181,6 @@ guild:
# Special
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
- verification: 352442727016693763
voice_gate: 764802555427029012
# Staff
@@ -186,6 +193,8 @@ guild:
mods: &MODS 305126844661760000
mod_alerts: 473092532147060736
mod_spam: &MOD_SPAM 620607373828030464
+ mod_tools: &MOD_TOOLS 775413915391098921
+ mod_meta: &MOD_META 775412552795947058
organisation: &ORGANISATION 551789653284356126
staff_lounge: &STAFF_LOUNGE 464905259261755392
duck_pond: &DUCK_POND 637820308341915648
@@ -219,6 +228,8 @@ guild:
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
+ - *MOD_META
+ - *MOD_TOOLS
- *MODS
- *MOD_SPAM
@@ -244,8 +255,6 @@ guild:
python_community: &PY_COMMUNITY_ROLE 458226413825294336
sprinters: &SPRINTERS 758422482289426471
- unverified: 739794855945044069
- verified: 352427296948486144 # @Developers on PyDis
voice_verified: 764802720779337729
# Staff
@@ -424,7 +433,7 @@ code_block:
# The channels which will be affected by a cooldown. These channels are also whitelisted.
cooldown_channels:
- - *PY_DISCUSSION
+ - *PY_GENERAL
# Sending instructions triggers a cooldown on a per-channel basis.
# More instruction messages will not be sent in the same channel until the cooldown has elapsed.
@@ -489,7 +498,7 @@ redirect_output:
duck_pond:
- threshold: 4
+ threshold: 5
channel_blacklist:
- *ANNOUNCEMENTS
- *PYNEWS_CHANNEL
@@ -514,18 +523,6 @@ python_news:
webhook: *PYNEWS_WEBHOOK
-verification:
- unverified_after: 3 # Days after which non-Developers receive the @Unverified role
- kicked_after: 30 # Days after which non-Developers get kicked from the guild
- reminder_frequency: 28 # Hours between @Unverified pings
- bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification
-
- # Number in range [0, 1] determining the percentage of unverified users that are safe
- # to be kicked from the guild in one batch, any larger amount will require staff confirmation,
- # set this to 0 to require explicit approval for batches of any size
- kick_confirmation_threshold: 0.01 # 1%
-
-
voice_gate:
minimum_days_member: 3 # How many days the user must have been a member for
minimum_messages: 50 # How many messages a user must have to be eligible for voice
@@ -534,5 +531,9 @@ voice_gate:
voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate
+branding:
+ cycle_frequency: 3 # How many days bot wait before refreshing server icon
+
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index 61673e1bb..27932be95 100644
--- a/tests/bot/exts/backend/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -188,30 +188,37 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the API requests that sync users."""
def setUp(self):
- patcher = mock.patch("bot.instance", new=helpers.MockBot())
- self.bot = patcher.start()
- self.addCleanup(patcher.stop)
+ bot_patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = bot_patcher.start()
+ self.addCleanup(bot_patcher.stop)
+
+ chunk_patcher = mock.patch("bot.exts.backend.sync._syncers.CHUNK_SIZE", 2)
+ self.chunk_size = chunk_patcher.start()
+ self.addCleanup(chunk_patcher.stop)
+
+ self.chunk_count = 2
+ self.users = [fake_user(id=i) for i in range(self.chunk_size * self.chunk_count)]
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)
+ diff = _Diff(self.users, [], None)
await UserSyncer._sync(diff)
- self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created)
+ self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[:self.chunk_size])
+ self.bot.api_client.post.assert_any_call("bot/users", json=diff.created[self.chunk_size:])
+ self.assertEqual(self.bot.api_client.post.call_count, self.chunk_count)
self.bot.api_client.put.assert_not_called()
self.bot.api_client.delete.assert_not_called()
async def test_sync_updated_users(self):
"""Only PUT requests should be made with the correct payload."""
- users = [fake_user(id=111), fake_user(id=222)]
-
- diff = _Diff([], users, None)
+ diff = _Diff([], self.users, None)
await UserSyncer._sync(diff)
- self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated)
+ self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[:self.chunk_size])
+ self.bot.api_client.patch.assert_any_call("bot/users/bulk_patch", json=diff.updated[self.chunk_size:])
+ self.assertEqual(self.bot.api_client.patch.call_count, self.chunk_count)
self.bot.api_client.post.assert_not_called()
self.bot.api_client.delete.assert_not_called()
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index d077be960..80731c9f0 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -65,7 +65,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase):
permissions=discord.Permissions(0),
)
- self.ctx.guild.roles.append([dummy_role, admin_role])
+ self.ctx.guild.roles.extend([dummy_role, admin_role])
self.cog.role_info.can_run = unittest.mock.AsyncMock()
self.cog.role_info.can_run.return_value = True
diff --git a/tests/bot/exts/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py
index dad751e0d..5483b7a64 100644
--- a/tests/bot/exts/moderation/test_slowmode.py
+++ b/tests/bot/exts/moderation/test_slowmode.py
@@ -85,22 +85,14 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase):
self.ctx.reset_mock()
- async def test_reset_slowmode_no_channel(self) -> None:
- """Reset slowmode without a given channel."""
- self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6)
-
- await self.cog.reset_slowmode(self.cog, self.ctx, None)
- self.ctx.send.assert_called_once_with(
- f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.'
- )
-
- async def test_reset_slowmode_with_channel(self) -> None:
+ async def test_reset_slowmode_sets_delay_to_zero(self) -> None:
"""Reset slowmode with a given channel."""
text_channel = MockTextChannel(name='meta', slowmode_delay=1)
+ self.cog.set_slowmode = mock.AsyncMock()
await self.cog.reset_slowmode(self.cog, self.ctx, text_channel)
- self.ctx.send.assert_called_once_with(
- f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.'
+ self.cog.set_slowmode.assert_awaited_once_with(
+ self.ctx, text_channel, relativedelta(seconds=0)
)
@mock.patch("bot.exts.moderation.slowmode.has_any_role")
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py
index 45e7b5b51..85d6a1173 100644
--- a/tests/bot/exts/utils/test_jams.py
+++ b/tests/bot/exts/utils/test_jams.py
@@ -118,11 +118,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(overwrites[member].read_messages)
self.assertTrue(overwrites[member].connect)
- # Everyone and verified role overwrite
+ # Everyone role overwrite
self.assertFalse(overwrites[self.guild.default_role].read_messages)
self.assertFalse(overwrites[self.guild.default_role].connect)
- self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages)
- self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect)
async def test_team_channels_creation(self):
"""Should create new voice and text channel for team."""