aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Matteo Bertucci <[email protected]>2019-11-29 09:14:05 +0100
committerGravatar GitHub <[email protected]>2019-11-29 09:14:05 +0100
commit2f654131925f844218044c3a84e22e96ea65ac9f (patch)
tree0f43dd416f4e11d5ed89f1067c57ddc2af8bcce1
parentRe-upload attachments to #attachment-log (diff)
parentRelock to d.py 1.2.5 due to API breaking change for emoji. (diff)
Merge branch 'master' into #549-show-attachments-staff
-rw-r--r--Pipfile.lock175
-rw-r--r--bot/cogs/doc.py214
-rw-r--r--bot/cogs/eval.py4
-rw-r--r--bot/cogs/filtering.py8
-rw-r--r--bot/cogs/information.py178
-rw-r--r--bot/cogs/moderation/infractions.py422
-rw-r--r--bot/cogs/moderation/scheduler.py413
-rw-r--r--bot/cogs/moderation/superstarify.py301
-rw-r--r--bot/cogs/moderation/utils.py21
-rw-r--r--bot/cogs/off_topic_names.py16
-rw-r--r--bot/cogs/reddit.py11
-rw-r--r--bot/cogs/site.py4
-rw-r--r--bot/cogs/verification.py75
-rw-r--r--bot/cogs/watchchannels/bigbrother.py10
-rw-r--r--bot/cogs/watchchannels/talentpool.py17
-rw-r--r--bot/constants.py7
-rw-r--r--bot/interpreter.py4
-rw-r--r--bot/utils/scheduling.py6
-rw-r--r--config-default.yml7
-rw-r--r--tests/bot/cogs/test_information.py448
-rw-r--r--tests/bot/cogs/test_token_remover.py2
-rw-r--r--tests/bot/rules/test_links.py101
-rw-r--r--tests/bot/test_api.py4
-rw-r--r--tests/bot/test_utils.py52
-rw-r--r--tests/bot/utils/test_checks.py6
-rw-r--r--tests/helpers.py391
-rw-r--r--tests/test_helpers.py163
27 files changed, 2005 insertions, 1055 deletions
diff --git a/Pipfile.lock b/Pipfile.lock
index 95955ff89..69caf4646 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0",
- "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a"
+ "sha256:1da038b3d2c1b49e0e816d87424e702912bb77f9b5197f2bf279217915b4f7ed",
+ "sha256:29fe851374b86c997a22174c04352b5941bc1c2e36bbf542918ac18a76cfc9d3"
],
"index": "pypi",
- "version": "==6.1.3"
+ "version": "==6.3.0"
},
"aiodns": {
"hashes": [
@@ -62,10 +62,10 @@
},
"aiormq": {
"hashes": [
- "sha256:c3e4dd01a2948a75f739fb637334dbb8c6f1a4cecf74d5ed662dc3bab7f39973",
- "sha256:e220d3f9477bb2959b729b79bec815148ddb8a7686fc6c3d05d41c88ebd7c59e"
+ "sha256:afc0d46837b121585e4faec0a7646706429b4e2f5110ae8d0b5cdc3708b4b0e5",
+ "sha256:dc0fbbc7f8ad5af6a2cc18e00ccc5f925984cde3db6e8fe952c07b7ef157b5f2"
],
- "version": "==2.8.0"
+ "version": "==2.9.1"
},
"alabaster": {
"hashes": [
@@ -83,10 +83,10 @@
},
"attrs": {
"hashes": [
- "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2",
- "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396"
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
- "version": "==19.2.0"
+ "version": "==19.3.0"
},
"babel": {
"hashes": [
@@ -112,36 +112,41 @@
},
"cffi": {
"hashes": [
- "sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774",
- "sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d",
- "sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90",
- "sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b",
- "sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63",
- "sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45",
- "sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25",
- "sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3",
- "sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b",
- "sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647",
- "sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016",
- "sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4",
- "sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb",
- "sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753",
- "sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7",
- "sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9",
- "sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f",
- "sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8",
- "sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f",
- "sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc",
- "sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42",
- "sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3",
- "sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909",
- "sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45",
- "sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d",
- "sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512",
- "sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff",
- "sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201"
- ],
- "version": "==1.12.3"
+ "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42",
+ "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04",
+ "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5",
+ "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54",
+ "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba",
+ "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57",
+ "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396",
+ "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12",
+ "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97",
+ "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43",
+ "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db",
+ "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3",
+ "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b",
+ "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579",
+ "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346",
+ "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159",
+ "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652",
+ "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e",
+ "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a",
+ "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506",
+ "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f",
+ "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d",
+ "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c",
+ "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20",
+ "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858",
+ "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc",
+ "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a",
+ "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3",
+ "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e",
+ "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410",
+ "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25",
+ "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b",
+ "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d"
+ ],
+ "version": "==1.13.2"
},
"chardet": {
"hashes": [
@@ -152,18 +157,18 @@
},
"deepdiff": {
"hashes": [
- "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476",
- "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127"
+ "sha256:3457ea7cecd51ba48015d89edbb569358af4d9b9e65e28bdb3209608420627f9",
+ "sha256:5e2343398e90538edaa59c0c99207e996a3a834fdc878c666376f632a760c35a"
],
"index": "pypi",
- "version": "==4.0.7"
+ "version": "==4.0.9"
},
"discord-py": {
"hashes": [
- "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
+ "sha256:7c843b523bb011062b453864e75c7b675a03faf573c58d14c9f096e85984329d"
],
"index": "pypi",
- "version": "==1.2.3"
+ "version": "==1.2.5"
},
"docutils": {
"hashes": [
@@ -221,6 +226,7 @@
"sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4",
"sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc",
"sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1",
+ "sha256:1409b14bf83a7d729f92e2a7fbfe7ec929d4883ca071b06e95c539ceedb6497c",
"sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046",
"sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36",
"sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5",
@@ -231,11 +237,14 @@
"sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc",
"sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7",
"sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38",
+ "sha256:9277562f175d2334744ad297568677056861070399cec56ff06abbe2564d1232",
"sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5",
"sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832",
+ "sha256:ae88588d687bd476be588010cbbe551e9c2872b816f2da8f01f6f1fda74e1ef0",
"sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a",
"sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f",
"sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9",
+ "sha256:c7fccd08b14aa437fe096c71c645c0f9be0655a9b1a4b7cffc77bcb23b3d61d2",
"sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692",
"sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84",
"sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79",
@@ -379,18 +388,18 @@
},
"pyparsing": {
"hashes": [
- "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
- "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
+ "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f",
+ "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"
],
- "version": "==2.4.2"
+ "version": "==2.4.5"
},
"python-dateutil": {
"hashes": [
- "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb",
- "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"
+ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
+ "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"index": "pypi",
- "version": "==2.8.0"
+ "version": "==2.8.1"
},
"python-json-logger": {
"hashes": [
@@ -434,10 +443,10 @@
},
"six": {
"hashes": [
- "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
- "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+ "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
+ "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
],
- "version": "==1.12.0"
+ "version": "==1.13.0"
},
"snowballstemmer": {
"hashes": [
@@ -448,18 +457,18 @@
},
"soupsieve": {
"hashes": [
- "sha256:605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3",
- "sha256:b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6"
+ "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5",
+ "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda"
],
- "version": "==1.9.4"
+ "version": "==1.9.5"
},
"sphinx": {
"hashes": [
- "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845",
- "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069"
+ "sha256:31088dfb95359384b1005619827eaee3056243798c62724fd3fa4b84ee4d71bd",
+ "sha256:52286a0b9d7caa31efee301ec4300dbdab23c3b05da1c9024b4e84896fb73d79"
],
"index": "pypi",
- "version": "==2.2.0"
+ "version": "==2.2.1"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -564,10 +573,10 @@
},
"attrs": {
"hashes": [
- "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2",
- "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396"
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
- "version": "==19.2.0"
+ "version": "==19.3.0"
},
"certifi": {
"hashes": [
@@ -658,11 +667,11 @@
},
"flake8": {
"hashes": [
- "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
- "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
+ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
+ "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
- "version": "==3.7.8"
+ "version": "==3.7.9"
},
"flake8-annotations": {
"hashes": [
@@ -738,6 +747,7 @@
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
],
+ "markers": "python_version < '3.8'",
"version": "==0.23"
},
"mccabe": {
@@ -770,11 +780,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f",
- "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a"
+ "sha256:9f152687127ec90642a2cc3e4d9e1e6240c4eb153615cb02aa1ad41d331cbb6e",
+ "sha256:c2e4810d2d3102d354947907514a78c5d30424d299dc0fe48f5aa049826e9b50"
],
"index": "pypi",
- "version": "==1.18.3"
+ "version": "==1.20.0"
},
"pycodestyle": {
"hashes": [
@@ -799,10 +809,10 @@
},
"pyparsing": {
"hashes": [
- "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
- "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
+ "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f",
+ "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"
],
- "version": "==2.4.2"
+ "version": "==2.4.5"
},
"pyyaml": {
"hashes": [
@@ -841,10 +851,10 @@
},
"six": {
"hashes": [
- "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
- "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+ "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
+ "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
],
- "version": "==1.12.0"
+ "version": "==1.13.0"
},
"snowballstemmer": {
"hashes": [
@@ -862,31 +872,36 @@
},
"typed-ast": {
"hashes": [
+ "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161",
"sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
"sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
"sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
"sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
+ "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47",
"sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
"sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
"sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
"sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
+ "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2",
+ "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e",
"sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
"sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
"sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
"sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
"sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
"sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
+ "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66",
"sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
],
"version": "==1.4.0"
},
"unittest-xml-reporting": {
"hashes": [
- "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c",
- "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72"
+ "sha256:358bbdaf24a26d904cc1c26ef3078bca7fc81541e0a54c8961693cc96a6f35e0",
+ "sha256:9d28ddf6524cf0ff9293f61bd12e792de298f8561a5c945acea63fb437789e0e"
],
"index": "pypi",
- "version": "==2.5.1"
+ "version": "==2.5.2"
},
"urllib3": {
"hashes": [
@@ -898,10 +913,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
- "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
+ "sha256:11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589",
+ "sha256:d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136"
],
- "version": "==16.7.5"
+ "version": "==16.7.7"
},
"zipp": {
"hashes": [
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 65cabe46f..e5b3a4062 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -4,17 +4,20 @@ import logging
import re
import textwrap
from collections import OrderedDict
+from contextlib import suppress
from typing import Any, Callable, Optional, Tuple
import discord
from bs4 import BeautifulSoup
-from bs4.element import PageElement
+from bs4.element import PageElement, Tag
+from discord.errors import NotFound
from discord.ext import commands
from markdownify import MarkdownConverter
-from requests import ConnectionError
+from requests import ConnectTimeout, ConnectionError, HTTPError
from sphinx.ext import intersphinx
+from urllib3.exceptions import ProtocolError
-from bot.constants import MODERATION_ROLES
+from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -23,10 +26,33 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
logging.getLogger('urllib3').setLevel(logging.WARNING)
-
-UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶')
+NO_OVERRIDE_GROUPS = (
+ "2to3fixer",
+ "token",
+ "label",
+ "pdbcommand",
+ "term",
+)
+NO_OVERRIDE_PACKAGES = (
+ "python",
+)
+
+SEARCH_END_TAG_ATTRS = (
+ "data",
+ "function",
+ "class",
+ "exception",
+ "seealso",
+ "section",
+ "rubric",
+ "sphinxsidebar",
+)
+UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶")
WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
+FAILED_REQUEST_RETRY_AMOUNT = 3
+NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
+
def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
"""
@@ -125,6 +151,7 @@ class Doc(commands.Cog):
self.base_urls = {}
self.bot = bot
self.inventories = {}
+ self.renamed_symbols = set()
self.bot.loop.create_task(self.init_refresh_inventory())
@@ -150,13 +177,32 @@ class Doc(commands.Cog):
"""
self.base_urls[package_name] = base_url
- fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url)
- for _, value in (await self.bot.loop.run_in_executor(None, fetch_func)).items():
- # Each value has a bunch of information in the form
- # `(package_name, version, relative_url, ???)`, and we only
- # need the relative documentation URL.
- for symbol, (_, _, relative_doc_url, _) in value.items():
+ package = await self._fetch_inventory(inventory_url, config)
+ if not package:
+ return None
+
+ for group, value in package.items():
+ for symbol, (package_name, _version, relative_doc_url, _) in value.items():
absolute_doc_url = base_url + relative_doc_url
+
+ if symbol in self.inventories:
+ group_name = group.split(":")[1]
+ symbol_base_url = self.inventories[symbol].split("/", 3)[2]
+ if (
+ group_name in NO_OVERRIDE_GROUPS
+ or any(package in symbol_base_url for package in NO_OVERRIDE_PACKAGES)
+ ):
+
+ symbol = f"{group_name}.{symbol}"
+ # If renamed `symbol` already exists, add library name in front to differentiate between them.
+ if symbol in self.renamed_symbols:
+ # Split `package_name` because of packages like Pillow that have spaces in them.
+ symbol = f"{package_name.split()[0]}.{symbol}"
+
+ self.inventories[symbol] = absolute_doc_url
+ self.renamed_symbols.add(symbol)
+ continue
+
self.inventories[symbol] = absolute_doc_url
log.trace(f"Fetched inventory for {package_name}.")
@@ -170,6 +216,7 @@ class Doc(commands.Cog):
# Also, reset the cache used for fetching documentation.
self.base_urls.clear()
self.inventories.clear()
+ self.renamed_symbols.clear()
async_cache.cache = OrderedDict()
# Since Intersphinx is intended to be used with Sphinx,
@@ -185,16 +232,15 @@ class Doc(commands.Cog):
]
await asyncio.gather(*coros)
- async def get_symbol_html(self, symbol: str) -> Optional[Tuple[str, str]]:
+ async def get_symbol_html(self, symbol: str) -> Optional[Tuple[list, str]]:
"""
Given a Python symbol, return its signature and description.
- Returns a tuple in the form (str, str), or `None`.
-
The first tuple element is the signature of the given symbol as a markup-free string, and
the second tuple element is the description of the given symbol with HTML markup included.
- If the given symbol could not be found, returns `None`.
+ If the given symbol is a module, returns a tuple `(None, str)`
+ else if the symbol could not be found, returns `None`.
"""
url = self.inventories.get(symbol)
if url is None:
@@ -207,21 +253,38 @@ class Doc(commands.Cog):
symbol_id = url.split('#')[-1]
soup = BeautifulSoup(html, 'lxml')
symbol_heading = soup.find(id=symbol_id)
- signature_buffer = []
+ search_html = str(soup)
if symbol_heading is None:
return None
- # Traverse the tags of the signature header and ignore any
- # unwanted symbols from it. Add all of it to a temporary buffer.
- for tag in symbol_heading.strings:
- if tag not in UNWANTED_SIGNATURE_SYMBOLS:
- signature_buffer.append(tag.replace('\\', ''))
+ if symbol_id == f"module-{symbol}":
+ # Get page content from the module headerlink to the
+ # first tag that has its class in `SEARCH_END_TAG_ATTRS`
+ start_tag = symbol_heading.find("a", attrs={"class": "headerlink"})
+ if start_tag is None:
+ return [], ""
+
+ end_tag = start_tag.find_next(self._match_end_tag)
+ if end_tag is None:
+ return [], ""
+
+ description_start_index = search_html.find(str(start_tag.parent)) + len(str(start_tag.parent))
+ description_end_index = search_html.find(str(end_tag))
+ description = search_html[description_start_index:description_end_index]
+ signatures = None
- signature = ''.join(signature_buffer)
- description = str(symbol_heading.next_sibling.next_sibling).replace('¶', '')
+ else:
+ signatures = []
+ description = str(symbol_heading.find_next_sibling("dd"))
+ description_pos = search_html.find(description)
+ # Get text of up to 3 signatures, remove unwanted symbols
+ for element in [symbol_heading] + symbol_heading.find_next_siblings("dt", limit=2):
+ signature = UNWANTED_SIGNATURE_SYMBOLS_RE.sub("", element.text)
+ if signature and search_html.find(str(element)) < description_pos:
+ signatures.append(signature)
- return signature, description
+ return signatures, description.replace('¶', '')
@async_cache(arg_offset=1)
async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
@@ -234,7 +297,7 @@ class Doc(commands.Cog):
if scraped_html is None:
return None
- signature = scraped_html[0]
+ signatures = scraped_html[0]
permalink = self.inventories[symbol]
description = markdownify(scraped_html[1])
@@ -242,26 +305,42 @@ class Doc(commands.Cog):
# of a double newline (interpreted as a paragraph) before index 1000.
if len(description) > 1000:
shortened = description[:1000]
- last_paragraph_end = shortened.rfind('\n\n')
- description = description[:last_paragraph_end] + f"... [read more]({permalink})"
+ last_paragraph_end = shortened.rfind('\n\n', 100)
+ if last_paragraph_end == -1:
+ last_paragraph_end = shortened.rfind('. ')
+ description = description[:last_paragraph_end]
+
+ # If there is an incomplete code block, cut it out
+ if description.count("```") % 2:
+ codeblock_start = description.rfind('```py')
+ description = description[:codeblock_start].rstrip()
+ description += f"... [read more]({permalink})"
description = WHITESPACE_AFTER_NEWLINES_RE.sub('', description)
- if not signature:
+ if signatures is None:
+ # If symbol is a module, don't show signature.
+ embed_description = description
+
+ elif not signatures:
# It's some "meta-page", for example:
# https://docs.djangoproject.com/en/dev/ref/views/#module-django.views
- return discord.Embed(
- title=f'`{symbol}`',
- url=permalink,
- description="This appears to be a generic page not tied to a specific symbol."
- )
+ embed_description = "This appears to be a generic page not tied to a specific symbol."
- signature = textwrap.shorten(signature, 500)
- return discord.Embed(
+ else:
+ embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
+ embed_description += f"\n{description}"
+
+ embed = discord.Embed(
title=f'`{symbol}`',
url=permalink,
- description=f"```py\n{signature}```{description}"
+ description=embed_description
)
+ # Show all symbols with the same name that were renamed in the footer.
+ embed.set_footer(
+ text=", ".join(renamed for renamed in self.renamed_symbols - {symbol} if renamed.endswith(f".{symbol}"))
+ )
+ return embed
@commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
@@ -307,7 +386,10 @@ class Doc(commands.Cog):
description=f"Sorry, I could not find any documentation for `{symbol}`.",
colour=discord.Colour.red()
)
- await ctx.send(embed=error_embed)
+ error_message = await ctx.send(embed=error_embed)
+ with suppress(NotFound):
+ await error_message.delete(delay=NOT_FOUND_DELETE_DELAY)
+ await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
else:
await ctx.send(embed=doc_embed)
@@ -365,6 +447,64 @@ class Doc(commands.Cog):
await self.refresh_inventory()
await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
+ @docs_group.command(name="refresh", aliases=("rfsh", "r"))
+ @with_role(*MODERATION_ROLES)
+ async def refresh_command(self, ctx: commands.Context) -> None:
+ """Refresh inventories and send differences to channel."""
+ old_inventories = set(self.base_urls)
+ with ctx.typing():
+ await self.refresh_inventory()
+ # Get differences of added and removed inventories
+ added = ', '.join(inv for inv in self.base_urls if inv not in old_inventories)
+ if added:
+ added = f"+ {added}"
+
+ removed = ', '.join(inv for inv in old_inventories if inv not in self.base_urls)
+ if removed:
+ removed = f"- {removed}"
+
+ embed = discord.Embed(
+ title="Inventories refreshed",
+ description=f"```diff\n{added}\n{removed}```" if added or removed else ""
+ )
+ await ctx.send(embed=embed)
+
+ async def _fetch_inventory(self, inventory_url: str, config: SphinxConfiguration) -> Optional[dict]:
+ """Get and return inventory from `inventory_url`. If fetching fails, return None."""
+ fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url)
+ for retry in range(1, FAILED_REQUEST_RETRY_AMOUNT+1):
+ try:
+ package = await self.bot.loop.run_in_executor(None, fetch_func)
+ except ConnectTimeout:
+ log.error(
+ f"Fetching of inventory {inventory_url} timed out,"
+ f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
+ )
+ except ProtocolError:
+ log.error(
+ f"Connection lost while fetching inventory {inventory_url},"
+ f" trying again. ({retry}/{FAILED_REQUEST_RETRY_AMOUNT})"
+ )
+ except HTTPError as e:
+ log.error(f"Fetching of inventory {inventory_url} failed with status code {e.response.status_code}.")
+ return None
+ except ConnectionError:
+ log.error(f"Couldn't establish connection to inventory {inventory_url}.")
+ return None
+ else:
+ return package
+ log.error(f"Fetching of inventory {inventory_url} failed.")
+ return None
+
+ @staticmethod
+ def _match_end_tag(tag: Tag) -> bool:
+ """Matches `tag` if its class value is in `SEARCH_END_TAG_ATTRS` or the tag is table."""
+ for attr in SEARCH_END_TAG_ATTRS:
+ if attr in tag.get("class", ()):
+ return True
+
+ return tag.name == "table"
+
def setup(bot: commands.Bot) -> None:
"""Doc cog load."""
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 9ce854f2c..00b988dde 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -148,7 +148,7 @@ class CodeEval(Cog):
self.env.update(env)
# Ignore this code, it works
- _code = """
+ code_ = """
async def func(): # (None,) -> Any
try:
with contextlib.redirect_stdout(self.stdout):
@@ -162,7 +162,7 @@ async def func(): # (None,) -> Any
""".format(textwrap.indent(code, ' '))
try:
- exec(_code, self.env) # noqa: B102,S102
+ exec(code_, self.env) # noqa: B102,S102
func = self.env['func']
res = await func()
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index be9b95bc7..1e7521054 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -43,7 +43,7 @@ class Filtering(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- _staff_mistake_str = "If you believe this was a mistake, please let staff know!"
+ staff_mistake_str = "If you believe this was a mistake, please let staff know!"
self.filters = {
"filter_zalgo": {
"enabled": Filter.filter_zalgo,
@@ -53,7 +53,7 @@ class Filtering(Cog):
"user_notification": Filter.notify_user_zalgo,
"notification_msg": (
"Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "
- f"{_staff_mistake_str}"
+ f"{staff_mistake_str}"
)
},
"filter_invites": {
@@ -63,7 +63,7 @@ class Filtering(Cog):
"content_only": True,
"user_notification": Filter.notify_user_invites,
"notification_msg": (
- f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
+ f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n"
r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"
)
},
@@ -74,7 +74,7 @@ class Filtering(Cog):
"content_only": True,
"user_notification": Filter.notify_user_domains,
"notification_msg": (
- f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}"
+ f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}"
)
},
"watch_rich_embeds": {
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 3a7ba0444..530453600 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -3,14 +3,16 @@ import logging
import pprint
import textwrap
import typing
+from collections import defaultdict
from typing import Any, Mapping, Optional
import discord
from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils
from discord.ext import commands
from discord.ext.commands import Bot, BucketType, Cog, Context, command, group
+from discord.utils import escape_markdown
-from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
+from bot import constants
from bot.decorators import InChannelCheckFailure, in_channel, with_role
from bot.utils.checks import cooldown_with_role_bypass, with_role_check
from bot.utils.time import time_since
@@ -24,7 +26,7 @@ class Information(Cog):
def __init__(self, bot: Bot):
self.bot = bot
- @with_role(*MODERATION_ROLES)
+ @with_role(*constants.MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
"""Returns a list of all roles and their corresponding IDs."""
@@ -48,7 +50,7 @@ class Information(Cog):
await ctx.send(embed=embed)
- @with_role(*MODERATION_ROLES)
+ @with_role(*constants.MODERATION_ROLES)
@command(name="role")
async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None:
"""
@@ -148,10 +150,10 @@ class Information(Cog):
Channel categories: {category_channels}
**Members**
- {Emojis.status_online} {online}
- {Emojis.status_idle} {idle}
- {Emojis.status_dnd} {dnd}
- {Emojis.status_offline} {offline}
+ {constants.Emojis.status_online} {online}
+ {constants.Emojis.status_idle} {idle}
+ {constants.Emojis.status_dnd} {dnd}
+ {constants.Emojis.status_offline} {offline}
""")
)
@@ -160,78 +162,156 @@ class Information(Cog):
await ctx.send(embed=embed)
@command(name="user", aliases=["user_info", "member", "member_info"])
- async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:
+ async def user_info(self, ctx: Context, user: Member = None) -> None:
"""Returns info about a user."""
if user is None:
user = ctx.author
# Do a role check if this is being executed on someone other than the caller
- if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES):
+ if user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES):
await ctx.send("You may not use this command on users other than yourself.")
return
- # Non-moderators may only do this in #bot-commands and can't see hidden infractions.
- if not with_role_check(ctx, *STAFF_ROLES):
- if not ctx.channel.id == Channels.bot:
- raise InChannelCheckFailure(Channels.bot)
- # Hide hidden infractions for users without a moderation role
- hidden = False
+ # 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:
+ raise InChannelCheckFailure(constants.Channels.bot)
- # User information
+ embed = await self.create_user_embed(ctx, user)
+
+ await ctx.send(embed=embed)
+
+ async def create_user_embed(self, ctx: Context, user: Member) -> Embed:
+ """Creates an embed containing information on the `user`."""
created = time_since(user.created_at, max_units=3)
+ # Custom status
+ custom_status = ''
+ for activity in user.activities:
+ if activity.name == 'Custom Status':
+ state = escape_markdown(activity.state)
+ custom_status = f'Status: {state}\n'
+
name = str(user)
if user.nick:
name = f"{user.nick} ({name})"
- # Member information
joined = time_since(user.joined_at, precision="days")
-
- # You're welcome, Volcyyyyyyyyyyyyyyyy
roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")
- # Infractions
+ description = [
+ textwrap.dedent(f"""
+ **User Information**
+ Created: {created}
+ Profile: {user.mention}
+ ID: {user.id}
+ {custom_status}
+ **Member Information**
+ Joined: {joined}
+ Roles: {roles or None}
+ """).strip()
+ ]
+
+ # Show more verbose output in moderation channels for infractions and nominations
+ if ctx.channel.id in constants.MODERATION_CHANNELS:
+ description.append(await self.expanded_user_infraction_counts(user))
+ description.append(await self.user_nomination_counts(user))
+ else:
+ description.append(await self.basic_user_infraction_counts(user))
+
+ # Let's build the embed now
+ embed = Embed(
+ title=name,
+ description="\n\n".join(description)
+ )
+
+ embed.set_thumbnail(url=user.avatar_url_as(format="png"))
+ embed.colour = user.top_role.colour if roles else Colour.blurple()
+
+ return embed
+
+ async def basic_user_infraction_counts(self, member: Member) -> str:
+ """Gets the total and active infraction counts for the given `member`."""
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
- 'hidden': str(hidden),
- 'user__id': str(user.id)
+ 'hidden': 'False',
+ 'user__id': str(member.id)
}
)
- infr_total = 0
- infr_active = 0
+ total_infractions = len(infractions)
+ active_infractions = sum(infraction['active'] for infraction in infractions)
- # At least it's readable.
- for infr in infractions:
- if infr["active"]:
- infr_active += 1
+ infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}"
- infr_total += 1
+ return infraction_output
- # Let's build the embed now
- embed = Embed(
- title=name,
- description=textwrap.dedent(f"""
- **User Information**
- Created: {created}
- Profile: {user.mention}
- ID: {user.id}
+ async def expanded_user_infraction_counts(self, member: Member) -> str:
+ """
+ Gets expanded infraction counts for the given `member`.
- **Member Information**
- Joined: {joined}
- Roles: {roles or None}
+ The counts will be split by infraction type and the number of active infractions for each type will indicated
+ in the output as well.
+ """
+ infractions = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'user__id': str(member.id)
+ }
+ )
- **Infractions**
- Total: {infr_total}
- Active: {infr_active}
- """)
+ infraction_output = ["**Infractions**"]
+ if not infractions:
+ infraction_output.append("This user has never received an infraction.")
+ else:
+ # Count infractions split by `type` and `active` status for this user
+ infraction_types = set()
+ infraction_counter = defaultdict(int)
+ for infraction in infractions:
+ infraction_type = infraction["type"]
+ infraction_active = 'active' if infraction["active"] else 'inactive'
+
+ infraction_types.add(infraction_type)
+ infraction_counter[f"{infraction_active} {infraction_type}"] += 1
+
+ # Format the output of the infraction counts
+ for infraction_type in sorted(infraction_types):
+ active_count = infraction_counter[f"active {infraction_type}"]
+ total_count = active_count + infraction_counter[f"inactive {infraction_type}"]
+
+ line = f"{infraction_type.capitalize()}s: {total_count}"
+ if active_count:
+ line += f" ({active_count} active)"
+
+ infraction_output.append(line)
+
+ return "\n".join(infraction_output)
+
+ async def user_nomination_counts(self, member: Member) -> str:
+ """Gets the active and historical nomination counts for the given `member`."""
+ nominations = await self.bot.api_client.get(
+ 'bot/nominations',
+ params={
+ 'user__id': str(member.id)
+ }
)
- embed.set_thumbnail(url=user.avatar_url_as(format="png"))
- embed.colour = user.top_role.colour if roles else Colour.blurple()
+ output = ["**Nominations**"]
- await ctx.send(embed=embed)
+ if not nominations:
+ output.append("This user has never been nominated.")
+ else:
+ count = len(nominations)
+ is_currently_nominated = any(nomination["active"] for nomination in nominations)
+ nomination_noun = "nomination" if count == 1 else "nominations"
+
+ if is_currently_nominated:
+ output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).")
+ else:
+ output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.")
+
+ return "\n".join(output)
def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
"""Format a mapping to be readable to a human."""
@@ -268,9 +348,9 @@ class Information(Cog):
# remove trailing whitespace
return out.rstrip()
- @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=STAFF_ROLES)
+ @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
@group(invoke_without_command=True)
- @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+ @in_channel(constants.Channels.bot, bypass_roles=constants.STAFF_ROLES)
async def raw(self, ctx: Context, *, message: discord.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 997ffe524..2713a1b68 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -1,24 +1,17 @@
import logging
-import textwrap
import typing as t
-from datetime import datetime
-from gettext import ngettext
-import dateutil.parser
import discord
from discord import Member
from discord.ext import commands
from discord.ext.commands import Context, command
from bot import constants
-from bot.api import ResponseCodeError
-from bot.constants import Colours, Event, STAFF_CHANNELS
+from bot.constants import Event
from bot.decorators import respect_role_hierarchy
-from bot.utils import time
from bot.utils.checks import with_role_check
-from bot.utils.scheduling import Scheduler
from . import utils
-from .modlog import ModLog
+from .scheduler import InfractionScheduler
from .utils import MemberObject
log = logging.getLogger(__name__)
@@ -26,67 +19,35 @@ log = logging.getLogger(__name__)
MemberConverter = t.Union[utils.UserTypes, utils.proxy_user]
-class Infractions(Scheduler, commands.Cog):
+class Infractions(InfractionScheduler, commands.Cog):
"""Apply and pardon infractions on users for moderation purposes."""
category = "Moderation"
category_description = "Server moderation tools."
def __init__(self, bot: commands.Bot):
- super().__init__()
+ super().__init__(bot, supported_infractions={"ban", "kick", "mute", "note", "warning"})
- self.bot = bot
self.category = "Moderation"
self._muted_role = discord.Object(constants.Roles.muted)
- self.bot.loop.create_task(self.reschedule_infractions())
-
- @property
- def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
-
- async def reschedule_infractions(self) -> None:
- """Schedule expiration for previous infractions."""
- await self.bot.wait_until_ready()
-
- infractions = await self.bot.api_client.get(
- 'bot/infractions',
- params={'active': 'true'}
- )
- for infraction in infractions:
- if infraction["expires_at"] is not None:
- self.schedule_task(self.bot.loop, infraction["id"], infraction)
-
@commands.Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""Reapply active mute infractions for returning members."""
active_mutes = await self.bot.api_client.get(
- 'bot/infractions',
+ "bot/infractions",
params={
- 'user__id': str(member.id),
- 'type': 'mute',
- 'active': 'true'
+ "active": "true",
+ "type": "mute",
+ "user__id": member.id
}
)
- if not active_mutes:
- return
- # Assume a single mute because of restrictions elsewhere.
- mute = active_mutes[0]
+ if active_mutes:
+ reason = f"Re-applying active mute: {active_mutes[0]['id']}"
+ action = member.add_roles(self._muted_role, reason=reason)
- # Calculate the time remaining, in seconds, for the mute.
- expiry = dateutil.parser.isoparse(mute["expires_at"]).replace(tzinfo=None)
- delta = (expiry - datetime.utcnow()).total_seconds()
-
- # Mark as inactive if less than a minute remains.
- if delta < 60:
- await self.deactivate_infraction(mute)
- return
-
- # Allowing mod log since this is a passive action that should be logged.
- await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}")
- log.debug(f"User {member.id} has been re-muted on rejoin.")
+ await self.reapply_infraction(active_mutes[0], action)
# region: Permanent infractions
@@ -234,7 +195,7 @@ class Infractions(Scheduler, commands.Cog):
await self.pardon_infraction(ctx, "ban", user)
# endregion
- # region: Base infraction functions
+ # region: Base apply functions
async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
@@ -278,328 +239,63 @@ class Infractions(Scheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user, action)
# endregion
- # region: Utility functions
-
- async def _scheduled_task(self, infraction: utils.Infraction) -> None:
- """
- Marks an infraction expired after the delay from time of scheduling to time of expiration.
-
- At the time of expiration, the infraction is marked as inactive on the website and the
- expiration task is cancelled.
- """
- _id = infraction["id"]
-
- expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
- await time.wait_until(expiry)
-
- log.debug(f"Marking infraction {_id} as inactive (expired).")
- await self.deactivate_infraction(infraction)
-
- async def deactivate_infraction(
- self,
- infraction: utils.Infraction,
- send_log: bool = True
- ) -> t.Dict[str, str]:
- """
- Deactivate an active infraction and return a dictionary of lines to send in a mod log.
-
- The infraction is removed from Discord, marked as inactive in the database, and has its
- expiration task cancelled. If `send_log` is True, a mod log is sent for the
- deactivation of the infraction.
-
- Supported infraction types are mute and ban. Other types will raise a ValueError.
- """
- guild = self.bot.get_guild(constants.Guild.id)
- mod_role = guild.get_role(constants.Roles.moderator)
- user_id = infraction["user"]
- _type = infraction["type"]
- _id = infraction["id"]
- reason = f"Infraction #{_id} expired or was pardoned."
-
- log.debug(f"Marking infraction #{_id} as inactive (expired).")
-
- log_content = None
- log_text = {
- "Member": str(user_id),
- "Actor": str(self.bot.user),
- "Reason": infraction["reason"]
- }
-
- try:
- if _type == "mute":
- user = guild.get_member(user_id)
- if user:
- # Remove the muted role.
- self.mod_log.ignore(Event.member_update, user.id)
- await user.remove_roles(self._muted_role, reason=reason)
-
- # DM the user about the expiration.
- notified = await utils.notify_pardon(
- user=user,
- title="You have been unmuted.",
- content="You may now send messages in the server.",
- icon_url=utils.INFRACTION_ICONS["mute"][1]
- )
-
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
- log_text["DM"] = "Sent" if notified else "**Failed**"
- else:
- log.info(f"Failed to unmute user {user_id}: user not found")
- log_text["Failure"] = "User was not found in the guild."
- elif _type == "ban":
- user = discord.Object(user_id)
- self.mod_log.ignore(Event.member_unban, user_id)
- try:
- await guild.unban(user, reason=reason)
- except discord.NotFound:
- log.info(f"Failed to unban user {user_id}: no active ban found on Discord")
- log_text["Note"] = "No active ban found on Discord."
- else:
- raise ValueError(
- f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!"
- )
- except discord.Forbidden:
- log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions")
- log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)"
- log_content = mod_role.mention
- except discord.HTTPException as e:
- log.exception(f"Failed to deactivate infraction #{_id} ({_type})")
- log_text["Failure"] = f"HTTPException with code {e.code}."
- log_content = mod_role.mention
-
- # Check if the user is currently being watched by Big Brother.
- try:
- active_watch = await self.bot.api_client.get(
- "bot/infractions",
- params={
- "active": "true",
- "type": "watch",
- "user__id": user_id
- }
+ # region: Base pardon functions
+
+ async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]:
+ """Remove a user's muted role, DM them a notification, and return a log dict."""
+ user = guild.get_member(user_id)
+ log_text = {}
+
+ if user:
+ # Remove the muted role.
+ self.mod_log.ignore(Event.member_update, user.id)
+ await user.remove_roles(self._muted_role, reason=reason)
+
+ # DM the user about the expiration.
+ notified = await utils.notify_pardon(
+ user=user,
+ title="You have been unmuted",
+ content="You may now send messages in the server.",
+ icon_url=utils.INFRACTION_ICONS["mute"][1]
)
- log_text["Watching"] = "Yes" if active_watch else "No"
- except ResponseCodeError:
- log.exception(f"Failed to fetch watch status for user {user_id}")
- log_text["Watching"] = "Unknown - failed to fetch watch status."
-
- try:
- # Mark infraction as inactive in the database.
- await self.bot.api_client.patch(
- f"bot/infractions/{_id}",
- json={"active": False}
- )
- except ResponseCodeError as e:
- log.exception(f"Failed to deactivate infraction #{_id} ({_type})")
- log_line = f"API request failed with code {e.status}."
- log_content = mod_role.mention
-
- # Append to an existing failure message if possible
- if "Failure" in log_text:
- log_text["Failure"] += f" {log_line}"
- else:
- log_text["Failure"] = log_line
-
- # Cancel the expiration task.
- if infraction["expires_at"] is not None:
- self.cancel_task(infraction["id"])
-
- # Send a log message to the mod log.
- if send_log:
- log_title = f"expiration failed" if "Failure" in log_text else "expired"
-
- await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[_type][1],
- colour=Colours.soft_green,
- title=f"Infraction {log_title}: {_type}",
- text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
- footer=f"ID: {_id}",
- content=log_content,
- )
+ log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["DM"] = "Sent" if notified else "**Failed**"
+ else:
+ log.info(f"Failed to unmute user {user_id}: user not found")
+ log_text["Failure"] = "User was not found in the guild."
return log_text
- async def apply_infraction(
- self,
- ctx: Context,
- infraction: utils.Infraction,
- user: MemberObject,
- action_coro: t.Optional[t.Awaitable] = None
- ) -> None:
- """Apply an infraction to the user, log the infraction, and optionally notify the user."""
- infr_type = infraction["type"]
- icon = utils.INFRACTION_ICONS[infr_type][0]
- reason = infraction["reason"]
- expiry = infraction["expires_at"]
-
- if expiry:
- expiry = time.format_infraction(expiry)
-
- # Default values for the confirmation message and mod log.
- confirm_msg = f":ok_hand: applied"
-
- # Specifying an expiry for a note or warning makes no sense.
- if infr_type in ("note", "warning"):
- expiry_msg = ""
- else:
- expiry_msg = f" until {expiry}" if expiry else " permanently"
-
- dm_result = ""
- dm_log_text = ""
- expiry_log_text = f"Expires: {expiry}" if expiry else ""
- log_title = "applied"
- log_content = None
-
- # DM the user about the infraction if it's not a shadow/hidden infraction.
- if not infraction["hidden"]:
- # Sometimes user is a discord.Object; make it a proper user.
- await self.bot.fetch_user(user.id)
-
- # Accordingly display whether the user was successfully notified via DM.
- if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
- dm_result = ":incoming_envelope: "
- dm_log_text = "\nDM: Sent"
- else:
- dm_log_text = "\nDM: **Failed**"
- log_content = ctx.author.mention
-
- if infraction["actor"] == self.bot.user.id:
- end_msg = f" (reason: {infraction['reason']})"
- elif ctx.channel.id not in STAFF_CHANNELS:
- end_msg = ''
- else:
- infractions = await self.bot.api_client.get(
- "bot/infractions",
- params={"user__id": str(user.id)}
- )
- total = len(infractions)
- end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"
-
- # Execute the necessary actions to apply the infraction on Discord.
- if action_coro:
- try:
- await action_coro
- if expiry:
- # Schedule the expiration of the infraction.
- self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
- except discord.Forbidden:
- # Accordingly display that applying the infraction failed.
- confirm_msg = f":x: failed to apply"
- expiry_msg = ""
- log_content = ctx.author.mention
- log_title = "failed to apply"
-
- # Send a confirmation message to the invoking context.
- await ctx.send(
- f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}."
- )
+ async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]:
+ """Remove a user's ban on the Discord guild and return a log dict."""
+ user = discord.Object(user_id)
+ log_text = {}
- # Send a log message to the mod log.
- await self.mod_log.send_log_message(
- icon_url=icon,
- colour=Colours.soft_red,
- title=f"Infraction {log_title}: {infr_type}",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}{dm_log_text}
- Reason: {reason}
- {expiry_log_text}
- """),
- content=log_content,
- footer=f"ID {infraction['id']}"
- )
+ self.mod_log.ignore(Event.member_unban, user_id)
- async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None:
- """Prematurely end an infraction for a user and log the action in the mod log."""
- # Check the current active infraction
- response = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': infr_type,
- 'user__id': user.id
- }
- )
+ try:
+ await guild.unban(user, reason=reason)
+ except discord.NotFound:
+ log.info(f"Failed to unban user {user_id}: no active ban found on Discord")
+ log_text["Note"] = "No active ban found on Discord."
- if not response:
- await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.")
- return
+ return log_text
- # Deactivate the infraction and cancel its scheduled expiration task.
- log_text = await self.deactivate_infraction(response[0], send_log=False)
-
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
- log_text["Actor"] = str(ctx.message.author)
- log_content = None
- footer = f"ID: {response[0]['id']}"
-
- # If multiple active infractions were found, mark them as inactive in the database
- # and cancel their expiration tasks.
- if len(response) > 1:
- log.warning(f"Found more than one active {infr_type} infraction for user {user.id}")
-
- footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
-
- log_note = f"Found multiple **active** {infr_type} infractions in the database."
- if "Note" in log_text:
- log_text["Note"] = f" {log_note}"
- else:
- log_text["Note"] = log_note
-
- # deactivate_infraction() is not called again because:
- # 1. Discord cannot store multiple active bans or assign multiples of the same role
- # 2. It would send a pardon DM for each active infraction, which is redundant
- for infraction in response[1:]:
- _id = infraction['id']
- try:
- # Mark infraction as inactive in the database.
- await self.bot.api_client.patch(
- f"bot/infractions/{_id}",
- json={"active": False}
- )
- except ResponseCodeError:
- log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})")
- # This is simpler and cleaner than trying to concatenate all the errors.
- log_text["Failure"] = "See bot's logs for details."
-
- # Cancel pending expiration task.
- if infraction["expires_at"] is not None:
- self.cancel_task(infraction["id"])
-
- # Accordingly display whether the user was successfully notified via DM.
- dm_emoji = ""
- if log_text.get("DM") == "Sent":
- dm_emoji = ":incoming_envelope: "
- elif "DM" in log_text:
- # Mention the actor because the DM failed to send.
- log_content = ctx.author.mention
-
- # Accordingly display whether the pardon failed.
- if "Failure" in log_text:
- confirm_msg = ":x: failed to pardon"
- log_title = "pardon failed"
- log_content = ctx.author.mention
- else:
- confirm_msg = f":ok_hand: pardoned"
- log_title = "pardoned"
+ async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ """
+ Execute deactivation steps specific to the infraction's type and return a log dict.
- # Send a confirmation message to the invoking context.
- await ctx.send(
- f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
- f"{log_text.get('Failure', '')}"
- )
+ If an infraction type is unsupported, return None instead.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+ user_id = infraction["user"]
+ reason = f"Infraction #{infraction['id']} expired or was pardoned."
- # Send a log message to the mod log.
- await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[infr_type][1],
- colour=Colours.soft_green,
- title=f"Infraction {log_title}: {infr_type}",
- thumbnail=user.avatar_url_as(static_format="png"),
- text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
- footer=footer,
- content=log_content,
- )
+ if infraction["type"] == "mute":
+ return await self.pardon_mute(user_id, guild, reason)
+ elif infraction["type"] == "ban":
+ return await self.pardon_ban(user_id, guild, reason)
# endregion
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
new file mode 100644
index 000000000..49b61f35e
--- /dev/null
+++ b/bot/cogs/moderation/scheduler.py
@@ -0,0 +1,413 @@
+import logging
+import textwrap
+import typing as t
+from abc import abstractmethod
+from datetime import datetime
+from gettext import ngettext
+
+import dateutil.parser
+import discord
+from discord.ext.commands import Bot, Context
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.constants import Colours, STAFF_CHANNELS
+from bot.utils import time
+from bot.utils.scheduling import Scheduler
+from . import utils
+from .modlog import ModLog
+from .utils import MemberObject
+
+log = logging.getLogger(__name__)
+
+
+class InfractionScheduler(Scheduler):
+ """Handles the application, pardoning, and expiration of infractions."""
+
+ def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
+ super().__init__()
+
+ self.bot = bot
+ self.bot.loop.create_task(self.reschedule_infractions(supported_infractions))
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get the currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ async def reschedule_infractions(self, supported_infractions: t.Container[str]) -> None:
+ """Schedule expiration for previous infractions."""
+ await self.bot.wait_until_ready()
+
+ log.trace(f"Rescheduling infractions for {self.__class__.__name__}.")
+
+ infractions = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'active': 'true'}
+ )
+ for infraction in infractions:
+ if infraction["expires_at"] is not None and infraction["type"] in supported_infractions:
+ self.schedule_task(self.bot.loop, infraction["id"], infraction)
+
+ async def reapply_infraction(
+ self,
+ infraction: utils.Infraction,
+ apply_coro: t.Optional[t.Awaitable]
+ ) -> None:
+ """Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
+ # Calculate the time remaining, in seconds, for the mute.
+ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
+ delta = (expiry - datetime.utcnow()).total_seconds()
+
+ # Mark as inactive if less than a minute remains.
+ if delta < 60:
+ log.info(
+ "Infraction will be deactivated instead of re-applied "
+ "because less than 1 minute remains."
+ )
+ await self.deactivate_infraction(infraction)
+ return
+
+ # Allowing mod log since this is a passive action that should be logged.
+ await apply_coro
+ log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.")
+
+ async def apply_infraction(
+ self,
+ ctx: Context,
+ infraction: utils.Infraction,
+ user: MemberObject,
+ action_coro: t.Optional[t.Awaitable] = None
+ ) -> None:
+ """Apply an infraction to the user, log the infraction, and optionally notify the user."""
+ infr_type = infraction["type"]
+ icon = utils.INFRACTION_ICONS[infr_type][0]
+ reason = infraction["reason"]
+ expiry = infraction["expires_at"]
+ id_ = infraction['id']
+
+ log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")
+
+ if expiry:
+ expiry = time.format_infraction(expiry)
+
+ # Default values for the confirmation message and mod log.
+ confirm_msg = f":ok_hand: applied"
+
+ # Specifying an expiry for a note or warning makes no sense.
+ if infr_type in ("note", "warning"):
+ expiry_msg = ""
+ else:
+ expiry_msg = f" until {expiry}" if expiry else " permanently"
+
+ dm_result = ""
+ dm_log_text = ""
+ expiry_log_text = f"Expires: {expiry}" if expiry else ""
+ log_title = "applied"
+ log_content = None
+
+ # DM the user about the infraction if it's not a shadow/hidden infraction.
+ if not infraction["hidden"]:
+ # Sometimes user is a discord.Object; make it a proper user.
+ user = await self.bot.fetch_user(user.id)
+
+ # Accordingly display whether the user was successfully notified via DM.
+ if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ dm_result = ":incoming_envelope: "
+ dm_log_text = "\nDM: Sent"
+ else:
+ dm_log_text = "\nDM: **Failed**"
+ log_content = ctx.author.mention
+
+ if infraction["actor"] == self.bot.user.id:
+ log.trace(
+ f"Infraction #{id_} actor is bot; including the reason in the confirmation message."
+ )
+
+ end_msg = f" (reason: {infraction['reason']})"
+ elif ctx.channel.id not in STAFF_CHANNELS:
+ log.trace(
+ f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
+ )
+
+ end_msg = ""
+ else:
+ log.trace(f"Fetching total infraction count for {user}.")
+
+ infractions = await self.bot.api_client.get(
+ "bot/infractions",
+ params={"user__id": str(user.id)}
+ )
+ total = len(infractions)
+ end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"
+
+ # Execute the necessary actions to apply the infraction on Discord.
+ if action_coro:
+ log.trace(f"Awaiting the infraction #{id_} application action coroutine.")
+ try:
+ await action_coro
+ if expiry:
+ # Schedule the expiration of the infraction.
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
+ except discord.Forbidden:
+ # Accordingly display that applying the infraction failed.
+ confirm_msg = f":x: failed to apply"
+ expiry_msg = ""
+ log_content = ctx.author.mention
+ log_title = "failed to apply"
+
+ log.warning(f"Failed to apply {infr_type} infraction #{id_} to {user}.")
+
+ # Send a confirmation message to the invoking context.
+ log.trace(f"Sending infraction #{id_} confirmation message.")
+ await ctx.send(
+ f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}."
+ )
+
+ # Send a log message to the mod log.
+ log.trace(f"Sending apply mod log for infraction #{id_}.")
+ await self.mod_log.send_log_message(
+ icon_url=icon,
+ colour=Colours.soft_red,
+ title=f"Infraction {log_title}: {infr_type}",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}{dm_log_text}
+ Reason: {reason}
+ {expiry_log_text}
+ """),
+ content=log_content,
+ footer=f"ID {infraction['id']}"
+ )
+
+ log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
+
+ async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None:
+ """Prematurely end an infraction for a user and log the action in the mod log."""
+ log.trace(f"Pardoning {infr_type} infraction for {user}.")
+
+ # Check the current active infraction
+ log.trace(f"Fetching active {infr_type} infractions for {user}.")
+ response = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': infr_type,
+ 'user__id': user.id
+ }
+ )
+
+ if not response:
+ log.debug(f"No active {infr_type} infraction found for {user}.")
+ await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.")
+ return
+
+ # Deactivate the infraction and cancel its scheduled expiration task.
+ log_text = await self.deactivate_infraction(response[0], send_log=False)
+
+ log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["Actor"] = str(ctx.message.author)
+ log_content = None
+ id_ = response[0]['id']
+ footer = f"ID: {id_}"
+
+ # If multiple active infractions were found, mark them as inactive in the database
+ # and cancel their expiration tasks.
+ if len(response) > 1:
+ log.warning(
+ f"Found more than one active {infr_type} infraction for user {user.id}; "
+ "deactivating the extra active infractions too."
+ )
+
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+
+ log_note = f"Found multiple **active** {infr_type} infractions in the database."
+ if "Note" in log_text:
+ log_text["Note"] = f" {log_note}"
+ else:
+ log_text["Note"] = log_note
+
+ # deactivate_infraction() is not called again because:
+ # 1. Discord cannot store multiple active bans or assign multiples of the same role
+ # 2. It would send a pardon DM for each active infraction, which is redundant
+ for infraction in response[1:]:
+ id_ = infraction['id']
+ try:
+ # Mark infraction as inactive in the database.
+ await self.bot.api_client.patch(
+ f"bot/infractions/{id_}",
+ json={"active": False}
+ )
+ except ResponseCodeError:
+ log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})")
+ # This is simpler and cleaner than trying to concatenate all the errors.
+ log_text["Failure"] = "See bot's logs for details."
+
+ # Cancel pending expiration task.
+ if infraction["expires_at"] is not None:
+ self.cancel_task(infraction["id"])
+
+ # Accordingly display whether the user was successfully notified via DM.
+ dm_emoji = ""
+ if log_text.get("DM") == "Sent":
+ dm_emoji = ":incoming_envelope: "
+ elif "DM" in log_text:
+ # Mention the actor because the DM failed to send.
+ log_content = ctx.author.mention
+
+ # Accordingly display whether the pardon failed.
+ if "Failure" in log_text:
+ confirm_msg = ":x: failed to pardon"
+ log_title = "pardon failed"
+ log_content = ctx.author.mention
+
+ log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.")
+ else:
+ confirm_msg = f":ok_hand: pardoned"
+ log_title = "pardoned"
+
+ 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', '')}"
+ )
+
+ # Send a log message to the mod log.
+ await self.mod_log.send_log_message(
+ icon_url=utils.INFRACTION_ICONS[infr_type][1],
+ colour=Colours.soft_green,
+ title=f"Infraction {log_title}: {infr_type}",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
+ footer=footer,
+ content=log_content,
+ )
+
+ async def deactivate_infraction(
+ self,
+ infraction: utils.Infraction,
+ send_log: bool = True
+ ) -> t.Dict[str, str]:
+ """
+ Deactivate an active infraction and return a dictionary of lines to send in a mod log.
+
+ The infraction is removed from Discord, marked as inactive in the database, and has its
+ expiration task cancelled. If `send_log` is True, a mod log is sent for the
+ deactivation of the infraction.
+
+ Infractions of unsupported types will raise a ValueError.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+ mod_role = guild.get_role(constants.Roles.moderator)
+ user_id = infraction["user"]
+ type_ = infraction["type"]
+ id_ = infraction["id"]
+
+ log.info(f"Marking infraction #{id_} as inactive (expired).")
+
+ log_content = None
+ log_text = {
+ "Member": str(user_id),
+ "Actor": str(self.bot.user),
+ "Reason": infraction["reason"]
+ }
+
+ try:
+ log.trace("Awaiting the pardon action coroutine.")
+ returned_log = await self._pardon_action(infraction)
+
+ if returned_log is not None:
+ log_text = {**log_text, **returned_log} # Merge the logs together
+ else:
+ raise ValueError(
+ f"Attempted to deactivate an unsupported infraction #{id_} ({type_})!"
+ )
+ except discord.Forbidden:
+ log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions")
+ log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)"
+ log_content = mod_role.mention
+ except discord.HTTPException as e:
+ log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
+ log_text["Failure"] = f"HTTPException with code {e.code}."
+ log_content = mod_role.mention
+
+ # Check if the user is currently being watched by Big Brother.
+ try:
+ log.trace(f"Determining if user {user_id} is currently being watched by Big Brother.")
+
+ active_watch = await self.bot.api_client.get(
+ "bot/infractions",
+ params={
+ "active": "true",
+ "type": "watch",
+ "user__id": user_id
+ }
+ )
+
+ log_text["Watching"] = "Yes" if active_watch else "No"
+ except ResponseCodeError:
+ log.exception(f"Failed to fetch watch status for user {user_id}")
+ log_text["Watching"] = "Unknown - failed to fetch watch status."
+
+ try:
+ # Mark infraction as inactive in the database.
+ log.trace(f"Marking infraction #{id_} as inactive in the database.")
+ await self.bot.api_client.patch(
+ f"bot/infractions/{id_}",
+ json={"active": False}
+ )
+ except ResponseCodeError as e:
+ log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
+ log_line = f"API request failed with code {e.status}."
+ log_content = mod_role.mention
+
+ # Append to an existing failure message if possible
+ if "Failure" in log_text:
+ log_text["Failure"] += f" {log_line}"
+ else:
+ log_text["Failure"] = log_line
+
+ # Cancel the expiration task.
+ if infraction["expires_at"] is not None:
+ self.cancel_task(infraction["id"])
+
+ # Send a log message to the mod log.
+ if send_log:
+ log_title = f"expiration failed" if "Failure" in log_text else "expired"
+
+ log.trace(f"Sending deactivation mod log for infraction #{id_}.")
+ await self.mod_log.send_log_message(
+ icon_url=utils.INFRACTION_ICONS[type_][1],
+ colour=Colours.soft_green,
+ title=f"Infraction {log_title}: {type_}",
+ text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
+ footer=f"ID: {id_}",
+ content=log_content,
+ )
+
+ return log_text
+
+ @abstractmethod
+ async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ """
+ Execute deactivation steps specific to the infraction's type and return a log dict.
+
+ If an infraction type is unsupported, return None instead.
+ """
+ raise NotImplementedError
+
+ async def _scheduled_task(self, infraction: utils.Infraction) -> None:
+ """
+ Marks an infraction expired after the delay from time of scheduling to time of expiration.
+
+ At the time of expiration, the infraction is marked as inactive on the website and the
+ expiration task is cancelled.
+ """
+ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
+ await time.wait_until(expiry)
+
+ await self.deactivate_infraction(infraction)
diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py
index 82f8621fc..9b3c62403 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -1,17 +1,18 @@
import json
import logging
import random
+import textwrap
+import typing as t
from pathlib import Path
from discord import Colour, Embed, Member
-from discord.errors import Forbidden
from discord.ext.commands import Bot, Cog, Context, command
from bot import constants
from bot.utils.checks import with_role_check
from bot.utils.time import format_infraction
from . import utils
-from .modlog import ModLog
+from .scheduler import InfractionScheduler
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -20,132 +21,96 @@ with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:
STAR_NAMES = json.load(stars_file)
-class Superstarify(Cog):
+class Superstarify(InfractionScheduler, Cog):
"""A set of commands to moderate terrible nicknames."""
def __init__(self, bot: Bot):
- self.bot = bot
-
- @property
- def modlog(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
+ super().__init__(bot, supported_infractions={"superstar"})
@Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
- """
- This event will trigger when someone changes their name.
-
- At this point we will look up the user in our database and check whether they are allowed to
- change their names, or if they are in superstar-prison. If they are not allowed, we will
- change it back.
- """
+ """Revert nickname edits if the user has an active superstarify infraction."""
if before.display_name == after.display_name:
return # User didn't change their nickname. Abort!
log.trace(
- f"{before.display_name} is trying to change their nickname to {after.display_name}. "
- "Checking if the user is in superstar-prison..."
+ f"{before} ({before.display_name}) is trying to change their nickname to "
+ f"{after.display_name}. Checking if the user is in superstar-prison..."
)
active_superstarifies = await self.bot.api_client.get(
- 'bot/infractions',
+ "bot/infractions",
params={
- 'active': 'true',
- 'type': 'superstar',
- 'user__id': str(before.id)
+ "active": "true",
+ "type": "superstar",
+ "user__id": str(before.id)
}
)
- if active_superstarifies:
- [infraction] = active_superstarifies
- forced_nick = self.get_nick(infraction['id'], before.id)
- if after.display_name == forced_nick:
- return # Nick change was triggered by this event. Ignore.
-
- log.info(
- f"{after.display_name} is currently in superstar-prison. "
- f"Changing the nick back to {before.display_name}."
- )
- await after.edit(nick=forced_nick)
- end_timestamp_human = format_infraction(infraction['expires_at'])
-
- try:
- await after.send(
- "You have tried to change your nickname on the **Python Discord** server "
- f"from **{before.display_name}** to **{after.display_name}**, but as you "
- "are currently in superstar-prison, you do not have permission to do so. "
- "You will be allowed to change your nickname again at the following time:\n\n"
- f"**{end_timestamp_human}**."
- )
- except Forbidden:
- log.warning(
- "The user tried to change their nickname while in superstar-prison. "
- "This led to the bot trying to DM the user to let them know they cannot do that, "
- "but the user had either blocked the bot or disabled DMs, so it was not possible "
- "to DM them, and a discord.errors.Forbidden error was incurred."
- )
+ if not active_superstarifies:
+ log.trace(f"{before} has no active superstar infractions.")
+ return
+
+ infraction = active_superstarifies[0]
+ forced_nick = self.get_nick(infraction["id"], before.id)
+ if after.display_name == forced_nick:
+ return # Nick change was triggered by this event. Ignore.
+
+ log.info(
+ f"{after.display_name} is currently in superstar-prison. "
+ f"Changing the nick back to {before.display_name}."
+ )
+ await after.edit(
+ nick=forced_nick,
+ reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
+ )
+
+ notified = await utils.notify_infraction(
+ user=after,
+ infr_type="Superstarify",
+ expires_at=format_infraction(infraction["expires_at"]),
+ reason=(
+ "You have tried to change your nickname on the **Python Discord** server "
+ f"from **{before.display_name}** to **{after.display_name}**, but as you "
+ "are currently in superstar-prison, you do not have permission to do so."
+ ),
+ icon_url=utils.INFRACTION_ICONS["superstar"][0]
+ )
+
+ if not notified:
+ log.warning("Failed to DM user about why they cannot change their nickname.")
@Cog.listener()
async def on_member_join(self, member: Member) -> None:
- """
- This event will trigger when someone (re)joins the server.
-
- At this point we will look up the user in our database and check whether they are in
- superstar-prison. If so, we will change their name back to the forced nickname.
- """
+ """Reapply active superstar infractions for returning members."""
active_superstarifies = await self.bot.api_client.get(
- 'bot/infractions',
+ "bot/infractions",
params={
- 'active': 'true',
- 'type': 'superstar',
- 'user__id': member.id
+ "active": "true",
+ "type": "superstar",
+ "user__id": member.id
}
)
if active_superstarifies:
- [infraction] = active_superstarifies
- forced_nick = self.get_nick(infraction['id'], member.id)
- await member.edit(nick=forced_nick)
- end_timestamp_human = format_infraction(infraction['expires_at'])
-
- try:
- await member.send(
- "You have left and rejoined the **Python Discord** server, effectively resetting "
- f"your nickname from **{forced_nick}** to **{member.name}**, "
- "but as you are currently in superstar-prison, you do not have permission to do so. "
- "Therefore your nickname was automatically changed back. You will be allowed to "
- "change your nickname again at the following time:\n\n"
- f"**{end_timestamp_human}**."
- )
- except Forbidden:
- log.warning(
- "The user left and rejoined the server while in superstar-prison. "
- "This led to the bot trying to DM the user to let them know their name was restored, "
- "but the user had either blocked the bot or disabled DMs, so it was not possible "
- "to DM them, and a discord.errors.Forbidden error was incurred."
- )
-
- # Log to the mod_log channel
- log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
- mod_log_message = (
- f"**{member}** (`{member.id}`)\n\n"
- f"Superstarified member potentially tried to escape the prison.\n"
- f"Restored enforced nickname: `{forced_nick}`\n"
- f"Superstardom ends: **{end_timestamp_human}**"
- )
- await self.modlog.send_log_message(
- icon_url=constants.Icons.user_update,
- colour=Colour.gold(),
- title="Superstar member rejoined server",
- text=mod_log_message,
- thumbnail=member.avatar_url_as(static_format="png")
+ infraction = active_superstarifies[0]
+ action = member.edit(
+ nick=self.get_nick(infraction["id"], member.id),
+ reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
)
- @command(name='superstarify', aliases=('force_nick', 'star'))
- async def superstarify(self, ctx: Context, member: Member, duration: utils.Expiry, reason: str = None) -> None:
+ await self.reapply_infraction(infraction, action)
+
+ @command(name="superstarify", aliases=("force_nick", "star"))
+ async def superstarify(
+ self,
+ ctx: Context,
+ member: Member,
+ duration: utils.Expiry,
+ reason: str = None
+ ) -> None:
"""
- Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration.
+ Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname.
A unit of time should be appended to the duration.
Units (∗case-sensitive):
@@ -165,91 +130,103 @@ class Superstarify(Cog):
if await utils.has_active_infraction(ctx, member, "superstar"):
return
- reason = reason or ('old nick: ' + member.display_name)
- infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=duration)
- forced_nick = self.get_nick(infraction['id'], member.id)
- expiry_str = format_infraction(infraction["expires_at"])
+ # Post the infraction to the API
+ reason = reason or f"old nick: {member.display_name}"
+ infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration)
+ id_ = infraction["id"]
- embed = Embed()
- embed.title = "Congratulations!"
- embed.description = (
- f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. "
- f"Your new nickname will be **{forced_nick}**.\n\n"
- f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n"
- "If you're confused by this, please read our "
- f"[official nickname policy]({NICKNAME_POLICY_URL})."
- )
+ old_nick = member.display_name
+ forced_nick = self.get_nick(id_, member.id)
+ expiry_str = format_infraction(infraction["expires_at"])
- # Log to the mod_log channel
- log.trace("Logging to the #mod-log channel. This could fail because of channel permissions.")
- mod_log_message = (
- f"**{member}** (`{member.id}`)\n\n"
- f"Superstarified by **{ctx.author.name}**\n"
- f"Old nickname: `{member.display_name}`\n"
- f"New nickname: `{forced_nick}`\n"
- f"Superstardom ends: **{expiry_str}**"
- )
- await self.modlog.send_log_message(
- icon_url=constants.Icons.user_update,
- colour=Colour.gold(),
- title="Member Achieved Superstardom",
- text=mod_log_message,
- thumbnail=member.avatar_url_as(static_format="png")
- )
+ # Apply the infraction and schedule the expiration task.
+ log.debug(f"Changing nickname of {member} to {forced_nick}.")
+ self.mod_log.ignore(constants.Event.member_update, member.id)
+ await member.edit(nick=forced_nick, reason=reason)
+ self.schedule_task(ctx.bot.loop, id_, infraction)
+ # Send a DM to the user to notify them of their new infraction.
await utils.notify_infraction(
user=member,
infr_type="Superstarify",
expires_at=expiry_str,
+ icon_url=utils.INFRACTION_ICONS["superstar"][0],
reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
)
- # Change the nick and return the embed
- log.trace("Changing the users nickname and sending the embed.")
- await member.edit(nick=forced_nick)
+ # Send an embed with the infraction information to the invoking context.
+ log.trace(f"Sending superstar #{id_} embed.")
+ embed = Embed(
+ title="Congratulations!",
+ colour=constants.Colours.soft_orange,
+ description=(
+ f"Your previous nickname, **{old_nick}**, "
+ f"was so bad that we have decided to change it. "
+ f"Your new nickname will be **{forced_nick}**.\n\n"
+ f"You will be unable to change your nickname until **{expiry_str}**.\n\n"
+ "If you're confused by this, please read our "
+ f"[official nickname policy]({NICKNAME_POLICY_URL})."
+ )
+ )
await ctx.send(embed=embed)
- @command(name='unsuperstarify', aliases=('release_nick', 'unstar'))
- async def unsuperstarify(self, ctx: Context, member: Member) -> None:
- """Remove the superstarify entry from our database, allowing the user to change their nickname."""
- log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}")
+ # Log to the mod log channel.
+ log.trace(f"Sending apply mod log for superstar #{id_}.")
+ await self.mod_log.send_log_message(
+ icon_url=utils.INFRACTION_ICONS["superstar"][0],
+ colour=Colour.gold(),
+ title="Member achieved superstardom",
+ thumbnail=member.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {member.mention} (`{member.id}`)
+ Actor: {ctx.message.author}
+ Reason: {reason}
+ Expires: {expiry_str}
+ Old nickname: `{old_nick}`
+ New nickname: `{forced_nick}`
+ """),
+ footer=f"ID {id_}"
+ )
- embed = Embed()
- embed.colour = Colour.blurple()
+ @command(name="unsuperstarify", aliases=("release_nick", "unstar"))
+ async def unsuperstarify(self, ctx: Context, member: Member) -> None:
+ """Remove the superstarify infraction and allow the user to change their nickname."""
+ await self.pardon_infraction(ctx, "superstar", member)
- active_superstarifies = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'superstar',
- 'user__id': str(member.id)
- }
- )
- if not active_superstarifies:
- await ctx.send(":x: There is no active superstarify infraction for this user.")
+ async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ """Pardon a superstar infraction and return a log dict."""
+ if infraction["type"] != "superstar":
return
- [infraction] = active_superstarifies
- await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction['id']),
- json={'active': False}
- )
-
- embed = Embed()
- embed.description = "User has been released from superstar-prison."
- embed.title = random.choice(constants.POSITIVE_REPLIES)
+ guild = self.bot.get_guild(constants.Guild.id)
+ user = guild.get_member(infraction["user"])
- await utils.notify_pardon(
- user=member,
- title="You are no longer superstarified.",
- content="You may now change your nickname on the server."
+ # Don't bother sending a notification if the user left the guild.
+ if not user:
+ log.debug(
+ "User left the guild and therefore won't be notified about superstar "
+ f"{infraction['id']} pardon."
+ )
+ return {}
+
+ # DM the user about the expiration.
+ notified = await utils.notify_pardon(
+ user=user,
+ title="You are no longer superstarified",
+ content="You may now change your nickname on the server.",
+ icon_url=utils.INFRACTION_ICONS["superstar"][1]
)
- log.trace(f"{member.display_name} was successfully released from superstar-prison.")
- await ctx.send(embed=embed)
+
+ return {
+ "Member": f"{user.mention}(`{user.id}`)",
+ "DM": "Sent" if notified else "**Failed**"
+ }
@staticmethod
def get_nick(infraction_id: int, member_id: int) -> str:
"""Randomly select a nickname from the Superstarify nickname list."""
+ log.trace(f"Choosing a random nickname for superstar #{infraction_id}.")
+
rng = random.Random(str(infraction_id) + str(member_id))
return rng.choice(STAR_NAMES)
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
index 788a40d40..325b9567a 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -15,11 +15,12 @@ log = logging.getLogger(__name__)
# apply icon, pardon icon
INFRACTION_ICONS = {
- "mute": (Icons.user_mute, Icons.user_unmute),
- "kick": (Icons.sign_out, None),
"ban": (Icons.user_ban, Icons.user_unban),
- "warning": (Icons.user_warn, None),
+ "kick": (Icons.sign_out, None),
+ "mute": (Icons.user_mute, Icons.user_unmute),
"note": (Icons.user_warn, None),
+ "superstar": (Icons.superstarify, Icons.unsuperstarify),
+ "warning": (Icons.user_warn, None),
}
RULES_URL = "https://pythondiscord.com/pages/rules"
APPEALABLE_INFRACTIONS = ("ban", "mute")
@@ -36,6 +37,8 @@ def proxy_user(user_id: str) -> discord.Object:
Used when a Member or User object cannot be resolved.
"""
+ log.trace(f"Attempting to create a proxy user for the user id {user_id}.")
+
try:
user_id = int(user_id)
except ValueError:
@@ -58,6 +61,8 @@ async def post_infraction(
active: bool = True,
) -> t.Optional[dict]:
"""Posts an infraction to the API."""
+ log.trace(f"Posting {infr_type} infraction for {user} to the API.")
+
payload = {
"actor": ctx.message.author.id,
"hidden": hidden,
@@ -91,6 +96,8 @@ async def post_infraction(
async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool:
"""Checks if a user already has an active infraction of the given type."""
+ log.trace(f"Checking if {user} has active infractions of type {infr_type}.")
+
active_infractions = await ctx.bot.api_client.get(
'bot/infractions',
params={
@@ -100,12 +107,14 @@ async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str
}
)
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
else:
+ log.trace(f"{user} does not have active infractions of type {infr_type}.")
return False
@@ -117,6 +126,8 @@ async def notify_infraction(
icon_url: str = Icons.token_removed
) -> bool:
"""DM a user about their new infraction and return True if the DM is successful."""
+ log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
+
embed = discord.Embed(
description=textwrap.dedent(f"""
**Type:** {infr_type.capitalize()}
@@ -126,7 +137,7 @@ async def notify_infraction(
colour=Colours.soft_red
)
- embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL)
+ embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL)
embed.title = f"Please review our rules over at {RULES_URL}"
embed.url = RULES_URL
@@ -145,6 +156,8 @@ async def notify_pardon(
icon_url: str = Icons.user_verified
) -> bool:
"""DM a user about their pardoned infraction and return True if the DM is successful."""
+ log.trace(f"Sending {user} a DM about their pardoned infraction.")
+
embed = discord.Embed(
description=content,
colour=Colours.soft_green
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 1f9fb0b4f..78792240f 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -24,6 +24,9 @@ class OffTopicName(Converter):
"""Attempt to replace any invalid characters with their approximate Unicode equivalent."""
allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
+ # Chain multiple words to a single one
+ argument = "-".join(argument.split())
+
if not (2 <= len(argument) <= 96):
raise BadArgument("Channel name must be between 2 and 96 chars long")
@@ -97,15 +100,12 @@ class OffTopicNames(Cog):
@otname_group.command(name='add', aliases=('a',))
@with_role(*MODERATION_ROLES)
- async def add_command(self, ctx: Context, *names: OffTopicName) -> None:
+ async def add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""
Adds a new off-topic name to the rotation.
The name is not added if it is too similar to an existing name.
"""
- # Chain multiple words to a single one
- name = "-".join(names)
-
existing_names = await self.bot.api_client.get('bot/off-topic-channel-names')
close_match = difflib.get_close_matches(name, existing_names, n=1, cutoff=0.8)
@@ -123,10 +123,8 @@ class OffTopicNames(Cog):
@otname_group.command(name='forceadd', aliases=('fa',))
@with_role(*MODERATION_ROLES)
- async def force_add_command(self, ctx: Context, *names: OffTopicName) -> None:
+ async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Forcefully adds a new off-topic name to the rotation."""
- # Chain multiple words to a single one
- name = "-".join(names)
await self._add_name(ctx, name)
async def _add_name(self, ctx: Context, name: str) -> None:
@@ -138,10 +136,8 @@ class OffTopicNames(Cog):
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
@with_role(*MODERATION_ROLES)
- async def delete_command(self, ctx: Context, *names: OffTopicName) -> None:
+ async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
- # Chain multiple words to a single one
- name = "-".join(names)
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
log.info(f"{ctx.author} deleted the off-topic channel name '{name}'")
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 7749d237f..0d06e9c26 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -2,14 +2,14 @@ import asyncio
import logging
import random
import textwrap
-from datetime import datetime
+from datetime import datetime, timedelta
from typing import List
from discord import Colour, Embed, TextChannel
from discord.ext.commands import Bot, Cog, Context, group
from discord.ext.tasks import loop
-from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES, Webhooks
+from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks
from bot.converters import Subreddit
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -117,9 +117,9 @@ class Reddit(Cog):
link = self.URL + data["permalink"]
embed.description += (
- f"[**{title}**]({link})\n"
+ f"**[{title}]({link})**\n"
f"{text}"
- f"| {ups} upvotes | {comments} comments | u/{author} | {subreddit} |\n\n"
+ f"{Emojis.upvotes} {ups} {Emojis.comments} {comments} {Emojis.user} {author}\n\n"
)
embed.colour = Colour.blurple()
@@ -130,7 +130,8 @@ class Reddit(Cog):
"""Post the top 5 posts daily, and the top 5 posts weekly."""
# once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter
now = datetime.utcnow()
- midnight_tomorrow = now.replace(day=now.day + 1, hour=0, minute=0, second=0)
+ tomorrow = now + timedelta(days=1)
+ midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0)
seconds_until = (midnight_tomorrow - now).total_seconds()
await asyncio.sleep(seconds_until)
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index d95359159..683613788 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -3,8 +3,7 @@ import logging
from discord import Colour, Embed
from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import Channels, STAFF_ROLES, URLs
-from bot.decorators import redirect_output
+from bot.constants import URLs
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -105,7 +104,6 @@ class Site(Cog):
await ctx.send(embed=embed)
@site_group.command(aliases=['r', 'rule'], name='rules')
- @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
async def site_rules(self, ctx: Context, *rules: int) -> None:
"""Provides a link to all rules or, if specified, displays specific rule(s)."""
rules_embed = Embed(title='Rules', color=Colour.blurple())
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 5b115deaa..b5e8d4357 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,12 +1,16 @@
import logging
from datetime import datetime
-from discord import Message, NotFound, Object
+from discord import Colour, Message, NotFound, Object
from discord.ext import tasks
from discord.ext.commands import Bot, Cog, Context, command
from bot.cogs.moderation import ModLog
-from bot.constants import Bot as BotConfig, Channels, Event, Roles
+from bot.constants import (
+ Bot as BotConfig,
+ Channels, Colours, Event,
+ Filter, Icons, Roles
+)
from bot.decorators import InChannelCheckFailure, in_channel, without_role
log = logging.getLogger(__name__)
@@ -31,7 +35,7 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
PERIODIC_PING = (
f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`."
- f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process."
+ f" If you encounter any problems during the verification process, ping the <@&{Roles.admin}> role in this channel."
)
@@ -53,32 +57,59 @@ class Verification(Cog):
if message.author.bot:
return # They're a bot, ignore
+ if message.channel.id != Channels.verification:
+ return # Only listen for #checkpoint messages
+
+ # if a user mentions a role or guild member
+ # alert the mods in mod-alerts channel
+ if message.mentions or message.role_mentions:
+ log.debug(
+ f"{message.author} mentioned one or more users "
+ f"and/or roles in {message.channel.name}"
+ )
+
+ embed_text = (
+ f"{message.author.mention} sent a message in "
+ f"{message.channel.mention} that contained user and/or role mentions."
+ f"\n\n**Original message:**\n>>> {message.content}"
+ )
+
+ # Send pretty mod log embed to mod-alerts
+ await self.mod_log.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"User/Role mentioned in {message.channel.name}",
+ text=embed_text,
+ thumbnail=message.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=Filter.ping_everyone,
+ )
+
ctx = await self.bot.get_context(message) # type: Context
if ctx.command is not None and ctx.command.name == "accept":
return # They used the accept command
- if ctx.channel.id == Channels.verification: # We're in the verification channel
- for role in ctx.author.roles:
- if role.id == Roles.verified:
- log.warning(f"{ctx.author} posted '{ctx.message.content}' "
- "in the verification channel, but is already verified.")
- return # They're already verified
-
- log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification "
- "channel. We are providing instructions how to verify.")
- await ctx.send(
- f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
- f"and gain access to the rest of the server.",
- delete_after=20
- )
+ for role in ctx.author.roles:
+ if role.id == Roles.verified:
+ log.warning(f"{ctx.author} posted '{ctx.message.content}' "
+ "in the verification channel, but is already verified.")
+ return # They're already verified
+
+ log.debug(f"{ctx.author} posted '{ctx.message.content}' in the verification "
+ "channel. We are providing instructions how to verify.")
+ await ctx.send(
+ f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
+ f"and gain access to the rest of the server.",
+ delete_after=20
+ )
- log.trace(f"Deleting the message posted by {ctx.author}")
+ log.trace(f"Deleting the message posted by {ctx.author}")
- try:
- await ctx.message.delete()
- except NotFound:
- log.trace("No message found, it must have been deleted by another bot.")
+ try:
+ await ctx.message.delete()
+ except NotFound:
+ log.trace("No message found, it must have been deleted by another bot.")
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(Roles.verified)
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index c516508ca..49783bb09 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -6,7 +6,7 @@ from discord import User
from discord.ext.commands import Bot, Cog, Context, group
from bot.cogs.moderation.utils import post_infraction
-from bot.constants import Channels, Roles, Webhooks
+from bot.constants import Channels, MODERATION_ROLES, Webhooks
from bot.decorators import with_role
from .watchchannel import WatchChannel, proxy_user
@@ -27,13 +27,13 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
)
@group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @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")
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
Shows the users that are currently being monitored by Big Brother.
@@ -44,7 +44,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await self.list_watched_users(ctx, update_cache)
@bigbrother_group.command(name='watch', aliases=('w',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#big-brother` channel.
@@ -91,7 +91,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.send(msg)
@bigbrother_group.command(name='unwatch', aliases=('uw',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
active_watches = await self.bot.api_client.get(
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index 176c6f760..4ec42dcc1 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -7,14 +7,13 @@ from discord import Color, Embed, Member, User
from discord.ext.commands import Bot, Cog, Context, group
from bot.api import ResponseCodeError
-from bot.constants import Channels, Guild, Roles, Webhooks
+from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils import time
from .watchchannel import WatchChannel, proxy_user
log = logging.getLogger(__name__)
-STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge?
class TalentPool(WatchChannel, Cog, name="Talentpool"):
@@ -31,13 +30,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @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")
@nomination_group.command(name='watched', aliases=('all', 'list'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
"""
Shows the users that are currently being monitored in the talent pool.
@@ -48,7 +47,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await self.list_watched_users(ctx, update_cache)
@nomination_group.command(name='watch', aliases=('w', 'add', 'a'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*STAFF_ROLES)
async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#talent-pool` channel.
@@ -113,7 +112,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(msg)
@nomination_group.command(name='history', aliases=('info', 'search'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
@@ -142,7 +141,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@nomination_group.command(name='unwatch', aliases=('end', ))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
@@ -170,13 +169,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
self._remove_user(user.id)
@nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @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")
@nomination_edit_group.command(name='reason')
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""
Edits the reason/unnominate reason for the nomination with the given `id` depending on the status.
diff --git a/bot/constants.py b/bot/constants.py
index 9582fea96..0fee51df9 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -259,6 +259,10 @@ class Emojis(metaclass=YAMLGetter):
pencil: str
cross_mark: str
+ upvotes: str
+ comments: str
+ user: str
+
class Icons(metaclass=YAMLGetter):
section = "style"
@@ -308,6 +312,9 @@ class Icons(metaclass=YAMLGetter):
questionmark: str
+ superstarify: str
+ unsuperstarify: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
diff --git a/bot/interpreter.py b/bot/interpreter.py
index a42b45a2d..76a3fc293 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -20,8 +20,8 @@ class Interpreter(InteractiveInterpreter):
write_callable = None
def __init__(self, bot: Bot):
- _locals = {"bot": bot}
- super().__init__(_locals)
+ locals_ = {"bot": bot}
+ super().__init__(locals_)
async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any:
"""Execute the provided source code as the bot & return the output."""
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 08abd91d7..ee6c0a8e6 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -36,11 +36,15 @@ class Scheduler(metaclass=CogABCMeta):
`task_data` is passed to `Scheduler._scheduled_expiration`
"""
if task_id in self.scheduled_tasks:
+ log.debug(
+ f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled."
+ )
return
task: asyncio.Task = create_task(loop, self._scheduled_task(task_data))
self.scheduled_tasks[task_id] = task
+ log.debug(f"{self.cog_name}: scheduled task #{task_id}.")
def cancel_task(self, task_id: str) -> None:
"""Un-schedules a task."""
@@ -51,7 +55,7 @@ class Scheduler(metaclass=CogABCMeta):
return
task.cancel()
- log.debug(f"{self.cog_name}: Unscheduled {task_id}.")
+ log.debug(f"{self.cog_name}: unscheduled task #{task_id}.")
del self.scheduled_tasks[task_id]
diff --git a/config-default.yml b/config-default.yml
index 6e8c01ad5..608e5660c 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -37,6 +37,10 @@ style:
new: "\U0001F195"
cross_mark: "\u274C"
+ upvotes: "<:upvotes:638729835245731840>"
+ comments: "<:comments:638729835073765387>"
+ user: "<:user:638729835442602003>"
+
icons:
crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
crown_green: "https://cdn.discordapp.com/emojis/469964154719961088.png"
@@ -82,6 +86,9 @@ style:
questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png"
+ superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png"
+ unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png"
+
guild:
id: 267624335836053506
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py
index 9bbd35a91..4496a2ae0 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/cogs/test_information.py
@@ -7,7 +7,11 @@ import discord
from bot import constants
from bot.cogs import information
-from tests.helpers import AsyncMock, MockBot, MockContext, MockGuild, MockMember, MockRole
+from bot.decorators import InChannelCheckFailure
+from tests import helpers
+
+
+COG_PATH = "bot.cogs.information.Information"
class InformationCogTests(unittest.TestCase):
@@ -15,22 +19,22 @@ class InformationCogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
- cls.moderator_role = MockRole(name="Moderator", role_id=constants.Roles.moderator)
+ cls.moderator_role = helpers.MockRole(name="Moderator", id=constants.Roles.moderator)
def setUp(self):
"""Sets up fresh objects for each test."""
- self.bot = MockBot()
+ self.bot = helpers.MockBot()
self.cog = information.Information(self.bot)
- self.ctx = MockContext()
+ self.ctx = helpers.MockContext()
self.ctx.author.roles.append(self.moderator_role)
def test_roles_command_command(self):
"""Test if the `role_info` command correctly returns the `moderator_role`."""
self.ctx.guild.roles.append(self.moderator_role)
- self.cog.roles_info.can_run = AsyncMock()
+ self.cog.roles_info.can_run = helpers.AsyncMock()
self.cog.roles_info.can_run.return_value = True
coroutine = self.cog.roles_info.callback(self.cog, self.ctx)
@@ -48,18 +52,18 @@ class InformationCogTests(unittest.TestCase):
def test_role_info_command(self):
"""Tests the `role info` command."""
- dummy_role = MockRole(
+ dummy_role = helpers.MockRole(
name="Dummy",
- role_id=112233445566778899,
+ id=112233445566778899,
colour=discord.Colour.blurple(),
position=10,
members=[self.ctx.author],
permissions=discord.Permissions(0)
)
- admin_role = MockRole(
+ admin_role = helpers.MockRole(
name="Admins",
- role_id=998877665544332211,
+ id=998877665544332211,
colour=discord.Colour.red(),
position=3,
members=[self.ctx.author],
@@ -68,7 +72,7 @@ class InformationCogTests(unittest.TestCase):
self.ctx.guild.roles.append([dummy_role, admin_role])
- self.cog.role_info.can_run = AsyncMock()
+ self.cog.role_info.can_run = helpers.AsyncMock()
self.cog.role_info.can_run.return_value = True
coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role)
@@ -99,7 +103,7 @@ class InformationCogTests(unittest.TestCase):
def test_server_info_command(self, time_since_patch):
time_since_patch.return_value = '2 days ago'
- self.ctx.guild = MockGuild(
+ self.ctx.guild = helpers.MockGuild(
features=('lemons', 'apples'),
region="The Moon",
roles=[self.moderator_role],
@@ -121,10 +125,10 @@ class InformationCogTests(unittest.TestCase):
)
],
members=[
- *(MockMember(status='online') for _ in range(2)),
- *(MockMember(status='idle') for _ in range(1)),
- *(MockMember(status='dnd') for _ in range(4)),
- *(MockMember(status='offline') for _ in range(3)),
+ *(helpers.MockMember(status='online') for _ in range(2)),
+ *(helpers.MockMember(status='idle') for _ in range(1)),
+ *(helpers.MockMember(status='dnd') for _ in range(4)),
+ *(helpers.MockMember(status='offline') for _ in range(3)),
],
member_count=1_234,
icon_url='a-lemon.jpg',
@@ -162,3 +166,417 @@ class InformationCogTests(unittest.TestCase):
)
)
self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg')
+
+
+class UserInfractionHelperMethodTests(unittest.TestCase):
+ """Tests for the helper methods of the `!user` command."""
+
+ def setUp(self):
+ """Common set-up steps done before for each test."""
+ self.bot = helpers.MockBot()
+ self.bot.api_client.get = helpers.AsyncMock()
+ self.cog = information.Information(self.bot)
+ self.member = helpers.MockMember(id=1234)
+
+ def test_user_command_helper_method_get_requests(self):
+ """The helper methods should form the correct get requests."""
+ test_values = (
+ {
+ "helper_method": self.cog.basic_user_infraction_counts,
+ "expected_args": ("bot/infractions", {'hidden': 'False', 'user__id': str(self.member.id)}),
+ },
+ {
+ "helper_method": self.cog.expanded_user_infraction_counts,
+ "expected_args": ("bot/infractions", {'user__id': str(self.member.id)}),
+ },
+ {
+ "helper_method": self.cog.user_nomination_counts,
+ "expected_args": ("bot/nominations", {'user__id': str(self.member.id)}),
+ },
+ )
+
+ for test_value in test_values:
+ helper_method = test_value["helper_method"]
+ endpoint, params = test_value["expected_args"]
+
+ with self.subTest(method=helper_method, endpoint=endpoint, params=params):
+ asyncio.run(helper_method(self.member))
+ self.bot.api_client.get.assert_called_once_with(endpoint, params=params)
+ self.bot.api_client.get.reset_mock()
+
+ def _method_subtests(self, method, test_values, default_header):
+ """Helper method that runs the subtests for the different helper methods."""
+ for test_value in test_values:
+ api_response = test_value["api response"]
+ expected_lines = test_value["expected_lines"]
+
+ with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):
+ self.bot.api_client.get.return_value = api_response
+
+ expected_output = "\n".join(default_header + expected_lines)
+ actual_output = asyncio.run(method(self.member))
+
+ self.assertEqual(expected_output, actual_output)
+
+ def test_basic_user_infraction_counts_returns_correct_strings(self):
+ """The method should correctly list both the total and active number of non-hidden infractions."""
+ test_values = (
+ # No infractions means zero counts
+ {
+ "api response": [],
+ "expected_lines": ["Total: 0", "Active: 0"],
+ },
+ # Simple, single-infraction dictionaries
+ {
+ "api response": [{"type": "ban", "active": True}],
+ "expected_lines": ["Total: 1", "Active: 1"],
+ },
+ {
+ "api response": [{"type": "ban", "active": False}],
+ "expected_lines": ["Total: 1", "Active: 0"],
+ },
+ # Multiple infractions with various `active` status
+ {
+ "api response": [
+ {"type": "ban", "active": True},
+ {"type": "kick", "active": False},
+ {"type": "ban", "active": True},
+ {"type": "ban", "active": False},
+ ],
+ "expected_lines": ["Total: 4", "Active: 2"],
+ },
+ )
+
+ header = ["**Infractions**"]
+
+ self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
+
+ def test_expanded_user_infraction_counts_returns_correct_strings(self):
+ """The method should correctly list the total and active number of all infractions split by infraction type."""
+ test_values = (
+ {
+ "api response": [],
+ "expected_lines": ["This user has never received an infraction."],
+ },
+ # Shows non-hidden inactive infraction as expected
+ {
+ "api response": [{"type": "kick", "active": False, "hidden": False}],
+ "expected_lines": ["Kicks: 1"],
+ },
+ # Shows non-hidden active infraction as expected
+ {
+ "api response": [{"type": "mute", "active": True, "hidden": False}],
+ "expected_lines": ["Mutes: 1 (1 active)"],
+ },
+ # Shows hidden inactive infraction as expected
+ {
+ "api response": [{"type": "superstar", "active": False, "hidden": True}],
+ "expected_lines": ["Superstars: 1"],
+ },
+ # Shows hidden active infraction as expected
+ {
+ "api response": [{"type": "ban", "active": True, "hidden": True}],
+ "expected_lines": ["Bans: 1 (1 active)"],
+ },
+ # Correctly displays tally of multiple infractions of mixed properties in alphabetical order
+ {
+ "api response": [
+ {"type": "kick", "active": False, "hidden": True},
+ {"type": "ban", "active": True, "hidden": True},
+ {"type": "superstar", "active": True, "hidden": True},
+ {"type": "mute", "active": True, "hidden": True},
+ {"type": "ban", "active": False, "hidden": False},
+ {"type": "note", "active": False, "hidden": True},
+ {"type": "note", "active": False, "hidden": True},
+ {"type": "warn", "active": False, "hidden": False},
+ {"type": "note", "active": False, "hidden": True},
+ ],
+ "expected_lines": [
+ "Bans: 2 (1 active)",
+ "Kicks: 1",
+ "Mutes: 1 (1 active)",
+ "Notes: 3",
+ "Superstars: 1 (1 active)",
+ "Warns: 1",
+ ],
+ },
+ )
+
+ header = ["**Infractions**"]
+
+ self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
+
+ def test_user_nomination_counts_returns_correct_strings(self):
+ """The method should list the number of active and historical nominations for the user."""
+ test_values = (
+ {
+ "api response": [],
+ "expected_lines": ["This user has never been nominated."],
+ },
+ {
+ "api response": [{'active': True}],
+ "expected_lines": ["This user is **currently** nominated (1 nomination in total)."],
+ },
+ {
+ "api response": [{'active': True}, {'active': False}],
+ "expected_lines": ["This user is **currently** nominated (2 nominations in total)."],
+ },
+ {
+ "api response": [{'active': False}],
+ "expected_lines": ["This user has 1 historical nomination, but is currently not nominated."],
+ },
+ {
+ "api response": [{'active': False}, {'active': False}],
+ "expected_lines": ["This user has 2 historical nominations, but is currently not nominated."],
+ },
+
+ )
+
+ header = ["**Nominations**"]
+
+ self._method_subtests(self.cog.user_nomination_counts, test_values, header)
+
+
[email protected]("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50])
+class UserEmbedTests(unittest.TestCase):
+ """Tests for the creation of the `!user` embed."""
+
+ def setUp(self):
+ """Common set-up steps done before for each test."""
+ self.bot = helpers.MockBot()
+ self.bot.api_client.get = helpers.AsyncMock()
+ self.cog = information.Information(self.bot)
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
+ """The embed should use the string representation of the user if they don't have a nick."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
+ user = helpers.MockMember()
+ user.nick = None
+ user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.title, "Mr. Hemlock")
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_nick_in_title_if_available(self):
+ """The embed should use the nick if it's available."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
+ user = helpers.MockMember()
+ user.nick = "Cat lover"
+ user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)")
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_ignores_everyone_role(self):
+ """Created `!user` embeds should not contain mention of the @everyone-role."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
+ admins_role = helpers.MockRole(name='Admins')
+ admins_role.colour = 100
+
+ # A `MockMember` has the @Everyone role by default; we add the Admins to that.
+ user = helpers.MockMember(roles=[admins_role], top_role=admins_role)
+
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertIn("&Admins", embed.description)
+ self.assertNotIn("&Everyone", embed.description)
+
+ @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=helpers.AsyncMock)
+ @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=helpers.AsyncMock)
+ def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts):
+ """The embed should contain expanded infractions and nomination info in mod channels."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50))
+
+ moderators_role = helpers.MockRole(name='Moderators')
+ moderators_role.colour = 100
+
+ infraction_counts.return_value = "expanded infractions info"
+ nomination_counts.return_value = "nomination info"
+
+ user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ infraction_counts.assert_called_once_with(user)
+ nomination_counts.assert_called_once_with(user)
+
+ self.assertEqual(
+ textwrap.dedent(f"""
+ **User Information**
+ Created: {"1 year ago"}
+ Profile: {user.mention}
+ ID: {user.id}
+
+ **Member Information**
+ Joined: {"1 year ago"}
+ Roles: &Moderators
+
+ expanded infractions info
+
+ nomination info
+ """).strip(),
+ embed.description
+ )
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=helpers.AsyncMock)
+ def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):
+ """The embed should contain only basic infraction data outside of mod channels."""
+ ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100))
+
+ moderators_role = helpers.MockRole(name='Moderators')
+ moderators_role.colour = 100
+
+ infraction_counts.return_value = "basic infractions info"
+
+ user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ infraction_counts.assert_called_once_with(user)
+
+ self.assertEqual(
+ textwrap.dedent(f"""
+ **User Information**
+ Created: {"1 year ago"}
+ Profile: {user.mention}
+ ID: {user.id}
+
+ **Member Information**
+ Joined: {"1 year ago"}
+ Roles: &Moderators
+
+ basic infractions info
+ """).strip(),
+ embed.description
+ )
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
+ """The embed should be created with the colour of the top role, if a top role is available."""
+ ctx = helpers.MockContext()
+
+ moderators_role = helpers.MockRole(name='Moderators')
+ moderators_role.colour = 100
+
+ user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.colour, discord.Colour(moderators_role.colour))
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
+ """The embed should be created with a blurple colour if the user has no assigned roles."""
+ ctx = helpers.MockContext()
+
+ user = helpers.MockMember(id=217)
+ embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+
+ self.assertEqual(embed.colour, discord.Colour.blurple())
+
+ @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=helpers.AsyncMock(return_value=""))
+ def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
+ """The embed thumbnail should be set to the user's avatar in `png` format."""
+ ctx = helpers.MockContext()
+
+ user = helpers.MockMember(id=217)
+ 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")
+ self.assertEqual(embed.thumbnail.url, "avatar url")
+
+
[email protected]("bot.cogs.information.constants")
+class UserCommandTests(unittest.TestCase):
+ """Tests for the `!user` command."""
+
+ def setUp(self):
+ """Set up steps executed before each test is run."""
+ self.bot = helpers.MockBot()
+ self.cog = information.Information(self.bot)
+
+ self.moderator_role = helpers.MockRole(name="Moderators", id=2, position=10)
+ self.flautist_role = helpers.MockRole(name="Flautists", id=3, position=2)
+ self.bassist_role = helpers.MockRole(name="Bassists", id=4, position=3)
+
+ self.author = helpers.MockMember(id=1, name="syntaxaire")
+ self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])
+ self.target = helpers.MockMember(id=3, name="__fluzz__")
+
+ def test_regular_member_cannot_target_another_member(self, constants):
+ """A regular user should not be able to use `!user` targeting another user."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+
+ ctx = helpers.MockContext(author=self.author)
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+
+ ctx.send.assert_called_once_with("You may not use this command on users other than yourself.")
+
+ def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):
+ """A regular user should not be able to use this command outside of bot-commands."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ 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):
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
+ """A regular user should be allowed to use `!user` targeting themselves in bot-commands."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ create_embed.assert_called_once_with(ctx, self.author)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants):
+ """A user should target itself with `!user` when a `user` argument was not provided."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author))
+
+ create_embed.assert_called_once_with(ctx, self.author)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
+ """Staff members should be able to bypass the bot-commands channel restriction."""
+ constants.STAFF_ROLES = [self.moderator_role.id]
+ constants.Channels.bot = 50
+
+ ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+
+ create_embed.assert_called_once_with(ctx, self.moderator)
+ ctx.send.assert_called_once()
+
+ @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=helpers.AsyncMock)
+ def test_moderators_can_target_another_member(self, create_embed, constants):
+ """A moderator should be able to use `!user` targeting another user."""
+ constants.MODERATION_ROLES = [self.moderator_role.id]
+ constants.STAFF_ROLES = [self.moderator_role.id]
+
+ ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50))
+
+ asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+
+ create_embed.assert_called_once_with(ctx, self.target)
+ ctx.send.assert_called_once()
diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py
index dfb1bafc9..3276cf5a5 100644
--- a/tests/bot/cogs/test_token_remover.py
+++ b/tests/bot/cogs/test_token_remover.py
@@ -24,7 +24,7 @@ class TokenRemoverTests(unittest.TestCase):
self.bot.get_cog.return_value.send_log_message = AsyncMock()
self.cog = TokenRemover(bot=self.bot)
- self.msg = MockMessage(message_id=555, content='')
+ self.msg = MockMessage(id=555, content='')
self.msg.author.__str__ = MagicMock()
self.msg.author.__str__.return_value = 'lemon'
self.msg.author.bot = False
diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py
new file mode 100644
index 000000000..be832843b
--- /dev/null
+++ b/tests/bot/rules/test_links.py
@@ -0,0 +1,101 @@
+import unittest
+from typing import List, NamedTuple, Tuple
+
+from bot.rules import links
+from tests.helpers import async_test
+
+
+class FakeMessage(NamedTuple):
+ author: str
+ content: str
+
+
+class Case(NamedTuple):
+ recent_messages: List[FakeMessage]
+ relevant_messages: Tuple[FakeMessage]
+ culprit: Tuple[str]
+ total_links: int
+
+
+def msg(author: str, total_links: int) -> FakeMessage:
+ """Makes a message with *total_links* links."""
+ content = " ".join(["https://pydis.com"] * total_links)
+ return FakeMessage(author=author, content=content)
+
+
+class LinksTests(unittest.TestCase):
+ """Tests applying the `links` rule."""
+
+ def setUp(self):
+ self.config = {
+ "max": 2,
+ "interval": 10
+ }
+
+ @async_test
+ async def test_links_within_limit(self):
+ """Messages with an allowed amount of links."""
+ cases = (
+ [msg("bob", 0)],
+ [msg("bob", 2)],
+ [msg("bob", 3)], # Filter only applies if len(messages_with_links) > 1
+ [msg("bob", 1), msg("bob", 1)],
+ [msg("bob", 2), msg("alice", 2)] # Only messages from latest author count
+ )
+
+ for recent_messages in cases:
+ last_message = recent_messages[0]
+
+ with self.subTest(
+ last_message=last_message,
+ recent_messages=recent_messages,
+ config=self.config
+ ):
+ self.assertIsNone(
+ await links.apply(last_message, recent_messages, self.config)
+ )
+
+ @async_test
+ async def test_links_exceeding_limit(self):
+ """Messages with a a higher than allowed amount of links."""
+ cases = (
+ Case(
+ [msg("bob", 1), msg("bob", 2)],
+ (msg("bob", 1), msg("bob", 2)),
+ ("bob",),
+ 3
+ ),
+ Case(
+ [msg("alice", 1), msg("alice", 1), msg("alice", 1)],
+ (msg("alice", 1), msg("alice", 1), msg("alice", 1)),
+ ("alice",),
+ 3
+ ),
+ Case(
+ [msg("alice", 2), msg("bob", 3), msg("alice", 1)],
+ (msg("alice", 2), msg("alice", 1)),
+ ("alice",),
+ 3
+ )
+ )
+
+ for recent_messages, relevant_messages, culprit, total_links in cases:
+ last_message = recent_messages[0]
+
+ with self.subTest(
+ last_message=last_message,
+ recent_messages=recent_messages,
+ relevant_messages=relevant_messages,
+ culprit=culprit,
+ total_links=total_links,
+ config=self.config
+ ):
+ desired_output = (
+ f"sent {total_links} links in {self.config['interval']}s",
+ culprit,
+ relevant_messages
+ )
+ self.assertTupleEqual(
+ await links.apply(last_message, recent_messages, self.config),
+ desired_output
+ )
diff --git a/tests/bot/test_api.py b/tests/bot/test_api.py
index e0ede0eb1..5a88adc5c 100644
--- a/tests/bot/test_api.py
+++ b/tests/bot/test_api.py
@@ -121,7 +121,9 @@ class LoggingHandlerTests(LoggingTestCase):
def test_schedule_queued_tasks_for_nonempty_queue(self):
"""`APILoggingHandler` should schedule logs when the queue is not empty."""
- with self.assertLogs(level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task:
+ log = logging.getLogger("bot.api")
+
+ with self.assertLogs(logger=log, level=logging.DEBUG) as logs, patch('asyncio.create_task') as create_task:
self.log_handler.queue = [555]
self.log_handler.schedule_queued_tasks()
self.assertListEqual(self.log_handler.queue, [])
diff --git a/tests/bot/test_utils.py b/tests/bot/test_utils.py
new file mode 100644
index 000000000..58ae2a81a
--- /dev/null
+++ b/tests/bot/test_utils.py
@@ -0,0 +1,52 @@
+import unittest
+
+from bot import utils
+
+
+class CaseInsensitiveDictTests(unittest.TestCase):
+ """Tests for the `CaseInsensitiveDict` container."""
+
+ def test_case_insensitive_key_access(self):
+ """Tests case insensitive key access and storage."""
+ instance = utils.CaseInsensitiveDict()
+
+ key = 'LEMON'
+ value = 'trees'
+
+ instance[key] = value
+ self.assertIn(key, instance)
+ self.assertEqual(instance.get(key), value)
+ self.assertEqual(instance.get(key.casefold()), value)
+ self.assertEqual(instance.pop(key.casefold()), value)
+ self.assertNotIn(key, instance)
+ self.assertNotIn(key.casefold(), instance)
+
+ instance.setdefault(key, value)
+ del instance[key]
+ self.assertNotIn(key, instance)
+
+ def test_initialization_from_kwargs(self):
+ """Tests creating the dictionary from keyword arguments."""
+ instance = utils.CaseInsensitiveDict({'FOO': 'bar'})
+ self.assertEqual(instance['foo'], 'bar')
+
+ def test_update_from_other_mapping(self):
+ """Tests updating the dictionary from another mapping."""
+ instance = utils.CaseInsensitiveDict()
+ instance.update({'FOO': 'bar'})
+ self.assertEqual(instance['foo'], 'bar')
+
+
+class ChunkTests(unittest.TestCase):
+ """Tests the `chunk` method."""
+
+ def test_empty_chunking(self):
+ """Tests chunking on an empty iterable."""
+ generator = utils.chunks(iterable=[], size=5)
+ self.assertEqual(list(generator), [])
+
+ def test_list_chunking(self):
+ """Tests chunking a non-empty list."""
+ iterable = [1, 2, 3, 4, 5]
+ generator = utils.chunks(iterable=iterable, size=2)
+ self.assertEqual(list(generator), [[1, 2], [3, 4], [5]])
diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py
index 19b758336..9610771e5 100644
--- a/tests/bot/utils/test_checks.py
+++ b/tests/bot/utils/test_checks.py
@@ -22,7 +22,7 @@ class ChecksTests(unittest.TestCase):
def test_with_role_check_with_guild_and_required_role(self):
"""`with_role_check` returns `True` if `Context.author` has the required role."""
- self.ctx.author.roles.append(MockRole(role_id=10))
+ self.ctx.author.roles.append(MockRole(id=10))
self.assertTrue(checks.with_role_check(self.ctx, 10))
def test_without_role_check_without_guild(self):
@@ -33,13 +33,13 @@ class ChecksTests(unittest.TestCase):
def test_without_role_check_returns_false_with_unwanted_role(self):
"""`without_role_check` returns `False` if `Context.author` has unwanted role."""
role_id = 42
- self.ctx.author.roles.append(MockRole(role_id=role_id))
+ self.ctx.author.roles.append(MockRole(id=role_id))
self.assertFalse(checks.without_role_check(self.ctx, role_id))
def test_without_role_check_returns_true_without_unwanted_role(self):
"""`without_role_check` returns `True` if `Context.author` does not have unwanted role."""
role_id = 42
- self.ctx.author.roles.append(MockRole(role_id=role_id))
+ self.ctx.author.roles.append(MockRole(id=role_id))
self.assertTrue(checks.without_role_check(self.ctx, role_id + 10))
def test_in_channel_check_for_correct_channel(self):
diff --git a/tests/helpers.py b/tests/helpers.py
index 892d42e6c..8a14aeef4 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,14 +1,28 @@
from __future__ import annotations
import asyncio
+import collections
import functools
+import inspect
+import itertools
+import logging
import unittest.mock
-from typing import Iterable, Optional
+from typing import Any, Iterable, Optional
import discord
from discord.ext.commands import Bot, Context
+for logger in logging.Logger.manager.loggerDict.values():
+ # Set all loggers to CRITICAL by default to prevent screen clutter during testing
+
+ if not isinstance(logger, logging.Logger):
+ # There might be some logging.PlaceHolder objects in there
+ continue
+
+ logger.setLevel(logging.CRITICAL)
+
+
def async_test(wrapped):
"""
Run a test case via asyncio.
@@ -24,19 +38,6 @@ def async_test(wrapped):
return wrapper
-# TODO: Remove me in Python 3.8
-class AsyncMock(unittest.mock.MagicMock):
- """
- A MagicMock subclass to mock async callables.
-
- Python 3.8 will introduce an AsyncMock class in the standard library that will have some more
- features; this stand-in only overwrites the `__call__` method to an async version.
- """
-
- async def __call__(self, *args, **kwargs):
- return super(AsyncMock, self).__call__(*args, **kwargs)
-
-
class HashableMixin(discord.mixins.EqualityComparable):
"""
Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin.
@@ -61,15 +62,66 @@ class ColourMixin:
self.colour = color
-class AttributeMock:
- """Ensures attributes of our mock types will be instantiated with the correct mock type."""
+class CustomMockMixin:
+ """
+ Provides common functionality for our custom Mock types.
+
+ The cooperative `__init__` automatically creates `AsyncMock` attributes for every coroutine
+ function `inspect` detects in the `spec` instance we provide. In addition, this mixin takes care
+ of making sure child mocks are instantiated with the correct class. By default, the mock of the
+ children will be `unittest.mock.MagicMock`, but this can be overwritten by setting the attribute
+ `child_mock_type` on the custom mock inheriting from this mixin.
+ """
+
+ child_mock_type = unittest.mock.MagicMock
+ discord_id = itertools.count(0)
+
+ def __init__(self, spec_set: Any = None, **kwargs):
+ name = kwargs.pop('name', None) # `name` has special meaning for Mock classes, so we need to set it manually.
+ super().__init__(spec_set=spec_set, **kwargs)
+
+ if name:
+ self.name = name
+ if spec_set:
+ self._extract_coroutine_methods_from_spec_instance(spec_set)
+
+ def _get_child_mock(self, **kw):
+ """
+ Overwrite of the `_get_child_mock` method to stop the propagation of our custom mock classes.
+
+ Mock objects automatically create children when you access an attribute or call a method on them. By default,
+ the class of these children is the type of the parent itself. However, this would mean that the children created
+ for our custom mock types would also be instances of that custom mock type. This is not desirable, as attributes
+ of, e.g., a `Bot` object are not `Bot` objects themselves. The Python docs for `unittest.mock` hint that
+ overwriting this method is the best way to deal with that.
+
+ This override will look for an attribute called `child_mock_type` and use that as the type of the child mock.
+ """
+ klass = self.child_mock_type
+
+ if self._mock_sealed:
+ attribute = "." + kw["name"] if "name" in kw else "()"
+ mock_name = self._extract_mock_name() + attribute
+ raise AttributeError(mock_name)
+
+ return klass(**kw)
- def __new__(cls, *args, **kwargs):
- """Stops the regular parent class from propagating to newly mocked attributes."""
- if 'parent' in kwargs:
- return cls.attribute_mocktype(*args, **kwargs)
+ def _extract_coroutine_methods_from_spec_instance(self, source: Any) -> None:
+ """Automatically detect coroutine functions in `source` and set them as AsyncMock attributes."""
+ for name, _method in inspect.getmembers(source, inspect.iscoroutinefunction):
+ setattr(self, name, AsyncMock())
- return super().__new__(cls)
+
+# TODO: Remove me in Python 3.8
+class AsyncMock(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock async callables.
+
+ Python 3.8 will introduce an AsyncMock class in the standard library that will have some more
+ features; this stand-in only overwrites the `__call__` method to an async version.
+ """
+ async def __call__(self, *args, **kwargs):
+ return super(AsyncMock, self).__call__(*args, **kwargs)
# Create a guild instance to get a realistic Mock of `discord.Guild`
@@ -95,7 +147,7 @@ guild_data = {
guild_instance = discord.Guild(data=guild_data, state=unittest.mock.MagicMock())
-class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin):
+class MockGuild(CustomMockMixin, unittest.mock.Mock, HashableMixin):
"""
A `Mock` subclass to mock `discord.Guild` objects.
@@ -121,81 +173,33 @@ class MockGuild(AttributeMock, unittest.mock.Mock, HashableMixin):
For more info, see the `Mocking` section in `tests/README.md`.
"""
+ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id), 'members': []}
+ super().__init__(spec_set=guild_instance, **collections.ChainMap(kwargs, default_kwargs))
- attribute_mocktype = unittest.mock.MagicMock
-
- def __init__(
- self,
- guild_id: int = 1,
- roles: Optional[Iterable[MockRole]] = None,
- members: Optional[Iterable[MockMember]] = None,
- **kwargs,
- ) -> None:
- super().__init__(spec=guild_instance, **kwargs)
-
- self.id = guild_id
-
- self.roles = [MockRole("@everyone", 1)]
+ self.roles = [MockRole(name="@everyone", position=1, id=0)]
if roles:
self.roles.extend(roles)
- self.members = []
- if members:
- self.members.extend(members)
-
- # `discord.Guild` coroutines
- self.create_category_channel = AsyncMock()
- self.ban = AsyncMock()
- self.bans = AsyncMock()
- self.create_category = AsyncMock()
- self.create_custom_emoji = AsyncMock()
- self.create_role = AsyncMock()
- self.create_text_channel = AsyncMock()
- self.create_voice_channel = AsyncMock()
- self.delete = AsyncMock()
- self.edit = AsyncMock()
- self.estimate_pruned_members = AsyncMock()
- self.fetch_ban = AsyncMock()
- self.fetch_channels = AsyncMock()
- self.fetch_emoji = AsyncMock()
- self.fetch_emojis = AsyncMock()
- self.fetch_member = AsyncMock()
- self.invites = AsyncMock()
- self.kick = AsyncMock()
- self.leave = AsyncMock()
- self.prune_members = AsyncMock()
- self.unban = AsyncMock()
- self.vanity_invite = AsyncMock()
- self.webhooks = AsyncMock()
- self.widget = AsyncMock()
-
# Create a Role instance to get a realistic Mock of `discord.Role`
role_data = {'name': 'role', 'id': 1}
role_instance = discord.Role(guild=guild_instance, state=unittest.mock.MagicMock(), data=role_data)
-class MockRole(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin):
+class MockRole(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
"""
A Mock subclass to mock `discord.Role` objects.
Instances of this class will follow the specifications of `discord.Role` instances. For more
information, see the `MockGuild` docstring.
"""
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id), 'name': 'role', 'position': 1}
+ super().__init__(spec_set=role_instance, **collections.ChainMap(kwargs, default_kwargs))
- attribute_mocktype = unittest.mock.MagicMock
-
- def __init__(self, name: str = "role", role_id: int = 1, position: int = 1, **kwargs) -> None:
- super().__init__(spec=role_instance, **kwargs)
-
- self.name = name
- self.id = role_id
- self.position = position
- self.mention = f'&{self.name}'
-
- # 'discord.Role' coroutines
- self.delete = AsyncMock()
- self.edit = AsyncMock()
+ if 'mention' not in kwargs:
+ self.mention = f'&{self.name}'
def __lt__(self, other):
"""Simplified position-based comparisons similar to those of `discord.Role`."""
@@ -208,126 +212,50 @@ state_mock = unittest.mock.MagicMock()
member_instance = discord.Member(data=member_data, guild=guild_instance, state=state_mock)
-class MockMember(AttributeMock, unittest.mock.Mock, ColourMixin, HashableMixin):
+class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
"""
A Mock subclass to mock Member objects.
Instances of this class will follow the specifications of `discord.Member` instances. For more
information, see the `MockGuild` docstring.
"""
+ def __init__(self, roles: Optional[Iterable[MockRole]] = None, **kwargs) -> None:
+ default_kwargs = {'name': 'member', 'id': next(self.discord_id)}
+ super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs))
- attribute_mocktype = unittest.mock.MagicMock
-
- def __init__(
- self,
- name: str = "member",
- user_id: int = 1,
- roles: Optional[Iterable[MockRole]] = None,
- **kwargs,
- ) -> None:
- super().__init__(spec=member_instance, **kwargs)
-
- self.name = name
- self.id = user_id
-
- self.roles = [MockRole("@everyone", 1)]
+ self.roles = [MockRole(name="@everyone", position=1, id=0)]
if roles:
self.roles.extend(roles)
- self.mention = f"@{self.name}"
-
- # `discord.Member` coroutines
- self.add_roles = AsyncMock()
- self.ban = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.kick = AsyncMock()
- self.move_to = AsyncMock()
- self.pins = AsyncMock()
- self.remove_roles = AsyncMock()
- self.send = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.unban = AsyncMock()
+ if 'mention' not in kwargs:
+ self.mention = f"@{self.name}"
# Create a Bot instance to get a realistic MagicMock of `discord.ext.commands.Bot`
bot_instance = Bot(command_prefix=unittest.mock.MagicMock())
+bot_instance.http_session = None
+bot_instance.api_client = None
-class MockBot(AttributeMock, unittest.mock.MagicMock):
+class MockBot(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Bot objects.
Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.
For more information, see the `MockGuild` docstring.
"""
-
- attribute_mocktype = unittest.mock.MagicMock
-
def __init__(self, **kwargs) -> None:
- super().__init__(spec=bot_instance, **kwargs)
-
- # `discord.ext.commands.Bot` coroutines
- self._before_invoke = AsyncMock()
- self._after_invoke = AsyncMock()
- self.application_info = AsyncMock()
- self.change_presence = AsyncMock()
- self.connect = AsyncMock()
- self.close = AsyncMock()
- self.create_guild = AsyncMock()
- self.delete_invite = AsyncMock()
- self.fetch_channel = AsyncMock()
- self.fetch_guild = AsyncMock()
- self.fetch_guilds = AsyncMock()
- self.fetch_invite = AsyncMock()
- self.fetch_user = AsyncMock()
- self.fetch_user_profile = AsyncMock()
- self.fetch_webhook = AsyncMock()
- self.fetch_widget = AsyncMock()
- self.get_context = AsyncMock()
- self.get_prefix = AsyncMock()
- self.invoke = AsyncMock()
- self.is_owner = AsyncMock()
- self.login = AsyncMock()
- self.logout = AsyncMock()
- self.on_command_error = AsyncMock()
- self.on_error = AsyncMock()
- self.process_commands = AsyncMock()
- self.request_offline_members = AsyncMock()
- self.start = AsyncMock()
- self.wait_until_ready = AsyncMock()
- self.wait_for = AsyncMock()
-
+ super().__init__(spec_set=bot_instance, **kwargs)
-# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
-context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
-
-
-class MockContext(AttributeMock, unittest.mock.MagicMock):
- """
- A MagicMock subclass to mock Context objects.
-
- Instances of this class will follow the specifications of `discord.ext.commands.Context`
- instances. For more information, see the `MockGuild` docstring.
- """
+ # self.wait_for is *not* a coroutine function, but returns a coroutine nonetheless and
+ # and should therefore be awaited. (The documentation calls it a coroutine as well, which
+ # is technically incorrect, since it's a regular def.)
+ self.wait_for = AsyncMock()
- attribute_mocktype = unittest.mock.MagicMock
-
- def __init__(self, **kwargs) -> None:
- super().__init__(spec=context_instance, **kwargs)
- self.bot = MockBot()
- self.guild = MockGuild()
- self.author = MockMember()
- self.command = unittest.mock.MagicMock()
-
- # `discord.ext.commands.Context` coroutines
- self.fetch_message = AsyncMock()
- self.invoke = AsyncMock()
- self.pins = AsyncMock()
- self.reinvoke = AsyncMock()
- self.send = AsyncMock()
- self.send_help = AsyncMock()
- self.trigger_typing = AsyncMock()
+ # Since calling `create_task` on our MockBot does not actually schedule the coroutine object
+ # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object
+ # to prevent "has not been awaited"-warnings.
+ self.loop.create_task.side_effect = lambda coroutine: coroutine.close()
# Create a TextChannel instance to get a realistic MagicMock of `discord.TextChannel`
@@ -346,38 +274,19 @@ guild = unittest.mock.MagicMock()
channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data)
-class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin):
+class MockTextChannel(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.
"""
-
- attribute_mocktype = unittest.mock.MagicMock
-
def __init__(self, name: str = 'channel', channel_id: int = 1, **kwargs) -> None:
- super().__init__(spec=channel_instance, **kwargs)
- self.id = channel_id
- self.name = name
- self.guild = MockGuild()
- self.mention = f"#{self.name}"
-
- # `discord.TextChannel` coroutines
- self.clone = AsyncMock()
- self.create_invite = AsyncMock()
- self.create_webhook = AsyncMock()
- self.delete = AsyncMock()
- self.delete_messages = AsyncMock()
- self.edit = AsyncMock()
- self.fetch_message = AsyncMock()
- self.invites = AsyncMock()
- self.pins = AsyncMock()
- self.purge = AsyncMock()
- self.send = AsyncMock()
- self.set_permissions = AsyncMock()
- self.trigger_typing = AsyncMock()
- self.webhooks = AsyncMock()
+ default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()}
+ super().__init__(spec_set=channel_instance, **collections.ChainMap(kwargs, default_kwargs))
+
+ if 'mention' not in kwargs:
+ self.mention = f"#{self.name}"
# Create a Message instance to get a realistic MagicMock of `discord.Message`
@@ -402,27 +311,79 @@ channel = unittest.mock.MagicMock()
message_instance = discord.Message(state=state, channel=channel, data=message_data)
-class MockMessage(AttributeMock, unittest.mock.MagicMock):
+# Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context`
+context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock())
+
+
+class MockContext(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Context objects.
+
+ Instances of this class will follow the specifications of `discord.ext.commands.Context`
+ instances. For more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=context_instance, **kwargs)
+ self.bot = kwargs.get('bot', MockBot())
+ self.guild = kwargs.get('guild', MockGuild())
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
+
+
+class MockMessage(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Message objects.
Instances of this class will follow the specifications of `discord.Message` instances. For more
information, see the `MockGuild` docstring.
"""
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=message_instance, **kwargs)
+ self.author = kwargs.get('author', MockMember())
+ self.channel = kwargs.get('channel', MockTextChannel())
+
+
+emoji_data = {'require_colons': True, 'managed': True, 'id': 1, 'name': 'hyperlemon'}
+emoji_instance = discord.Emoji(guild=MockGuild(), state=unittest.mock.MagicMock(), data=emoji_data)
+
+
+class MockEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Emoji objects.
+
+ Instances of this class will follow the specifications of `discord.Emoji` instances. For more
+ information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=emoji_instance, **kwargs)
+ self.guild = kwargs.get('guild', MockGuild())
+
+
+partial_emoji_instance = discord.PartialEmoji(animated=False, name='guido')
+
+
+class MockPartialEmoji(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock PartialEmoji objects.
+
+ Instances of this class will follow the specifications of `discord.PartialEmoji` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=partial_emoji_instance, **kwargs)
- attribute_mocktype = unittest.mock.MagicMock
+reaction_instance = discord.Reaction(message=MockMessage(), data={'me': True}, emoji=MockEmoji())
+
+
+class MockReaction(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Reaction objects.
+
+ Instances of this class will follow the specifications of `discord.Reaction` instances. For
+ more information, see the `MockGuild` docstring.
+ """
def __init__(self, **kwargs) -> None:
- super().__init__(spec=message_instance, **kwargs)
- self.author = MockMember()
- self.channel = MockTextChannel()
-
- # `discord.Message` coroutines
- self.ack = AsyncMock()
- self.add_reaction = AsyncMock()
- self.clear_reactions = AsyncMock()
- self.delete = AsyncMock()
- self.edit = AsyncMock()
- self.pin = AsyncMock()
- self.remove_reaction = AsyncMock()
- self.unpin = AsyncMock()
+ super().__init__(spec_set=reaction_instance, **kwargs)
+ self.emoji = kwargs.get('emoji', MockEmoji())
+ self.message = kwargs.get('message', MockMessage())
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index f08239981..7894e104a 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -19,7 +19,6 @@ class DiscordMocksTests(unittest.TestCase):
self.assertIsInstance(role, discord.Role)
self.assertEqual(role.name, "role")
- self.assertEqual(role.id, 1)
self.assertEqual(role.position, 1)
self.assertEqual(role.mention, "&role")
@@ -27,7 +26,7 @@ class DiscordMocksTests(unittest.TestCase):
"""Test if MockRole initializes with the arguments provided."""
role = helpers.MockRole(
name="Admins",
- role_id=90210,
+ id=90210,
position=10,
)
@@ -67,22 +66,21 @@ class DiscordMocksTests(unittest.TestCase):
self.assertIsInstance(member, discord.Member)
self.assertEqual(member.name, "member")
- self.assertEqual(member.id, 1)
- self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1)])
+ self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0)])
self.assertEqual(member.mention, "@member")
def test_mock_member_alternative_arguments(self):
"""Test if MockMember initializes with the arguments provided."""
- core_developer = helpers.MockRole("Core Developer", 2)
+ core_developer = helpers.MockRole(name="Core Developer", position=2)
member = helpers.MockMember(
name="Mark",
- user_id=12345,
+ id=12345,
roles=[core_developer]
)
self.assertEqual(member.name, "Mark")
self.assertEqual(member.id, 12345)
- self.assertListEqual(member.roles, [helpers.MockRole("@everyone", 1), core_developer])
+ self.assertListEqual(member.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer])
self.assertEqual(member.mention, "@Mark")
def test_mock_member_accepts_dynamic_arguments(self):
@@ -102,19 +100,19 @@ class DiscordMocksTests(unittest.TestCase):
# The `spec` argument makes sure `isistance` checks with `discord.Guild` pass
self.assertIsInstance(guild, discord.Guild)
- self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1)])
+ self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0)])
self.assertListEqual(guild.members, [])
def test_mock_guild_alternative_arguments(self):
"""Test if MockGuild initializes with the arguments provided."""
- core_developer = helpers.MockRole("Core Developer", 2)
+ core_developer = helpers.MockRole(name="Core Developer", position=2)
guild = helpers.MockGuild(
roles=[core_developer],
- members=[helpers.MockMember(user_id=54321)],
+ members=[helpers.MockMember(id=54321)],
)
- self.assertListEqual(guild.roles, [helpers.MockRole("@everyone", 1), core_developer])
- self.assertListEqual(guild.members, [helpers.MockMember(user_id=54321)])
+ self.assertListEqual(guild.roles, [helpers.MockRole(name="@everyone", position=1, id=0), core_developer])
+ self.assertListEqual(guild.members, [helpers.MockMember(id=54321)])
def test_mock_guild_accepts_dynamic_arguments(self):
"""Test if MockGuild accepts and sets abitrary keyword arguments."""
@@ -191,51 +189,30 @@ class DiscordMocksTests(unittest.TestCase):
with self.assertRaises(AttributeError):
mock.the_cake_is_a_lie
- def test_custom_mock_methods_are_valid_discord_object_methods(self):
- """The `AsyncMock` attributes of the mocks should be valid for the class they're mocking."""
- mocks = (
- (helpers.MockGuild, helpers.guild_instance),
- (helpers.MockRole, helpers.role_instance),
- (helpers.MockMember, helpers.member_instance),
- (helpers.MockBot, helpers.bot_instance),
- (helpers.MockContext, helpers.context_instance),
- (helpers.MockTextChannel, helpers.channel_instance),
- (helpers.MockMessage, helpers.message_instance),
+ def test_mocks_use_mention_when_provided_as_kwarg(self):
+ """The mock should use the passed `mention` instead of the default one if present."""
+ test_cases = (
+ (helpers.MockRole, "role mention"),
+ (helpers.MockMember, "member mention"),
+ (helpers.MockTextChannel, "channel mention"),
)
- for mock_class, instance in mocks:
- mock = mock_class()
- async_methods = (
- attr for attr in dir(mock) if isinstance(getattr(mock, attr), helpers.AsyncMock)
- )
-
- # spec_mock = unittest.mock.MagicMock(spec=instance)
- for method in async_methods:
- with self.subTest(mock_class=mock_class, method=method):
- try:
- getattr(instance, method)
- except AttributeError:
- msg = f"method {method} is not a method attribute of {instance.__class__}"
- self.fail(msg)
-
- @unittest.mock.patch(f'{__name__}.DiscordMocksTests.subTest')
- def test_the_custom_mock_methods_test(self, subtest_mock):
- """The custom method test should raise AssertionError for invalid methods."""
- class FakeMockBot(helpers.AttributeMock, unittest.mock.MagicMock):
- """Fake MockBot class with invalid attribute/method `release_the_walrus`."""
-
- attribute_mocktype = unittest.mock.MagicMock
+ for mock_type, mention in test_cases:
+ with self.subTest(mock_type=mock_type, mention=mention):
+ mock = mock_type(mention=mention)
+ self.assertEqual(mock.mention, mention)
- def __init__(self, **kwargs):
- super().__init__(spec=helpers.bot_instance, **kwargs)
+ def test_create_test_on_mock_bot_closes_passed_coroutine(self):
+ """`bot.loop.create_task` should close the passed coroutine object to prevent warnings."""
+ async def dementati():
+ """Dummy coroutine for testing purposes."""
- # Fake attribute
- self.release_the_walrus = helpers.AsyncMock()
+ coroutine_object = dementati()
- with unittest.mock.patch("tests.helpers.MockBot", new=FakeMockBot):
- msg = "method release_the_walrus is not a valid method of <class 'discord.ext.commands.bot.Bot'>"
- with self.assertRaises(AssertionError, msg=msg):
- self.test_custom_mock_methods_are_valid_discord_object_methods()
+ bot = helpers.MockBot()
+ bot.loop.create_task(coroutine_object)
+ with self.assertRaises(RuntimeError, msg="cannot reuse already awaited coroutine"):
+ asyncio.run(coroutine_object)
class MockObjectTests(unittest.TestCase):
@@ -266,14 +243,14 @@ class MockObjectTests(unittest.TestCase):
def test_hashable_mixin_uses_id_for_equality_comparison(self):
"""Test if the HashableMixing uses the id attribute for hashing."""
- class MockScragly(unittest.mock.Mock, helpers.HashableMixin):
+ class MockScragly(helpers.HashableMixin):
pass
- scragly = MockScragly(spec=object)
+ scragly = MockScragly()
scragly.id = 10
- eevee = MockScragly(spec=object)
+ eevee = MockScragly()
eevee.id = 10
- python = MockScragly(spec=object)
+ python = MockScragly()
python.id = 20
self.assertTrue(scragly == eevee)
@@ -281,14 +258,14 @@ class MockObjectTests(unittest.TestCase):
def test_hashable_mixin_uses_id_for_nonequality_comparison(self):
"""Test if the HashableMixing uses the id attribute for hashing."""
- class MockScragly(unittest.mock.Mock, helpers.HashableMixin):
+ class MockScragly(helpers.HashableMixin):
pass
- scragly = MockScragly(spec=object)
+ scragly = MockScragly()
scragly.id = 10
- eevee = MockScragly(spec=object)
+ eevee = MockScragly()
eevee.id = 10
- python = MockScragly(spec=object)
+ python = MockScragly()
python.id = 20
self.assertTrue(scragly != python)
@@ -298,7 +275,7 @@ class MockObjectTests(unittest.TestCase):
"""Test if the MagicMock subclasses that implement the HashableMixin use id for hash."""
for mock in self.hashable_mocks:
with self.subTest(mock_class=mock):
- instance = helpers.MockRole(role_id=100)
+ instance = helpers.MockRole(id=100)
self.assertEqual(hash(instance), instance.id)
def test_mock_class_with_hashable_mixin_uses_id_for_equality(self):
@@ -331,6 +308,18 @@ class MockObjectTests(unittest.TestCase):
self.assertFalse(instance_one != instance_two)
self.assertTrue(instance_one != instance_three)
+ def test_custom_mock_mixin_accepts_mock_seal(self):
+ """The `CustomMockMixin` should support `unittest.mock.seal`."""
+ class MyMock(helpers.CustomMockMixin, unittest.mock.MagicMock):
+
+ child_mock_type = unittest.mock.MagicMock
+ pass
+
+ mock = MyMock()
+ unittest.mock.seal(mock)
+ with self.assertRaises(AttributeError, msg="MyMock.shirayuki"):
+ mock.shirayuki = "hello!"
+
def test_spec_propagation_of_mock_subclasses(self):
"""Test if the `spec` does not propagate to attributes of the mock object."""
test_values = (
@@ -339,6 +328,10 @@ class MockObjectTests(unittest.TestCase):
(helpers.MockMember, "display_name"),
(helpers.MockBot, "owner_id"),
(helpers.MockContext, "command_failed"),
+ (helpers.MockMessage, "mention_everyone"),
+ (helpers.MockEmoji, 'managed'),
+ (helpers.MockPartialEmoji, 'url'),
+ (helpers.MockReaction, 'me'),
)
for mock_type, valid_attribute in test_values:
@@ -346,7 +339,53 @@ class MockObjectTests(unittest.TestCase):
mock = mock_type()
self.assertTrue(isinstance(mock, mock_type))
attribute = getattr(mock, valid_attribute)
- self.assertTrue(isinstance(attribute, mock_type.attribute_mocktype))
+ self.assertTrue(isinstance(attribute, mock_type.child_mock_type))
+
+ def test_extract_coroutine_methods_from_spec_instance_should_extract_all_and_only_coroutines(self):
+ """Test if all coroutine functions are extracted, but not regular methods or attributes."""
+ class CoroutineDonor:
+ def __init__(self):
+ self.some_attribute = 'alpha'
+
+ async def first_coroutine():
+ """This coroutine function should be extracted."""
+
+ async def second_coroutine():
+ """This coroutine function should be extracted."""
+
+ def regular_method():
+ """This regular function should not be extracted."""
+
+ class Receiver:
+ pass
+
+ donor = CoroutineDonor()
+ receiver = Receiver()
+
+ helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance(receiver, donor)
+
+ self.assertIsInstance(receiver.first_coroutine, helpers.AsyncMock)
+ self.assertIsInstance(receiver.second_coroutine, helpers.AsyncMock)
+ self.assertFalse(hasattr(receiver, 'regular_method'))
+ self.assertFalse(hasattr(receiver, 'some_attribute'))
+
+ @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock())
+ @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance")
+ def test_custom_mock_mixin_init_with_spec(self, extract_method_mock):
+ """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method."""
+ spec_set = "pydis"
+
+ helpers.CustomMockMixin(spec_set=spec_set)
+
+ extract_method_mock.assert_called_once_with(spec_set)
+
+ @unittest.mock.patch("builtins.super", new=unittest.mock.MagicMock())
+ @unittest.mock.patch("tests.helpers.CustomMockMixin._extract_coroutine_methods_from_spec_instance")
+ def test_custom_mock_mixin_init_without_spec(self, extract_method_mock):
+ """Test if CustomMockMixin correctly passes on spec/kwargs and calls the extraction method."""
+ helpers.CustomMockMixin()
+
+ extract_method_mock.assert_not_called()
def test_async_mock_provides_coroutine_for_dunder_call(self):
"""Test if AsyncMock objects have a coroutine for their __call__ method."""