aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ks129 <[email protected]>2020-05-20 10:48:22 +0300
committerGravatar GitHub <[email protected]>2020-05-20 10:48:22 +0300
commit4c47697e6f70fdb0dc20414557b93e899834d877 (patch)
tree31d91e8338babe40d057a68a0e09160d46549e10
parentInfraction Tests: Small fixes (diff)
parentMerge pull request #944 from Numerlor/eval-timeout-increase (diff)
Merge branch 'master' into ban-kick-reason-length
-rw-r--r--Pipfile5
-rw-r--r--Pipfile.lock161
-rw-r--r--bot/__init__.py9
-rw-r--r--bot/__main__.py17
-rw-r--r--bot/async_stats.py39
-rw-r--r--bot/bot.py32
-rw-r--r--bot/cogs/antimalware.py12
-rw-r--r--bot/cogs/antispam.py1
-rw-r--r--bot/cogs/bot.py19
-rw-r--r--bot/cogs/clean.py2
-rw-r--r--bot/cogs/defcon.py19
-rw-r--r--bot/cogs/error_handler.py53
-rw-r--r--bot/cogs/eval.py2
-rw-r--r--bot/cogs/extensions.py8
-rw-r--r--bot/cogs/filtering.py2
-rw-r--r--bot/cogs/free.py103
-rw-r--r--bot/cogs/help.py713
-rw-r--r--bot/cogs/help_channels.py868
-rw-r--r--bot/cogs/information.py8
-rw-r--r--bot/cogs/moderation/infractions.py20
-rw-r--r--bot/cogs/moderation/management.py2
-rw-r--r--bot/cogs/moderation/modlog.py8
-rw-r--r--bot/cogs/moderation/scheduler.py26
-rw-r--r--bot/cogs/moderation/superstarify.py2
-rw-r--r--bot/cogs/moderation/utils.py30
-rw-r--r--bot/cogs/off_topic_names.py2
-rw-r--r--bot/cogs/python_news.py234
-rw-r--r--bot/cogs/reddit.py10
-rw-r--r--bot/cogs/reminders.py4
-rw-r--r--bot/cogs/site.py2
-rw-r--r--bot/cogs/snekbox.py15
-rw-r--r--bot/cogs/stats.py107
-rw-r--r--bot/cogs/sync/syncers.py3
-rw-r--r--bot/cogs/tags.py5
-rw-r--r--bot/cogs/token_remover.py2
-rw-r--r--bot/cogs/utils.py54
-rw-r--r--bot/cogs/verification.py15
-rw-r--r--bot/cogs/watchchannels/bigbrother.py2
-rw-r--r--bot/cogs/watchchannels/talentpool.py4
-rw-r--r--bot/cogs/webhook_remover.py2
-rw-r--r--bot/cogs/wolfram.py8
-rw-r--r--bot/constants.py46
-rw-r--r--bot/decorators.py91
-rw-r--r--bot/pagination.py2
-rw-r--r--bot/resources/elements.json120
-rw-r--r--bot/resources/tags/free.md5
-rw-r--r--bot/resources/tags/mutability.md37
-rw-r--r--bot/utils/scheduling.py22
-rw-r--r--config-default.yml92
-rw-r--r--tests/bot/cogs/sync/test_base.py3
-rw-r--r--tests/bot/cogs/test_cogs.py4
-rw-r--r--tests/bot/cogs/test_information.py6
-rw-r--r--tests/bot/cogs/test_snekbox.py11
-rw-r--r--tests/bot/test_decorators.py147
-rw-r--r--tests/helpers.py23
55 files changed, 2369 insertions, 870 deletions
diff --git a/Pipfile b/Pipfile
index 04cc98427..14c9ef926 100644
--- a/Pipfile
+++ b/Pipfile
@@ -19,7 +19,10 @@ requests = "~=2.22"
more_itertools = "~=8.2"
sentry-sdk = "~=0.14"
coloredlogs = "~=14.0"
-colorama = {version = "~=0.4.3", sys_platform = "== 'win32'"}
+colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
+statsd = "~=3.3"
+feedparser = "~=5.2"
+beautifulsoup4 = "~=4.9"
[dev-packages]
coverage = "~=5.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index ad9a3173a..4e7050a13 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "2d3ba484e8467a115126b2ba39fa5f36f103ea455477813dd658797875c79cc9"
+ "sha256": "64620e7e825c74fd3010821fb30843b19f5dafb2b5a1f6eafedc0a5febd99b69"
},
"pipfile-spec": 6,
"requires": {
@@ -87,18 +87,19 @@
},
"beautifulsoup4": {
"hashes": [
- "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a",
- "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887",
- "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"
+ "sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8",
+ "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368",
+ "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"
],
- "version": "==4.8.2"
+ "index": "pypi",
+ "version": "==4.9.0"
},
"certifi": {
"hashes": [
- "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
- "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
+ "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
+ "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
],
- "version": "==2019.11.28"
+ "version": "==2020.4.5.1"
},
"cffi": {
"hashes": [
@@ -167,10 +168,10 @@
},
"discord-py": {
"hashes": [
- "sha256:7424be26b07b37ecad4404d9383d685995a0e0b3df3f9c645bdd3a4d977b83b4"
+ "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580"
],
"index": "pypi",
- "version": "==1.3.2"
+ "version": "==1.3.3"
},
"docutils": {
"hashes": [
@@ -179,6 +180,15 @@
],
"version": "==0.16"
},
+ "feedparser": {
+ "hashes": [
+ "sha256:bd030652c2d08532c034c27fcd7c85868e7fa3cb2b17f230a44a6bbc92519bf9",
+ "sha256:cd2485472e41471632ed3029d44033ee420ad0b57111db95c240c9160a85831c",
+ "sha256:ce875495c90ebd74b179855449040003a1beb40cd13d5f037a0654251e260b02"
+ ],
+ "index": "pypi",
+ "version": "==5.2.1"
+ },
"fuzzywuzzy": {
"hashes": [
"sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8",
@@ -189,10 +199,10 @@
},
"humanfriendly": {
"hashes": [
- "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2",
- "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb"
+ "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12",
+ "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080"
],
- "version": "==8.1"
+ "version": "==8.2"
},
"idna": {
"hashes": [
@@ -210,10 +220,10 @@
},
"jinja2": {
"hashes": [
- "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
- "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
+ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
+ "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
],
- "version": "==2.11.1"
+ "version": "==2.11.2"
},
"lxml": {
"hashes": [
@@ -393,17 +403,10 @@
},
"pyparsing": {
"hashes": [
- "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
- "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
- ],
- "version": "==2.4.6"
- },
- "pyreadline": {
- "hashes": [
- "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"
+ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
+ "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
- "markers": "sys_platform == 'win32'",
- "version": "==2.1"
+ "version": "==2.4.7"
},
"python-dateutil": {
"hashes": [
@@ -524,12 +527,20 @@
],
"version": "==1.1.4"
},
+ "statsd": {
+ "hashes": [
+ "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa",
+ "sha256:e3e6db4c246f7c59003e51c9720a51a7f39a396541cb9b147ff4b14d15b5dd1f"
+ ],
+ "index": "pypi",
+ "version": "==3.3.0"
+ },
"urllib3": {
"hashes": [
- "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
- "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
+ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
+ "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
],
- "version": "==1.25.8"
+ "version": "==1.25.9"
},
"websockets": {
"hashes": [
@@ -605,40 +616,40 @@
},
"coverage": {
"hashes": [
- "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0",
- "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30",
- "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b",
- "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0",
- "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823",
- "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe",
- "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037",
- "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6",
- "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31",
- "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd",
- "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892",
- "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1",
- "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78",
- "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac",
- "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006",
- "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014",
- "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2",
- "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7",
- "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8",
- "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7",
- "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9",
- "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1",
- "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307",
- "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a",
- "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435",
- "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0",
- "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5",
- "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441",
- "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732",
- "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de",
- "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1"
- ],
- "index": "pypi",
- "version": "==5.0.4"
+ "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
+ "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355",
+ "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65",
+ "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7",
+ "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9",
+ "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1",
+ "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0",
+ "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55",
+ "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c",
+ "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6",
+ "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef",
+ "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019",
+ "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e",
+ "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0",
+ "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf",
+ "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24",
+ "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2",
+ "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c",
+ "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4",
+ "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0",
+ "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd",
+ "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04",
+ "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e",
+ "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730",
+ "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2",
+ "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768",
+ "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796",
+ "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7",
+ "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a",
+ "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489",
+ "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"
+ ],
+ "index": "pypi",
+ "version": "==5.1"
},
"distlib": {
"hashes": [
@@ -670,11 +681,11 @@
},
"flake8-annotations": {
"hashes": [
- "sha256:a38b44d01abd480586a92a02a2b0a36231ec42dcc5e114de78fa5db016d8d3f9",
- "sha256:d5b0e8704e4e7728b352fa1464e23539ff2341ba11cc153b536fa2cf921ee659"
+ "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554",
+ "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75"
],
"index": "pypi",
- "version": "==2.0.1"
+ "version": "==2.1.0"
},
"flake8-bugbear": {
"hashes": [
@@ -717,11 +728,11 @@
},
"flake8-tidy-imports": {
"hashes": [
- "sha256:5b6e75cec6d751e66534c522fbdce7dac1c2738b1216b0f6b10453995932e188",
- "sha256:cf26fbb3ab31a398f265d53b6f711d80006450c19221e41b2b7b0e0b14ac39c5"
+ "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd",
+ "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3"
],
"index": "pypi",
- "version": "==4.0.1"
+ "version": "==4.1.0"
},
"flake8-todo": {
"hashes": [
@@ -732,10 +743,10 @@
},
"identify": {
"hashes": [
- "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059",
- "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89"
+ "sha256:2bb8760d97d8df4408f4e805883dad26a2d076f04be92a10a3e43f09c6060742",
+ "sha256:faffea0fd8ec86bb146ac538ac350ed0c73908326426d387eded0bcc9d077522"
],
- "version": "==1.4.13"
+ "version": "==1.4.14"
},
"mccabe": {
"hashes": [
@@ -835,10 +846,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:87831f1070534b636fea2241dd66f3afe37ac9041bcca6d0af3215cdcfbf7d82",
- "sha256:f3128d882383c503003130389bf892856341c1da12c881ae24d6358c82561b55"
+ "sha256:5021396e8f03d0d002a770da90e31e61159684db2859d0ba4850fbea752aa675",
+ "sha256:ac53ade75ca189bc97b6c1d9ec0f1a50efe33cbf178ae09452dcd9fd309013c1"
],
- "version": "==20.0.13"
+ "version": "==20.0.18"
}
}
}
diff --git a/bot/__init__.py b/bot/__init__.py
index c9dbc3f40..d63086fe2 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,3 +1,4 @@
+import asyncio
import logging
import os
import sys
@@ -33,7 +34,7 @@ log_format = logging.Formatter(format_string)
log_file = Path("logs", "bot.log")
log_file.parent.mkdir(exist_ok=True)
-file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7)
+file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
file_handler.setFormatter(log_format)
root_log = logging.getLogger()
@@ -58,4 +59,10 @@ coloredlogs.install(logger=root_log, stream=sys.stdout)
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
+logging.getLogger("chardet").setLevel(logging.WARNING)
logging.getLogger(__name__)
+
+
+# On Windows, the selector event loop is required for aiodns.
+if os.name == "nt":
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
diff --git a/bot/__main__.py b/bot/__main__.py
index 8c3ae02e3..aa1d1aee8 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -5,9 +5,8 @@ import sentry_sdk
from discord.ext.commands import when_mentioned_or
from sentry_sdk.integrations.logging import LoggingIntegration
-from bot import patches
+from bot import constants, patches
from bot.bot import Bot
-from bot.constants import Bot as BotConfig
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
@@ -15,12 +14,12 @@ sentry_logging = LoggingIntegration(
)
sentry_sdk.init(
- dsn=BotConfig.sentry_dsn,
+ dsn=constants.Bot.sentry_dsn,
integrations=[sentry_logging]
)
bot = Bot(
- command_prefix=when_mentioned_or(BotConfig.prefix),
+ command_prefix=when_mentioned_or(constants.Bot.prefix),
activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
max_messages=10_000,
@@ -47,17 +46,18 @@ bot.load_extension("bot.cogs.verification")
# Feature cogs
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.duck_pond")
-bot.load_extension("bot.cogs.free")
+bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
+bot.load_extension("bot.cogs.python_news")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
bot.load_extension("bot.cogs.snekbox")
+bot.load_extension("bot.cogs.stats")
bot.load_extension("bot.cogs.sync")
bot.load_extension("bot.cogs.tags")
bot.load_extension("bot.cogs.token_remover")
@@ -66,8 +66,11 @@ bot.load_extension("bot.cogs.watchchannels")
bot.load_extension("bot.cogs.webhook_remover")
bot.load_extension("bot.cogs.wolfram")
+if constants.HelpChannels.enable:
+ bot.load_extension("bot.cogs.help_channels")
+
# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
patches.message_edited_at.apply_patch()
-bot.run(BotConfig.token)
+bot.run(constants.Bot.token)
diff --git a/bot/async_stats.py b/bot/async_stats.py
new file mode 100644
index 000000000..58a80f528
--- /dev/null
+++ b/bot/async_stats.py
@@ -0,0 +1,39 @@
+import asyncio
+import socket
+
+from statsd.client.base import StatsClientBase
+
+
+class AsyncStatsClient(StatsClientBase):
+ """An async transport method for statsd communication."""
+
+ def __init__(
+ self,
+ loop: asyncio.AbstractEventLoop,
+ host: str = 'localhost',
+ port: int = 8125,
+ prefix: str = None
+ ):
+ """Create a new client."""
+ family, _, _, _, addr = socket.getaddrinfo(
+ host, port, socket.AF_INET, socket.SOCK_DGRAM)[0]
+ self._addr = addr
+ self._prefix = prefix
+ self._loop = loop
+ self._transport = None
+
+ async def create_socket(self) -> None:
+ """Use the loop.create_datagram_endpoint method to create a socket."""
+ self._transport, _ = await self._loop.create_datagram_endpoint(
+ asyncio.DatagramProtocol,
+ family=socket.AF_INET,
+ remote_addr=self._addr
+ )
+
+ def _send(self, data: str) -> None:
+ """Start an async task to send data to statsd."""
+ self._loop.create_task(self._async_send(data))
+
+ async def _async_send(self, data: str) -> None:
+ """Send data to the statsd server using the async transport."""
+ self._transport.sendto(data.encode('ascii'), self._addr)
diff --git a/bot/bot.py b/bot/bot.py
index 950ac6751..a85a22aa9 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -7,9 +7,10 @@ from typing import Optional
import aiohttp
import discord
from discord.ext import commands
+from sentry_sdk import push_scope
-from bot import api
-from bot import constants
+from bot import DEBUG_MODE, api, constants
+from bot.async_stats import AsyncStatsClient
log = logging.getLogger('bot')
@@ -33,6 +34,16 @@ class Bot(commands.Bot):
self._resolver = None
self._guild_available = asyncio.Event()
+ statsd_url = constants.Stats.statsd_host
+
+ if DEBUG_MODE:
+ # Since statsd is UDP, there are no errors for sending to a down port.
+ # For this reason, setting the statsd host to 127.0.0.1 for development
+ # will effectively disable stats.
+ statsd_url = "127.0.0.1"
+
+ self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot")
+
def add_cog(self, cog: commands.Cog) -> None:
"""Adds a "cog" to the bot and logs the operation."""
super().add_cog(cog)
@@ -50,7 +61,7 @@ class Bot(commands.Bot):
super().clear()
async def close(self) -> None:
- """Close the Discord connection and the aiohttp session, connector, and resolver."""
+ """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver."""
await super().close()
await self.api_client.close()
@@ -64,9 +75,13 @@ class Bot(commands.Bot):
if self._resolver:
await self._resolver.close()
+ if self.stats._transport:
+ self.stats._transport.close()
+
async def login(self, *args, **kwargs) -> None:
"""Re-create the connector and set up sessions before logging into Discord."""
self._recreate()
+ await self.stats.create_socket()
await super().login(*args, **kwargs)
def _recreate(self) -> None:
@@ -141,3 +156,14 @@ class Bot(commands.Bot):
gateway event before giving up and thus not populating the cache for unavailable guilds.
"""
await self._guild_available.wait()
+
+ async def on_error(self, event: str, *args, **kwargs) -> None:
+ """Log errors raised in event listeners rather than printing them to stderr."""
+ self.stats.incr(f"errors.event.{event}")
+
+ with push_scope() as scope:
+ scope.set_tag("event", event)
+ scope.set_extra("args", args)
+ scope.set_extra("kwargs", kwargs)
+
+ log.exception(f"Unhandled exception in {event}.")
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index 79bf486a4..66b5073e8 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -38,6 +38,18 @@ class AntiMalware(Cog):
"It looks like you tried to attach a Python file - "
f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
)
+ elif ".txt" in extensions_blocked:
+ # Work around Discord AutoConversion of messages longer than 2000 chars to .txt
+ cmd_channel = self.bot.get_channel(Channels.bot_commands)
+ embed.description = (
+ "**Uh-oh!** It looks like your message got zapped by our spam filter. "
+ "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n"
+ "• If you attempted to send a message longer than 2000 characters, try shortening your message "
+ "to fit within the character limit or use a pasting service (see below) \n\n"
+ "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in "
+ f"{cmd_channel.mention} for more information) or use a pasting service like: "
+ f"\n\n{URLs.site_schema}{URLs.site_paste}"
+ )
elif extensions_blocked:
whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)
meta_channel = self.bot.get_channel(Channels.meta)
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index baa6b9459..d63acbc4a 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -182,6 +182,7 @@ class AntiSpam(Cog):
# which contains the reason for why the message violated the rule and
# an iterable of all members that violated the rule.
if result is not None:
+ self.bot.stats.incr(f"mod_alerts.{rule_name}")
reason, members, relevant_messages = result
full_reason = f"`{rule_name}` rule: {reason}"
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 7b66b48c2..f6aea51c5 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command, group
from bot.bot import Bot
from bot.cogs.token_remover import TokenRemover
-from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
+from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
@@ -26,14 +26,6 @@ class BotCog(Cog, name="Bot"):
# Stores allowed channels plus epoch time since last call.
self.channel_cooldowns = {
- Channels.help_0: 0,
- Channels.help_1: 0,
- Channels.help_2: 0,
- Channels.help_3: 0,
- Channels.help_4: 0,
- Channels.help_5: 0,
- Channels.help_6: 0,
- Channels.help_7: 0,
Channels.python_discussion: 0,
}
@@ -49,7 +41,7 @@ class BotCog(Cog, name="Bot"):
@with_role(Roles.verified)
async def botinfo_group(self, ctx: Context) -> None:
"""Bot informational commands."""
- await ctx.invoke(self.bot.get_command("help"), "bot")
+ await ctx.send_help(ctx.command)
@botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
@@ -231,9 +223,14 @@ class BotCog(Cog, name="Bot"):
If poorly formatted code is detected, send the user a helpful message explaining how to do
properly formatted Python syntax highlighting codeblocks.
"""
+ is_help_channel = (
+ getattr(msg.channel, "category", None)
+ and msg.channel.category.id in (Categories.help_available, Categories.help_in_use)
+ )
parse_codeblock = (
(
- msg.channel.id in self.channel_cooldowns
+ is_help_channel
+ or msg.channel.id in self.channel_cooldowns
or msg.channel.id in self.channel_whitelist
)
and not msg.author.bot
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 5cdf0b048..b5d9132cb 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -180,7 +180,7 @@ class Clean(Cog):
@with_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
- await ctx.invoke(self.bot.get_command("help"), "clean")
+ await ctx.send_help(ctx.command)
@clean_group.command(name="user", aliases=["users"])
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index cc0f79fe8..25b0a6ad5 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -104,6 +104,7 @@ class Defcon(Cog):
log.exception(f"Unable to send rejection message to user: {member}")
await member.kick(reason="DEFCON active, user is too new")
+ self.bot.stats.incr("defcon.leaves")
message = (
f"{member} (`{member.id}`) was denied entry because their account is too new."
@@ -121,10 +122,23 @@ class Defcon(Cog):
@with_role(Roles.admins, Roles.owners)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
- await ctx.invoke(self.bot.get_command("help"), "defcon")
+ await ctx.send_help(ctx.command)
async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:
"""Providing a structured way to do an defcon action."""
+ try:
+ response = await self.bot.api_client.get('bot/bot-settings/defcon')
+ data = response['data']
+
+ if "enable_date" in data and action is Action.DISABLED:
+ enabled = datetime.fromisoformat(data["enable_date"])
+
+ delta = datetime.now() - enabled
+
+ self.bot.stats.timing("defcon.enabled", delta)
+ except Exception:
+ pass
+
error = None
try:
await self.bot.api_client.put(
@@ -135,6 +149,7 @@ class Defcon(Cog):
# TODO: retrieve old days count
'days': days,
'enabled': action is not Action.DISABLED,
+ 'enable_date': datetime.now().isoformat()
}
}
)
@@ -145,6 +160,8 @@ class Defcon(Cog):
await ctx.send(self.build_defcon_msg(action, error))
await self.send_defcon_log(action, ctx.author, error)
+ self.bot.stats.gauge("defcon.threshold", days)
+
@defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admins, Roles.owners)
async def enable_command(self, ctx: Context) -> None:
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index 6a622d2ce..23d1eed82 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -2,14 +2,14 @@ import contextlib
import logging
import typing as t
-from discord.ext.commands import Cog, Command, Context, errors
+from discord.ext.commands import Cog, Context, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels
from bot.converters import TagNameConverter
-from bot.decorators import InChannelCheckFailure
+from bot.decorators import InWhitelistCheckFailure
log = logging.getLogger(__name__)
@@ -79,19 +79,13 @@ class ErrorHandler(Cog):
f"{e.__class__.__name__}: {e}"
)
- async def get_help_command(self, command: t.Optional[Command]) -> t.Tuple:
- """Return the help command invocation args to display help for `command`."""
- parent = None
- if command is not None:
- parent = command.parent
-
- # Retrieve the help command for the invoked command.
- if parent and command:
- return self.bot.get_command("help"), parent.name, command.name
- elif command:
- return self.bot.get_command("help"), command.name
- else:
- return self.bot.get_command("help")
+ @staticmethod
+ def get_help_command(ctx: Context) -> t.Coroutine:
+ """Return a prepared `help` command invocation coroutine."""
+ if ctx.command:
+ return ctx.send_help(ctx.command)
+
+ return ctx.send_help()
async def try_silence(self, ctx: Context) -> bool:
"""
@@ -165,25 +159,30 @@ class ErrorHandler(Cog):
* ArgumentParsingError: send an error message
* Other: send an error message and the help command
"""
- # TODO: use ctx.send_help() once PR #519 is merged.
- help_command = await self.get_help_command(ctx.command)
+ prepared_help_command = self.get_help_command(ctx)
if isinstance(e, errors.MissingRequiredArgument):
await ctx.send(f"Missing required argument `{e.param.name}`.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
+ self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
await ctx.send(f"Too many arguments provided.")
- await ctx.invoke(*help_command)
+ await prepared_help_command
+ self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
await ctx.send(f"Bad argument: {e}\n")
- await ctx.invoke(*help_command)
+ await prepared_help_command
+ self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
+ self.bot.stats.incr("errors.bad_union_argument")
elif isinstance(e, errors.ArgumentParsingError):
await ctx.send(f"Argument parsing error: {e}")
+ self.bot.stats.incr("errors.argument_parsing_error")
else:
await ctx.send("Something about your input seems off. Check the arguments:")
- await ctx.invoke(*help_command)
+ await prepared_help_command
+ self.bot.stats.incr("errors.other_user_input_error")
@staticmethod
async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
@@ -196,7 +195,7 @@ class ErrorHandler(Cog):
* BotMissingRole
* BotMissingAnyRole
* NoPrivateMessage
- * InChannelCheckFailure
+ * InWhitelistCheckFailure
"""
bot_missing_errors = (
errors.BotMissingPermissions,
@@ -205,10 +204,12 @@ class ErrorHandler(Cog):
)
if isinstance(e, bot_missing_errors):
+ ctx.bot.stats.incr("errors.bot_permission_error")
await ctx.send(
f"Sorry, it looks like I don't have the permissions or roles I need to do that."
)
- elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)):
+ elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
+ ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
await ctx.send(e)
@staticmethod
@@ -217,16 +218,20 @@ class ErrorHandler(Cog):
if e.status == 404:
await ctx.send("There does not seem to be anything matching your query.")
log.debug(f"API responded with 404 for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_error_404")
elif e.status == 400:
content = await e.response.json()
log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
await ctx.send("According to the API, your request is malformed.")
+ ctx.bot.stats.incr("errors.api_error_400")
elif 500 <= e.status < 600:
await ctx.send("Sorry, there seems to be an internal issue with the API.")
log.warning(f"API responded with {e.status} for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_internal_server_error")
else:
await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
+ ctx.bot.stats.incr(f"errors.api_error_{e.status}")
@staticmethod
async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None:
@@ -236,6 +241,8 @@ class ErrorHandler(Cog):
f"```{e.__class__.__name__}: {e}```"
)
+ ctx.bot.stats.incr("errors.unexpected")
+
with push_scope() as scope:
scope.user = {
"id": ctx.author.id,
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 52136fc8d..eb8bfb1cf 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -178,7 +178,7 @@ async def func(): # (None,) -> Any
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
- await ctx.invoke(self.bot.get_command("help"), "internal")
+ await ctx.send_help(ctx.command)
@internal_group.command(name='eval', aliases=('e',))
@with_role(Roles.admins, Roles.owners)
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
index fb6cd9aa3..365f198ff 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -65,7 +65,7 @@ class Extensions(commands.Cog):
@group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
async def extensions_group(self, ctx: Context) -> None:
"""Load, unload, reload, and list loaded extensions."""
- await ctx.invoke(self.bot.get_command("help"), "extensions")
+ await ctx.send_help(ctx.command)
@extensions_group.command(name="load", aliases=("l",))
async def load_command(self, ctx: Context, *extensions: Extension) -> None:
@@ -75,7 +75,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions load")
+ await ctx.send_help(ctx.command)
return
if "*" in extensions or "**" in extensions:
@@ -92,7 +92,7 @@ class Extensions(commands.Cog):
If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions unload")
+ await ctx.send_help(ctx.command)
return
blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
@@ -118,7 +118,7 @@ class Extensions(commands.Cog):
If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
""" # noqa: W605
if not extensions:
- await ctx.invoke(self.bot.get_command("help"), "extensions reload")
+ await ctx.send_help(ctx.command)
return
if "**" in extensions:
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 3f3dbb853..6a703f5a1 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -207,6 +207,8 @@ class Filtering(Cog):
log.debug(message)
+ self.bot.stats.incr(f"filters.{filter_name}")
+
additional_embeds = None
additional_embeds_msg = None
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
deleted file mode 100644
index 33b55e79a..000000000
--- a/bot/cogs/free.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import logging
-from datetime import datetime
-from operator import itemgetter
-
-from discord import Colour, Embed, Member, utils
-from discord.ext.commands import Cog, Context, command
-
-from bot.bot import Bot
-from bot.constants import Categories, Channels, Free, STAFF_ROLES
-from bot.decorators import redirect_output
-
-log = logging.getLogger(__name__)
-
-TIMEOUT = Free.activity_timeout
-RATE = Free.cooldown_rate
-PER = Free.cooldown_per
-
-
-class Free(Cog):
- """Tries to figure out which help channels are free."""
-
- PYTHON_HELP_ID = Categories.python_help
-
- @command(name="free", aliases=('f',))
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
- async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None:
- """
- Lists free help channels by likeliness of availability.
-
- seek is used only when this command is invoked in a help channel.
- You cannot override seek without mentioning a user first.
-
- When seek is 2, we are avoiding considering the last active message
- in a channel to be the one that invoked this command.
-
- When seek is 3 or more, a user has been mentioned on the assumption
- that they asked if the channel is free or they asked their question
- in an active channel, and we want the message before that happened.
- """
- free_channels = []
- python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID)
-
- if user is not None and seek == 2:
- seek = 3
- elif not 0 < seek < 10:
- seek = 3
-
- # Iterate through all the help channels
- # to check latest activity
- for channel in python_help.channels:
- # Seek further back in the help channel
- # the command was invoked in
- if channel.id == ctx.channel.id:
- messages = await channel.history(limit=seek).flatten()
- msg = messages[seek - 1]
- # Otherwise get last message
- else:
- msg = await channel.history(limit=1).next() # noqa: B305
-
- inactive = (datetime.utcnow() - msg.created_at).seconds
- if inactive > TIMEOUT:
- free_channels.append((inactive, channel))
-
- embed = Embed()
- embed.colour = Colour.blurple()
- embed.title = "**Looking for a free help channel?**"
-
- if user is not None:
- embed.description = f"**Hey {user.mention}!**\n\n"
- else:
- embed.description = ""
-
- # Display all potentially inactive channels
- # in descending order of inactivity
- if free_channels:
- # Sort channels in descending order by seconds
- # Get position in list, inactivity, and channel object
- # For each channel, add to embed.description
- sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True)
-
- for (inactive, channel) in sorted_channels[:3]:
- minutes, seconds = divmod(inactive, 60)
- if minutes > 59:
- hours, minutes = divmod(minutes, 60)
- embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n"
- else:
- embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n"
-
- embed.set_footer(text="Please confirm these channels are free before posting")
- else:
- embed.description = (
- "Doesn't look like any channels are available right now. "
- "You're welcome to check for yourself to be sure. "
- "If all channels are truly busy, please be patient "
- "as one will likely be available soon."
- )
-
- await ctx.send(embed=embed)
-
-
-def setup(bot: Bot) -> None:
- """Load the Free cog."""
- bot.add_cog(Free())
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index 744722220..542f19139 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -1,34 +1,48 @@
-import asyncio
import itertools
+import logging
+from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
-from typing import Union
+from typing import List, Union
-from discord import Colour, Embed, HTTPException, Message, Reaction, User
-from discord.ext import commands
-from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
+from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
from bot import constants
-from bot.bot import Bot
from bot.constants import Channels, Emojis, STAFF_ROLES
from bot.decorators import redirect_output
-from bot.pagination import (
- FIRST_EMOJI, LAST_EMOJI,
- LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
-)
+from bot.pagination import LinePaginator
+log = logging.getLogger(__name__)
+
+COMMANDS_PER_PAGE = 8
DELETE_EMOJI = Emojis.trashcan
+PREFIX = constants.Bot.prefix
+
+Category = namedtuple("Category", ["name", "description", "cogs"])
+
+
+async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
+ """
+ Runs the cleanup for the help command.
+
+ Adds the :trashcan: reaction that, when clicked, will delete the help message.
+ After a 300 second timeout, the reaction will be removed.
+ """
+ def check(reaction: Reaction, user: User) -> bool:
+ """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
+ return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id
-REACTIONS = {
- FIRST_EMOJI: 'first',
- LEFT_EMOJI: 'back',
- RIGHT_EMOJI: 'next',
- LAST_EMOJI: 'end',
- DELETE_EMOJI: 'stop',
-}
+ await message.add_reaction(DELETE_EMOJI)
-Cog = namedtuple('Cog', ['name', 'description', 'commands'])
+ try:
+ await bot.wait_for("reaction_add", check=check, timeout=300)
+ await message.delete()
+ except TimeoutError:
+ await message.remove_reaction(DELETE_EMOJI, bot.user)
+ except NotFound:
+ pass
class HelpQueryNotFound(ValueError):
@@ -46,22 +60,9 @@ class HelpQueryNotFound(ValueError):
self.possible_matches = possible_matches
-class HelpSession:
+class CustomHelpCommand(HelpCommand):
"""
- An interactive session for bot and command help output.
-
- Expected attributes include:
- * title: str
- The title of the help message.
- * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
- * description: str
- The description of the query.
- * pages: list[str]
- A list of the help content split into manageable pages.
- * message: `discord.Message`
- The message object that's showing the help contents.
- * destination: `discord.abc.Messageable`
- Where the help message is to be sent to.
+ An interactive instance for the bot help command.
Cogs can be grouped into custom categories. All cogs with the same category will be displayed
under a single category name in the help output. Custom categories are defined inside the cogs
@@ -70,499 +71,299 @@ class HelpSession:
the regular description (class docstring) of the first cog found in the category.
"""
- def __init__(
- self,
- ctx: Context,
- *command,
- cleanup: bool = False,
- only_can_run: bool = True,
- show_hidden: bool = False,
- max_lines: int = 15
- ):
- """Creates an instance of the HelpSession class."""
- self._ctx = ctx
- self._bot = ctx.bot
- self.title = "Command Help"
-
- # set the query details for the session
- if command:
- query_str = ' '.join(command)
- self.query = self._get_query(query_str)
- self.description = self.query.description or self.query.help
- else:
- self.query = ctx.bot
- self.description = self.query.description
- self.author = ctx.author
- self.destination = ctx.channel
-
- # set the config for the session
- self._cleanup = cleanup
- self._only_can_run = only_can_run
- self._show_hidden = show_hidden
- self._max_lines = max_lines
-
- # init session states
- self._pages = None
- self._current_page = 0
- self.message = None
- self._timeout_task = None
- self.reset_timeout()
-
- def _get_query(self, query: str) -> Union[Command, Cog]:
+ def __init__(self):
+ super().__init__(command_attrs={"help": "Shows help for bot commands"})
+
+ @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ async def command_callback(self, ctx: Context, *, command: str = None) -> None:
"""Attempts to match the provided query with a valid command or cog."""
- command = self._bot.get_command(query)
- if command:
- return command
+ # the only reason we need to tamper with this is because d.py does not support "categories",
+ # so we need to deal with them ourselves.
+
+ bot = ctx.bot
+
+ if command is None:
+ # quick and easy, send bot help if command is none
+ mapping = self.get_bot_mapping()
+ await self.send_bot_help(mapping)
+ return
- # Find all cog categories that match.
cog_matches = []
description = None
- for cog in self._bot.cogs.values():
- if hasattr(cog, "category") and cog.category == query:
+ for cog in bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == command:
cog_matches.append(cog)
if hasattr(cog, "category_description"):
description = cog.category_description
- # Try to search by cog name if no categories match.
- if not cog_matches:
- cog = self._bot.cogs.get(query)
-
- # Don't consider it a match if the cog has a category.
- if cog and not hasattr(cog, "category"):
- cog_matches = [cog]
-
if cog_matches:
- cog = cog_matches[0]
- cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
-
- return Cog(
- name=cog.category if hasattr(cog, "category") else cog.qualified_name,
- description=description or cog.description,
- commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
- )
-
- self._handle_not_found(query)
-
- def _handle_not_found(self, query: str) -> None:
- """
- Handles when a query does not match a valid command or cog.
-
- Will pass on possible close matches along with the `HelpQueryNotFound` exception.
- """
- # Combine command and cog names
- choices = list(self._bot.all_commands) + list(self._bot.cogs)
-
- result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
-
- raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
-
- async def timeout(self, seconds: int = 30) -> None:
- """Waits for a set number of seconds, then stops the help session."""
- await asyncio.sleep(seconds)
- await self.stop()
-
- def reset_timeout(self) -> None:
- """Cancels the original timeout task and sets it again from the start."""
- # cancel original if it exists
- if self._timeout_task:
- if not self._timeout_task.cancelled():
- self._timeout_task.cancel()
-
- # recreate the timeout task
- self._timeout_task = self._bot.loop.create_task(self.timeout())
-
- async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
- """Event handler for when reactions are added on the help message."""
- # ensure it was the relevant session message
- if reaction.message.id != self.message.id:
- return
-
- # ensure it was the session author who reacted
- if user.id != self.author.id:
- return
-
- emoji = str(reaction.emoji)
-
- # check if valid action
- if emoji not in REACTIONS:
+ category = Category(name=command, description=description, cogs=cog_matches)
+ await self.send_category_help(category)
return
- self.reset_timeout()
-
- # Run relevant action method
- action = getattr(self, f'do_{REACTIONS[emoji]}', None)
- if action:
- await action()
-
- # remove the added reaction to prep for re-use
- with suppress(HTTPException):
- await self.message.remove_reaction(reaction, user)
-
- async def on_message_delete(self, message: Message) -> None:
- """Closes the help session when the help message is deleted."""
- if message.id == self.message.id:
- await self.stop()
-
- async def prepare(self) -> None:
- """Sets up the help session pages, events, message and reactions."""
- # create paginated content
- await self.build_pages()
-
- # setup listeners
- self._bot.add_listener(self.on_reaction_add)
- self._bot.add_listener(self.on_message_delete)
-
- # Send the help message
- await self.update_page()
- self.add_reactions()
-
- def add_reactions(self) -> None:
- """Adds the relevant reactions to the help message based on if pagination is required."""
- # if paginating
- if len(self._pages) > 1:
- for reaction in REACTIONS:
- self._bot.loop.create_task(self.message.add_reaction(reaction))
+ # it's either a cog, group, command or subcommand; let the parent class deal with it
+ await super().command_callback(ctx, command=command)
- # if single-page
- else:
- self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
-
- def _category_key(self, cmd: Command) -> str:
+ async def get_all_help_choices(self) -> set:
"""
- Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
+ Get all the possible options for getting help in the bot.
- A zero width space is used as a prefix for results with no cogs to force them last in ordering.
- """
- if cmd.cog:
- try:
- if cmd.cog.category:
- return f'**{cmd.cog.category}**'
- except AttributeError:
- pass
-
- return f'**{cmd.cog_name}**'
- else:
- return "**\u200bNo Category:**"
+ This will only display commands the author has permission to run.
- def _get_command_params(self, cmd: Command) -> str:
- """
- Returns the command usage signature.
+ These include:
+ - Category names
+ - Cog names
+ - Group command names (and aliases)
+ - Command names (and aliases)
+ - Subcommand names (with parent group and aliases for subcommand, but not including aliases for group)
- This is a custom implementation of `command.signature` in order to format the command
- signature without aliases.
+ Options and choices are case sensitive.
"""
- results = []
- for name, param in cmd.clean_params.items():
-
- # if argument has a default value
- if param.default is not param.empty:
-
- if isinstance(param.default, str):
- show_default = param.default
- else:
- show_default = param.default is not None
-
- # if default is not an empty string or None
- if show_default:
- results.append(f'[{name}={param.default}]')
- else:
- results.append(f'[{name}]')
-
- # if variable length argument
- elif param.kind == param.VAR_POSITIONAL:
- results.append(f'[{name}...]')
-
- # if required
+ # first get all commands including subcommands and full command name aliases
+ choices = set()
+ for command in await self.filter_commands(self.context.bot.walk_commands()):
+ # the the command or group name
+ choices.add(str(command))
+
+ if isinstance(command, Command):
+ # all aliases if it's just a command
+ choices.update(command.aliases)
else:
- results.append(f'<{name}>')
+ # otherwise we need to add the parent name in
+ choices.update(f"{command.full_parent_name} {alias}" for alias in command.aliases)
- return f"{cmd.name} {' '.join(results)}"
+ # all cog names
+ choices.update(self.context.bot.cogs)
- async def build_pages(self) -> None:
- """Builds the list of content pages to be paginated through in the help message, as a list of str."""
- # Use LinePaginator to restrict embed line height
- paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
+ # all category names
+ choices.update(cog.category for cog in self.context.bot.cogs.values() if hasattr(cog, "category"))
+ return choices
- prefix = constants.Bot.prefix
-
- # show signature if query is a command
- if isinstance(self.query, commands.Command):
- signature = self._get_command_params(self.query)
- parent = self.query.full_parent_name + ' ' if self.query.parent else ''
- paginator.add_line(f'**```{prefix}{parent}{signature}```**')
-
- # show command aliases
- aliases = ', '.join(f'`{a}`' for a in self.query.aliases)
- if aliases:
- paginator.add_line(f'**Can also use:** {aliases}\n')
-
- if not await self.query.can_run(self._ctx):
- paginator.add_line('***You cannot run this command.***\n')
-
- # show name if query is a cog
- if isinstance(self.query, Cog):
- paginator.add_line(f'**{self.query.name}**')
+ async def command_not_found(self, string: str) -> "HelpQueryNotFound":
+ """
+ Handles when a query does not match a valid command, group, cog or category.
- if self.description:
- paginator.add_line(f'*{self.description}*')
+ Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
+ """
+ choices = await self.get_all_help_choices()
+ result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60)
- # list all children commands of the queried object
- if isinstance(self.query, (commands.GroupMixin, Cog)):
+ return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))
- # remove hidden commands if session is not wanting hiddens
- if not self._show_hidden:
- filtered = [c for c in self.query.commands if not c.hidden]
- else:
- filtered = self.query.commands
+ async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound":
+ """
+ Redirects the error to `command_not_found`.
- # if after filter there are no commands, finish up
- if not filtered:
- self._pages = paginator.pages
- return
+ `command_not_found` deals with searching and getting best choices for both commands and subcommands.
+ """
+ return await self.command_not_found(f"{command.qualified_name} {string}")
- # set category to Commands if cog
- if isinstance(self.query, Cog):
- grouped = (('**Commands:**', self.query.commands),)
+ async def send_error_message(self, error: HelpQueryNotFound) -> None:
+ """Send the error message to the channel."""
+ embed = Embed(colour=Colour.red(), title=str(error))
- # set category to Subcommands if command
- elif isinstance(self.query, commands.Command):
- grouped = (('**Subcommands:**', self.query.commands),)
+ if getattr(error, "possible_matches", None):
+ matches = "\n".join(f"`{match}`" for match in error.possible_matches)
+ embed.description = f"**Did you mean:**\n{matches}"
- # don't show prefix for subcommands
- prefix = ''
+ await self.context.send(embed=embed)
- # otherwise sort and organise all commands into categories
- else:
- cat_sort = sorted(filtered, key=self._category_key)
- grouped = itertools.groupby(cat_sort, key=self._category_key)
+ async def command_formatting(self, command: Command) -> Embed:
+ """
+ Takes a command and turns it into an embed.
- # process each category
- for category, cmds in grouped:
- cmds = sorted(cmds, key=lambda c: c.name)
+ It will add an author, command signature + help, aliases and a note if the user can't run the command.
+ """
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- # if there are no commands, skip category
- if len(cmds) == 0:
- continue
+ parent = command.full_parent_name
- cat_cmds = []
+ name = str(command) if not parent else f"{parent} {command.name}"
+ command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
- # format details for each child command
- for command in cmds:
+ # show command aliases
+ aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ if aliases:
+ command_details += f"**Can also use:** {aliases}\n\n"
- # skip if hidden and hide if session is set to
- if command.hidden and not self._show_hidden:
- continue
+ # 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"
- # see if the user can run the command
- strikeout = ''
+ command_details += f"*{command.help or 'No details provided.'}*\n"
+ embed.description = command_details
- # Patch to make the !help command work outside of #bot-commands again
- # This probably needs a proper rewrite, but this will make it work in
- # the mean time.
- try:
- can_run = await command.can_run(self._ctx)
- except CheckFailure:
- can_run = False
+ return embed
- if not can_run:
- # skip if we don't show commands they can't run
- if self._only_can_run:
- continue
- strikeout = '~~'
+ async def send_command_help(self, command: Command) -> None:
+ """Send help for a single command."""
+ embed = await self.command_formatting(command)
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- signature = self._get_command_params(command)
- info = f"{strikeout}**`{prefix}{signature}`**{strikeout}"
+ @staticmethod
+ def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
+ """
+ Formats the prefix, command name and signature, and short doc for an iterable of commands.
- # handle if the command has no docstring
- if command.short_doc:
- cat_cmds.append(f'{info}\n*{command.short_doc}*')
- else:
- cat_cmds.append(f'{info}\n*No details provided.*')
+ return_as_list is helpful for passing these command details into the paginator as a list of command details.
+ """
+ details = []
+ for command in commands_:
+ signature = f" {command.signature}" if command.signature else ""
+ details.append(
+ f"\n**`{PREFIX}{command.qualified_name}{signature}`**\n*{command.short_doc or 'No details provided'}*"
+ )
+ if return_as_list:
+ return details
+ else:
+ return "".join(details)
- # state var for if the category should be added next
- print_cat = 1
- new_page = True
+ async def send_group_help(self, group: Group) -> None:
+ """Sends help for a group command."""
+ subcommands = group.commands
- for details in cat_cmds:
+ if len(subcommands) == 0:
+ # no subcommands, just treat it like a regular command
+ await self.send_command_help(group)
+ return
- # keep details together, paginating early if it won't fit
- lines_adding = len(details.split('\n')) + print_cat
- if paginator._linecount + lines_adding > self._max_lines:
- paginator._linecount = 0
- new_page = True
- paginator.close_page()
+ # remove commands that the user can't run and are hidden, and sort by name
+ commands_ = await self.filter_commands(subcommands, sort=True)
- # new page so print category title again
- print_cat = 1
+ embed = await self.command_formatting(group)
- if print_cat:
- if new_page:
- paginator.add_line('')
- paginator.add_line(category)
- print_cat = 0
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n**Subcommands:**\n{command_details}"
- paginator.add_line(details)
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- # save organised pages to session
- self._pages = paginator.pages
+ async def send_cog_help(self, cog: Cog) -> None:
+ """Send help for a cog."""
+ # sort commands by name, and remove any the user cant run or are hidden.
+ commands_ = await self.filter_commands(cog.get_commands(), sort=True)
- def embed_page(self, page_number: int = 0) -> Embed:
- """Returns an Embed with the requested page formatted within."""
embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+ embed.description = f"**{cog.qualified_name}**\n*{cog.description}*"
- # if command or cog, add query to title for pages other than first
- if isinstance(self.query, (commands.Command, Cog)) and page_number > 0:
- title = f'Command Help | "{self.query.name}"'
- else:
- title = self.title
-
- embed.set_author(name=title, icon_url=constants.Icons.questionmark)
- embed.description = self._pages[page_number]
+ command_details = self.get_commands_brief_details(commands_)
+ if command_details:
+ embed.description += f"\n\n**Commands:**\n{command_details}"
- # add page counter to footer if paginating
- page_count = len(self._pages)
- if page_count > 1:
- embed.set_footer(text=f'Page {self._current_page+1} / {page_count}')
+ message = await self.context.send(embed=embed)
+ await help_cleanup(self.context.bot, self.context.author, message)
- return embed
-
- async def update_page(self, page_number: int = 0) -> None:
- """Sends the intial message, or changes the existing one to the given page number."""
- self._current_page = page_number
- embed_page = self.embed_page(page_number)
+ @staticmethod
+ def _category_key(command: Command) -> str:
+ """
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
- if not self.message:
- self.message = await self.destination.send(embed=embed_page)
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
+ """
+ if command.cog:
+ with suppress(AttributeError):
+ if command.cog.category:
+ return f"**{command.cog.category}**"
+ return f"**{command.cog_name}**"
else:
- await self.message.edit(embed=embed_page)
+ return "**\u200bNo Category:**"
- @classmethod
- async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
+ async def send_category_help(self, category: Category) -> None:
"""
- Create and begin a help session based on the given command context.
-
- Available options kwargs:
- * cleanup: Optional[bool]
- Set to `True` to have the message deleted on session end. Defaults to `False`.
- * only_can_run: Optional[bool]
- Set to `True` to hide commands the user can't run. Defaults to `False`.
- * show_hidden: Optional[bool]
- Set to `True` to include hidden commands. Defaults to `False`.
- * max_lines: Optional[int]
- Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ Sends help for a bot category.
+
+ This sends a brief help for all commands in all cogs registered to the category.
"""
- session = cls(ctx, *command, **options)
- await session.prepare()
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
- return session
+ all_commands = []
+ for cog in category.cogs:
+ all_commands.extend(cog.get_commands())
- async def stop(self) -> None:
- """Stops the help session, removes event listeners and attempts to delete the help message."""
- self._bot.remove_listener(self.on_reaction_add)
- self._bot.remove_listener(self.on_message_delete)
+ filtered_commands = await self.filter_commands(all_commands, sort=True)
- # ignore if permission issue, or the message doesn't exist
- with suppress(HTTPException, AttributeError):
- if self._cleanup:
- await self.message.delete()
- else:
- await self.message.clear_reactions()
+ command_detail_lines = self.get_commands_brief_details(filtered_commands, return_as_list=True)
+ description = f"**{category.name}**\n*{category.description}*"
- @property
- def is_first_page(self) -> bool:
- """Check if session is currently showing the first page."""
- return self._current_page == 0
+ if command_detail_lines:
+ description += "\n\n**Commands:**"
- @property
- def is_last_page(self) -> bool:
- """Check if the session is currently showing the last page."""
- return self._current_page == (len(self._pages)-1)
+ await LinePaginator.paginate(
+ command_detail_lines,
+ self.context,
+ embed,
+ prefix=description,
+ max_lines=COMMANDS_PER_PAGE,
+ max_size=2040,
+ )
- async def do_first(self) -> None:
- """Event that is called when the user requests the first page."""
- if not self.is_first_page:
- await self.update_page(0)
+ async def send_bot_help(self, mapping: dict) -> None:
+ """Sends help for all bot commands and cogs."""
+ bot = self.context.bot
- async def do_back(self) -> None:
- """Event that is called when the user requests the previous page."""
- if not self.is_first_page:
- await self.update_page(self._current_page-1)
-
- async def do_next(self) -> None:
- """Event that is called when the user requests the next page."""
- if not self.is_last_page:
- await self.update_page(self._current_page+1)
+ embed = Embed()
+ embed.set_author(name="Command Help", icon_url=constants.Icons.questionmark)
+
+ filter_commands = await self.filter_commands(bot.commands, sort=True, key=self._category_key)
+
+ cog_or_category_pages = []
+
+ for cog_or_category, _commands in itertools.groupby(filter_commands, key=self._category_key):
+ sorted_commands = sorted(_commands, key=lambda c: c.name)
+
+ if len(sorted_commands) == 0:
+ continue
+
+ command_detail_lines = self.get_commands_brief_details(sorted_commands, return_as_list=True)
+
+ # Split cogs or categories which have too many commands to fit in one page.
+ # The length of commands is included for later use when aggregating into pages for the paginator.
+ for index in range(0, len(sorted_commands), COMMANDS_PER_PAGE):
+ truncated_lines = command_detail_lines[index:index + COMMANDS_PER_PAGE]
+ joined_lines = "".join(truncated_lines)
+ cog_or_category_pages.append((f"**{cog_or_category}**{joined_lines}", len(truncated_lines)))
+
+ pages = []
+ counter = 0
+ page = ""
+ for page_details, length in cog_or_category_pages:
+ counter += length
+ if counter > COMMANDS_PER_PAGE:
+ # force a new page on paginator even if it falls short of the max pages
+ # since we still want to group categories/cogs.
+ counter = length
+ pages.append(page)
+ page = f"{page_details}\n\n"
+ else:
+ page += f"{page_details}\n\n"
- async def do_end(self) -> None:
- """Event that is called when the user requests the last page."""
- if not self.is_last_page:
- await self.update_page(len(self._pages)-1)
+ if page:
+ # add any remaining command help that didn't get added in the last iteration above.
+ pages.append(page)
- async def do_stop(self) -> None:
- """Event that is called when the user requests to stop the help session."""
- await self.message.delete()
+ await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040)
-class Help(DiscordCog):
+class Help(Cog):
"""Custom Embed Pagination Help feature."""
- @commands.command('help')
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
- async def new_help(self, ctx: Context, *commands) -> None:
- """Shows Command Help."""
- try:
- await HelpSession.start(ctx, *commands)
- except HelpQueryNotFound as error:
- embed = Embed()
- embed.colour = Colour.red()
- embed.title = str(error)
-
- if error.possible_matches:
- matches = '\n'.join(error.possible_matches.keys())
- embed.description = f'**Did you mean:**\n`{matches}`'
-
- await ctx.send(embed=embed)
-
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.old_help_command = bot.help_command
+ bot.help_command = CustomHelpCommand()
+ bot.help_command.cog = self
-def unload(bot: Bot) -> None:
- """
- Reinstates the original help command.
-
- This is run if the cog raises an exception on load, or if the extension is unloaded.
- """
- bot.remove_command('help')
- bot.add_command(bot._old_help)
+ def cog_unload(self) -> None:
+ """Reset the help command when the cog is unloaded."""
+ self.bot.help_command = self.old_help_command
def setup(bot: Bot) -> None:
- """
- The setup for the help extension.
-
- This is called automatically on `bot.load_extension` being run.
-
- Stores the original help command instance on the `bot._old_help` attribute for later
- reinstatement, before removing it from the command registry so the new help command can be
- loaded successfully.
-
- If an exception is raised during the loading of the cog, `unload` will be called in order to
- reinstate the original help command.
- """
- bot._old_help = bot.get_command('help')
- bot.remove_command('help')
-
- try:
- bot.add_cog(Help())
- except Exception:
- unload(bot)
- raise
-
-
-def teardown(bot: Bot) -> None:
- """
- The teardown for the help extension.
-
- This is called automatically on `bot.unload_extension` being run.
-
- Calls `unload` in order to reinstate the original help command.
- """
- unload(bot)
+ """Load the Help cog."""
+ bot.add_cog(Help(bot))
+ log.info("Cog loaded: Help")
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
new file mode 100644
index 000000000..1bd1f9d68
--- /dev/null
+++ b/bot/cogs/help_channels.py
@@ -0,0 +1,868 @@
+import asyncio
+import inspect
+import json
+import logging
+import random
+import typing as t
+from collections import deque
+from contextlib import suppress
+from datetime import datetime
+from pathlib import Path
+
+import discord
+import discord.abc
+from discord.ext import commands
+
+from bot import constants
+from bot.bot import Bot
+from bot.utils.checks import with_role_check
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
+MAX_CHANNELS_PER_CATEGORY = 50
+EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,)
+
+AVAILABLE_TOPIC = """
+This channel is available. Feel free to ask a question in order to claim this channel!
+"""
+
+IN_USE_TOPIC = """
+This channel is currently in use. If you'd like to discuss a different problem, please claim a new \
+channel from the Help: Available category.
+"""
+
+DORMANT_TOPIC = """
+This channel is temporarily archived. If you'd like to ask a question, please use one of the \
+channels in the Help: Available category.
+"""
+
+AVAILABLE_MSG = f"""
+This help channel is now **available**, which means that you can claim it by simply typing your \
+question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \
+and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \
+is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \
+the **Help: Dormant** category.
+
+You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \
+currently cannot send a message in this channel, it means you are on cooldown and need to wait.
+
+Try to write the best question you can by providing a detailed description and telling us what \
+you've tried already. For more information on asking a good question, \
+check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
+"""
+
+DORMANT_MSG = f"""
+This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
+category at the bottom of the channel list. It is no longer possible to send messages in this \
+channel until it becomes available again.
+
+If your question wasn't answered yet, you can claim a new help channel from the \
+**Help: Available** category by simply asking your question again. Consider rephrasing the \
+question to maximize your chance of getting a good answer. If you're not sure how, have a look \
+through our guide for [asking a good question]({ASKING_GUIDE_URL}).
+"""
+
+AVAILABLE_EMOJI = "✅"
+IN_USE_ANSWERED_EMOJI = "⌛"
+IN_USE_UNANSWERED_EMOJI = "⏳"
+NAME_SEPARATOR = "|"
+
+CoroutineFunc = t.Callable[..., t.Coroutine]
+
+
+class TaskData(t.NamedTuple):
+ """Data for a scheduled task."""
+
+ wait_time: int
+ callback: t.Awaitable
+
+
+class HelpChannels(Scheduler, commands.Cog):
+ """
+ Manage the help channel system of the guild.
+
+ The system is based on a 3-category system:
+
+ Available Category
+
+ * Contains channels which are ready to be occupied by someone who needs help
+ * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically
+ from the pool of dormant channels
+ * Prioritise using the channels which have been dormant for the longest amount of time
+ * If there are no more dormant channels, the bot will automatically create a new one
+ * If there are no dormant channels to move, helpers will be notified (see `notify()`)
+ * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG`
+ * User can only claim a channel at an interval `constants.HelpChannels.claim_minutes`
+ * To keep track of cooldowns, user which claimed a channel will have a temporary role
+
+ In Use Category
+
+ * Contains all channels which are occupied by someone needing help
+ * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
+ * Command can prematurely mark a channel as dormant
+ * Channel claimant is allowed to use the command
+ * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`
+ * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent
+
+ Dormant Category
+
+ * Contains channels which aren't in use
+ * Channels are used to refill the Available category
+
+ Help channels are named after the chemical elements in `bot/resources/elements.json`.
+ """
+
+ def __init__(self, bot: Bot):
+ super().__init__()
+
+ self.bot = bot
+ self.help_channel_claimants: (
+ t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]]
+ ) = {}
+
+ # Categories
+ self.available_category: discord.CategoryChannel = None
+ self.in_use_category: discord.CategoryChannel = None
+ self.dormant_category: discord.CategoryChannel = None
+
+ # Queues
+ self.channel_queue: asyncio.Queue[discord.TextChannel] = None
+ self.name_queue: t.Deque[str] = None
+
+ self.name_positions = self.get_names()
+ self.last_notification: t.Optional[datetime] = None
+
+ # Asyncio stuff
+ self.queue_tasks: t.List[asyncio.Task] = []
+ self.ready = asyncio.Event()
+ self.on_message_lock = asyncio.Lock()
+ self.init_task = self.bot.loop.create_task(self.init_cog())
+
+ # Stats
+
+ # This dictionary maps a help channel to the time it was claimed
+ self.claim_times: t.Dict[int, datetime] = {}
+
+ # This dictionary maps a help channel to whether it has had any
+ # activity other than the original claimant. True being no other
+ # activity and False being other activity.
+ self.unanswered: t.Dict[int, bool] = {}
+
+ def cog_unload(self) -> None:
+ """Cancel the init task and scheduled tasks when the cog unloads."""
+ log.trace("Cog unload: cancelling the init_cog task")
+ self.init_task.cancel()
+
+ log.trace("Cog unload: cancelling the channel queue tasks")
+ for task in self.queue_tasks:
+ task.cancel()
+
+ self.cancel_all()
+
+ def create_channel_queue(self) -> asyncio.Queue:
+ """
+ Return a queue of dormant channels to use for getting the next available channel.
+
+ The channels are added to the queue in a random order.
+ """
+ log.trace("Creating the channel queue.")
+
+ channels = list(self.get_category_channels(self.dormant_category))
+ random.shuffle(channels)
+
+ log.trace("Populating the channel queue with channels.")
+ queue = asyncio.Queue()
+ for channel in channels:
+ queue.put_nowait(channel)
+
+ return queue
+
+ async def create_dormant(self) -> t.Optional[discord.TextChannel]:
+ """
+ Create and return a new channel in the Dormant category.
+
+ The new channel will sync its permission overwrites with the category.
+
+ Return None if no more channel names are available.
+ """
+ log.trace("Getting a name for a new dormant channel.")
+
+ try:
+ name = self.name_queue.popleft()
+ except IndexError:
+ log.debug("No more names available for new dormant channels.")
+ return None
+
+ log.debug(f"Creating a new dormant channel named {name}.")
+ return await self.dormant_category.create_text_channel(name)
+
+ def create_name_queue(self) -> deque:
+ """Return a queue of element names to use for creating new channels."""
+ log.trace("Creating the chemical element name queue.")
+
+ used_names = self.get_used_names()
+
+ log.trace("Determining the available names.")
+ available_names = (name for name in self.name_positions if name not in used_names)
+
+ log.trace("Populating the name queue with names.")
+ return deque(available_names)
+
+ async def dormant_check(self, ctx: commands.Context) -> bool:
+ """Return True if the user is the help channel claimant or passes the role check."""
+ if self.help_channel_claimants.get(ctx.channel) == ctx.author:
+ log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")
+ self.bot.stats.incr("help.dormant_invoke.claimant")
+ return True
+
+ log.trace(f"{ctx.author} is not the help channel claimant, checking roles.")
+ role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist)
+
+ if role_check:
+ self.bot.stats.incr("help.dormant_invoke.staff")
+
+ return role_check
+
+ @commands.command(name="close", aliases=["dormant", "solved"], enabled=False)
+ async def close_command(self, ctx: commands.Context) -> None:
+ """
+ Make the current in-use help channel dormant.
+
+ Make the channel dormant if the user passes the `dormant_check`,
+ delete the message that invoked this,
+ and reset the send permissions cooldown for the user who started the session.
+ """
+ log.trace("close command invoked; checking if the channel is in-use.")
+ if ctx.channel.category == self.in_use_category:
+ if await self.dormant_check(ctx):
+ with suppress(KeyError):
+ del self.help_channel_claimants[ctx.channel]
+
+ await self.remove_cooldown_role(ctx.author)
+ # Ignore missing task when cooldown has passed but the channel still isn't dormant.
+ self.cancel_task(ctx.author.id, ignore_missing=True)
+
+ await self.move_to_dormant(ctx.channel, "command")
+ self.cancel_task(ctx.channel.id)
+ else:
+ log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel")
+
+ async def get_available_candidate(self) -> discord.TextChannel:
+ """
+ Return a dormant channel to turn into an available channel.
+
+ If no channel is available, wait indefinitely until one becomes available.
+ """
+ log.trace("Getting an available channel candidate.")
+
+ try:
+ channel = self.channel_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ log.info("No candidate channels in the queue; creating a new channel.")
+ channel = await self.create_dormant()
+
+ if not channel:
+ log.info("Couldn't create a candidate channel; waiting to get one from the queue.")
+ await self.notify()
+ channel = await self.wait_for_dormant_channel()
+
+ return channel
+
+ @staticmethod
+ def get_clean_channel_name(channel: discord.TextChannel) -> str:
+ """Return a clean channel name without status emojis prefix."""
+ prefix = constants.HelpChannels.name_prefix
+ try:
+ # Try to remove the status prefix using the index of the channel prefix
+ name = channel.name[channel.name.index(prefix):]
+ log.trace(f"The clean name for `{channel}` is `{name}`")
+ except ValueError:
+ # If, for some reason, the channel name does not contain "help-" fall back gracefully
+ log.info(f"Can't get clean name because `{channel}` isn't prefixed by `{prefix}`.")
+ name = channel.name
+
+ return name
+
+ @staticmethod
+ def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:
+ """Check if a channel should be excluded from the help channel system."""
+ return not isinstance(channel, discord.TextChannel) or channel.id in EXCLUDED_CHANNELS
+
+ def get_category_channels(self, category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:
+ """Yield the text channels of the `category` in an unsorted manner."""
+ log.trace(f"Getting text channels in the category '{category}' ({category.id}).")
+
+ # This is faster than using category.channels because the latter sorts them.
+ for channel in self.bot.get_guild(constants.Guild.id).channels:
+ if channel.category_id == category.id and not self.is_excluded_channel(channel):
+ yield channel
+
+ @staticmethod
+ def get_names() -> t.List[str]:
+ """
+ Return a truncated list of prefixed element names.
+
+ The amount of names is configured with `HelpChannels.max_total_channels`.
+ The prefix is configured with `HelpChannels.name_prefix`.
+ """
+ count = constants.HelpChannels.max_total_channels
+ prefix = constants.HelpChannels.name_prefix
+
+ log.trace(f"Getting the first {count} element names from JSON.")
+
+ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file:
+ all_names = json.load(elements_file)
+
+ if prefix:
+ return [prefix + name for name in all_names[:count]]
+ else:
+ return all_names[:count]
+
+ def get_used_names(self) -> t.Set[str]:
+ """Return channel names which are already being used."""
+ log.trace("Getting channel names which are already being used.")
+
+ names = set()
+ for cat in (self.available_category, self.in_use_category, self.dormant_category):
+ for channel in self.get_category_channels(cat):
+ names.add(self.get_clean_channel_name(channel))
+
+ if len(names) > MAX_CHANNELS_PER_CATEGORY:
+ log.warning(
+ f"Too many help channels ({len(names)}) already exist! "
+ f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category."
+ )
+
+ log.trace(f"Got {len(names)} used names: {names}")
+ return names
+
+ @classmethod
+ async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]:
+ """
+ Return the time elapsed, in seconds, since the last message sent in the `channel`.
+
+ Return None if the channel has no messages.
+ """
+ log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
+
+ msg = await cls.get_last_message(channel)
+ if not msg:
+ log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
+ return None
+
+ idle_time = (datetime.utcnow() - msg.created_at).seconds
+
+ log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
+ return idle_time
+
+ @staticmethod
+ async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]:
+ """Return the last message sent in the channel or None if no messages exist."""
+ log.trace(f"Getting the last message in #{channel} ({channel.id}).")
+
+ try:
+ return await channel.history(limit=1).next() # noqa: B305
+ except discord.NoMoreItems:
+ log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.")
+ return None
+
+ async def init_available(self) -> None:
+ """Initialise the Available category with channels."""
+ log.trace("Initialising the Available category with channels.")
+
+ channels = list(self.get_category_channels(self.available_category))
+ missing = constants.HelpChannels.max_available - len(channels)
+
+ log.trace(f"Moving {missing} missing channels to the Available category.")
+
+ for _ in range(missing):
+ await self.move_to_available()
+
+ async def init_categories(self) -> None:
+ """Get the help category objects. Remove the cog if retrieval fails."""
+ log.trace("Getting the CategoryChannel objects for the help categories.")
+
+ try:
+ self.available_category = await self.try_get_channel(
+ constants.Categories.help_available
+ )
+ self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use)
+ self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant)
+ except discord.HTTPException:
+ log.exception(f"Failed to get a category; cog will be removed")
+ self.bot.remove_cog(self.qualified_name)
+
+ async def init_cog(self) -> None:
+ """Initialise the help channel system."""
+ log.trace("Waiting for the guild to be available before initialisation.")
+ await self.bot.wait_until_guild_available()
+
+ log.trace("Initialising the cog.")
+ await self.init_categories()
+ await self.reset_send_permissions()
+
+ self.channel_queue = self.create_channel_queue()
+ self.name_queue = self.create_name_queue()
+
+ log.trace("Moving or rescheduling in-use channels.")
+ for channel in self.get_category_channels(self.in_use_category):
+ await self.move_idle_channel(channel, has_task=False)
+
+ # Prevent the command from being used until ready.
+ # The ready event wasn't used because channels could change categories between the time
+ # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet).
+ # This may confuse users. So would potentially long delays for the cog to become ready.
+ self.close_command.enabled = True
+
+ await self.init_available()
+
+ log.info("Cog is ready!")
+ self.ready.set()
+
+ self.report_stats()
+
+ def report_stats(self) -> None:
+ """Report the channel count stats."""
+ total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category))
+ total_available = sum(1 for _ in self.get_category_channels(self.available_category))
+ total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category))
+
+ self.bot.stats.gauge("help.total.in_use", total_in_use)
+ self.bot.stats.gauge("help.total.available", total_available)
+ self.bot.stats.gauge("help.total.dormant", total_dormant)
+
+ @staticmethod
+ def is_claimant(member: discord.Member) -> bool:
+ """Return True if `member` has the 'Help Cooldown' role."""
+ return any(constants.Roles.help_cooldown == role.id for role in member.roles)
+
+ def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool:
+ """Return True if the contents of the `message` match `DORMANT_MSG`."""
+ if not message or not message.embeds:
+ return False
+
+ embed = message.embeds[0]
+ return message.author == self.bot.user and embed.description.strip() == DORMANT_MSG.strip()
+
+ @staticmethod
+ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
+ """Return True if `channel` is within a category with `category_id`."""
+ actual_category = getattr(channel, "category", None)
+ return actual_category is not None and actual_category.id == category_id
+
+ async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
+ """
+ Make the `channel` dormant if idle or schedule the move if still active.
+
+ If `has_task` is True and rescheduling is required, the extant task to make the channel
+ dormant will first be cancelled.
+ """
+ log.trace(f"Handling in-use channel #{channel} ({channel.id}).")
+
+ idle_seconds = constants.HelpChannels.idle_minutes * 60
+ time_elapsed = await self.get_idle_time(channel)
+
+ if time_elapsed is None or time_elapsed >= idle_seconds:
+ log.info(
+ f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
+ f"and will be made dormant."
+ )
+
+ await self.move_to_dormant(channel, "auto")
+ else:
+ # Cancel the existing task, if any.
+ if has_task:
+ self.cancel_task(channel.id)
+
+ data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel))
+
+ log.info(
+ f"#{channel} ({channel.id}) is still active; "
+ f"scheduling it to be moved after {data.wait_time} seconds."
+ )
+
+ self.schedule_task(channel.id, data)
+
+ 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 documention 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 self.try_get_channel(category_id)
+
+ payload = [{"id": c.id, "position": c.position} for c in category.channels]
+
+ # Calculate the bottom position based on the current highest position in the category. If the
+ # category is currently empty, we simply use the current position of the channel to avoid making
+ # unnecessary changes to positions in the guild.
+ bottom_position = payload[-1]["position"] + 1 if payload else channel.position
+
+ payload.append(
+ {
+ "id": channel.id,
+ "position": bottom_position,
+ "parent_id": category.id,
+ "lock_permissions": True,
+ }
+ )
+
+ # We use d.py's method to ensure our request is processed by d.py's rate limit manager
+ await self.bot.http.bulk_channel_update(category.guild.id, payload)
+
+ # Now that the channel is moved, we can edit the other attributes
+ if options:
+ await channel.edit(**options)
+
+ async def move_to_available(self) -> None:
+ """Make a channel available."""
+ log.trace("Making a channel available.")
+
+ channel = await self.get_available_candidate()
+ log.info(f"Making #{channel} ({channel.id}) available.")
+
+ await self.send_available_message(channel)
+
+ log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
+
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_available,
+ name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
+ topic=AVAILABLE_TOPIC,
+ )
+
+ self.report_stats()
+
+ async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None:
+ """
+ Make the `channel` dormant.
+
+ A caller argument is provided for metrics.
+ """
+ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
+
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_dormant,
+ name=self.get_clean_channel_name(channel),
+ topic=DORMANT_TOPIC,
+ )
+
+ self.bot.stats.incr(f"help.dormant_calls.{caller}")
+
+ if channel.id in self.claim_times:
+ claimed = self.claim_times[channel.id]
+ in_use_time = datetime.now() - claimed
+ self.bot.stats.timing("help.in_use_time", in_use_time)
+
+ if channel.id in self.unanswered:
+ if self.unanswered[channel.id]:
+ self.bot.stats.incr("help.sessions.unanswered")
+ else:
+ self.bot.stats.incr("help.sessions.answered")
+
+ log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
+
+ log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
+ embed = discord.Embed(description=DORMANT_MSG)
+ await channel.send(embed=embed)
+
+ log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
+ self.channel_queue.put_nowait(channel)
+ self.report_stats()
+
+ async def move_to_in_use(self, channel: discord.TextChannel) -> None:
+ """Make a channel in-use and schedule it to be made dormant."""
+ log.info(f"Moving #{channel} ({channel.id}) to the In Use category.")
+
+ await self.move_to_bottom_position(
+ channel=channel,
+ category_id=constants.Categories.help_in_use,
+ name=f"{IN_USE_UNANSWERED_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
+ topic=IN_USE_TOPIC,
+ )
+
+ timeout = constants.HelpChannels.idle_minutes * 60
+
+ log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")
+ data = TaskData(timeout, self.move_idle_channel(channel))
+ self.schedule_task(channel.id, data)
+ self.report_stats()
+
+ async def notify(self) -> None:
+ """
+ Send a message notifying about a lack of available help channels.
+
+ Configuration:
+
+ * `HelpChannels.notify` - toggle notifications
+ * `HelpChannels.notify_channel` - destination channel for notifications
+ * `HelpChannels.notify_minutes` - minimum interval between notifications
+ * `HelpChannels.notify_roles` - roles mentioned in notifications
+ """
+ if not constants.HelpChannels.notify:
+ return
+
+ log.trace("Notifying about lack of channels.")
+
+ if self.last_notification:
+ elapsed = (datetime.utcnow() - self.last_notification).seconds
+ minimum_interval = constants.HelpChannels.notify_minutes * 60
+ should_send = elapsed >= minimum_interval
+ else:
+ should_send = True
+
+ if not should_send:
+ log.trace("Notification not sent because it's too recent since the previous one.")
+ return
+
+ try:
+ log.trace("Sending notification message.")
+
+ channel = self.bot.get_channel(constants.HelpChannels.notify_channel)
+ mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles)
+
+ message = await channel.send(
+ f"{mentions} A new available help channel is needed but there "
+ f"are no more dormant ones. Consider freeing up some in-use channels manually by "
+ f"using the `{constants.Bot.prefix}dormant` command within the channels."
+ )
+
+ self.bot.stats.incr("help.out_of_channel_alerts")
+
+ self.last_notification = message.created_at
+ except Exception:
+ # Handle it here cause this feature isn't critical for the functionality of the system.
+ log.exception("Failed to send notification about lack of dormant channels!")
+
+ async def check_for_answer(self, message: discord.Message) -> None:
+ """Checks for whether new content in a help channel comes from non-claimants."""
+ channel = message.channel
+
+ # Confirm the channel is an in use help channel
+ if self.is_in_category(channel, constants.Categories.help_in_use):
+ log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
+
+ # Check if there is an entry in unanswered (does not persist across restarts)
+ if channel.id in self.unanswered:
+ claimant_id = self.help_channel_claimants[channel].id
+
+ # Check the message did not come from the claimant
+ if claimant_id != message.author.id:
+ # Mark the channel as answered
+ self.unanswered[channel.id] = False
+
+ # Change the emoji in the channel name to signify activity
+ log.trace(f"#{channel} ({channel.id}) has been answered; changing its emoji")
+ name = self.get_clean_channel_name(channel)
+ await channel.edit(name=f"{IN_USE_ANSWERED_EMOJI}{NAME_SEPARATOR}{name}")
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Move an available channel to the In Use category and replace it with a dormant one."""
+ if message.author.bot:
+ return # Ignore messages sent by bots.
+
+ channel = message.channel
+
+ await self.check_for_answer(message)
+
+ if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel):
+ return # Ignore messages outside the Available category or in excluded channels.
+
+ log.trace("Waiting for the cog to be ready before processing messages.")
+ await self.ready.wait()
+
+ log.trace("Acquiring lock to prevent a channel from being processed twice...")
+ async with self.on_message_lock:
+ log.trace(f"on_message lock acquired for {message.id}.")
+
+ if not self.is_in_category(channel, constants.Categories.help_available):
+ log.debug(
+ f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
+ f"because another message in the channel already triggered that."
+ )
+ return
+
+ log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")
+ await self.move_to_in_use(channel)
+ await self.revoke_send_permissions(message.author)
+ # Add user with channel for dormant check.
+ self.help_channel_claimants[channel] = message.author
+
+ self.bot.stats.incr("help.claimed")
+
+ self.claim_times[channel.id] = datetime.now()
+ self.unanswered[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()
+
+ async def reset_send_permissions(self) -> None:
+ """Reset send permissions in the Available category for claimants."""
+ log.trace("Resetting send permissions in the Available category.")
+ guild = self.bot.get_guild(constants.Guild.id)
+
+ # TODO: replace with a persistent cache cause checking every member is quite slow
+ for member in guild.members:
+ if self.is_claimant(member):
+ await self.remove_cooldown_role(member)
+
+ async def add_cooldown_role(self, member: discord.Member) -> None:
+ """Add the help cooldown role to `member`."""
+ log.trace(f"Adding cooldown role for {member} ({member.id}).")
+ await self._change_cooldown_role(member, member.add_roles)
+
+ async def remove_cooldown_role(self, member: discord.Member) -> None:
+ """Remove the help cooldown role from `member`."""
+ log.trace(f"Removing cooldown role for {member} ({member.id}).")
+ await self._change_cooldown_role(member, member.remove_roles)
+
+ async def _change_cooldown_role(self, member: discord.Member, coro_func: CoroutineFunc) -> None:
+ """
+ Change `member`'s cooldown role via awaiting `coro_func` and handle errors.
+
+ `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+ role = guild.get_role(constants.Roles.help_cooldown)
+ if role is None:
+ log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!")
+ return
+
+ try:
+ await coro_func(role)
+ except discord.NotFound:
+ log.debug(f"Failed to change role for {member} ({member.id}): member not found")
+ except discord.Forbidden:
+ log.debug(
+ f"Forbidden to change role for {member} ({member.id}); "
+ f"possibly due to role hierarchy"
+ )
+ except discord.HTTPException as e:
+ log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}")
+
+ async def revoke_send_permissions(self, member: discord.Member) -> None:
+ """
+ Disallow `member` to send messages in the Available category for a certain time.
+
+ The time until permissions are reinstated can be configured with
+ `HelpChannels.claim_minutes`.
+ """
+ log.trace(
+ f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
+ )
+
+ await self.add_cooldown_role(member)
+
+ # Cancel the existing task, if any.
+ # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
+ self.cancel_task(member.id, ignore_missing=True)
+
+ timeout = constants.HelpChannels.claim_minutes * 60
+ callback = self.remove_cooldown_role(member)
+
+ log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.")
+ self.schedule_task(member.id, TaskData(timeout, callback))
+
+ async def send_available_message(self, channel: discord.TextChannel) -> None:
+ """Send the available message by editing a dormant message or sending a new message."""
+ channel_info = f"#{channel} ({channel.id})"
+ log.trace(f"Sending available message in {channel_info}.")
+
+ embed = discord.Embed(description=AVAILABLE_MSG)
+
+ msg = await self.get_last_message(channel)
+ if self.is_dormant_message(msg):
+ log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.")
+ await msg.edit(embed=embed)
+ else:
+ log.trace(f"Dormant message not found in {channel_info}; sending a new message.")
+ await channel.send(embed=embed)
+
+ async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel:
+ """Attempt to get or fetch a channel and return it."""
+ log.trace(f"Getting the channel {channel_id}.")
+
+ channel = self.bot.get_channel(channel_id)
+ if not channel:
+ log.debug(f"Channel {channel_id} is not in cache; fetching from API.")
+ channel = await self.bot.fetch_channel(channel_id)
+
+ log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
+ return channel
+
+ async def wait_for_dormant_channel(self) -> discord.TextChannel:
+ """Wait for a dormant channel to become available in the queue and return it."""
+ log.trace("Waiting for a dormant channel.")
+
+ task = asyncio.create_task(self.channel_queue.get())
+ self.queue_tasks.append(task)
+ channel = await task
+
+ log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.")
+ self.queue_tasks.remove(task)
+
+ return channel
+
+ async def _scheduled_task(self, data: TaskData) -> None:
+ """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds."""
+ try:
+ log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.")
+ await asyncio.sleep(data.wait_time)
+
+ # Use asyncio.shield to prevent callback from cancelling itself.
+ # The parent task (_scheduled_task) will still get cancelled.
+ log.trace("Done waiting; now awaiting the callback.")
+ await asyncio.shield(data.callback)
+ finally:
+ if inspect.iscoroutine(data.callback):
+ log.trace("Explicitly closing coroutine.")
+ data.callback.close()
+
+
+def validate_config() -> None:
+ """Raise a ValueError if the cog's config is invalid."""
+ log.trace("Validating config.")
+ total = constants.HelpChannels.max_total_channels
+ available = constants.HelpChannels.max_available
+
+ if total == 0 or available == 0:
+ raise ValueError("max_total_channels and max_available and must be greater than 0.")
+
+ if total < available:
+ raise ValueError(
+ f"max_total_channels ({total}) must be greater than or equal to max_available "
+ f"({available})."
+ )
+
+ if total > MAX_CHANNELS_PER_CATEGORY:
+ raise ValueError(
+ f"max_total_channels ({total}) must be less than or equal to "
+ f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category."
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the HelpChannels cog."""
+ try:
+ validate_config()
+ except ValueError as e:
+ log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}")
+ else:
+ bot.add_cog(HelpChannels(bot))
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 7921a4932..ef2f308ca 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -12,7 +12,7 @@ from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.decorators import InChannelCheckFailure, in_channel, with_role
+from bot.decorators import InWhitelistCheckFailure, in_whitelist, with_role
from bot.pagination import LinePaginator
from bot.utils.checks import cooldown_with_role_bypass, with_role_check
from bot.utils.time import time_since
@@ -152,7 +152,7 @@ class Information(Cog):
# Non-staff may only do this in #bot-commands
if not with_role_check(ctx, *constants.STAFF_ROLES):
if not ctx.channel.id == constants.Channels.bot_commands:
- raise InChannelCheckFailure(constants.Channels.bot_commands)
+ raise InWhitelistCheckFailure(constants.Channels.bot_commands)
embed = await self.create_user_embed(ctx, user)
@@ -206,7 +206,7 @@ class Information(Cog):
description="\n\n".join(description)
)
- embed.set_thumbnail(url=user.avatar_url_as(format="png"))
+ embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
embed.colour = user.top_role.colour if roles else Colour.blurple()
return embed
@@ -331,7 +331,7 @@ class Information(Cog):
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
@group(invoke_without_command=True)
- @in_channel(constants.Channels.bot_commands, bypass_roles=constants.STAFF_ROLES)
+ @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."""
# I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index 3340744b0..01266d346 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -200,7 +200,7 @@ class Infractions(InfractionScheduler, commands.Cog):
async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
- if await utils.has_active_infraction(ctx, user, "mute"):
+ if await utils.get_active_infraction(ctx, user, "mute"):
return
infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
@@ -236,8 +236,22 @@ class Infractions(InfractionScheduler, commands.Cog):
Will also remove the banned user from the Big Brother watch list if applicable.
"""
- if await utils.has_active_infraction(ctx, user, "ban"):
- return
+ # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
+ is_temporary = kwargs.get("expires_at") is not None
+ active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary)
+
+ if active_infraction:
+ if is_temporary:
+ log.trace("Tempban ignored as it cannot overwrite an active ban.")
+ return
+
+ if active_infraction.get('expires_at') is None:
+ log.trace("Permaban already exists, notify.")
+ await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).")
+ return
+
+ log.trace("Old tempban is being replaced by new permaban.")
+ await self.pardon_infraction(ctx, "ban", user, is_temporary)
infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index 250a24247..edfdfd9e2 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -43,7 +43,7 @@ class ModManagement(commands.Cog):
@commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
async def infraction_group(self, ctx: Context) -> None:
"""Infraction manipulation commands."""
- await ctx.invoke(self.bot.get_command("help"), "infraction")
+ await ctx.send_help(ctx.command)
@infraction_group.command(name='edit')
async def infraction_edit(
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index eb8bd65cf..c6497b38d 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context
from discord.utils import escape_markdown
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -191,6 +191,12 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.guild_channel_update].remove(before.id)
return
+ # Two channel updates are sent for a single edit: 1 for topic and 1 for category change.
+ # TODO: remove once support is added for ignoring multiple occurrences for the same channel.
+ help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use)
+ if after.category and after.category.id in help_categories:
+ return
+
diff = DeepDiff(before, after)
changes = []
done = []
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index 58e363da6..b65048f4c 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -200,8 +200,19 @@ class InfractionScheduler(Scheduler):
log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
- async def pardon_infraction(self, ctx: Context, infr_type: str, user: UserSnowflake) -> None:
- """Prematurely end an infraction for a user and log the action in the mod log."""
+ async def pardon_infraction(
+ self,
+ ctx: Context,
+ infr_type: str,
+ user: UserSnowflake,
+ send_msg: bool = True
+ ) -> None:
+ """
+ Prematurely end an infraction for a user and log the action in the mod log.
+
+ If `send_msg` is True, then a pardoning confirmation message will be sent to
+ the context channel. Otherwise, no such message will be sent.
+ """
log.trace(f"Pardoning {infr_type} infraction for {user}.")
# Check the current active infraction
@@ -286,11 +297,12 @@ class InfractionScheduler(Scheduler):
log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.")
# Send a confirmation message to the invoking context.
- log.trace(f"Sending infraction #{id_} pardon confirmation message.")
- await ctx.send(
- f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
- f"{log_text.get('Failure', '')}"
- )
+ if send_msg:
+ log.trace(f"Sending infraction #{id_} pardon confirmation message.")
+ await ctx.send(
+ f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
+ f"{log_text.get('Failure', '')}"
+ )
# Move reason to end of entry to avoid cutting out some keys
log_text["Reason"] = log_text.pop("Reason")
diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py
index e221ad909..45a010f00 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -130,7 +130,7 @@ class Superstarify(InfractionScheduler, Cog):
An optional reason can be provided. If no reason is given, the original name will be shown
in a generated reason.
"""
- if await utils.has_active_infraction(ctx, member, "superstar"):
+ if await utils.get_active_infraction(ctx, member, "superstar"):
return
# Post the infraction to the API
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
index fc8c26031..1b716b2ea 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -97,8 +97,19 @@ async def post_infraction(
return
-async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool:
- """Checks if a user already has an active infraction of the given type."""
+async def get_active_infraction(
+ ctx: Context,
+ user: UserSnowflake,
+ infr_type: str,
+ send_msg: bool = True
+) -> t.Optional[dict]:
+ """
+ Retrieves an active infraction of the given type for the user.
+
+ If `send_msg` is True and the user has an active infraction matching the `infr_type` parameter,
+ then a message for the moderator will be sent to the context channel letting them know.
+ Otherwise, no message will be sent.
+ """
log.trace(f"Checking if {user} has active infractions of type {infr_type}.")
active_infractions = await ctx.bot.api_client.get(
@@ -110,15 +121,16 @@ async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: st
}
)
if active_infractions:
- log.trace(f"{user} has active infractions of type {infr_type}.")
- await ctx.send(
- f":x: According to my records, this user already has a {infr_type} infraction. "
- f"See infraction **#{active_infractions[0]['id']}**."
- )
- return True
+ # Checks to see if the moderator should be told there is an active infraction
+ if send_msg:
+ log.trace(f"{user} has active infractions of type {infr_type}.")
+ await ctx.send(
+ f":x: According to my records, this user already has a {infr_type} infraction. "
+ f"See infraction **#{active_infractions[0]['id']}**."
+ )
+ return active_infractions[0]
else:
log.trace(f"{user} does not have active infractions of type {infr_type}.")
- return False
async def notify_infraction(
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 81511f99d..201579a0b 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -97,7 +97,7 @@ class OffTopicNames(Cog):
@with_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
- await ctx.invoke(self.bot.get_command("help"), "otname")
+ await ctx.send_help(ctx.command)
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py
new file mode 100644
index 000000000..57ce61638
--- /dev/null
+++ b/bot/cogs/python_news.py
@@ -0,0 +1,234 @@
+import logging
+import typing as t
+from datetime import date, datetime
+
+import discord
+import feedparser
+from bs4 import BeautifulSoup
+from discord.ext.commands import Cog
+from discord.ext.tasks import loop
+
+from bot import constants
+from bot.bot import Bot
+
+PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
+
+RECENT_THREADS_TEMPLATE = "https://mail.python.org/archives/list/{name}@python.org/recent-threads"
+THREAD_TEMPLATE_URL = "https://mail.python.org/archives/api/list/{name}@python.org/thread/{id}/"
+MAILMAN_PROFILE_URL = "https://mail.python.org/archives/users/{id}/"
+THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id}/"
+
+AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
+
+log = logging.getLogger(__name__)
+
+
+class PythonNews(Cog):
+ """Post new PEPs and Python News to `#python-news`."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.webhook_names = {}
+ self.webhook: t.Optional[discord.Webhook] = None
+
+ self.bot.loop.create_task(self.get_webhook_names())
+ self.bot.loop.create_task(self.get_webhook_and_channel())
+
+ async def start_tasks(self) -> None:
+ """Start the tasks for fetching new PEPs and mailing list messages."""
+ self.fetch_new_media.start()
+
+ @loop(minutes=20)
+ async def fetch_new_media(self) -> None:
+ """Fetch new mailing list messages and then new PEPs."""
+ await self.post_maillist_news()
+ await self.post_pep_news()
+
+ async def sync_maillists(self) -> None:
+ """Sync currently in-use maillists with API."""
+ # Wait until guild is available to avoid running before everything is ready
+ await self.bot.wait_until_guild_available()
+
+ response = await self.bot.api_client.get("bot/bot-settings/news")
+ for mail in constants.PythonNews.mail_lists:
+ if mail not in response["data"]:
+ response["data"][mail] = []
+
+ # Because we are handling PEPs differently, we don't include it to mail lists
+ if "pep" not in response["data"]:
+ response["data"]["pep"] = []
+
+ await self.bot.api_client.put("bot/bot-settings/news", json=response)
+
+ async def get_webhook_names(self) -> None:
+ """Get webhook author names from maillist API."""
+ await self.bot.wait_until_guild_available()
+
+ async with self.bot.http_session.get("https://mail.python.org/archives/api/lists") as resp:
+ lists = await resp.json()
+
+ for mail in lists:
+ if mail["name"].split("@")[0] in constants.PythonNews.mail_lists:
+ self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"]
+
+ async def post_pep_news(self) -> None:
+ """Fetch new PEPs and when they don't have announcement in #python-news, create it."""
+ # Wait until everything is ready and http_session available
+ await self.bot.wait_until_guild_available()
+ await self.sync_maillists()
+
+ async with self.bot.http_session.get(PEPS_RSS_URL) as resp:
+ data = feedparser.parse(await resp.text("utf-8"))
+
+ news_listing = await self.bot.api_client.get("bot/bot-settings/news")
+ payload = news_listing.copy()
+ pep_numbers = news_listing["data"]["pep"]
+
+ # Reverse entries to send oldest first
+ data["entries"].reverse()
+ for new in data["entries"]:
+ try:
+ new_datetime = datetime.strptime(new["published"], "%a, %d %b %Y %X %Z")
+ except ValueError:
+ log.warning(f"Wrong datetime format passed in PEP new: {new['published']}")
+ continue
+ pep_nr = new["title"].split(":")[0].split()[1]
+ if (
+ pep_nr in pep_numbers
+ or new_datetime.date() < date.today()
+ ):
+ continue
+
+ msg = await self.send_webhook(
+ title=new["title"],
+ description=new["summary"],
+ timestamp=new_datetime,
+ url=new["link"],
+ webhook_profile_name=data["feed"]["title"],
+ footer=data["feed"]["title"]
+ )
+ payload["data"]["pep"].append(pep_nr)
+
+ if msg.channel.is_news():
+ log.trace("Publishing PEP annnouncement because it was in a news channel")
+ await msg.publish()
+
+ # Apply new sent news to DB to avoid duplicate sending
+ await self.bot.api_client.put("bot/bot-settings/news", json=payload)
+
+ async def post_maillist_news(self) -> None:
+ """Send new maillist threads to #python-news that is listed in configuration."""
+ await self.bot.wait_until_guild_available()
+ await self.sync_maillists()
+ existing_news = await self.bot.api_client.get("bot/bot-settings/news")
+ payload = existing_news.copy()
+
+ for maillist in constants.PythonNews.mail_lists:
+ async with self.bot.http_session.get(RECENT_THREADS_TEMPLATE.format(name=maillist)) as resp:
+ recents = BeautifulSoup(await resp.text(), features="lxml")
+
+ # When a <p> element is present in the response then the mailing list
+ # has not had any activity during the current month, so therefore it
+ # can be ignored.
+ if recents.p:
+ continue
+
+ for thread in recents.html.body.div.find_all("a", href=True):
+ # We want only these threads that have identifiers
+ if "latest" in thread["href"]:
+ continue
+
+ thread_information, email_information = await self.get_thread_and_first_mail(
+ maillist, thread["href"].split("/")[-2]
+ )
+
+ try:
+ new_date = datetime.strptime(email_information["date"], "%Y-%m-%dT%X%z")
+ except ValueError:
+ log.warning(f"Invalid datetime from Thread email: {email_information['date']}")
+ continue
+
+ if (
+ thread_information["thread_id"] in existing_news["data"][maillist]
+ or new_date.date() < date.today()
+ ):
+ continue
+
+ content = email_information["content"]
+ link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist)
+ msg = await self.send_webhook(
+ title=thread_information["subject"],
+ description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content,
+ timestamp=new_date,
+ url=link,
+ author=f"{email_information['sender_name']} ({email_information['sender']['address']})",
+ author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]),
+ webhook_profile_name=self.webhook_names[maillist],
+ footer=f"Posted to {self.webhook_names[maillist]}"
+ )
+ payload["data"][maillist].append(thread_information["thread_id"])
+
+ if msg.channel.is_news():
+ log.trace("Publishing mailing list message because it was in a news channel")
+ await msg.publish()
+
+ await self.bot.api_client.put("bot/bot-settings/news", json=payload)
+
+ async def send_webhook(self,
+ title: str,
+ description: str,
+ timestamp: datetime,
+ url: str,
+ webhook_profile_name: str,
+ footer: str,
+ author: t.Optional[str] = None,
+ author_url: t.Optional[str] = None,
+ ) -> discord.Message:
+ """Send webhook entry and return sent message."""
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ timestamp=timestamp,
+ url=url,
+ colour=constants.Colours.soft_green
+ )
+ if author and author_url:
+ embed.set_author(
+ name=author,
+ url=author_url
+ )
+ embed.set_footer(text=footer, icon_url=AVATAR_URL)
+
+ return await self.webhook.send(
+ embed=embed,
+ username=webhook_profile_name,
+ avatar_url=AVATAR_URL,
+ wait=True
+ )
+
+ async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]:
+ """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`."""
+ async with self.bot.http_session.get(
+ THREAD_TEMPLATE_URL.format(name=maillist, id=thread_identifier)
+ ) as resp:
+ thread_information = await resp.json()
+
+ async with self.bot.http_session.get(thread_information["starting_email"]) as resp:
+ email_information = await resp.json()
+ return thread_information, email_information
+
+ async def get_webhook_and_channel(self) -> None:
+ """Storage #python-news channel Webhook and `TextChannel` to `News.webhook` and `channel`."""
+ await self.bot.wait_until_guild_available()
+ self.webhook = await self.bot.fetch_webhook(constants.PythonNews.webhook)
+
+ await self.start_tasks()
+
+ def cog_unload(self) -> None:
+ """Stop news posting tasks on cog unload."""
+ self.fetch_new_media.cancel()
+
+
+def setup(bot: Bot) -> None:
+ """Add `News` cog."""
+ bot.add_cog(PythonNews(bot))
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 5a7fa100f..3b77538a0 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -218,7 +218,10 @@ class Reddit(Cog):
for subreddit in RedditConfig.subreddits:
top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
- await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts)
+ message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts, wait=True)
+
+ if message.channel.is_news():
+ await message.publish()
async def top_weekly_posts(self) -> None:
"""Post a summary of the top posts."""
@@ -242,10 +245,13 @@ class Reddit(Cog):
await message.pin()
+ if message.channel.is_news():
+ await message.publish()
+
@group(name="reddit", invoke_without_command=True)
async def reddit_group(self, ctx: Context) -> None:
"""View the top posts from various subreddits."""
- await ctx.invoke(self.bot.get_command("help"), "reddit")
+ await ctx.send_help(ctx.command)
@reddit_group.command(name="top")
async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index 24c279357..c242d2920 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -158,7 +158,7 @@ class Reminders(Scheduler, Cog):
)
await self._delete_reminder(reminder["id"])
- @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
+ @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
"""Commands for managing your reminders."""
await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
@@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog):
@remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
async def edit_reminder_group(self, ctx: Context) -> None:
"""Commands for modifying your current reminders."""
- await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
+ await ctx.send_help(ctx.command)
@edit_reminder_group.command(name="duration", aliases=("time",))
async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index 853e29568..7fc2a9c34 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -21,7 +21,7 @@ class Site(Cog):
@group(name="site", aliases=("s",), invoke_without_command=True)
async def site_group(self, ctx: Context) -> None:
"""Commands for getting info about our website."""
- await ctx.invoke(self.bot.get_command("help"), "site")
+ await ctx.send_help(ctx.command)
@site_group.command(name="home", aliases=("about",))
async def site_main(self, ctx: Context) -> None:
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 315383b12..6ef659f28 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -12,8 +12,8 @@ from discord import HTTPException, Message, NotFound, Reaction, User
from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
-from bot.constants import Channels, Roles, URLs
-from bot.decorators import in_channel
+from bot.constants import Categories, Channels, Roles, URLs
+from bot.decorators import in_whitelist
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -38,11 +38,16 @@ RAW_CODE_REGEX = re.compile(
)
MAX_PASTE_LEN = 1000
+
+# `!eval` command whitelists
+EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
+EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)
EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
SIGKILL = 9
REEVAL_EMOJI = '\U0001f501' # :repeat:
+REEVAL_TIMEOUT = 30
class Snekbox(Cog):
@@ -223,7 +228,7 @@ class Snekbox(Cog):
_, new_message = await self.bot.wait_for(
'message_edit',
check=_predicate_eval_message_edit,
- timeout=10
+ timeout=REEVAL_TIMEOUT
)
await ctx.message.add_reaction(REEVAL_EMOJI)
await self.bot.wait_for(
@@ -265,7 +270,7 @@ class Snekbox(Cog):
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
+ @in_whitelist(channels=EVAL_CHANNELS, categories=EVAL_CATEGORIES, roles=EVAL_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
@@ -285,7 +290,7 @@ class Snekbox(Cog):
return
if not code: # None or empty string
- await ctx.invoke(self.bot.get_command("help"), "eval")
+ await ctx.send_help(ctx.command)
return
log.info(f"Received code from {ctx.author} for evaluation:\n{code}")
diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py
new file mode 100644
index 000000000..d253db913
--- /dev/null
+++ b/bot/cogs/stats.py
@@ -0,0 +1,107 @@
+import string
+from datetime import datetime
+
+from discord import Member, Message, Status
+from discord.ext.commands import Bot, Cog, Context
+
+from bot.constants import Channels, Guild, Stats as StatConf
+
+
+CHANNEL_NAME_OVERRIDES = {
+ Channels.off_topic_0: "off_topic_0",
+ Channels.off_topic_1: "off_topic_1",
+ Channels.off_topic_2: "off_topic_2",
+ Channels.staff_lounge: "staff_lounge"
+}
+
+ALLOWED_CHARS = string.ascii_letters + string.digits + "_"
+
+
+class Stats(Cog):
+ """A cog which provides a way to hook onto Discord events and forward to stats."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.last_presence_update = None
+
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Report message events in the server to statsd."""
+ if message.guild is None:
+ return
+
+ if message.guild.id != Guild.id:
+ return
+
+ reformatted_name = message.channel.name.replace('-', '_')
+
+ if CHANNEL_NAME_OVERRIDES.get(message.channel.id):
+ reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id)
+
+ reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS)
+
+ stat_name = f"channels.{reformatted_name}"
+ self.bot.stats.incr(stat_name)
+
+ # Increment the total message count
+ self.bot.stats.incr("messages")
+
+ @Cog.listener()
+ async def on_command_completion(self, ctx: Context) -> None:
+ """Report completed commands to statsd."""
+ command_name = ctx.command.qualified_name.replace(" ", "_")
+
+ self.bot.stats.incr(f"commands.{command_name}")
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """Update member count stat on member join."""
+ if member.guild.id != Guild.id:
+ return
+
+ self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+
+ @Cog.listener()
+ async def on_member_leave(self, member: Member) -> None:
+ """Update member count stat on member leave."""
+ if member.guild.id != Guild.id:
+ return
+
+ self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+
+ @Cog.listener()
+ async def on_member_update(self, _before: Member, after: Member) -> None:
+ """Update presence estimates on member update."""
+ if after.guild.id != Guild.id:
+ return
+
+ if self.last_presence_update:
+ if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout:
+ return
+
+ self.last_presence_update = datetime.now()
+
+ online = 0
+ idle = 0
+ dnd = 0
+ offline = 0
+
+ for member in after.guild.members:
+ if member.status is Status.online:
+ online += 1
+ elif member.status is Status.dnd:
+ dnd += 1
+ elif member.status is Status.idle:
+ idle += 1
+ elif member.status is Status.offline:
+ offline += 1
+
+ self.bot.stats.gauge("guild.status.online", online)
+ self.bot.stats.gauge("guild.status.idle", idle)
+ self.bot.stats.gauge("guild.status.do_not_disturb", dnd)
+ self.bot.stats.gauge("guild.status.offline", offline)
+
+
+def setup(bot: Bot) -> None:
+ """Load the stats cog."""
+ bot.add_cog(Stats(bot))
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index 003bf3727..e55bf27fd 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -1,4 +1,5 @@
import abc
+import asyncio
import logging
import typing as t
from collections import namedtuple
@@ -122,7 +123,7 @@ class Syncer(abc.ABC):
check=partial(self._reaction_check, author, message),
timeout=constants.Sync.confirm_timeout
)
- except TimeoutError:
+ except asyncio.TimeoutError:
# reaction will remain none thus sync will be aborted in the finally block below.
log.debug(f"The {self.name} syncer confirmation prompt timed out.")
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index a6e5952ff..a813ffff5 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -43,7 +43,7 @@ class Tags(Cog):
tag = {
"title": tag_title,
"embed": {
- "description": file.read_text()
+ "description": file.read_text(encoding="utf-8")
}
}
cache[tag_title] = tag
@@ -207,6 +207,9 @@ class Tags(Cog):
"time": time.time(),
"channel": ctx.channel.id
}
+
+ self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}")
+
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 421ad23e2..6721f0e02 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -93,6 +93,8 @@ class TokenRemover(Cog):
channel_id=Channels.mod_alerts,
)
+ self.bot.stats.incr("tokens.removed_tokens")
+
@classmethod
def find_token_in_message(cls, msg: Message) -> t.Optional[str]:
"""Return a seemingly valid token found in `msg` or `None` if no token is found."""
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 3ed471bbf..6b59d37c8 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -2,19 +2,16 @@ import difflib
import logging
import re
import unicodedata
-from asyncio import TimeoutError, sleep
from email.parser import HeaderParser
from io import StringIO
from typing import Tuple, Union
-from dateutil import relativedelta
-from discord import Colour, Embed, Message, Role
+from discord import Colour, Embed
from discord.ext.commands import BadArgument, Cog, Context, command
from bot.bot import Bot
-from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES
-from bot.decorators import in_channel, with_role
-from bot.utils.time import humanize_delta
+from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES
+from bot.decorators import in_whitelist, with_role
log = logging.getLogger(__name__)
@@ -58,7 +55,7 @@ class Utils(Cog):
if pep_number.isdigit():
pep_number = int(pep_number)
else:
- await ctx.invoke(self.bot.get_command("help"), "pep")
+ await ctx.send_help(ctx.command)
return
# Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
@@ -118,7 +115,7 @@ class Utils(Cog):
await ctx.message.channel.send(embed=pep_embed)
@command()
- @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES)
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
"""Shows you information on up to 25 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
@@ -162,47 +159,6 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command()
- @with_role(*MODERATION_ROLES)
- async def mention(self, ctx: Context, *, role: Role) -> None:
- """Set a role to be mentionable for a limited time."""
- if role.mentionable:
- await ctx.send(f"{role} is already mentionable!")
- return
-
- await role.edit(reason=f"Role unlocked by {ctx.author}", mentionable=True)
-
- human_time = humanize_delta(relativedelta.relativedelta(seconds=Mention.message_timeout))
- await ctx.send(
- f"{role} has been made mentionable. I will reset it in {human_time}, or when someone mentions this role."
- )
-
- def check(m: Message) -> bool:
- """Checks that the message contains the role mention."""
- return role in m.role_mentions
-
- try:
- msg = await self.bot.wait_for("message", check=check, timeout=Mention.message_timeout)
- except TimeoutError:
- await role.edit(mentionable=False, reason="Automatic role lock - timeout.")
- await ctx.send(f"{ctx.author.mention}, you took too long. I have reset {role} to be unmentionable.")
- return
-
- if any(r.id in MODERATION_ROLES for r in msg.author.roles):
- await sleep(Mention.reset_delay)
- await role.edit(mentionable=False, reason=f"Automatic role lock by {msg.author}")
- await ctx.send(
- f"{ctx.author.mention}, I have reset {role} to be unmentionable as "
- f"{msg.author if msg.author != ctx.author else 'you'} sent a message mentioning it."
- )
- return
-
- await role.edit(mentionable=False, reason=f"Automatic role lock - unauthorised use by {msg.author}")
- await ctx.send(
- f"{ctx.author.mention}, I have reset {role} to be unmentionable "
- f"as I detected unauthorised use by {msg.author} (ID: {msg.author.id})."
- )
-
- @command()
async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None:
"""
Show the Zen of Python.
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index b0a493e68..77e8b5706 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
from bot.cogs.moderation import ModLog
-from bot.decorators import InChannelCheckFailure, in_channel, without_role
+from bot.decorators import InWhitelistCheckFailure, in_whitelist, without_role
from bot.utils.checks import without_role_check
log = logging.getLogger(__name__)
@@ -40,7 +40,7 @@ else:
PERIODIC_PING = (
f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`."
" If you encounter any problems during the verification process, "
- f"ping the <@&{constants.Roles.admins}> role in this channel."
+ f"send a direct message to a staff member."
)
BOT_MESSAGE_DELETE_DELAY = 10
@@ -92,7 +92,6 @@ class Verification(Cog):
text=embed_text,
thumbnail=message.author.avatar_url_as(static_format="png"),
channel_id=constants.Channels.mod_alerts,
- ping_everyone=constants.Filter.ping_everyone,
)
ctx: Context = await self.bot.get_context(message)
@@ -122,7 +121,7 @@ class Verification(Cog):
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(constants.Roles.verified)
- @in_channel(constants.Channels.verification)
+ @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.")
@@ -138,7 +137,7 @@ class Verification(Cog):
await ctx.message.delete()
@command(name='subscribe')
- @in_channel(constants.Channels.bot_commands)
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Subscribe to announcement notifications by assigning yourself the role."""
has_role = False
@@ -162,7 +161,7 @@ class Verification(Cog):
)
@command(name='unsubscribe')
- @in_channel(constants.Channels.bot_commands)
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
"""Unsubscribe from announcement notifications by removing the role from yourself."""
has_role = False
@@ -187,8 +186,8 @@ class Verification(Cog):
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InChannelCheckFailure."""
- if isinstance(error, InChannelCheckFailure):
+ """Check for & ignore any InWhitelistCheckFailure."""
+ if isinstance(error, InWhitelistCheckFailure):
error.handled = True
@staticmethod
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index 69df849f0..702d371f4 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -31,7 +31,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
@with_role(*MODERATION_ROLES)
async def bigbrother_group(self, ctx: Context) -> None:
"""Monitors users by relaying their messages to the Big Brother watch channel."""
- await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+ await ctx.send_help(ctx.command)
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index 15af7e34d..896a7b404 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool")
+ await ctx.send_help(ctx.command)
@nomination_group.command(name='watched', aliases=('all', 'list'))
@with_role(*MODERATION_ROLES)
@@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@with_role(*MODERATION_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
- await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit")
+ await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
@with_role(*MODERATION_ROLES)
diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py
index 49692113d..1b5c3f821 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/cogs/webhook_remover.py
@@ -54,6 +54,8 @@ class WebhookRemover(Cog):
channel_id=Channels.mod_alerts
)
+ self.bot.stats.incr("tokens.removed_webhooks")
+
@Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Check if a Discord webhook URL is in `message`."""
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index 5d6b4630b..e6cae3bb8 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -60,6 +60,14 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
A list of roles may be provided to ignore the per-user cooldown
"""
async def predicate(ctx: Context) -> bool:
+ if ctx.invoked_with == 'help':
+ # if the invoked command is help we don't want to increase the ratelimits since it's not actually
+ # invoking the command/making a request, so instead just check if the user/guild are on cooldown.
+ guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
+ if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
+ return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
+ return guild_cooldown
+
user_bucket = usercd.get_bucket(ctx.message)
if all(role.id not in ignore for role in ctx.author.roles):
diff --git a/bot/constants.py b/bot/constants.py
index 549e69c8f..fd280e9de 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -350,12 +350,21 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+class Stats(metaclass=YAMLGetter):
+ section = "bot"
+ subsection = "stats"
+
+ presence_update_timeout: int
+ statsd_host: str
+
class Categories(metaclass=YAMLGetter):
section = "guild"
subsection = "categories"
- python_help: int
+ help_available: int
+ help_in_use: int
+ help_dormant: int
class Channels(metaclass=YAMLGetter):
@@ -373,15 +382,8 @@ class Channels(metaclass=YAMLGetter):
dev_core: int
dev_log: int
esoteric: int
- help_0: int
- help_1: int
- help_2: int
- help_3: int
- help_4: int
- help_5: int
- help_6: int
- help_7: int
helpers: int
+ how_to_get_help: int
message_log: int
meta: int
mod_alerts: int
@@ -420,6 +422,7 @@ class Roles(metaclass=YAMLGetter):
announcements: int
contributors: int
core_developers: int
+ help_cooldown: int
helpers: int
jammers: int
moderators: int
@@ -531,11 +534,20 @@ class Free(metaclass=YAMLGetter):
cooldown_per: float
-class Mention(metaclass=YAMLGetter):
- section = 'mention'
+class HelpChannels(metaclass=YAMLGetter):
+ section = 'help_channels'
- message_timeout: int
- reset_delay: int
+ enable: bool
+ claim_minutes: int
+ cmd_whitelist: List[int]
+ idle_minutes: int
+ max_available: int
+ max_total_channels: int
+ name_prefix: str
+ notify: bool
+ notify_channel: int
+ notify_minutes: int
+ notify_roles: List[int]
class RedirectOutput(metaclass=YAMLGetter):
@@ -552,6 +564,14 @@ class Sync(metaclass=YAMLGetter):
max_diff: int
+class PythonNews(metaclass=YAMLGetter):
+ section = 'python_news'
+
+ mail_lists: List[str]
+ channel: int
+ webhook: int
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
diff --git a/bot/decorators.py b/bot/decorators.py
index 2d18eaa6a..306f0830c 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,9 +1,9 @@
import logging
import random
-from asyncio import Lock, sleep
+from asyncio import Lock, create_task, sleep
from contextlib import suppress
from functools import wraps
-from typing import Callable, Container, Union
+from typing import Callable, Container, Optional, Union
from weakref import WeakValueDictionary
from discord import Colour, Embed, Member
@@ -11,54 +11,79 @@ from discord.errors import NotFound
from discord.ext import commands
from discord.ext.commands import CheckFailure, Cog, Context
-from bot.constants import ERROR_REPLIES, RedirectOutput
+from bot.constants import Channels, ERROR_REPLIES, RedirectOutput
from bot.utils.checks import with_role_check, without_role_check
log = logging.getLogger(__name__)
-class InChannelCheckFailure(CheckFailure):
- """Raised when a check fails for a message being sent in a whitelisted channel."""
+class InWhitelistCheckFailure(CheckFailure):
+ """Raised when the `in_whitelist` check fails."""
- def __init__(self, *channels: int):
- self.channels = channels
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
+ def __init__(self, redirect_channel: Optional[int]) -> None:
+ self.redirect_channel = redirect_channel
- super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
+ if redirect_channel:
+ redirect_message = f" here. Please use the <#{redirect_channel}> channel instead"
+ else:
+ redirect_message = ""
+ error_message = f"You are not allowed to use that command{redirect_message}."
+
+ super().__init__(error_message)
+
+
+def in_whitelist(
+ *,
+ channels: Container[int] = (),
+ categories: Container[int] = (),
+ roles: Container[int] = (),
+ redirect: Optional[int] = Channels.bot_commands,
-def in_channel(
- *channels: int,
- hidden_channels: Container[int] = None,
- bypass_roles: Container[int] = None
) -> Callable:
"""
- Checks that the message is in a whitelisted channel or optionally has a bypass role.
+ Check if a command was issued in a whitelisted context.
+
+ The whitelists that can be provided are:
+
+ - `channels`: a container with channel ids for whitelisted channels
+ - `categories`: a container with category ids for whitelisted categories
+ - `roles`: a container with with role ids for whitelisted roles
- Hidden channels are channels which will not be displayed in the InChannelCheckFailure error
- message.
+ If the command was invoked in a context that was not whitelisted, the member is either
+ redirected to the `redirect` channel that was passed (default: #bot-commands) or simply
+ told that they're not allowed to use this particular command (if `None` was passed).
"""
- hidden_channels = hidden_channels or []
- bypass_roles = bypass_roles or []
+ if redirect and redirect not in channels:
+ # It does not make sense for the channel whitelist to not contain the redirection
+ # channel (if applicable). That's why we add the redirection channel to the `channels`
+ # container if it's not already in it. As we allow any container type to be passed,
+ # we first create a tuple in order to safely add the redirection channel.
+ #
+ # Note: It's possible for the redirect channel to be in a whitelisted category, but
+ # there's no easy way to check that and as a channel can easily be moved in and out of
+ # categories, it's probably not wise to rely on its category in any case.
+ channels = tuple(channels) + (redirect,)
def predicate(ctx: Context) -> bool:
- """In-channel checker predicate."""
- if ctx.channel.id in channels or ctx.channel.id in hidden_channels:
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The command was used in a whitelisted channel.")
+ """Check if a command was issued in a whitelisted context."""
+ if channels and ctx.channel.id in channels:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted channel.")
return True
- if bypass_roles:
- if any(r.id in bypass_roles for r in ctx.author.roles):
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The command was not used in a whitelisted channel, "
- f"but the author had a role to bypass the in_channel check.")
- return True
+ # Only check the category id if we have a category whitelist and the channel has a `category_id`
+ if categories and hasattr(ctx.channel, "category_id") and ctx.channel.category_id in categories:
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they are in a whitelisted category.")
+ return True
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The in_channel check failed.")
+ # Only check the roles whitelist if we have one and ensure the author's roles attribute returns
+ # an iterable to prevent breakage in DM channels (for if we ever decide to enable commands there).
+ if roles and any(r.id in roles for r in getattr(ctx.author, "roles", ())):
+ log.trace(f"{ctx.author} may use the `{ctx.command.name}` command as they have a whitelisted role.")
+ return True
- raise InChannelCheckFailure(*channels)
+ log.trace(f"{ctx.author} may not use the `{ctx.command.name}` command within this context.")
+ raise InWhitelistCheckFailure(redirect)
return commands.check(predicate)
@@ -137,13 +162,12 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
ctx.channel = redirect_channel
await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
- await func(self, ctx, *args, **kwargs)
+ create_task(func(self, ctx, *args, **kwargs))
message = await old_channel.send(
f"Hey, {ctx.author.mention}, you can find the output of your command here: "
f"{redirect_channel.mention}"
)
-
if RedirectOutput.delete_invocation:
await sleep(RedirectOutput.delete_delay)
@@ -154,6 +178,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
with suppress(NotFound):
await ctx.message.delete()
log.trace("Redirect output: Deleted invocation message")
+
return inner
return wrap
diff --git a/bot/pagination.py b/bot/pagination.py
index 90c8f849c..b0c4b70e2 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -102,7 +102,7 @@ class LinePaginator(Paginator):
timeout: int = 300,
footer_text: str = None,
url: str = None,
- exception_on_empty_embed: bool = False
+ exception_on_empty_embed: bool = False,
) -> t.Optional[discord.Message]:
"""
Use a paginator and set of reactions to provide pagination over a set of lines.
diff --git a/bot/resources/elements.json b/bot/resources/elements.json
new file mode 100644
index 000000000..2dc9b6fd6
--- /dev/null
+++ b/bot/resources/elements.json
@@ -0,0 +1,120 @@
+[
+ "hydrogen",
+ "helium",
+ "lithium",
+ "beryllium",
+ "boron",
+ "carbon",
+ "nitrogen",
+ "oxygen",
+ "fluorine",
+ "neon",
+ "sodium",
+ "magnesium",
+ "aluminium",
+ "silicon",
+ "phosphorus",
+ "sulfur",
+ "chlorine",
+ "argon",
+ "potassium",
+ "calcium",
+ "scandium",
+ "titanium",
+ "vanadium",
+ "chromium",
+ "manganese",
+ "iron",
+ "cobalt",
+ "nickel",
+ "copper",
+ "zinc",
+ "gallium",
+ "germanium",
+ "arsenic",
+ "selenium",
+ "bromine",
+ "krypton",
+ "rubidium",
+ "strontium",
+ "yttrium",
+ "zirconium",
+ "niobium",
+ "molybdenum",
+ "technetium",
+ "ruthenium",
+ "rhodium",
+ "palladium",
+ "silver",
+ "cadmium",
+ "indium",
+ "tin",
+ "antimony",
+ "tellurium",
+ "iodine",
+ "xenon",
+ "caesium",
+ "barium",
+ "lanthanum",
+ "cerium",
+ "praseodymium",
+ "neodymium",
+ "promethium",
+ "samarium",
+ "europium",
+ "gadolinium",
+ "terbium",
+ "dysprosium",
+ "holmium",
+ "erbium",
+ "thulium",
+ "ytterbium",
+ "lutetium",
+ "hafnium",
+ "tantalum",
+ "tungsten",
+ "rhenium",
+ "osmium",
+ "iridium",
+ "platinum",
+ "gold",
+ "mercury",
+ "thallium",
+ "lead",
+ "bismuth",
+ "polonium",
+ "astatine",
+ "radon",
+ "francium",
+ "radium",
+ "actinium",
+ "thorium",
+ "protactinium",
+ "uranium",
+ "neptunium",
+ "plutonium",
+ "americium",
+ "curium",
+ "berkelium",
+ "californium",
+ "einsteinium",
+ "fermium",
+ "mendelevium",
+ "nobelium",
+ "lawrencium",
+ "rutherfordium",
+ "dubnium",
+ "seaborgium",
+ "bohrium",
+ "hassium",
+ "meitnerium",
+ "darmstadtium",
+ "roentgenium",
+ "copernicium",
+ "nihonium",
+ "flerovium",
+ "moscovium",
+ "livermorium",
+ "tennessine",
+ "oganesson"
+]
diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md
new file mode 100644
index 000000000..582cca9da
--- /dev/null
+++ b/bot/resources/tags/free.md
@@ -0,0 +1,5 @@
+**We have a new help channel system!**
+
+We recently moved to a new help channel system. You can now use any channel in the **<#691405807388196926>** category to ask your question.
+
+For more information, check out [our website](https://pythondiscord.com/pages/resources/guides/help-channels/).
diff --git a/bot/resources/tags/mutability.md b/bot/resources/tags/mutability.md
new file mode 100644
index 000000000..bde9b5e7e
--- /dev/null
+++ b/bot/resources/tags/mutability.md
@@ -0,0 +1,37 @@
+**Mutable vs immutable objects**
+
+Imagine that you want to make all letters in a string upper case. Conveniently, strings have an `.upper()` method.
+
+You might think that this would work:
+```python
+>>> greeting = "hello"
+>>> greeting.upper()
+'HELLO'
+>>> greeting
+'hello'
+```
+
+`greeting` didn't change. Why is that so?
+
+That's because strings in Python are _immutable_. You can't change them, you can only pass around existing strings or create new ones.
+
+```python
+>>> greeting = "hello"
+>>> greeting = greeting.upper()
+>>> greeting
+'HELLO'
+```
+
+`greeting.upper()` creates and returns a new string which is like the old one, but with all the letters turned to upper case.
+
+`int`, `float`, `complex`, `tuple`, `frozenset` are other examples of immutable data types in Python.
+
+Mutable data types like `list`, on the other hand, can be changed in-place:
+```python
+>>> my_list = [1, 2, 3]
+>>> my_list.append(4)
+>>> my_list
+[1, 2, 3, 4]
+```
+
+Other examples of mutable data types in Python are `dict` and `set`. Instances of user-defined classes are also mutable.
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 5760ec2d4..8b778a093 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -51,20 +51,32 @@ class Scheduler(metaclass=CogABCMeta):
self._scheduled_tasks[task_id] = task
log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.")
- def cancel_task(self, task_id: t.Hashable) -> None:
- """Unschedule the task identified by `task_id`."""
+ def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None:
+ """
+ Unschedule the task identified by `task_id`.
+
+ If `ignore_missing` is True, a warning will not be sent if a task isn't found.
+ """
log.trace(f"{self.cog_name}: cancelling task #{task_id}...")
task = self._scheduled_tasks.get(task_id)
if not task:
- log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")
+ if not ignore_missing:
+ log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")
return
- task.cancel()
del self._scheduled_tasks[task_id]
+ task.cancel()
log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.")
+ def cancel_all(self) -> None:
+ """Unschedule all known tasks."""
+ log.debug(f"{self.cog_name}: unscheduling all tasks")
+
+ for task_id in self._scheduled_tasks.copy():
+ self.cancel_task(task_id, ignore_missing=True)
+
def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None:
"""
Delete the task and raise its exception if one exists.
@@ -98,6 +110,6 @@ class Scheduler(metaclass=CogABCMeta):
# Log the exception if one exists.
if exception:
log.error(
- f"{self.cog_name}: error in task #{task_id} {id(scheduled_task)}!",
+ f"{self.cog_name}: error in task #{task_id} {id(done_task)}!",
exc_info=exception
)
diff --git a/config-default.yml b/config-default.yml
index a9578d9bb..c0b5b062f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -3,6 +3,10 @@ bot:
token: !ENV "BOT_TOKEN"
sentry_dsn: !ENV "BOT_SENTRY_DSN"
+ stats:
+ statsd_host: "graphite"
+ presence_update_timeout: 300
+
cooldowns:
# Per channel, per tag.
tags: 60
@@ -111,11 +115,14 @@ guild:
id: 267624335836053506
categories:
- python_help: 356013061213126657
+ help_available: 691405807388196926
+ help_in_use: 696958401460043776
+ help_dormant: 691405908919451718
channels:
announcements: 354619224620138496
user_event_announcements: &USER_EVENT_A 592000283102674944
+ python_news: &PYNEWS_CHANNEL 704372456592506880
# Development
dev_contrib: &DEV_CONTRIB 635950537262759947
@@ -126,6 +133,9 @@ guild:
meta: 429409067623251969
python_discussion: 267624335836053506
+ # Python Help: Available
+ how_to_get_help: 704250143020417084
+
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
message_log: &MESSAGE_LOG 467752170159079424
@@ -138,16 +148,6 @@ guild:
off_topic_1: 463035241142026251
off_topic_2: 463035268514185226
- # Python Help
- help_0: 303906576991780866
- help_1: 303906556754395136
- help_2: 303906514266226689
- help_3: 439702951246692352
- help_4: 451312046647148554
- help_5: 454941769734422538
- help_6: 587375753306570782
- help_7: 587375768556797982
-
# Special
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
@@ -205,6 +205,7 @@ guild:
roles:
announcements: 463658397560995840
contributors: 295488872404484098
+ help_cooldown: 699189276025421825
muted: &MUTED_ROLE 277914926603829249
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
@@ -235,11 +236,12 @@ guild:
- *HELPERS_ROLE
webhooks:
- talent_pool: 569145364800602132
- big_brother: 569133704568373283
- reddit: 635408384794951680
- duck_pond: 637821475327311927
- dev_log: 680501655111729222
+ talent_pool: 569145364800602132
+ big_brother: 569133704568373283
+ reddit: 635408384794951680
+ duck_pond: 637821475327311927
+ dev_log: 680501655111729222
+ python_news: &PYNEWS_WEBHOOK 704381182279942324
filter:
@@ -263,7 +265,8 @@ filter:
guild_invite_whitelist:
- 280033776820813825 # Functional Programming
- 267624335836053506 # Python Discord
- - 440186186024222721 # Python Discord: ModLog Emojis
+ - 440186186024222721 # Python Discord: Emojis 1
+ - 578587418123304970 # Python Discord: Emojis 2
- 273944235143593984 # STEM
- 348658686962696195 # RLBot
- 531221516914917387 # Pallets
@@ -280,6 +283,11 @@ filter:
- 336642139381301249 # discord.py
- 405403391410438165 # Sentdex
- 172018499005317120 # The Coding Den
+ - 666560367173828639 # PyWeek
+ - 702724176489873509 # Microsoft Python
+ - 81384788765712384 # Discord API
+ - 613425648685547541 # Discord Developers
+ - 185590609631903755 # Blender Hub
domain_blacklist:
- pornhub.com
@@ -308,6 +316,8 @@ filter:
- poweredbydialup.online
- poweredbysecurity.org
- poweredbysecurity.online
+ - ssteam.site
+ - steamwalletgift.com
word_watchlist:
- goo+ks*
@@ -479,7 +489,6 @@ anti_malware:
- '.mp3'
- '.wav'
- '.ogg'
- - '.md'
reddit:
@@ -508,9 +517,42 @@ free:
cooldown_rate: 1
cooldown_per: 60.0
-mention:
- message_timeout: 300
- reset_delay: 5
+
+help_channels:
+ enable: true
+
+ # Minimum interval before allowing a certain user to claim a new help channel
+ claim_minutes: 15
+
+ # Roles which are allowed to use the command which makes channels dormant
+ cmd_whitelist:
+ - *HELPERS_ROLE
+
+ # Allowed duration of inactivity before making a channel dormant
+ idle_minutes: 30
+
+ # Maximum number of channels to put in the available category
+ max_available: 2
+
+ # Maximum number of channels across all 3 categories
+ # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50
+ max_total_channels: 32
+
+ # Prefix for help channel names
+ name_prefix: 'help-'
+
+ # Notify if more available channels are needed but there are no more dormant ones
+ notify: true
+
+ # Channel in which to send notifications
+ notify_channel: *HELPERS
+
+ # Minimum interval between helper notifications
+ notify_minutes: 15
+
+ # Mention these roles in notifications
+ notify_roles:
+ - *HELPERS_ROLE
redirect_output:
delete_invocation: true
@@ -537,5 +579,13 @@ duck_pond:
- *DUCKY_MAUL
- *DUCKY_SANTA
+python_news:
+ mail_lists:
+ - 'python-ideas'
+ - 'python-announce-list'
+ - 'pypi-announce'
+ channel: *PYNEWS_CHANNEL
+ webhook: *PYNEWS_WEBHOOK
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py
index 6ee9dfda6..70aea2bab 100644
--- a/tests/bot/cogs/sync/test_base.py
+++ b/tests/bot/cogs/sync/test_base.py
@@ -1,3 +1,4 @@
+import asyncio
import unittest
from unittest import mock
@@ -211,7 +212,7 @@ class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase):
subtests = (
(constants.Emojis.check_mark, True, None),
("InVaLiD", False, None),
- (None, False, TimeoutError),
+ (None, False, asyncio.TimeoutError),
)
for emoji, ret_val, side_effect in subtests:
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py
index 39f6492cb..fdda59a8f 100644
--- a/tests/bot/cogs/test_cogs.py
+++ b/tests/bot/cogs/test_cogs.py
@@ -31,7 +31,7 @@ class CommandNameTests(unittest.TestCase):
def walk_modules() -> t.Iterator[ModuleType]:
"""Yield imported modules from the bot.cogs subpackage."""
def on_error(name: str) -> t.NoReturn:
- raise ImportError(name=name)
+ raise ImportError(name=name) # pragma: no cover
# The mock prevents asyncio.get_event_loop() from being called.
with mock.patch("discord.ext.tasks.loop"):
@@ -71,7 +71,7 @@ class CommandNameTests(unittest.TestCase):
for name in self.get_qualified_names(cmd):
with self.subTest(cmd=func_name, name=name):
- if name in all_names:
+ if name in all_names: # pragma: no cover
conflicts = ", ".join(all_names.get(name, ""))
self.fail(
f"Name '{name}' of the command {func_name} conflicts with {conflicts}."
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index 3c26374f5..b5f928dd6 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -7,7 +7,7 @@ import discord
from bot import constants
from bot.cogs import information
-from bot.decorators import InChannelCheckFailure
+from bot.decorators import InWhitelistCheckFailure
from tests import helpers
@@ -485,7 +485,7 @@ class UserEmbedTests(unittest.TestCase):
user.avatar_url_as.return_value = "avatar url"
embed = asyncio.run(self.cog.create_user_embed(ctx, user))
- user.avatar_url_as.assert_called_once_with(format="png")
+ user.avatar_url_as.assert_called_once_with(static_format="png")
self.assertEqual(embed.thumbnail.url, "avatar url")
@@ -525,7 +525,7 @@ class UserCommandTests(unittest.TestCase):
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))
msg = "Sorry, but you may only use this command within <#50>."
- with self.assertRaises(InChannelCheckFailure, msg=msg):
+ with self.assertRaises(InWhitelistCheckFailure, msg=msg):
asyncio.run(self.cog.user_info.callback(self.cog, ctx))
@unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
index 1dec0ccaf..14299e766 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -208,10 +208,9 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
async def test_eval_command_call_help(self):
"""Test if the eval command call the help command if no code is provided."""
- ctx = MockContext()
- ctx.invoke = AsyncMock()
+ ctx = MockContext(command="sentinel")
await self.cog.eval_command.callback(self.cog, ctx=ctx, code='')
- ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval")
+ ctx.send_help.assert_called_once_with("sentinel")
async def test_send_eval(self):
"""Test the send_eval function."""
@@ -291,7 +290,11 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(actual, expected)
self.bot.wait_for.assert_has_awaits(
(
- call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10),
+ call(
+ 'message_edit',
+ check=partial_mock(snekbox.predicate_eval_message_edit, ctx),
+ timeout=snekbox.REEVAL_TIMEOUT,
+ ),
call('reaction_add', check=partial_mock(snekbox.predicate_eval_emoji_reaction, ctx), timeout=10)
)
)
diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py
new file mode 100644
index 000000000..a17dd3e16
--- /dev/null
+++ b/tests/bot/test_decorators.py
@@ -0,0 +1,147 @@
+import collections
+import unittest
+import unittest.mock
+
+from bot import constants
+from bot.decorators import InWhitelistCheckFailure, in_whitelist
+from tests import helpers
+
+
+InWhitelistTestCase = collections.namedtuple("WhitelistedContextTestCase", ("kwargs", "ctx", "description"))
+
+
+class InWhitelistTests(unittest.TestCase):
+ """Tests for the `in_whitelist` check."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up helpers that only need to be defined once."""
+ cls.bot_commands = helpers.MockTextChannel(id=123456789, category_id=123456)
+ cls.help_channel = helpers.MockTextChannel(id=987654321, category_id=987654)
+ cls.non_whitelisted_channel = helpers.MockTextChannel(id=666666)
+ cls.dm_channel = helpers.MockDMChannel()
+
+ cls.non_staff_member = helpers.MockMember()
+ cls.staff_role = helpers.MockRole(id=121212)
+ cls.staff_member = helpers.MockMember(roles=(cls.staff_role,))
+
+ cls.channels = (cls.bot_commands.id,)
+ cls.categories = (cls.help_channel.category_id,)
+ cls.roles = (cls.staff_role.id,)
+
+ def test_predicate_returns_true_for_whitelisted_context(self):
+ """The predicate should return `True` if a whitelisted context was passed to it."""
+ test_cases = (
+ InWhitelistTestCase(
+ kwargs={"channels": self.channels},
+ ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member),
+ description="In whitelisted channels by members without whitelisted roles",
+ ),
+ InWhitelistTestCase(
+ kwargs={"redirect": self.bot_commands.id},
+ ctx=helpers.MockContext(channel=self.bot_commands, author=self.non_staff_member),
+ description="`redirect` should be implicitly added to `channels`",
+ ),
+ InWhitelistTestCase(
+ kwargs={"categories": self.categories},
+ ctx=helpers.MockContext(channel=self.help_channel, author=self.non_staff_member),
+ description="Whitelisted category without whitelisted role",
+ ),
+ InWhitelistTestCase(
+ kwargs={"roles": self.roles},
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.staff_member),
+ description="Whitelisted role outside of whitelisted channel/category"
+ ),
+ InWhitelistTestCase(
+ kwargs={
+ "channels": self.channels,
+ "categories": self.categories,
+ "roles": self.roles,
+ "redirect": self.bot_commands,
+ },
+ ctx=helpers.MockContext(channel=self.help_channel, author=self.staff_member),
+ description="Case with all whitelist kwargs used",
+ ),
+ )
+
+ for test_case in test_cases:
+ # patch `commands.check` with a no-op lambda that just returns the predicate passed to it
+ # so we can test the predicate that was generated from the specified kwargs.
+ with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate):
+ predicate = in_whitelist(**test_case.kwargs)
+
+ with self.subTest(test_description=test_case.description):
+ self.assertTrue(predicate(test_case.ctx))
+
+ def test_predicate_raises_exception_for_non_whitelisted_context(self):
+ """The predicate should raise `InWhitelistCheckFailure` for a non-whitelisted context."""
+ test_cases = (
+ # Failing check with explicit `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": self.bot_commands.id,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check with an explicit redirect channel",
+ ),
+
+ # Failing check with implicit `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check with an implicit redirect channel",
+ ),
+
+ # Failing check without `redirect`
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": None,
+ },
+ ctx=helpers.MockContext(channel=self.non_whitelisted_channel, author=self.non_staff_member),
+ description="Failing check without a redirect channel",
+ ),
+
+ # Command issued in DM channel
+ InWhitelistTestCase(
+ kwargs={
+ "categories": self.categories,
+ "channels": self.channels,
+ "roles": self.roles,
+ "redirect": None,
+ },
+ ctx=helpers.MockContext(channel=self.dm_channel, author=self.dm_channel.me),
+ description="Commands issued in DM channel should be rejected",
+ ),
+ )
+
+ for test_case in test_cases:
+ if "redirect" not in test_case.kwargs or test_case.kwargs["redirect"] is not None:
+ # There are two cases in which we have a redirect channel:
+ # 1. No redirect channel was passed; the default value of `bot_commands` is used
+ # 2. An explicit `redirect` is set that is "not None"
+ redirect_channel = test_case.kwargs.get("redirect", constants.Channels.bot_commands)
+ redirect_message = f" here. Please use the <#{redirect_channel}> channel instead"
+ else:
+ # If an explicit `None` was passed for `redirect`, there is no redirect channel
+ redirect_message = ""
+
+ exception_message = f"You are not allowed to use that command{redirect_message}."
+
+ # patch `commands.check` with a no-op lambda that just returns the predicate passed to it
+ # so we can test the predicate that was generated from the specified kwargs.
+ with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate):
+ predicate = in_whitelist(**test_case.kwargs)
+
+ with self.subTest(test_description=test_case.description):
+ with self.assertRaisesRegex(InWhitelistCheckFailure, exception_message):
+ predicate(test_case.ctx)
diff --git a/tests/helpers.py b/tests/helpers.py
index 227bac95f..91d814b3a 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -319,7 +319,7 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
spec_set = channel_instance
- def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None:
+ def __init__(self, **kwargs) -> None:
default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
@@ -327,6 +327,27 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
self.mention = f"#{self.name}"
+# Create data for the DMChannel instance
+state = unittest.mock.MagicMock()
+me = unittest.mock.MagicMock()
+dm_channel_data = {"id": 1, "recipients": [unittest.mock.MagicMock()]}
+dm_channel_instance = discord.DMChannel(me=me, state=state, data=dm_channel_data)
+
+
+class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ """
+ A MagicMock subclass to mock TextChannel objects.
+
+ Instances of this class will follow the specifications of `discord.TextChannel` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ spec_set = dm_channel_instance
+
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id), 'recipient': MockUser(), "me": MockUser()}
+ super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+
+
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
'id': 1,