aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar MarkKoz <[email protected]>2020-02-08 07:32:14 -0800
committerGravatar MarkKoz <[email protected]>2020-02-08 07:32:14 -0800
commit4d667bbefcd18998714841b603889c7d39e1301b (patch)
tree930e30a34d4ad048cd4bdb0ea186d1a5e16116b3
parentFix linting error (diff)
parentMerge pull request #743 from python-discord/dep/b734/discord.py-1.3 (diff)
Merge remote-tracking branch 'origin/master' into emoji-cleanup
-rw-r--r--.github/CODEOWNERS1
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock646
-rw-r--r--README.md2
-rw-r--r--azure-pipelines.yml2
-rw-r--r--bot/__init__.py1
-rw-r--r--bot/__main__.py27
-rw-r--r--bot/api.py41
-rw-r--r--bot/bot.py53
-rw-r--r--bot/cogs/alias.py25
-rw-r--r--bot/cogs/antimalware.py50
-rw-r--r--bot/cogs/antispam.py39
-rw-r--r--bot/cogs/bot.py27
-rw-r--r--bot/cogs/clean.py68
-rw-r--r--bot/cogs/defcon.py6
-rw-r--r--bot/cogs/doc.py263
-rw-r--r--bot/cogs/duck_pond.py182
-rw-r--r--bot/cogs/error_handler.py16
-rw-r--r--bot/cogs/eval.py10
-rw-r--r--bot/cogs/extensions.py4
-rw-r--r--bot/cogs/filtering.py55
-rw-r--r--bot/cogs/free.py31
-rw-r--r--bot/cogs/help.py3
-rw-r--r--bot/cogs/information.py188
-rw-r--r--bot/cogs/jams.py8
-rw-r--r--bot/cogs/logging.py6
-rw-r--r--bot/cogs/moderation/__init__.py16
-rw-r--r--bot/cogs/moderation/infractions.py467
-rw-r--r--bot/cogs/moderation/management.py75
-rw-r--r--bot/cogs/moderation/modlog.py272
-rw-r--r--bot/cogs/moderation/scheduler.py418
-rw-r--r--bot/cogs/moderation/superstarify.py305
-rw-r--r--bot/cogs/moderation/utils.py108
-rw-r--r--bot/cogs/off_topic_names.py22
-rw-r--r--bot/cogs/reddit.py323
-rw-r--r--bot/cogs/reminders.py19
-rw-r--r--bot/cogs/security.py7
-rw-r--r--bot/cogs/site.py14
-rw-r--r--bot/cogs/snekbox.py8
-rw-r--r--bot/cogs/sync/__init__.py10
-rw-r--r--bot/cogs/sync/cog.py58
-rw-r--r--bot/cogs/sync/syncers.py7
-rw-r--r--bot/cogs/tags.py97
-rw-r--r--bot/cogs/token_remover.py77
-rw-r--r--bot/cogs/utils.py20
-rw-r--r--bot/cogs/verification.py91
-rw-r--r--bot/cogs/watchchannels/__init__.py13
-rw-r--r--bot/cogs/watchchannels/bigbrother.py26
-rw-r--r--bot/cogs/watchchannels/talentpool.py34
-rw-r--r--bot/cogs/watchchannels/watchchannel.py21
-rw-r--r--bot/cogs/wolfram.py10
-rw-r--r--bot/constants.py99
-rw-r--r--bot/converters.py97
-rw-r--r--bot/decorators.py18
-rw-r--r--bot/interpreter.py8
-rw-r--r--bot/rules/attachments.py4
-rw-r--r--bot/utils/checks.py6
-rw-r--r--bot/utils/messages.py49
-rw-r--r--bot/utils/scheduling.py6
-rw-r--r--bot/utils/time.py52
-rw-r--r--config-default.yml66
-rw-r--r--docker-compose.yml2
-rw-r--r--tests/README.md10
-rw-r--r--tests/bot/cogs/test_duck_pond.py584
-rw-r--r--tests/bot/cogs/test_information.py448
-rw-r--r--tests/bot/cogs/test_security.py11
-rw-r--r--tests/bot/cogs/test_token_remover.py10
-rw-r--r--tests/bot/rules/test_attachments.py110
-rw-r--r--tests/bot/rules/test_links.py97
-rw-r--r--tests/bot/rules/test_mentions.py95
-rw-r--r--tests/bot/test_api.py4
-rw-r--r--tests/bot/test_utils.py52
-rw-r--r--tests/bot/utils/test_checks.py14
-rw-r--r--tests/bot/utils/test_time.py162
-rw-r--r--tests/helpers.py521
-rw-r--r--tests/test_helpers.py163
76 files changed, 4834 insertions, 2128 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..cf5f1590d
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @python-discord/core-developers
diff --git a/Pipfile b/Pipfile
index 48d839fc3..7fd3efae8 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
-discord-py = "~=1.2"
+discord-py = "~=1.3.1"
aiodns = "~=2.0"
logmatic-python = "~=0.1"
aiohttp = "~=3.5"
diff --git a/Pipfile.lock b/Pipfile.lock
index 95955ff89..bf8ff47e9 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "c27d699b4aeeed204dee41f924f682ae2a670add8549a8826e58776594370582"
+ "sha256": "0a0354a8cbd25b19c61b68f928493a445e737dc6447c97f4c4b52fbf72d887ac"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:1dcec3e3e3309e277511dc0d7d157676d0165c174a6a745673fc9cf0510db8f0",
- "sha256:dd5a23ca26a4872ee73bd107e4c545bace572cdec2a574aeb61f4062c7774b2a"
+ "sha256:a5837277e53755078db3a9e8c45bbca605c8ba9ecba7a02d74a7a1779f444723",
+ "sha256:fa32e33b4b7d0804dcf439ae6ff24d2f0a83d1ba280ee9f555e647d71d394ff5"
],
"index": "pypi",
- "version": "==6.1.3"
+ "version": "==6.4.1"
},
"aiodns": {
"hashes": [
@@ -34,38 +34,28 @@
},
"aiohttp": {
"hashes": [
- "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55",
- "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed",
- "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10",
- "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5",
- "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1",
- "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939",
- "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390",
- "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa",
- "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc",
- "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5",
- "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d",
- "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf",
- "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6",
- "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72",
- "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12",
- "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366",
- "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4",
- "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300",
- "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d",
- "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303",
- "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6",
- "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889"
+ "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
+ "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
+ "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
+ "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
+ "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
+ "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
+ "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
+ "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
+ "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
+ "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
+ "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
+ "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
],
"index": "pypi",
- "version": "==3.5.4"
+ "version": "==3.6.2"
},
"aiormq": {
"hashes": [
- "sha256:c3e4dd01a2948a75f739fb637334dbb8c6f1a4cecf74d5ed662dc3bab7f39973",
- "sha256:e220d3f9477bb2959b729b79bec815148ddb8a7686fc6c3d05d41c88ebd7c59e"
+ "sha256:8c215a970133ab5ee7c478decac55b209af7731050f52d11439fe910fa0f9e9d",
+ "sha256:9210f3389200aee7d8067f6435f4a9eff2d3a30b88beb5eaae406ccc11c0fc01"
],
- "version": "==2.8.0"
+ "version": "==3.2.0"
},
"alabaster": {
"hashes": [
@@ -83,65 +73,65 @@
},
"attrs": {
"hashes": [
- "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2",
- "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396"
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
- "version": "==19.2.0"
+ "version": "==19.3.0"
},
"babel": {
"hashes": [
- "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
- "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
+ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
+ "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
],
- "version": "==2.7.0"
+ "version": "==2.8.0"
},
"beautifulsoup4": {
"hashes": [
- "sha256:5279c36b4b2ec2cb4298d723791467e3000e5384a43ea0cdf5d45207c7e97169",
- "sha256:6135db2ba678168c07950f9a16c4031822c6f4aec75a65e0a97bc5ca09789931",
- "sha256:dcdef580e18a76d54002088602eba453eec38ebbcafafeaabd8cab12b6155d57"
+ "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a",
+ "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887",
+ "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"
],
- "version": "==4.8.1"
+ "version": "==4.8.2"
},
"certifi": {
"hashes": [
- "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
- "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
+ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
+ "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
- "version": "==2019.9.11"
+ "version": "==2019.11.28"
},
"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:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff",
+ "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b",
+ "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac",
+ "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0",
+ "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384",
+ "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26",
+ "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6",
+ "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b",
+ "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e",
+ "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd",
+ "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2",
+ "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66",
+ "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc",
+ "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8",
+ "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55",
+ "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4",
+ "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5",
+ "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d",
+ "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78",
+ "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa",
+ "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793",
+ "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f",
+ "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a",
+ "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f",
+ "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30",
+ "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f",
+ "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3",
+ "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"
+ ],
+ "version": "==1.14.0"
},
"chardet": {
"hashes": [
@@ -152,26 +142,25 @@
},
"deepdiff": {
"hashes": [
- "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476",
- "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127"
+ "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a",
+ "sha256:ed7342d3ed3c0c2058a3fb05b477c943c9959ef62223dca9baa3375718a25d87"
],
"index": "pypi",
- "version": "==4.0.7"
+ "version": "==4.2.0"
},
"discord-py": {
"hashes": [
- "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
+ "sha256:8bfe5628d31771744000f19135c386c74ac337479d7282c26cc1627b9d31f360"
],
"index": "pypi",
- "version": "==1.2.3"
+ "version": "==1.3.1"
},
"docutils": {
"hashes": [
- "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
- "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
- "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
+ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
+ "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
],
- "version": "==0.15.2"
+ "version": "==0.16"
},
"fuzzywuzzy": {
"hashes": [
@@ -190,24 +179,17 @@
},
"imagesize": {
"hashes": [
- "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
- "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"
+ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
+ "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
],
- "version": "==1.1.0"
+ "version": "==1.2.0"
},
"jinja2": {
"hashes": [
- "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f",
- "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"
- ],
- "version": "==2.10.3"
- },
- "jsonpickle": {
- "hashes": [
- "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2",
- "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b"
+ "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
+ "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
],
- "version": "==1.2"
+ "version": "==2.11.1"
},
"logmatic-python": {
"hashes": [
@@ -218,31 +200,36 @@
},
"lxml": {
"hashes": [
- "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4",
- "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc",
- "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1",
- "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046",
- "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36",
- "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5",
- "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d",
- "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916",
- "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0",
- "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27",
- "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc",
- "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7",
- "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38",
- "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5",
- "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832",
- "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a",
- "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f",
- "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9",
- "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692",
- "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84",
- "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79",
- "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681"
- ],
- "index": "pypi",
- "version": "==4.4.1"
+ "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd",
+ "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c",
+ "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081",
+ "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f",
+ "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261",
+ "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a",
+ "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9",
+ "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a",
+ "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb",
+ "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60",
+ "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128",
+ "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a",
+ "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717",
+ "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89",
+ "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72",
+ "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8",
+ "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3",
+ "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7",
+ "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8",
+ "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77",
+ "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1",
+ "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15",
+ "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679",
+ "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012",
+ "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6",
+ "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc",
+ "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca"
+ ],
+ "index": "pypi",
+ "version": "==4.5.0"
},
"markdownify": {
"hashes": [
@@ -257,13 +244,16 @@
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
+ "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
+ "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
+ "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
@@ -280,7 +270,9 @@
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
- "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
+ "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
+ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
+ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
],
"version": "==1.1.1"
},
@@ -294,37 +286,25 @@
},
"multidict": {
"hashes": [
- "sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
- "sha256:041e9442b11409be5e4fc8b6a97e4bcead758ab1e11768d1e69160bdde18acc3",
- "sha256:045b4dd0e5f6121e6f314d81759abd2c257db4634260abcfe0d3f7083c4908ef",
- "sha256:047c0a04e382ef8bd74b0de01407e8d8632d7d1b4db6f2561106af812a68741b",
- "sha256:068167c2d7bbeebd359665ac4fff756be5ffac9cda02375b5c5a7c4777038e73",
- "sha256:148ff60e0fffa2f5fad2eb25aae7bef23d8f3b8bdaf947a65cdbe84a978092bc",
- "sha256:1d1c77013a259971a72ddaa83b9f42c80a93ff12df6a4723be99d858fa30bee3",
- "sha256:1d48bc124a6b7a55006d97917f695effa9725d05abe8ee78fd60d6588b8344cd",
- "sha256:31dfa2fc323097f8ad7acd41aa38d7c614dd1960ac6681745b6da124093dc351",
- "sha256:34f82db7f80c49f38b032c5abb605c458bac997a6c3142e0d6c130be6fb2b941",
- "sha256:3d5dd8e5998fb4ace04789d1d008e2bb532de501218519d70bb672c4c5a2fc5d",
- "sha256:4a6ae52bd3ee41ee0f3acf4c60ceb3f44e0e3bc52ab7da1c2b2aa6703363a3d1",
- "sha256:4b02a3b2a2f01d0490dd39321c74273fed0568568ea0e7ea23e02bd1fb10a10b",
- "sha256:4b843f8e1dd6a3195679d9838eb4670222e8b8d01bc36c9894d6c3538316fa0a",
- "sha256:5de53a28f40ef3c4fd57aeab6b590c2c663de87a5af76136ced519923d3efbb3",
- "sha256:61b2b33ede821b94fa99ce0b09c9ece049c7067a33b279f343adfe35108a4ea7",
- "sha256:6a3a9b0f45fd75dc05d8e93dc21b18fc1670135ec9544d1ad4acbcf6b86781d0",
- "sha256:76ad8e4c69dadbb31bad17c16baee61c0d1a4a73bed2590b741b2e1a46d3edd0",
- "sha256:7ba19b777dc00194d1b473180d4ca89a054dd18de27d0ee2e42a103ec9b7d014",
- "sha256:7c1b7eab7a49aa96f3db1f716f0113a8a2e93c7375dd3d5d21c4941f1405c9c5",
- "sha256:7fc0eee3046041387cbace9314926aa48b681202f8897f8bff3809967a049036",
- "sha256:8ccd1c5fff1aa1427100ce188557fc31f1e0a383ad8ec42c559aabd4ff08802d",
- "sha256:8e08dd76de80539d613654915a2f5196dbccc67448df291e69a88712ea21e24a",
- "sha256:c18498c50c59263841862ea0501da9f2b3659c00db54abfbf823a80787fde8ce",
- "sha256:c49db89d602c24928e68c0d510f4fcf8989d77defd01c973d6cbe27e684833b1",
- "sha256:ce20044d0317649ddbb4e54dab3c1bcc7483c78c27d3f58ab3d0c7e6bc60d26a",
- "sha256:d1071414dd06ca2eafa90c85a079169bfeb0e5f57fd0b45d44c092546fcd6fd9",
- "sha256:d3be11ac43ab1a3e979dac80843b42226d5d3cccd3986f2e03152720a4297cd7",
- "sha256:db603a1c235d110c860d5f39988ebc8218ee028f07a7cbc056ba6424372ca31b"
- ],
- "version": "==4.5.2"
+ "sha256:13f3ebdb5693944f52faa7b2065b751cb7e578b8dd0a5bb8e4ab05ad0188b85e",
+ "sha256:26502cefa86d79b86752e96639352c7247846515c864d7c2eb85d036752b643c",
+ "sha256:4fba5204d32d5c52439f88437d33ad14b5f228e25072a192453f658bddfe45a7",
+ "sha256:527124ef435f39a37b279653ad0238ff606b58328ca7989a6df372fd75d7fe26",
+ "sha256:5414f388ffd78c57e77bd253cf829373721f450613de53dc85a08e34d806e8eb",
+ "sha256:5eee66f882ab35674944dfa0d28b57fa51e160b4dce0ce19e47f495fdae70703",
+ "sha256:63810343ea07f5cd86ba66ab66706243a6f5af075eea50c01e39b4ad6bc3c57a",
+ "sha256:6bd10adf9f0d6a98ccc792ab6f83d18674775986ba9bacd376b643fe35633357",
+ "sha256:83c6ddf0add57c6b8a7de0bc7e2d656be3eefeff7c922af9a9aae7e49f225625",
+ "sha256:93166e0f5379cf6cd29746989f8a594fa7204dcae2e9335ddba39c870a287e1c",
+ "sha256:9a7b115ee0b9b92d10ebc246811d8f55d0c57e82dbb6a26b23c9a9a6ad40ce0c",
+ "sha256:a38baa3046cce174a07a59952c9f876ae8875ef3559709639c17fdf21f7b30dd",
+ "sha256:a6d219f49821f4b2c85c6d426346a5d84dab6daa6f85ca3da6c00ed05b54022d",
+ "sha256:a8ed33e8f9b67e3b592c56567135bb42e7e0e97417a4b6a771e60898dfd5182b",
+ "sha256:d7d428488c67b09b26928950a395e41cc72bb9c3d5abfe9f0521940ee4f796d4",
+ "sha256:dcfed56aa085b89d644af17442cdc2debaa73388feba4b8026446d168ca8dad7",
+ "sha256:f29b885e4903bd57a7789f09fe9d60b6475a6c1a4c0eca874d8558f00f9d4b51"
+ ],
+ "version": "==4.7.4"
},
"ordered-set": {
"hashes": [
@@ -334,10 +314,10 @@
},
"packaging": {
"hashes": [
- "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
- "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
+ "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
+ "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
],
- "version": "==19.2"
+ "version": "==20.1"
},
"pamqp": {
"hashes": [
@@ -348,21 +328,37 @@
},
"pycares": {
"hashes": [
- "sha256:2ca080db265ea238dc45f997f94effb62b979a617569889e265c26a839ed6305",
- "sha256:6f79c6afb6ce603009db2042fddc2e348ad093ece9784cbe2daa809499871a23",
- "sha256:70918d06eb0603016d37092a5f2c0228509eb4e6c5a3faacb4184f6ab7be7650",
- "sha256:755187d28d24a9ea63aa2b4c0638be31d65fbf7f0ce16d41261b9f8cb55a1b99",
- "sha256:7baa4b1f2146eb8423ff8303ebde3a20fb444a60db761fba0430d104fe35ddbf",
- "sha256:90b27d4df86395f465a171386bc341098d6d47b65944df46518814ae298f6cc6",
- "sha256:9e090dd6b2afa65cb51c133883b2bf2240fd0f717b130b0048714b33fb0f47ce",
- "sha256:a11b7d63c3718775f6e805d6464cb10943780395ab042c7e5a0a7a9f612735dd",
- "sha256:b253f5dcaa0ac7076b79388a3ac80dd8f3bd979108f813baade40d3a9b8bf0bd",
- "sha256:c7f4f65e44ba35e35ad3febc844270665bba21cfb0fb7d749434e705b556e087",
- "sha256:cdb342e6a254f035bd976d95807a2184038fc088d957a5104dcaab8be602c093",
- "sha256:cf08e164f8bfb83b9fe633feb56f2754fae6baefcea663593794fa0518f8f98c",
- "sha256:df9bc694cf03673878ea8ce674082c5acd134991d64d6c306d4bd61c0c1df98f"
- ],
- "version": "==3.0.0"
+ "sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f",
+ "sha256:0a24d2e580a8eb567140d7b69f12cb7de90c836bd7b6488ec69394d308605ac3",
+ "sha256:0c5bd1f6f885a219d5e972788d6eef7b8043b55c3375a845e5399638436e0bba",
+ "sha256:11c628402cc8fc8ef461076d4e47f88afc1f8609989ebbff0dbffcd54c97239f",
+ "sha256:18dfd4fd300f570d6c4536c1d987b7b7673b2a9d14346592c5d6ed716df0d104",
+ "sha256:1917b82494907a4a342db420bc4dd5bac355a5fa3984c35ba9bf51422b020b48",
+ "sha256:1b90fa00a89564df059fb18e796458864cc4e00cb55e364dbf921997266b7c55",
+ "sha256:1d8d177c40567de78108a7835170f570ab04f09084bfd32df9919c0eaec47aa1",
+ "sha256:236286f81664658b32c141c8e79d20afc3d54f6e2e49dfc8b702026be7265855",
+ "sha256:2e4f74677542737fb5af4ea9a2e415ec5ab31aa67e7b8c3c969fdb15c069f679",
+ "sha256:48a7750f04e69e1f304f4332b755728067e7c4b1abe2760bba1cacd9ff7a847a",
+ "sha256:7d86e62b700b21401ffe7fd1bbfe91e08489416fecae99c6570ab023c6896022",
+ "sha256:7e2d7effd08d2e5a3cb95d98a7286ebab71ab2fbce84fa93cc2dd56caf7240dd",
+ "sha256:81edb016d9e43dde7473bc3999c29cdfee3a6b67308fed1ea21049f458e83ae0",
+ "sha256:96c90e11b4a4c7c0b8ff5aaaae969c5035493136586043ff301979aae0623941",
+ "sha256:9a0a1845f8cb2e62332bca0aaa9ad5494603ac43fb60d510a61d5b5b170d7216",
+ "sha256:a05bbfdfd41f8410a905a818f329afe7510cbd9ee65c60f8860a72b6c64ce5dc",
+ "sha256:a5089fd660f0b0d228b14cdaa110d0d311edfa5a63f800618dbf1321dcaef66b",
+ "sha256:c457a709e6f2befea7e2996c991eda6d79705dd075f6521593ba6ebc1485b811",
+ "sha256:c5cb72644b04e5e5abfb1e10a0e7eb75da6684ea0e60871652f348e412cf3b11",
+ "sha256:cce46dd4717debfd2aab79d6d7f0cbdf6b1e982dc4d9bebad81658d59ede07c2",
+ "sha256:cfdd1f90bcf373b00f4b2c55ea47868616fe2f779f792fc913fa82a3d64ffe43",
+ "sha256:d88a279cbc5af613f73e86e19b3f63850f7a2e2736e249c51995dedcc830b1bb",
+ "sha256:eba9a9227438da5e78fc8eee32f32eb35d9a50cf0a0bd937eb6275c7cc3015fe",
+ "sha256:eee7b6a5f5b5af050cb7d66ab28179287b416f06d15a8974ac831437fec51336",
+ "sha256:f41ac1c858687e53242828c9f59c2e7b0b95dbcd5bdd09c7e5d3c48b0f89a25a",
+ "sha256:f8deaefefc3a589058df1b177275f79233e8b0eeee6734cf4336d80164ecd022",
+ "sha256:fa78e919f3bd7d6d075db262aa41079b4c02da315c6043c6f43881e2ebcdd623",
+ "sha256:fadb97d2e02dabdc15a0091591a972a938850d79ddde23d385d813c1731983f0"
+ ],
+ "version": "==3.1.1"
},
"pycparser": {
"hashes": [
@@ -372,25 +368,25 @@
},
"pygments": {
"hashes": [
- "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
- "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
+ "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
+ "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
],
- "version": "==2.4.2"
+ "version": "==2.5.2"
},
"pyparsing": {
"hashes": [
- "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
- "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
+ "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
+ "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
],
- "version": "==2.4.2"
+ "version": "==2.4.6"
},
"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": [
@@ -407,22 +403,20 @@
},
"pyyaml": {
"hashes": [
- "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
- "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
- "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
- "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
- "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
- "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
- "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
- "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
- "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
- "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
- "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
- "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
- "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
+ "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
+ "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
+ "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
+ "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
+ "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
+ "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
+ "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
+ "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
+ "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
+ "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
+ "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
],
"index": "pypi",
- "version": "==5.1.2"
+ "version": "==5.3"
},
"requests": {
"hashes": [
@@ -434,10 +428,10 @@
},
"six": {
"hashes": [
- "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
- "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
+ "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
- "version": "==1.12.0"
+ "version": "==1.14.0"
},
"snowballstemmer": {
"hashes": [
@@ -448,18 +442,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:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159",
+ "sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5"
],
"index": "pypi",
- "version": "==2.2.0"
+ "version": "==2.3.1"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -513,45 +507,52 @@
},
"websockets": {
"hashes": [
- "sha256:0e2f7d6567838369af074f0ef4d0b802d19fa1fee135d864acc656ceefa33136",
- "sha256:2a16dac282b2fdae75178d0ed3d5b9bc3258dabfae50196cbb30578d84b6f6a6",
- "sha256:5a1fa6072405648cb5b3688e9ed3b94be683ce4a4e5723e6f5d34859dee495c1",
- "sha256:5c1f55a1274df9d6a37553fef8cff2958515438c58920897675c9bc70f5a0538",
- "sha256:669d1e46f165e0ad152ed8197f7edead22854a6c90419f544e0f234cc9dac6c4",
- "sha256:695e34c4dbea18d09ab2c258994a8bf6a09564e762655408241f6a14592d2908",
- "sha256:6b2e03d69afa8d20253455e67b64de1a82ff8612db105113cccec35d3f8429f0",
- "sha256:79ca7cdda7ad4e3663ea3c43bfa8637fc5d5604c7737f19a8964781abbd1148d",
- "sha256:7fd2dd9a856f72e6ed06f82facfce01d119b88457cd4b47b7ae501e8e11eba9c",
- "sha256:82c0354ac39379d836719a77ee360ef865377aa6fdead87909d50248d0f05f4d",
- "sha256:8f3b956d11c5b301206382726210dc1d3bee1a9ccf7aadf895aaf31f71c3716c",
- "sha256:91ec98640220ae05b34b79ee88abf27f97ef7c61cf525eec57ea8fcea9f7dddb",
- "sha256:952be9540d83dba815569d5cb5f31708801e0bbfc3a8c5aef1890b57ed7e58bf",
- "sha256:99ac266af38ba1b1fe13975aea01ac0e14bb5f3a3200d2c69f05385768b8568e",
- "sha256:9fa122e7adb24232247f8a89f2d9070bf64b7869daf93ac5e19546b409e47e96",
- "sha256:a0873eadc4b8ca93e2e848d490809e0123eea154aa44ecd0109c4d0171869584",
- "sha256:cb998bd4d93af46b8b49ecf5a72c0a98e5cc6d57fdca6527ba78ad89d6606484",
- "sha256:e02e57346f6a68523e3c43bbdf35dde5c440318d1f827208ae455f6a2ace446d",
- "sha256:e79a5a896bcee7fff24a788d72e5c69f13e61369d055f28113e71945a7eb1559",
- "sha256:ee55eb6bcf23ecc975e6b47c127c201b913598f38b6a300075f84eeef2d3baff",
- "sha256:f1414e6cbcea8d22843e7eafdfdfae3dd1aba41d1945f6ca66e4806c07c4f454"
- ],
- "version": "==6.0"
+ "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5",
+ "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5",
+ "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308",
+ "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb",
+ "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a",
+ "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c",
+ "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170",
+ "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422",
+ "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8",
+ "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485",
+ "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f",
+ "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8",
+ "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc",
+ "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779",
+ "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989",
+ "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1",
+ "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092",
+ "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824",
+ "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d",
+ "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55",
+ "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36",
+ "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"
+ ],
+ "version": "==8.1"
},
"yarl": {
"hashes": [
- "sha256:024ecdc12bc02b321bc66b41327f930d1c2c543fa9a561b39861da9388ba7aa9",
- "sha256:2f3010703295fbe1aec51023740871e64bb9664c789cba5a6bdf404e93f7568f",
- "sha256:3890ab952d508523ef4881457c4099056546593fa05e93da84c7250516e632eb",
- "sha256:3e2724eb9af5dc41648e5bb304fcf4891adc33258c6e14e2a7414ea32541e320",
- "sha256:5badb97dd0abf26623a9982cd448ff12cb39b8e4c94032ccdedf22ce01a64842",
- "sha256:73f447d11b530d860ca1e6b582f947688286ad16ca42256413083d13f260b7a0",
- "sha256:7ab825726f2940c16d92aaec7d204cfc34ac26c0040da727cf8ba87255a33829",
- "sha256:b25de84a8c20540531526dfbb0e2d2b648c13fd5dd126728c496d7c3fea33310",
- "sha256:c6e341f5a6562af74ba55205dbd56d248daf1b5748ec48a0200ba227bb9e33f4",
- "sha256:c9bb7c249c4432cd47e75af3864bc02d26c9594f49c82e2a28624417f0ae63b8",
- "sha256:e060906c0c585565c718d1c3841747b61c5439af2211e185f6739a9412dfbde1"
- ],
- "version": "==1.3.0"
+ "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
+ "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
+ "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
+ "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
+ "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
+ "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
+ "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
+ "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
+ "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
+ "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
+ "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
+ "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
+ "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
+ "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
+ "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
+ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
+ "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
+ ],
+ "version": "==1.4.2"
}
},
"develop": {
@@ -564,17 +565,17 @@
},
"attrs": {
"hashes": [
- "sha256:ec20e7a4825331c1b5ebf261d111e16fa9612c1f7a5e1f884f12bd53a664dfd2",
- "sha256:f913492e1663d3c36f502e5e9ba6cd13cf19d7fab50aa13239e420fef95e1396"
+ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
+ "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
- "version": "==19.2.0"
+ "version": "==19.3.0"
},
"certifi": {
"hashes": [
- "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
- "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
+ "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
+ "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
],
- "version": "==2019.9.11"
+ "version": "==2019.11.28"
},
"cfgv": {
"hashes": [
@@ -637,10 +638,11 @@
},
"dodgy": {
"hashes": [
- "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c"
+ "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a",
+ "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6"
],
"index": "pypi",
- "version": "==0.1.9"
+ "version": "==0.2.1"
},
"dparse": {
"hashes": [
@@ -658,19 +660,19 @@
},
"flake8": {
"hashes": [
- "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
- "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
+ "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
+ "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
- "version": "==3.7.8"
+ "version": "==3.7.9"
},
"flake8-annotations": {
"hashes": [
- "sha256:6ac7ca1e706307686b60af8043ff1db31dc2cfc1233c8210d67a3d9b8f364736",
- "sha256:b51131007000d67217608fa028a35ff80aa400b474e5972f1f99c2cf9d26bd2e"
+ "sha256:05b85538014c850a86dce7374bb6621c64481c24e35e8e90af1315f4d7a3dbaa",
+ "sha256:43e5233a76fda002b91a54a7cc4510f099c4bfd6279502ec70164016250eebd1"
],
"index": "pypi",
- "version": "==1.1.0"
+ "version": "==1.1.3"
},
"flake8-bugbear": {
"hashes": [
@@ -721,10 +723,10 @@
},
"identify": {
"hashes": [
- "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017",
- "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e"
+ "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5",
+ "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96"
],
- "version": "==1.4.7"
+ "version": "==1.4.11"
},
"idna": {
"hashes": [
@@ -735,10 +737,11 @@
},
"importlib-metadata": {
"hashes": [
- "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
- "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
+ "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302",
+ "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"
],
- "version": "==0.23"
+ "markers": "python_version < '3.8'",
+ "version": "==1.5.0"
},
"mccabe": {
"hashes": [
@@ -747,34 +750,26 @@
],
"version": "==0.6.1"
},
- "more-itertools": {
- "hashes": [
- "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
- "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
- ],
- "index": "pypi",
- "version": "==7.2.0"
- },
"nodeenv": {
"hashes": [
- "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
+ "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212"
],
- "version": "==1.3.3"
+ "version": "==1.3.5"
},
"packaging": {
"hashes": [
- "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
- "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
+ "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
+ "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
],
- "version": "==19.2"
+ "version": "==20.1"
},
"pre-commit": {
"hashes": [
- "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f",
- "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a"
+ "sha256:8f48d8637bdae6fa70cc97db9c1dd5aa7c5c8bf71968932a380628c25978b850",
+ "sha256:f92a359477f3252452ae2e8d3029de77aec59415c16ae4189bcfba40b757e029"
],
"index": "pypi",
- "version": "==1.18.3"
+ "version": "==1.21.0"
},
"pycodestyle": {
"hashes": [
@@ -785,10 +780,10 @@
},
"pydocstyle": {
"hashes": [
- "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058",
- "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59"
+ "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
+ "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
],
- "version": "==4.0.1"
+ "version": "==5.0.2"
},
"pyflakes": {
"hashes": [
@@ -799,29 +794,27 @@
},
"pyparsing": {
"hashes": [
- "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
- "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
+ "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
+ "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
],
- "version": "==2.4.2"
+ "version": "==2.4.6"
},
"pyyaml": {
"hashes": [
- "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
- "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
- "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
- "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
- "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
- "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
- "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
- "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
- "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
- "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
- "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
- "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
- "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
+ "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
+ "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
+ "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
+ "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
+ "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
+ "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
+ "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
+ "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
+ "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
+ "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
+ "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
],
"index": "pypi",
- "version": "==5.1.2"
+ "version": "==5.3"
},
"requests": {
"hashes": [
@@ -841,10 +834,10 @@
},
"six": {
"hashes": [
- "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
- "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
+ "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
+ "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
- "version": "==1.12.0"
+ "version": "==1.14.0"
},
"snowballstemmer": {
"hashes": [
@@ -862,31 +855,38 @@
},
"typed-ast": {
"hashes": [
- "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
- "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
- "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
- "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
- "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
- "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
- "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
- "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
- "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
- "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
- "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
- "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
- "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
- "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
- "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
- ],
- "version": "==1.4.0"
+ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+ "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+ "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+ "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+ "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+ "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+ "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+ "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+ "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+ "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+ "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+ "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+ "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+ "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+ "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+ "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+ "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+ "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+ "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+ "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+ "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+ ],
+ "markers": "python_version < '3.8'",
+ "version": "==1.4.1"
},
"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,17 +898,17 @@
},
"virtualenv": {
"hashes": [
- "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
- "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
+ "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3",
+ "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb"
],
- "version": "==16.7.5"
+ "version": "==16.7.9"
},
"zipp": {
"hashes": [
- "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
- "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
+ "sha256:ccc94ed0909b58ffe34430ea5451f07bc0c76467d7081619a454bf5c98b89e28",
+ "sha256:feae2f18633c32fc71f2de629bfb3bd3c9325cd4419642b1f1da42ee488d9b98"
],
- "version": "==0.6.0"
+ "version": "==2.1.0"
}
}
}
diff --git a/README.md b/README.md
index 7a7f1b992..1e7b21271 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Python Utility Bot
-[![Discord](https://img.shields.io/discord/267624335836053506?color=%237289DA&label=Python%20Discord&logo=discord&logoColor=white)](https://discord.gg/2B963hn)
+[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)
[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index da3b06201..0400ac4d2 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -30,7 +30,7 @@ jobs:
- script: python -m flake8
displayName: 'Run linter'
- - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz coverage run -m xmlrunner
+ - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz REDDIT_CLIENT_ID=spam REDDIT_SECRET=ham coverage run -m xmlrunner
displayName: Run tests
- script: coverage report -m && coverage xml -o coverage.xml
diff --git a/bot/__init__.py b/bot/__init__.py
index 4a2df730d..789ace5c0 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -6,6 +6,7 @@ from pathlib import Path
from logmatic import JsonFormatter
+
logging.TRACE = 5
logging.addLevelName(logging.TRACE, "TRACE")
diff --git a/bot/__main__.py b/bot/__main__.py
index f352cd60e..84bc7094b 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,18 +1,11 @@
-import asyncio
-import logging
-import socket
-
import discord
-from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord.ext.commands import Bot, when_mentioned_or
+from discord.ext.commands import when_mentioned_or
from bot import patches
-from bot.api import APIClient, APILoggingHandler
+from bot.bot import Bot
from bot.constants import Bot as BotConfig, DEBUG_MODE
-log = logging.getLogger('bot')
-
bot = Bot(
command_prefix=when_mentioned_or(BotConfig.prefix),
activity=discord.Game(name="Commands: !help"),
@@ -20,18 +13,6 @@ bot = Bot(
max_messages=10_000,
)
-# Global aiohttp session for all cogs
-# - Uses asyncio for DNS resolution instead of threads, so we don't spam threads
-# - Uses AF_INET as its socket family to prevent https related problems both locally and in prod.
-bot.http_session = ClientSession(
- connector=TCPConnector(
- resolver=AsyncResolver(),
- family=socket.AF_INET,
- )
-)
-bot.api_client = APIClient(loop=asyncio.get_event_loop())
-log.addHandler(APILoggingHandler(bot.api_client))
-
# Internal/debug
bot.load_extension("bot.cogs.error_handler")
bot.load_extension("bot.cogs.filtering")
@@ -55,6 +36,7 @@ if not DEBUG_MODE:
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
bot.load_extension("bot.cogs.eval")
+bot.load_extension("bot.cogs.duck_pond")
bot.load_extension("bot.cogs.free")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
@@ -76,6 +58,3 @@ if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
patches.message_edited_at.apply_patch()
bot.run(BotConfig.token)
-
-# This calls a coroutine, so it doesn't do anything at the moment.
-# bot.http_session.close() # Close the aiohttp session when the bot finishes running
diff --git a/bot/api.py b/bot/api.py
index 7f26e5305..56db99828 100644
--- a/bot/api.py
+++ b/bot/api.py
@@ -32,7 +32,7 @@ class ResponseCodeError(ValueError):
class APIClient:
"""Django Site API wrapper."""
- def __init__(self, **kwargs):
+ def __init__(self, loop: asyncio.AbstractEventLoop, **kwargs):
auth_headers = {
'Authorization': f"Token {Keys.site_api}"
}
@@ -42,12 +42,39 @@ class APIClient:
else:
kwargs['headers'] = auth_headers
- self.session = aiohttp.ClientSession(**kwargs)
+ self.session: Optional[aiohttp.ClientSession] = None
+ self.loop = loop
+
+ self._ready = asyncio.Event(loop=loop)
+ self._creation_task = None
+ self._session_args = kwargs
+
+ self.recreate()
@staticmethod
def _url_for(endpoint: str) -> str:
return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}"
+ async def _create_session(self) -> None:
+ """Create the aiohttp session and set the ready event."""
+ self.session = aiohttp.ClientSession(**self._session_args)
+ self._ready.set()
+
+ async def close(self) -> None:
+ """Close the aiohttp session and unset the ready event."""
+ if not self._ready.is_set():
+ return
+
+ await self.session.close()
+ self._ready.clear()
+
+ def recreate(self) -> None:
+ """Schedule the aiohttp session to be created if it's been closed."""
+ if self.session is None or self.session.closed:
+ # Don't schedule a task if one is already in progress.
+ if self._creation_task is None or self._creation_task.done():
+ self._creation_task = self.loop.create_task(self._create_session())
+
async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
"""Raise ResponseCodeError for non-OK response if an exception should be raised."""
if should_raise and response.status >= 400:
@@ -60,30 +87,40 @@ class APIClient:
async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API GET."""
+ await self._ready.wait()
+
async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp:
await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API PATCH."""
+ await self._ready.wait()
+
async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp:
await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API POST."""
+ await self._ready.wait()
+
async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp:
await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
"""Site API PUT."""
+ await self._ready.wait()
+
async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp:
await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]:
"""Site API DELETE."""
+ await self._ready.wait()
+
async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp:
if resp.status == 204:
return None
diff --git a/bot/bot.py b/bot/bot.py
new file mode 100644
index 000000000..8f808272f
--- /dev/null
+++ b/bot/bot.py
@@ -0,0 +1,53 @@
+import logging
+import socket
+from typing import Optional
+
+import aiohttp
+from discord.ext import commands
+
+from bot import api
+
+log = logging.getLogger('bot')
+
+
+class Bot(commands.Bot):
+ """A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client."""
+
+ def __init__(self, *args, **kwargs):
+ # Use asyncio for DNS resolution instead of threads so threads aren't spammed.
+ # Use AF_INET as its socket family to prevent HTTPS related problems both locally
+ # and in production.
+ self.connector = aiohttp.TCPConnector(
+ resolver=aiohttp.AsyncResolver(),
+ family=socket.AF_INET,
+ )
+
+ super().__init__(*args, connector=self.connector, **kwargs)
+
+ self.http_session: Optional[aiohttp.ClientSession] = None
+ self.api_client = api.APIClient(loop=self.loop, connector=self.connector)
+
+ log.addHandler(api.APILoggingHandler(self.api_client))
+
+ def add_cog(self, cog: commands.Cog) -> None:
+ """Adds a "cog" to the bot and logs the operation."""
+ super().add_cog(cog)
+ log.info(f"Cog loaded: {cog.qualified_name}")
+
+ def clear(self) -> None:
+ """Clears the internal state of the bot and resets the API client."""
+ super().clear()
+ self.api_client.recreate()
+
+ async def close(self) -> None:
+ """Close the aiohttp session after closing the Discord connection."""
+ await super().close()
+
+ await self.http_session.close()
+ await self.api_client.close()
+
+ async def start(self, *args, **kwargs) -> None:
+ """Open an aiohttp session before logging in and connecting to Discord."""
+ self.http_session = aiohttp.ClientSession(connector=self.connector)
+
+ await super().start(*args, **kwargs)
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 5190c559b..0b800575f 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -1,13 +1,15 @@
import inspect
import logging
-from typing import Union
-from discord import Colour, Embed, Member, User
-from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group
+from discord import Colour, Embed
+from discord.ext.commands import (
+ Cog, Command, Context, Greedy,
+ clean_content, command, group,
+)
+from bot.bot import Bot
from bot.cogs.extensions import Extension
-from bot.cogs.watchchannels.watchchannel import proxy_user
-from bot.converters import TagNameConverter
+from bot.converters import FetchedMember, TagNameConverter
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -60,12 +62,12 @@ class Alias (Cog):
await self.invoke(ctx, "site tools")
@command(name="watch", hidden=True)
- async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Alias for invoking <prefix>bigbrother watch [user] [reason]."""
await self.invoke(ctx, "bigbrother watch", user, reason=reason)
@command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
@@ -80,7 +82,7 @@ class Alias (Cog):
await self.invoke(ctx, "site faq")
@command(name="rules", aliases=("rule",), hidden=True)
- async def site_rules_alias(self, ctx: Context, *rules: int) -> None:
+ async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None:
"""Alias for invoking <prefix>site rules."""
await self.invoke(ctx, "site rules", *rules)
@@ -131,12 +133,12 @@ class Alias (Cog):
await self.invoke(ctx, "docs get", symbol)
@command(name="nominate", hidden=True)
- async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Alias for invoking <prefix>talentpool add [user] [reason]."""
await self.invoke(ctx, "talentpool add", user, reason=reason)
@command(name="unnominate", hidden=True)
- async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Alias for invoking <prefix>nomination end [user] [reason]."""
await self.invoke(ctx, "nomination end", user, reason=reason)
@@ -147,6 +149,5 @@ class Alias (Cog):
def setup(bot: Bot) -> None:
- """Alias cog load."""
+ """Load the Alias cog."""
bot.add_cog(Alias(bot))
- log.info("Cog loaded: Alias")
diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py
index ababd6f18..28e3e5d96 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/cogs/antimalware.py
@@ -1,9 +1,10 @@
import logging
-from discord import Message, NotFound
-from discord.ext.commands import Bot, Cog
+from discord import Embed, Message, NotFound
+from discord.ext.commands import Cog
-from bot.constants import AntiMalware as AntiMalwareConfig, Channels
+from bot.bot import Bot
+from bot.constants import AntiMalware as AntiMalwareConfig, Channels, URLs
log = logging.getLogger(__name__)
@@ -17,31 +18,29 @@ class AntiMalware(Cog):
@Cog.listener()
async def on_message(self, message: Message) -> None:
"""Identify messages with prohibited attachments."""
- rejected_attachments = False
- detected_pyfile = False
+ if not message.attachments:
+ return
+
+ embed = Embed()
for attachment in message.attachments:
- if attachment.filename.lower().endswith('.py'):
- detected_pyfile = True
- break # Other detections irrelevant because we prioritize the .py message.
- if not attachment.filename.lower().endswith(tuple(AntiMalwareConfig.whitelist)):
- rejected_attachments = True
-
- if detected_pyfile or rejected_attachments:
- # Send a message to the user indicating the problem (with special treatment for .py)
- author = message.author
- if detected_pyfile:
- msg = (
- f"{author.mention}, it looks like you tried to attach a Python file - please "
- f"use a code-pasting service such as https://paste.pythondiscord.com/ instead."
+ filename = attachment.filename.lower()
+ if filename.endswith('.py'):
+ embed.description = (
+ f"It looks like you tried to attach a Python file - please "
+ f"use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"
)
- else:
+ break # Other detections irrelevant because we prioritize the .py message.
+ if not filename.endswith(tuple(AntiMalwareConfig.whitelist)):
+ whitelisted_types = ', '.join(AntiMalwareConfig.whitelist)
meta_channel = self.bot.get_channel(Channels.meta)
- msg = (
- f"{author.mention}, it looks like you tried to attach a file type we don't "
- f"allow. Feel free to ask in {meta_channel.mention} if you think this is a mistake."
+ embed.description = (
+ f"It looks like you tried to attach a file type that we "
+ f"do not allow. We currently allow the following file "
+ f"types: **{whitelisted_types}**. \n\n Feel free to ask "
+ f"in {meta_channel.mention} if you think this is a mistake."
)
-
- await message.channel.send(msg)
+ if embed.description:
+ await message.channel.send(f"Hey {message.author.mention}!", embed=embed)
# Delete the offending message:
try:
@@ -51,6 +50,5 @@ class AntiMalware(Cog):
def setup(bot: Bot) -> None:
- """Antimalware cog load."""
+ """Load the AntiMalware cog."""
bot.add_cog(AntiMalware(bot))
- log.info("Cog loaded: AntiMalware")
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 1340eb608..f67ef6f05 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -7,9 +7,10 @@ from operator import itemgetter
from typing import Dict, Iterable, List, Set
from discord import Colour, Member, Message, NotFound, Object, TextChannel
-from discord.ext.commands import Bot, Cog
+from discord.ext.commands import Cog
from bot import rules
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
@@ -18,6 +19,7 @@ from bot.constants import (
STAFF_ROLES,
)
from bot.converters import Duration
+from bot.utils.messages import send_attachments
log = logging.getLogger(__name__)
@@ -44,8 +46,9 @@ class DeletionContext:
members: Dict[int, Member] = field(default_factory=dict)
rules: Set[str] = field(default_factory=set)
messages: Dict[int, Message] = field(default_factory=dict)
+ attachments: List[List[str]] = field(default_factory=list)
- def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
+ async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
"""Adds new rule violation events to the deletion context."""
self.rules.add(rule_name)
@@ -57,6 +60,11 @@ class DeletionContext:
if message.id not in self.messages:
self.messages[message.id] = message
+ # Re-upload attachments
+ destination = message.guild.get_channel(Channels.attachment_log)
+ urls = await send_attachments(message, destination, link_large=False)
+ self.attachments.append(urls)
+
async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
"""Method that takes care of uploading the queue and posting modlog alert."""
triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values())
@@ -69,7 +77,7 @@ class DeletionContext:
# For multiple messages or those with excessive newlines, use the logs API
if len(self.messages) > 1 or 'newlines' in self.rules:
- url = await modlog.upload_log(self.messages.values(), actor_id)
+ url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments)
mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
else:
mod_alert_message += "Message:\n"
@@ -97,7 +105,7 @@ class DeletionContext:
class AntiSpam(Cog):
"""Cog that controls our anti-spam measures."""
- def __init__(self, bot: Bot, validation_errors: bool) -> None:
+ def __init__(self, bot: Bot, validation_errors: Dict[str, str]) -> None:
self.bot = bot
self.validation_errors = validation_errors
role_id = AntiSpamConfig.punishment['role_id']
@@ -105,7 +113,6 @@ class AntiSpam(Cog):
self.expiration_date_converter = Duration()
self.message_deletion_queue = dict()
- self.queue_consumption_tasks = dict()
self.bot.loop.create_task(self.alert_on_validation_error())
@@ -179,15 +186,14 @@ class AntiSpam(Cog):
full_reason = f"`{rule_name}` rule: {reason}"
# If there's no spam event going on for this channel, start a new Message Deletion Context
- if message.channel.id not in self.message_deletion_queue:
- log.trace(f"Creating queue for channel `{message.channel.id}`")
- self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel)
- self.queue_consumption_tasks = self.bot.loop.create_task(
- self._process_deletion_context(message.channel.id)
- )
+ channel = message.channel
+ if channel.id not in self.message_deletion_queue:
+ log.trace(f"Creating queue for channel `{channel.id}`")
+ self.message_deletion_queue[message.channel.id] = DeletionContext(channel)
+ self.bot.loop.create_task(self._process_deletion_context(message.channel.id))
# Add the relevant of this trigger to the Deletion Context
- self.message_deletion_queue[message.channel.id].add(
+ await self.message_deletion_queue[message.channel.id].add(
rule_name=rule_name,
members=members,
messages=relevant_messages
@@ -201,7 +207,7 @@ class AntiSpam(Cog):
self.punish(message, member, full_reason)
)
- await self.maybe_delete_messages(message.channel, relevant_messages)
+ await self.maybe_delete_messages(channel, relevant_messages)
break
async def punish(self, msg: Message, member: Member, reason: str) -> None:
@@ -254,10 +260,10 @@ class AntiSpam(Cog):
await deletion_context.upload_messages(self.bot.user.id, self.mod_log)
-def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
+def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
"""Validates the antispam configs."""
validation_errors = {}
- for name, config in rules.items():
+ for name, config in rules_.items():
if name not in RULE_FUNCTION_MAPPING:
log.error(
f"Unrecognized antispam rule `{name}`. "
@@ -276,7 +282,6 @@ def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
def setup(bot: Bot) -> None:
- """Antispam cog load."""
+ """Validate the AntiSpam configs and load the AntiSpam cog."""
validation_errors = validate_config()
bot.add_cog(AntiSpam(bot, validation_errors))
- log.info("Cog loaded: AntiSpam")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 7583b2f2d..73b1e8f41 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -4,9 +4,11 @@ import re
import time
from typing import Optional, Tuple
-from discord import Embed, Message, RawMessageUpdateEvent
-from discord.ext.commands import Bot, Cog, Context, command, group
+from discord import Embed, Message, RawMessageUpdateEvent, TextChannel
+from discord.ext.commands import Cog, Context, command, group
+from bot.bot import Bot
+from bot.cogs.token_remover import TokenRemover
from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
@@ -16,7 +18,7 @@ log = logging.getLogger(__name__)
RE_MARKDOWN = re.compile(r'([*_~`|>])')
-class Bot(Cog):
+class BotCog(Cog, name="Bot"):
"""Bot information commands."""
def __init__(self, bot: Bot):
@@ -71,9 +73,12 @@ class Bot(Cog):
@command(name='echo', aliases=('print',))
@with_role(*MODERATION_ROLES)
- async def echo_command(self, ctx: Context, *, text: str) -> None:
- """Send the input verbatim to the current channel."""
- await ctx.send(text)
+ async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
+ """Repeat the given message in either a specified channel or the current channel."""
+ if channel is None:
+ await ctx.send(text)
+ else:
+ await channel.send(text)
@command(name='embed')
@with_role(*MODERATION_ROLES)
@@ -235,9 +240,10 @@ class Bot(Cog):
)
and not msg.author.bot
and len(msg.content.splitlines()) > 3
+ and not TokenRemover.is_token_in_message(msg)
)
- if parse_codeblock:
+ if parse_codeblock: # no token in the msg
on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300
if not on_cooldown or DEBUG_MODE:
try:
@@ -370,10 +376,9 @@ class Bot(Cog):
bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
await bot_message.delete()
del self.codeblock_message_ids[payload.message_id]
- log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
+ log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
def setup(bot: Bot) -> None:
- """Bot cog load."""
- bot.add_cog(Bot(bot))
- log.info("Cog loaded: Bot")
+ """Load the Bot cog."""
+ bot.add_cog(BotCog(bot))
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index dca411d01..2104efe57 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -3,9 +3,10 @@ import random
import re
from typing import Optional
-from discord import Colour, Embed, Message, User
-from discord.ext.commands import Bot, Cog, Context, group
+from discord import Colour, Embed, Message, TextChannel, User
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import (
Channels, CleanMessages, Colours, Event,
@@ -37,9 +38,13 @@ class Clean(Cog):
return self.bot.get_cog("ModLog")
async def _clean_messages(
- self, amount: int, ctx: Context,
- bots_only: bool = False, user: User = None,
- regex: Optional[str] = None
+ self,
+ amount: int,
+ ctx: Context,
+ bots_only: bool = False,
+ user: User = None,
+ regex: Optional[str] = None,
+ channel: Optional[TextChannel] = None
) -> None:
"""A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
@@ -104,6 +109,10 @@ class Clean(Cog):
else:
predicate = None # Delete all messages
+ # Default to using the invoking context's channel
+ if not channel:
+ channel = ctx.channel
+
# Look through the history and retrieve message data
messages = []
message_ids = []
@@ -111,7 +120,7 @@ class Clean(Cog):
invocation_deleted = False
# To account for the invocation message, we index `amount + 1` messages.
- async for message in ctx.channel.history(limit=amount + 1):
+ async for message in channel.history(limit=amount + 1):
# If at any point the cancel command is invoked, we should stop.
if not self.cleaning:
@@ -135,7 +144,7 @@ class Clean(Cog):
self.mod_log.ignore(Event.message_delete, *message_ids)
# Use bulk delete to actually do the cleaning. It's far faster.
- await ctx.channel.purge(
+ await channel.purge(
limit=amount,
check=predicate
)
@@ -155,7 +164,7 @@ class Clean(Cog):
# Build the embed and send it
message = (
- f"**{len(message_ids)}** messages deleted in <#{ctx.channel.id}> by **{ctx.author.name}**\n\n"
+ f"**{len(message_ids)}** messages deleted in <#{channel.id}> by **{ctx.author.name}**\n\n"
f"A log of the deleted messages can be found [here]({log_url})."
)
@@ -167,7 +176,7 @@ class Clean(Cog):
channel_id=Channels.modlog,
)
- @group(invoke_without_command=True, name="clean", hidden=True)
+ @group(invoke_without_command=True, name="clean", aliases=["purge"])
@with_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
@@ -175,27 +184,49 @@ class Clean(Cog):
@clean_group.command(name="user", aliases=["users"])
@with_role(*MODERATION_ROLES)
- async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None:
+ async def clean_user(
+ self,
+ ctx: Context,
+ user: User,
+ amount: Optional[int] = 10,
+ channel: TextChannel = None
+ ) -> None:
"""Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, user=user)
+ await self._clean_messages(amount, ctx, user=user, channel=channel)
@clean_group.command(name="all", aliases=["everything"])
@with_role(*MODERATION_ROLES)
- async def clean_all(self, ctx: Context, amount: int = 10) -> None:
+ async def clean_all(
+ self,
+ ctx: Context,
+ amount: Optional[int] = 10,
+ channel: TextChannel = None
+ ) -> None:
"""Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx)
+ await self._clean_messages(amount, ctx, channel=channel)
@clean_group.command(name="bots", aliases=["bot"])
@with_role(*MODERATION_ROLES)
- async def clean_bots(self, ctx: Context, amount: int = 10) -> None:
+ async def clean_bots(
+ self,
+ ctx: Context,
+ amount: Optional[int] = 10,
+ channel: TextChannel = None
+ ) -> None:
"""Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, bots_only=True)
+ await self._clean_messages(amount, ctx, bots_only=True, channel=channel)
@clean_group.command(name="regex", aliases=["word", "expression"])
@with_role(*MODERATION_ROLES)
- async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None:
+ async def clean_regex(
+ self,
+ ctx: Context,
+ regex: str,
+ amount: Optional[int] = 10,
+ channel: TextChannel = None
+ ) -> None:
"""Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
- await self._clean_messages(amount, ctx, regex=regex)
+ await self._clean_messages(amount, ctx, regex=regex, channel=channel)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
@with_role(*MODERATION_ROLES)
@@ -211,6 +242,5 @@ class Clean(Cog):
def setup(bot: Bot) -> None:
- """Clean cog load."""
+ """Load the Clean cog."""
bot.add_cog(Clean(bot))
- log.info("Cog loaded: Clean")
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index bedd70c86..3e7350fcc 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -6,8 +6,9 @@ from datetime import datetime, timedelta
from enum import Enum
from discord import Colour, Embed, Member
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
from bot.decorators import with_role
@@ -236,6 +237,5 @@ class Defcon(Cog):
def setup(bot: Bot) -> None:
- """DEFCON cog load."""
+ """Load the Defcon cog."""
bot.add_cog(Defcon(bot))
- log.info("Cog loaded: Defcon")
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index 65cabe46f..6e7c00b6a 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -4,17 +4,22 @@ import logging
import re
import textwrap
from collections import OrderedDict
+from contextlib import suppress
+from types import SimpleNamespace
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.bot import Bot
+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 +28,43 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
logging.getLogger('urllib3').setLevel(logging.WARNING)
-
-UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶')
+# Since Intersphinx is intended to be used with Sphinx,
+# we need to mock its configuration.
+SPHINX_MOCK_APP = SimpleNamespace(
+ config=SimpleNamespace(
+ intersphinx_timeout=3,
+ tls_verify=True,
+ user_agent="python3:python-discord/bot:1.0.0"
+ )
+)
+
+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:
"""
@@ -75,18 +113,6 @@ def markdownify(html: str) -> DocMarkdownConverter:
return DocMarkdownConverter(bullets='•').convert(html)
-class DummyObject(object):
- """A dummy object which supports assigning anything, which the builtin `object()` does not support normally."""
-
-
-class SphinxConfiguration:
- """Dummy configuration for use with intersphinx."""
-
- config = DummyObject()
- config.intersphinx_timeout = 3
- config.tls_verify = True
-
-
class InventoryURL(commands.Converter):
"""
Represents an Intersphinx inventory URL.
@@ -101,7 +127,7 @@ class InventoryURL(commands.Converter):
async def convert(ctx: commands.Context, url: str) -> str:
"""Convert url to Intersphinx inventory URL."""
try:
- intersphinx.fetch_inventory(SphinxConfiguration(), '', url)
+ intersphinx.fetch_inventory(SPHINX_MOCK_APP, '', url)
except AttributeError:
raise commands.BadArgument(f"Failed to fetch Intersphinx inventory from URL `{url}`.")
except ConnectionError:
@@ -121,10 +147,11 @@ class InventoryURL(commands.Converter):
class Doc(commands.Cog):
"""A set of commands for querying & displaying documentation."""
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.base_urls = {}
self.bot = bot
self.inventories = {}
+ self.renamed_symbols = set()
self.bot.loop.create_task(self.init_refresh_inventory())
@@ -134,7 +161,7 @@ class Doc(commands.Cog):
await self.refresh_inventory()
async def update_single(
- self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration
+ self, package_name: str, base_url: str, inventory_url: str
) -> None:
"""
Rebuild the inventory for a single package.
@@ -145,18 +172,35 @@ class Doc(commands.Cog):
absolute paths that link to specific symbols
* `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
`intersphinx.fetch_inventory` in an executor on the bot's event loop
- * `config` is a `SphinxConfiguration` instance to mock the regular sphinx
- project layout, required for use with intersphinx
"""
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)
+ 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,31 +214,27 @@ 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,
- # we need to mock its configuration.
- config = SphinxConfiguration()
-
# Run all coroutines concurrently - since each of them performs a HTTP
# request, this speeds up fetching the inventory data heavily.
coros = [
self.update_single(
- package["package"], package["base_url"], package["inventory_url"], config
+ package["package"], package["base_url"], package["inventory_url"]
) for package in await self.bot.api_client.get('bot/documentation-links')
]
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 +247,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 [], ""
- signature = ''.join(signature_buffer)
- description = str(symbol_heading.next_sibling.next_sibling).replace('¶', '')
+ 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
- return signature, description
+ 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 signatures, description.replace('¶', '')
@async_cache(arg_offset=1)
async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
@@ -234,7 +291,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 +299,48 @@ 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})"
+ description_cutoff = shortened.rfind('\n\n', 100)
+ if description_cutoff == -1:
+ # Search the shortened version for cutoff points in decreasing desirability,
+ # cutoff at 1000 if none are found.
+ for string in (". ", ", ", ",", " "):
+ description_cutoff = shortened.rfind(string)
+ if description_cutoff != -1:
+ break
+ else:
+ description_cutoff = 1000
+ description = description[:description_cutoff]
+
+ # 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 signatures is None:
+ # If symbol is a module, don't show signature.
+ embed_description = description
- if not signature:
+ 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."
+
+ else:
+ embed_description = "".join(f"```py\n{textwrap.shorten(signature, 500)}```" for signature in signatures)
+ embed_description += f"\n{description}"
- signature = textwrap.shorten(signature, 500)
- return discord.Embed(
+ 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,8 +447,65 @@ 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) -> Optional[dict]:
+ """Get and return inventory from `inventory_url`. If fetching fails, return None."""
+ fetch_func = functools.partial(intersphinx.fetch_inventory, SPHINX_MOCK_APP, '', 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."""
+def setup(bot: Bot) -> None:
+ """Load the Doc cog."""
bot.add_cog(Doc(bot))
- log.info("Cog loaded: Doc")
diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py
new file mode 100644
index 000000000..345d2856c
--- /dev/null
+++ b/bot/cogs/duck_pond.py
@@ -0,0 +1,182 @@
+import logging
+from typing import Optional, Union
+
+import discord
+from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors
+from discord.ext.commands import Cog
+
+from bot import constants
+from bot.bot import Bot
+from bot.utils.messages import send_attachments
+
+log = logging.getLogger(__name__)
+
+
+class DuckPond(Cog):
+ """Relays messages to #duck-pond whenever a certain number of duck reactions have been achieved."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.webhook_id = constants.Webhooks.duck_pond
+ self.bot.loop.create_task(self.fetch_webhook())
+
+ async def fetch_webhook(self) -> None:
+ """Fetches the webhook object, so we can post to it."""
+ await self.bot.wait_until_ready()
+
+ try:
+ self.webhook = await self.bot.fetch_webhook(self.webhook_id)
+ except discord.HTTPException:
+ log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
+
+ @staticmethod
+ def is_staff(member: Union[User, Member]) -> bool:
+ """Check if a specific member or user is staff."""
+ if hasattr(member, "roles"):
+ for role in member.roles:
+ if role.id in constants.STAFF_ROLES:
+ return True
+ return False
+
+ async def has_green_checkmark(self, message: Message) -> bool:
+ """Check if the message has a green checkmark reaction."""
+ for reaction in message.reactions:
+ if reaction.emoji == "✅":
+ async for user in reaction.users():
+ if user == self.bot.user:
+ return True
+ return False
+
+ async def send_webhook(
+ self,
+ content: Optional[str] = None,
+ username: Optional[str] = None,
+ avatar_url: Optional[str] = None,
+ embed: Optional[Embed] = None,
+ ) -> None:
+ """Send a webhook to the duck_pond channel."""
+ try:
+ await self.webhook.send(
+ content=content,
+ username=username,
+ avatar_url=avatar_url,
+ embed=embed
+ )
+ except discord.HTTPException:
+ log.exception("Failed to send a message to the Duck Pool webhook")
+
+ async def count_ducks(self, message: Message) -> int:
+ """
+ Count the number of ducks in the reactions of a specific message.
+
+ Only counts ducks added by staff members.
+ """
+ duck_count = 0
+ duck_reactors = []
+
+ for reaction in message.reactions:
+ async for user in reaction.users():
+
+ # Is the user a staff member and not already counted as reactor?
+ if not self.is_staff(user) or user.id in duck_reactors:
+ continue
+
+ # Is the emoji a duck?
+ if hasattr(reaction.emoji, "id"):
+ if reaction.emoji.id in constants.DuckPond.custom_emojis:
+ duck_count += 1
+ duck_reactors.append(user.id)
+ elif isinstance(reaction.emoji, str):
+ if reaction.emoji == "🦆":
+ duck_count += 1
+ duck_reactors.append(user.id)
+ return duck_count
+
+ async def relay_message(self, message: Message) -> None:
+ """Relays the message's content and attachments to the duck pond channel."""
+ clean_content = message.clean_content
+
+ if clean_content:
+ await self.send_webhook(
+ content=message.clean_content,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
+
+ if message.attachments:
+ try:
+ await send_attachments(message, self.webhook)
+ except (errors.Forbidden, errors.NotFound):
+ e = Embed(
+ description=":x: **This message contained an attachment, but it could not be retrieved**",
+ color=Color.red()
+ )
+ await self.send_webhook(
+ embed=e,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
+ except discord.HTTPException:
+ log.exception(f"Failed to send an attachment to the webhook")
+
+ await message.add_reaction("✅")
+
+ @staticmethod
+ def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool:
+ """Test if the RawReactionActionEvent payload contains a duckpond emoji."""
+ if payload.emoji.is_custom_emoji():
+ if payload.emoji.id in constants.DuckPond.custom_emojis:
+ return True
+ elif payload.emoji.name == "🦆":
+ return True
+
+ return False
+
+ @Cog.listener()
+ async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
+ """
+ Determine if a message should be sent to the duck pond.
+
+ This will count the number of duck reactions on the message, and if this amount meets the
+ amount of ducks specified in the config under duck_pond/threshold, it will
+ send the message off to the duck pond.
+ """
+ # Is the emoji in the reaction a duck?
+ if not self._payload_has_duckpond_emoji(payload):
+ return
+
+ channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
+ message = await channel.fetch_message(payload.message_id)
+ member = discord.utils.get(message.guild.members, id=payload.user_id)
+
+ # Is the member a human and a staff member?
+ if not self.is_staff(member) or member.bot:
+ return
+
+ # Does the message already have a green checkmark?
+ if await self.has_green_checkmark(message):
+ return
+
+ # Time to count our ducks!
+ duck_count = await self.count_ducks(message)
+
+ # If we've got more than the required amount of ducks, send the message to the duck_pond.
+ if duck_count >= constants.DuckPond.threshold:
+ await self.relay_message(message)
+
+ @Cog.listener()
+ async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:
+ """Ensure that people don't remove the green checkmark from duck ponded messages."""
+ channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
+
+ # Prevent the green checkmark from being removed
+ if payload.emoji.name == "✅":
+ message = await channel.fetch_message(payload.message_id)
+ duck_count = await self.count_ducks(message)
+ if duck_count >= constants.DuckPond.threshold:
+ await message.add_reaction("✅")
+
+
+def setup(bot: Bot) -> None:
+ """Load the DuckPond cog."""
+ bot.add_cog(DuckPond(bot))
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index 49411814c..52893b2ee 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -14,9 +14,10 @@ from discord.ext.commands import (
NoPrivateMessage,
UserInputError,
)
-from discord.ext.commands import Bot, Cog, Context
+from discord.ext.commands import Cog, Context
from bot.api import ResponseCodeError
+from bot.bot import Bot
from bot.constants import Channels
from bot.decorators import InChannelCheckFailure
@@ -75,6 +76,16 @@ class ErrorHandler(Cog):
tags_get_command = self.bot.get_command("tags get")
ctx.invoked_from_error_handler = True
+ log_msg = "Cancelling attempt to fall back to a tag due to failed checks."
+ try:
+ if not await tags_get_command.can_run(ctx):
+ log.debug(log_msg)
+ return
+ except CommandError as tag_error:
+ log.debug(log_msg)
+ await self.on_command_error(ctx, tag_error)
+ return
+
# Return to not raise the exception
with contextlib.suppress(ResponseCodeError):
await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
@@ -143,6 +154,5 @@ class ErrorHandler(Cog):
def setup(bot: Bot) -> None:
- """Error handler cog load."""
+ """Load the ErrorHandler cog."""
bot.add_cog(ErrorHandler(bot))
- log.info("Cog loaded: Events")
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 9ce854f2c..9c729f28a 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -9,8 +9,9 @@ from io import StringIO
from typing import Any, Optional, Tuple
import discord
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
from bot.constants import Roles
from bot.decorators import with_role
from bot.interpreter import Interpreter
@@ -148,7 +149,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 +163,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()
@@ -197,6 +198,5 @@ async def func(): # (None,) -> Any
def setup(bot: Bot) -> None:
- """Code eval cog load."""
+ """Load the CodeEval cog."""
bot.add_cog(CodeEval(bot))
- log.info("Cog loaded: Eval")
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
index bb66e0b8e..f16e79fb7 100644
--- a/bot/cogs/extensions.py
+++ b/bot/cogs/extensions.py
@@ -6,8 +6,9 @@ from pkgutil import iter_modules
from discord import Colour, Embed
from discord.ext import commands
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Context, group
+from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
from bot.pagination import LinePaginator
from bot.utils.checks import with_role_check
@@ -233,4 +234,3 @@ class Extensions(commands.Cog):
def setup(bot: Bot) -> None:
"""Load the Extensions cog."""
bot.add_cog(Extensions(bot))
- log.info("Cog loaded: Extensions")
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 1d1d74e74..74538542a 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -5,11 +5,12 @@ from typing import Optional, Union
import discord.errors
from dateutil.relativedelta import relativedelta
from discord import Colour, DMChannel, Member, Message, TextChannel
-from discord.ext.commands import Bot, Cog
+from discord.ext.commands import Cog
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import (
- Channels, Colours, DEBUG_MODE,
+ Channels, Colours,
Filter, Icons, URLs
)
@@ -43,7 +44,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 +54,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 +64,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 +75,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": {
@@ -136,10 +137,6 @@ class Filtering(Cog):
and not msg.author.bot # Author not a bot
)
- # If we're running the bot locally, ignore role whitelist and only listen to #dev-test
- if DEBUG_MODE:
- filter_message = not msg.author.bot and msg.channel.id == Channels.devtest
-
# If none of the above, we can start filtering.
if filter_message:
for filter_name, _filter in self.filters.items():
@@ -154,11 +151,11 @@ class Filtering(Cog):
# Does the filter only need the message content or the full message?
if _filter["content_only"]:
- triggered = await _filter["function"](msg.content)
+ match = await _filter["function"](msg.content)
else:
- triggered = await _filter["function"](msg)
+ match = await _filter["function"](msg)
- if triggered:
+ if match:
# If this is a filter (not a watchlist), we should delete the message.
if _filter["type"] == "filter":
try:
@@ -184,12 +181,23 @@ class Filtering(Cog):
else:
channel_str = f"in {msg.channel.mention}"
+ # Word and match stats for watch_words and watch_tokens
+ if filter_name in ("watch_words", "watch_tokens"):
+ surroundings = match.string[max(match.start() - 10, 0): match.end() + 10]
+ message_content = (
+ f"**Match:** '{match[0]}'\n"
+ f"**Location:** '...{surroundings}...'\n"
+ f"\n**Original Message:**\n{msg.content}"
+ )
+ else: # Use content of discord Message
+ message_content = msg.content
+
message = (
f"The {filter_name} {_filter['type']} was triggered "
f"by **{msg.author}** "
f"(`{msg.author.id}`) {channel_str} with [the "
f"following message]({msg.jump_url}):\n\n"
- f"{msg.content}"
+ f"{message_content}"
)
log.debug(message)
@@ -199,7 +207,7 @@ class Filtering(Cog):
if filter_name == "filter_invites":
additional_embeds = []
- for invite, data in triggered.items():
+ for invite, data in match.items():
embed = discord.Embed(description=(
f"**Members:**\n{data['members']}\n"
f"**Active:**\n{data['active']}"
@@ -230,31 +238,33 @@ class Filtering(Cog):
break # We don't want multiple filters to trigger
@staticmethod
- async def _has_watchlist_words(text: str) -> bool:
+ async def _has_watchlist_words(text: str) -> Union[bool, re.Match]:
"""
Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config.
Only matches words with boundaries before and after the expression.
"""
for regex_pattern in WORD_WATCHLIST_PATTERNS:
- if regex_pattern.search(text):
- return True
+ match = regex_pattern.search(text)
+ if match:
+ return match # match objects always have a boolean value of True
return False
@staticmethod
- async def _has_watchlist_tokens(text: str) -> bool:
+ async def _has_watchlist_tokens(text: str) -> Union[bool, re.Match]:
"""
Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config.
This will match the expression even if it does not have boundaries before and after.
"""
for regex_pattern in TOKEN_WATCHLIST_PATTERNS:
- if regex_pattern.search(text):
+ match = regex_pattern.search(text)
+ if match:
# Make sure it's not a URL
if not URL_RE.search(text):
- return True
+ return match # match objects always have a boolean value of True
return False
@@ -361,6 +371,5 @@ class Filtering(Cog):
def setup(bot: Bot) -> None:
- """Filtering cog load."""
+ """Load the Filtering cog."""
bot.add_cog(Filtering(bot))
- log.info("Cog loaded: Filtering")
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
index 269c5c1b9..49cab6172 100644
--- a/bot/cogs/free.py
+++ b/bot/cogs/free.py
@@ -3,8 +3,9 @@ from datetime import datetime
from operator import itemgetter
from discord import Colour, Embed, Member, utils
-from discord.ext.commands import Bot, Cog, Context, command
+from discord.ext.commands import Cog, Context, command
+from bot.bot import Bot
from bot.constants import Categories, Channels, Free, STAFF_ROLES
from bot.decorators import redirect_output
@@ -72,35 +73,31 @@ class Free(Cog):
# Display all potentially inactive channels
# in descending order of inactivity
if free_channels:
- embed.description += "**The following channel{0} look{1} free:**\n\n**".format(
- 's' if len(free_channels) > 1 else '',
- '' if len(free_channels) > 1 else 's'
- )
-
# Sort channels in descending order by seconds
# Get position in list, inactivity, and channel object
# For each channel, add to embed.description
sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True)
- for i, (inactive, channel) in enumerate(sorted_channels, 1):
+
+ for (inactive, channel) in sorted_channels[:3]:
minutes, seconds = divmod(inactive, 60)
if minutes > 59:
hours, minutes = divmod(minutes, 60)
- embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n"
+ embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n"
else:
- embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n"
+ embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n"
- embed.description += ("**\nThese channels aren't guaranteed to be free, "
- "so use your best judgement and check for yourself.")
+ embed.set_footer(text="Please confirm these channels are free before posting")
else:
- embed.description = ("**Doesn't look like any channels are available right now. "
- "You're welcome to check for yourself to be sure. "
- "If all channels are truly busy, please be patient "
- "as one will likely be available soon.**")
+ embed.description = (
+ "Doesn't look like any channels are available right now. "
+ "You're welcome to check for yourself to be sure. "
+ "If all channels are truly busy, please be patient "
+ "as one will likely be available soon."
+ )
await ctx.send(embed=embed)
def setup(bot: Bot) -> None:
- """Free cog load."""
+ """Load the Free cog."""
bot.add_cog(Free())
- log.info("Cog loaded: Free")
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index f5538ce5e..ecf14d131 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -6,10 +6,11 @@ from typing import Union
from discord import Colour, Embed, HTTPException, Message, Reaction, User
from discord.ext import commands
-from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context
+from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context
from fuzzywuzzy import fuzz, process
from bot import constants
+from bot.bot import Bot
from bot.constants import Channels, Emojis, STAFF_ROLES
from bot.decorators import redirect_output
from bot.pagination import (
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 3a7ba0444..125d7ce24 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -3,14 +3,17 @@ 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.ext.commands import 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.bot import Bot
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 +27,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 +51,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 +151,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 +163,160 @@ 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:
+ # Check activity.state for None value if user has a custom status set
+ # This guards against a custom status with an emoji but no text, which will cause
+ # escape_markdown to raise an exception
+ # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class
+ if activity.name == 'Custom Status' and activity.state:
+ 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 +353,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
@@ -311,6 +396,5 @@ class Information(Cog):
def setup(bot: Bot) -> None:
- """Information cog load."""
+ """Load the Information cog."""
bot.add_cog(Information(bot))
- log.info("Cog loaded: Information")
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
index be9d33e3e..985f28ce5 100644
--- a/bot/cogs/jams.py
+++ b/bot/cogs/jams.py
@@ -4,6 +4,7 @@ from discord import Member, PermissionOverwrite, utils
from discord.ext import commands
from more_itertools import unique_everseen
+from bot.bot import Bot
from bot.constants import Roles
from bot.decorators import with_role
@@ -13,7 +14,7 @@ log = logging.getLogger(__name__)
class CodeJams(commands.Cog):
"""Manages the code-jam related parts of our server."""
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.bot = bot
@commands.command()
@@ -108,7 +109,6 @@ class CodeJams(commands.Cog):
)
-def setup(bot: commands.Bot) -> None:
- """Code Jams cog load."""
+def setup(bot: Bot) -> None:
+ """Load the CodeJams cog."""
bot.add_cog(CodeJams(bot))
- log.info("Cog loaded: CodeJams")
diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py
index c92b619ff..d1b7dcab3 100644
--- a/bot/cogs/logging.py
+++ b/bot/cogs/logging.py
@@ -1,8 +1,9 @@
import logging
from discord import Embed
-from discord.ext.commands import Bot, Cog
+from discord.ext.commands import Cog
+from bot.bot import Bot
from bot.constants import Channels, DEBUG_MODE
@@ -37,6 +38,5 @@ class Logging(Cog):
def setup(bot: Bot) -> None:
- """Logging cog load."""
+ """Load the Logging cog."""
bot.add_cog(Logging(bot))
- log.info("Cog loaded: Logging")
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
index 7383ed44e..5243cb92d 100644
--- a/bot/cogs/moderation/__init__.py
+++ b/bot/cogs/moderation/__init__.py
@@ -1,25 +1,13 @@
-import logging
-
-from discord.ext.commands import Bot
-
+from bot.bot import Bot
from .infractions import Infractions
from .management import ModManagement
from .modlog import ModLog
from .superstarify import Superstarify
-log = logging.getLogger(__name__)
-
def setup(bot: Bot) -> None:
- """Load the moderation extension (Infractions, ModManagement, ModLog, & Superstarify cogs)."""
+ """Load the Infractions, ModManagement, ModLog, and Superstarify cogs."""
bot.add_cog(Infractions(bot))
- log.info("Cog loaded: Infractions")
-
bot.add_cog(ModLog(bot))
- log.info("Cog loaded: ModLog")
-
bot.add_cog(ModManagement(bot))
- log.info("Cog loaded: ModManagement")
-
bot.add_cog(Superstarify(bot))
- log.info("Cog loaded: Superstarify")
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index 997ffe524..f4e296df9 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/cogs/moderation/infractions.py
@@ -1,92 +1,53 @@
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.bot import Bot
+from bot.constants import Event
+from bot.converters import Expiry, FetchedMember
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 .utils import MemberObject
+from .scheduler import InfractionScheduler
+from .utils import UserSnowflake
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__()
+ def __init__(self, bot: Bot):
+ 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]
- # 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()
+ if active_mutes:
+ reason = f"Re-applying active mute: {active_mutes[0]['id']}"
+ action = member.add_roles(self._muted_role, reason=reason)
- # 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
@@ -105,7 +66,7 @@ class Infractions(Scheduler, commands.Cog):
await self.apply_kick(ctx, user, reason, active=False)
@command()
- async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
"""Permanently ban a user for the given reason."""
await self.apply_ban(ctx, user, reason)
@@ -113,7 +74,7 @@ class Infractions(Scheduler, commands.Cog):
# region: Temporary infractions
@command(aliases=["mute"])
- async def tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None:
+ async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None:
"""
Temporarily mute a user for the given reason and duration.
@@ -132,7 +93,7 @@ class Infractions(Scheduler, commands.Cog):
await self.apply_mute(ctx, user, reason, expires_at=duration)
@command()
- async def tempban(self, ctx: Context, user: MemberConverter, duration: utils.Expiry, *, reason: str = None) -> None:
+ async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None:
"""
Temporarily ban a user for the given reason and duration.
@@ -154,7 +115,7 @@ class Infractions(Scheduler, commands.Cog):
# region: Permanent shadow infractions
@command(hidden=True)
- async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
"""Create a private note for a user with the given reason without notifying the user."""
infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
if infraction is None:
@@ -168,7 +129,7 @@ class Infractions(Scheduler, commands.Cog):
await self.apply_kick(ctx, user, reason, hidden=True, active=False)
@command(hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None:
"""Permanently ban a user for the given reason without notifying the user."""
await self.apply_ban(ctx, user, reason, hidden=True)
@@ -176,7 +137,7 @@ class Infractions(Scheduler, commands.Cog):
# region: Temporary shadow infractions
@command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"])
- async def shadow_tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None:
+ async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None:
"""
Temporarily mute a user for the given reason and duration without notifying the user.
@@ -198,8 +159,8 @@ class Infractions(Scheduler, commands.Cog):
async def shadow_tempban(
self,
ctx: Context,
- user: MemberConverter,
- duration: utils.Expiry,
+ user: FetchedMember,
+ duration: Expiry,
*,
reason: str = None
) -> None:
@@ -224,36 +185,41 @@ class Infractions(Scheduler, commands.Cog):
# region: Remove infractions (un- commands)
@command()
- async def unmute(self, ctx: Context, user: MemberConverter) -> None:
+ async def unmute(self, ctx: Context, user: FetchedMember) -> None:
"""Prematurely end the active mute infraction for the user."""
await self.pardon_infraction(ctx, "mute", user)
@command()
- async def unban(self, ctx: Context, user: MemberConverter) -> None:
+ async def unban(self, ctx: Context, user: FetchedMember) -> None:
"""Prematurely end the active ban infraction for the user."""
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`."""
if await utils.has_active_infraction(ctx, user, "mute"):
return
- infraction = await utils.post_infraction(ctx, user, "mute", reason, **kwargs)
+ infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
if infraction is None:
return
self.mod_log.ignore(Event.member_update, user.id)
- action = user.add_roles(self._muted_role, reason=reason)
- await self.apply_infraction(ctx, infraction, user, action)
+ async def action() -> None:
+ await user.add_roles(self._muted_role, reason=reason)
+
+ log.trace(f"Attempting to kick {user} from voice because they've been muted.")
+ await user.move_to(None, reason=reason)
+
+ await self.apply_infraction(ctx, infraction, user, action())
@respect_role_hierarchy()
async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
"""Apply a kick infraction with kwargs passed to `post_infraction`."""
- infraction = await utils.post_infraction(ctx, user, "kick", reason, **kwargs)
+ infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
if infraction is None:
return
@@ -263,12 +229,12 @@ class Infractions(Scheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user, action)
@respect_role_hierarchy()
- async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None:
+ async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None:
"""Apply a ban infraction with kwargs passed to `post_infraction`."""
if await utils.has_active_infraction(ctx, user, "ban"):
return
- infraction = await utils.post_infraction(ctx, user, "ban", reason, **kwargs)
+ infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
return
@@ -278,328 +244,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/management.py b/bot/cogs/moderation/management.py
index 491f6d400..0636422d3 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -2,40 +2,31 @@ import asyncio
import logging
import textwrap
import typing as t
+from datetime import datetime
import discord
from discord.ext import commands
from discord.ext.commands import Context
from bot import constants
-from bot.converters import InfractionSearchQuery
+from bot.bot import Bot
+from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user
from bot.pagination import LinePaginator
from bot.utils import time
-from bot.utils.checks import with_role_check
+from bot.utils.checks import in_channel_check, with_role_check
from . import utils
from .infractions import Infractions
from .modlog import ModLog
log = logging.getLogger(__name__)
-UserConverter = t.Union[discord.User, utils.proxy_user]
-
-
-def permanent_duration(expires_at: str) -> str:
- """Only allow an expiration to be 'permanent' if it is a string."""
- expires_at = expires_at.lower()
- if expires_at != "permanent":
- raise commands.BadArgument
- else:
- return expires_at
-
class ModManagement(commands.Cog):
"""Management of infractions."""
category = "Moderation"
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.bot = bot
@property
@@ -59,8 +50,8 @@ class ModManagement(commands.Cog):
async def infraction_edit(
self,
ctx: Context,
- infraction_id: int,
- duration: t.Union[utils.Expiry, permanent_duration, None],
+ infraction_id: t.Union[int, allowed_strings("l", "last", "recent")],
+ duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],
*,
reason: str = None
) -> None:
@@ -77,26 +68,45 @@ class ModManagement(commands.Cog):
\u2003`M` - minutes∗
\u2003`s` - seconds
- Use "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp
- can be provided for the duration.
+ Use "l", "last", or "recent" as the infraction ID to specify that the most recent infraction
+ authored by the command invoker should be edited.
+
+ Use "p" or "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601
+ timestamp can be provided for the duration.
"""
if duration is None and reason is None:
# Unlike UserInputError, the error handler will show a specified message for BadArgument
raise commands.BadArgument("Neither a new expiry nor a new reason was specified.")
# Retrieve the previous infraction for its information.
- old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}')
+ if isinstance(infraction_id, str):
+ params = {
+ "actor__id": ctx.author.id,
+ "ordering": "-inserted_at"
+ }
+ infractions = await self.bot.api_client.get(f"bot/infractions", params=params)
+
+ if infractions:
+ old_infraction = infractions[0]
+ infraction_id = old_infraction["id"]
+ else:
+ await ctx.send(
+ f":x: Couldn't find most recent infraction; you have never given an infraction."
+ )
+ return
+ else:
+ old_infraction = await self.bot.api_client.get(f"bot/infractions/{infraction_id}")
request_data = {}
confirm_messages = []
log_text = ""
- if duration == "permanent":
+ if isinstance(duration, str):
request_data['expires_at'] = None
confirm_messages.append("marked as permanent")
elif duration is not None:
request_data['expires_at'] = duration.isoformat()
- expiry = duration.strftime(time.INFRACTION_FORMAT)
+ expiry = time.format_infraction_with_duration(request_data['expires_at'])
confirm_messages.append(f"set to expire on {expiry}")
else:
confirm_messages.append("expiry unchanged")
@@ -128,7 +138,8 @@ class ModManagement(commands.Cog):
New expiry: {new_infraction['expires_at'] or "Permanent"}
""".rstrip()
- await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}")
+ changes = ' & '.join(confirm_messages)
+ await ctx.send(f":ok_hand: Updated infraction #{infraction_id}: {changes}")
# Get information about the infraction's user
user_id = new_infraction['user']
@@ -169,7 +180,7 @@ class ModManagement(commands.Cog):
await ctx.invoke(self.search_reason, query)
@infraction_search_group.command(name="user", aliases=("member", "id"))
- async def search_user(self, ctx: Context, user: UserConverter) -> None:
+ async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
"""Search for infractions by member."""
infraction_list = await self.bot.api_client.get(
'bot/infractions',
@@ -231,10 +242,17 @@ class ModManagement(commands.Cog):
user_id = infraction["user"]
hidden = infraction["hidden"]
created = time.format_infraction(infraction["inserted_at"])
+
+ if active:
+ remaining = time.until_expiration(infraction["expires_at"]) or "Expired"
+ else:
+ remaining = "Inactive"
+
if infraction["expires_at"] is None:
expires = "*Permanent*"
else:
- expires = time.format_infraction(infraction["expires_at"])
+ date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
+ expires = time.format_infraction_with_duration(infraction["expires_at"], date_from)
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
@@ -245,6 +263,7 @@ class ModManagement(commands.Cog):
Reason: {infraction["reason"] or "*None*"}
Created: {created}
Expires: {expires}
+ Remaining: {remaining}
Actor: {actor.mention if actor else actor_id}
ID: `{infraction["id"]}`
{"**===============**" if active else "==============="}
@@ -256,8 +275,12 @@ class ModManagement(commands.Cog):
# This cannot be static (must have a __func__ attribute).
def cog_check(self, ctx: Context) -> bool:
- """Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ """Only allow moderators from moderator channels to invoke the commands in this cog."""
+ checks = [
+ with_role_check(ctx, *constants.MODERATION_ROLES),
+ in_channel_check(ctx, *constants.MODERATION_CHANNELS)
+ ]
+ return all(checks)
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index 88f2b6c67..e8ae0dbe6 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -1,18 +1,21 @@
import asyncio
+import difflib
+import itertools
import logging
import typing as t
from datetime import datetime
+from itertools import zip_longest
import discord
from dateutil.relativedelta import relativedelta
from deepdiff import DeepDiff
from discord import Colour
from discord.abc import GuildChannel
-from discord.ext.commands import Bot, Cog, Context
+from discord.ext.commands import Cog, Context
+from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
from bot.utils.time import humanize_delta
-from .utils import UserTypes
log = logging.getLogger(__name__)
@@ -23,6 +26,12 @@ CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick")
ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
+VOICE_STATE_ATTRIBUTES = {
+ "channel.name": "Channel",
+ "self_stream": "Streaming",
+ "self_video": "Broadcasting",
+}
+
class ModLog(Cog, name="ModLog"):
"""Logging for server events and staff actions."""
@@ -34,14 +43,16 @@ class ModLog(Cog, name="ModLog"):
self._cached_deletes = []
self._cached_edits = []
- async def upload_log(self, messages: t.List[discord.Message], actor_id: int) -> str:
- """
- Uploads the log data to the database via an API endpoint for uploading logs.
-
- Used in several mod log embeds.
+ async def upload_log(
+ self,
+ messages: t.Iterable[discord.Message],
+ actor_id: int,
+ attachments: t.Iterable[t.List[str]] = None
+ ) -> str:
+ """Upload message logs to the database and return a URL to a page for viewing the logs."""
+ if attachments is None:
+ attachments = []
- Returns a URL that can be used to view the log.
- """
response = await self.bot.api_client.post(
'bot/deleted-messages',
json={
@@ -53,9 +64,10 @@ class ModLog(Cog, name="ModLog"):
'author': message.author.id,
'channel_id': message.channel.id,
'content': message.content,
- 'embeds': [embed.to_dict() for embed in message.embeds]
+ 'embeds': [embed.to_dict() for embed in message.embeds],
+ 'attachments': attachment,
}
- for message in messages
+ for message, attachment in zip_longest(messages, attachments)
]
}
)
@@ -203,7 +215,7 @@ class ModLog(Cog, name="ModLog"):
new = value["new_value"]
old = value["old_value"]
- changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+ changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
done.append(key)
@@ -281,7 +293,7 @@ class ModLog(Cog, name="ModLog"):
new = value["new_value"]
old = value["old_value"]
- changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+ changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
done.append(key)
@@ -331,7 +343,7 @@ class ModLog(Cog, name="ModLog"):
new = value["new_value"]
old = value["old_value"]
- changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+ changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
done.append(key)
@@ -352,7 +364,7 @@ class ModLog(Cog, name="ModLog"):
)
@Cog.listener()
- async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None:
+ async def on_member_ban(self, guild: discord.Guild, member: discord.Member) -> None:
"""Log ban event to user log."""
if guild.id != GuildConstant.id:
return
@@ -484,23 +496,23 @@ class ModLog(Cog, name="ModLog"):
old = value.get("old_value")
if new and old:
- changes.append(f"**{key.title()}:** `{old}` **->** `{new}`")
+ changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
done.append(key)
if before.name != after.name:
changes.append(
- f"**Username:** `{before.name}` **->** `{after.name}`"
+ f"**Username:** `{before.name}` **→** `{after.name}`"
)
if before.discriminator != after.discriminator:
changes.append(
- f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`"
+ f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`"
)
if before.display_name != after.display_name:
changes.append(
- f"**Display name:** `{before.display_name}` **->** `{after.display_name}`"
+ f"**Display name:** `{before.display_name}` **→** `{after.display_name}`"
)
if not changes:
@@ -618,80 +630,81 @@ class ModLog(Cog, name="ModLog"):
)
@Cog.listener()
- async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
+ async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None:
"""Log message edit event to message change log."""
if (
- not before.guild
- or before.guild.id != GuildConstant.id
- or before.channel.id in GuildConstant.ignored
- or before.author.bot
+ not msg_before.guild
+ or msg_before.guild.id != GuildConstant.id
+ or msg_before.channel.id in GuildConstant.ignored
+ or msg_before.author.bot
):
return
- self._cached_edits.append(before.id)
+ self._cached_edits.append(msg_before.id)
- if before.content == after.content:
+ if msg_before.content == msg_after.content:
return
- author = before.author
- channel = before.channel
+ author = msg_before.author
+ channel = msg_before.channel
+ channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
- if channel.category:
- before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{before.id}`\n"
- "\n"
- f"{before.clean_content}"
- )
-
- after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{before.id}`\n"
- "\n"
- f"{after.clean_content}"
- )
- else:
- before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** #{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{before.id}`\n"
- "\n"
- f"{before.clean_content}"
- )
+ # Getting the difference per words and group them by type - add, remove, same
+ # Note that this is intended grouping without sorting
+ diff = difflib.ndiff(msg_before.clean_content.split(), msg_after.clean_content.split())
+ diff_groups = tuple(
+ (diff_type, tuple(s[2:] for s in diff_words))
+ for diff_type, diff_words in itertools.groupby(diff, key=lambda s: s[0])
+ )
- after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** #{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{before.id}`\n"
- "\n"
- f"{after.clean_content}"
- )
+ content_before: t.List[str] = []
+ content_after: t.List[str] = []
+
+ for index, (diff_type, words) in enumerate(diff_groups):
+ sub = ' '.join(words)
+ if diff_type == '-':
+ content_before.append(f"[{sub}](http://o.hi)")
+ elif diff_type == '+':
+ content_after.append(f"[{sub}](http://o.hi)")
+ elif diff_type == ' ':
+ if len(words) > 2:
+ sub = (
+ f"{words[0] if index > 0 else ''}"
+ " ... "
+ f"{words[-1] if index < len(diff_groups) - 1 else ''}"
+ )
+ content_before.append(sub)
+ content_after.append(sub)
+
+ response = (
+ f"**Author:** {author} (`{author.id}`)\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{msg_before.id}`\n"
+ "\n"
+ f"**Before**:\n{' '.join(content_before)}\n"
+ f"**After**:\n{' '.join(content_after)}\n"
+ "\n"
+ f"[Jump to message]({msg_after.jump_url})"
+ )
- if before.edited_at:
+ if msg_before.edited_at:
# Message was previously edited, to assist with self-bot detection, use the edited_at
# datetime as the baseline and create a human-readable delta between this edit event
# and the last time the message was edited
- timestamp = before.edited_at
- delta = humanize_delta(relativedelta(after.edited_at, before.edited_at))
+ timestamp = msg_before.edited_at
+ delta = humanize_delta(relativedelta(msg_after.edited_at, msg_before.edited_at))
footer = f"Last edited {delta} ago"
else:
# Message was not previously edited, use the created_at datetime as the baseline, no
# delta calculation needed
- timestamp = before.created_at
+ timestamp = msg_before.created_at
footer = None
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response,
+ Icons.message_edit, Colour.blurple(), "Message edited", response,
channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer
)
- await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response,
- channel_id=Channels.message_log, timestamp_override=after.edited_at
- )
-
@Cog.listener()
async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None:
"""Log raw message edit event to message change log."""
@@ -718,39 +731,23 @@ class ModLog(Cog, name="ModLog"):
author = message.author
channel = message.channel
+ channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
+
+ before_response = (
+ f"**Author:** {author} (`{author.id}`)\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ "This message was not cached, so the message content cannot be displayed."
+ )
- if channel.category:
- before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{message.id}`\n"
- "\n"
- "This message was not cached, so the message content cannot be displayed."
- )
-
- after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{message.id}`\n"
- "\n"
- f"{message.clean_content}"
- )
- else:
- before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** #{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{message.id}`\n"
- "\n"
- "This message was not cached, so the message content cannot be displayed."
- )
-
- after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
- f"**Channel:** #{channel.name} (`{channel.id}`)\n"
- f"**Message ID:** `{message.id}`\n"
- "\n"
- f"{message.clean_content}"
- )
+ after_response = (
+ f"**Author:** {author} (`{author.id}`)\n"
+ f"**Channel:** {channel_name} (`{channel.id}`)\n"
+ f"**Message ID:** `{message.id}`\n"
+ "\n"
+ f"{message.clean_content}"
+ )
await self.send_log_message(
Icons.message_edit, Colour.blurple(), "Message edited (Before)",
@@ -761,3 +758,76 @@ class ModLog(Cog, name="ModLog"):
Icons.message_edit, Colour.blurple(), "Message edited (After)",
after_response, channel_id=Channels.message_log
)
+
+ @Cog.listener()
+ async def on_voice_state_update(
+ self,
+ member: discord.Member,
+ before: discord.VoiceState,
+ after: discord.VoiceState
+ ) -> None:
+ """Log member voice state changes to the voice log channel."""
+ if (
+ member.guild.id != GuildConstant.id
+ or (before.channel and before.channel.id in GuildConstant.ignored)
+ ):
+ return
+
+ if member.id in self._ignored[Event.voice_state_update]:
+ self._ignored[Event.voice_state_update].remove(member.id)
+ return
+
+ # Exclude all channel attributes except the name.
+ diff = DeepDiff(
+ before,
+ after,
+ exclude_paths=("root.session_id", "root.afk"),
+ exclude_regex_paths=r"root\.channel\.(?!name)",
+ )
+
+ # A type change seems to always take precedent over a value change. Furthermore, it will
+ # include the value change along with the type change anyway. Therefore, it's OK to
+ # "overwrite" values_changed; in practice there will never even be anything to overwrite.
+ diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})}
+
+ icon = Icons.voice_state_blue
+ colour = Colour.blurple()
+ changes = []
+
+ for attr, values in diff_values.items():
+ if not attr: # Not sure why, but it happens.
+ continue
+
+ old = values["old_value"]
+ new = values["new_value"]
+
+ attr = attr[5:] # Remove "root." prefix.
+ attr = VOICE_STATE_ATTRIBUTES.get(attr, attr.replace("_", " ").capitalize())
+
+ changes.append(f"**{attr}:** `{old}` **→** `{new}`")
+
+ # Set the embed icon and colour depending on which attribute changed.
+ if any(name in attr for name in ("Channel", "deaf", "mute")):
+ if new is None or new is True:
+ # Left a channel or was muted/deafened.
+ icon = Icons.voice_state_red
+ colour = Colours.soft_red
+ elif old is None or old is True:
+ # Joined a channel or was unmuted/undeafened.
+ icon = Icons.voice_state_green
+ colour = Colours.soft_green
+
+ if not changes:
+ return
+
+ message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes))
+ message = f"**{member}** (`{member.id}`)\n{message}"
+
+ await self.send_log_message(
+ icon_url=icon,
+ colour=colour,
+ title="Voice state updated",
+ text=message,
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.voice_log
+ )
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
new file mode 100644
index 000000000..e14c302cb
--- /dev/null
+++ b/bot/cogs/moderation/scheduler.py
@@ -0,0 +1,418 @@
+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 Context
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+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 UserSnowflake
+
+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: UserSnowflake,
+ 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 = time.format_infraction_with_duration(infraction["expires_at"])
+ id_ = infraction['id']
+
+ log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")
+
+ # 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"]:
+ dm_result = f"{constants.Emojis.failmail} "
+ dm_log_text = "\nDM: **Failed**"
+
+ # Sometimes user is a discord.Object; make it a proper user.
+ try:
+ if not isinstance(user, (discord.Member, discord.User)):
+ user = await self.bot.fetch_user(user.id)
+ except discord.HTTPException as e:
+ log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
+ else:
+ # Accordingly display whether the user was successfully notified via DM.
+ if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ dm_result = ":incoming_envelope: "
+ dm_log_text = "\nDM: Sent"
+
+ 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.HTTPException as e:
+ # 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_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}"
+ if isinstance(e, discord.Forbidden):
+ log.warning(f"{log_msg}: bot lacks permissions.")
+ else:
+ log.exception(log_msg)
+
+ # 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: UserSnowflake) -> 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:
+ dm_emoji = f"{constants.Emojis.failmail} "
+
+ # 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 status {e.status} and 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..050c847ac 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -1,17 +1,20 @@
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 discord.ext.commands import Cog, Context, command
from bot import constants
+from bot.bot import Bot
+from bot.converters import Expiry
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 +23,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: 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 +132,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, active=True)
+ 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..5052b9048 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/cogs/moderation/utils.py
@@ -4,60 +4,72 @@ import typing as t
from datetime import datetime
import discord
-from discord.ext import commands
from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.constants import Colours, Icons
-from bot.converters import Duration, ISODateTime
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")
-UserTypes = t.Union[discord.Member, discord.User]
-MemberObject = t.Union[UserTypes, discord.Object]
+# Type aliases
+UserObject = t.Union[discord.Member, discord.User]
+UserSnowflake = t.Union[UserObject, discord.Object]
Infraction = t.Dict[str, t.Union[str, int, bool]]
-Expiry = t.Union[Duration, ISODateTime]
-def proxy_user(user_id: str) -> discord.Object:
+async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
"""
- Create a proxy user object from the given id.
+ Create a new user in the database.
- Used when a Member or User object cannot be resolved.
+ Used when an infraction needs to be applied on a user absent in the guild.
"""
- try:
- user_id = int(user_id)
- except ValueError:
- raise commands.BadArgument
+ log.trace(f"Attempting to add user {user.id} to the database.")
+
+ if not isinstance(user, (discord.Member, discord.User)):
+ log.warning("The user being added to the DB is not a Member or User object.")
- user = discord.Object(user_id)
- user.mention = user.id
- user.avatar_url_as = lambda static_format: None
+ payload = {
+ 'avatar_hash': getattr(user, 'avatar', 0),
+ 'discriminator': int(getattr(user, 'discriminator', 0)),
+ 'id': user.id,
+ 'in_guild': False,
+ 'name': getattr(user, 'name', 'Name unknown'),
+ 'roles': []
+ }
- return user
+ try:
+ response = await ctx.bot.api_client.post('bot/users', json=payload)
+ log.info(f"User {user.id} added to the DB.")
+ return response
+ except ResponseCodeError as e:
+ log.error(f"Failed to add user {user.id} to the DB. {e}")
+ await ctx.send(f":x: The attempt to add the user to the DB failed: status {e.status}")
async def post_infraction(
ctx: Context,
- user: MemberObject,
+ user: UserSnowflake,
infr_type: str,
reason: str,
expires_at: datetime = None,
hidden: bool = False,
- active: bool = True,
+ 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,
@@ -69,28 +81,26 @@ async def post_infraction(
if expires_at:
payload['expires_at'] = expires_at.isoformat()
- try:
- response = await ctx.bot.api_client.post('bot/infractions', json=payload)
- except ResponseCodeError as exp:
- if exp.status == 400 and 'user' in exp.response_json:
- log.info(
- f"{ctx.author} tried to add a {infr_type} infraction to `{user.id}`, "
- "but that user id was not found in the database."
- )
- await ctx.send(
- f":x: Cannot add infraction, the specified user is not known to the database."
- )
- return
- else:
- log.exception("An unexpected ResponseCodeError occurred while adding an infraction:")
- await ctx.send(":x: There was an error adding the infraction.")
- return
-
- return response
-
-
-async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool:
+ # Try to apply the infraction. If it fails because the user doesn't exist, try to add it.
+ for should_post_user in (True, False):
+ try:
+ response = await ctx.bot.api_client.post('bot/infractions', json=payload)
+ return response
+ except ResponseCodeError as e:
+ if e.status == 400 and 'user' in e.response_json:
+ # Only one attempt to add the user to the database, not two:
+ if not should_post_user or await post_user(ctx, user) is None:
+ return
+ else:
+ log.exception(f"Unexpected error while adding an infraction for {user}:")
+ await ctx.send(f":x: There was an error adding the infraction: status {e.status}.")
+ return
+
+
+async def has_active_infraction(ctx: Context, user: UserSnowflake, infr_type: str) -> bool:
"""Checks if a user already has an active infraction of the given type."""
+ 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,23 +110,27 @@ 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
async def notify_infraction(
- user: UserTypes,
+ user: UserObject,
infr_type: str,
expires_at: t.Optional[str] = None,
reason: t.Optional[str] = None,
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 +140,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
@@ -139,12 +153,14 @@ async def notify_infraction(
async def notify_pardon(
- user: UserTypes,
+ user: UserObject,
title: str,
content: str,
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
@@ -155,7 +171,7 @@ async def notify_pardon(
return await send_private_embed(user, embed)
-async def send_private_embed(user: UserTypes, embed: discord.Embed) -> bool:
+async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool:
"""
A helper method for sending an embed to a user's DMs.
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index 1f9fb0b4f..bf777ea5a 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -4,9 +4,10 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group
+from discord.ext.commands import BadArgument, Cog, Context, Converter, group
from bot.api import ResponseCodeError
+from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -24,6 +25,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 +101,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 +124,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 +137,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}'")
@@ -188,6 +185,5 @@ class OffTopicNames(Cog):
def setup(bot: Bot) -> None:
- """Off topic names cog load."""
+ """Load the OffTopicNames cog."""
bot.add_cog(OffTopicNames(bot))
- log.info("Cog loaded: OffTopicNames")
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 0f575cece..aa487f18e 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -2,39 +2,121 @@ import asyncio
import logging
import random
import textwrap
+from collections import namedtuple
from datetime import datetime, timedelta
from typing import List
-from discord import Colour, Embed, Message, TextChannel
-from discord.ext.commands import Bot, Cog, Context, group
+from aiohttp import BasicAuth, ClientError
+from discord import Colour, Embed, TextChannel
+from discord.ext.commands import Cog, Context, group
+from discord.ext.tasks import loop
-from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES
+from bot.bot import Bot
+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
log = logging.getLogger(__name__)
+AccessToken = namedtuple("AccessToken", ["token", "expires_at"])
+
class Reddit(Cog):
"""Track subreddit posts and show detailed statistics about them."""
- HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}
+ HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"}
URL = "https://www.reddit.com"
- MAX_FETCH_RETRIES = 3
+ OAUTH_URL = "https://oauth.reddit.com"
+ MAX_RETRIES = 3
def __init__(self, bot: Bot):
self.bot = bot
- self.reddit_channel = None
+ self.webhook = None
+ self.access_token = None
+ self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret)
+
+ bot.loop.create_task(self.init_reddit_ready())
+ self.auto_poster_loop.start()
+
+ def cog_unload(self) -> None:
+ """Stop the loop task and revoke the access token when the cog is unloaded."""
+ self.auto_poster_loop.cancel()
+ if self.access_token.expires_at < datetime.utcnow():
+ self.revoke_access_token()
+
+ async def init_reddit_ready(self) -> None:
+ """Sets the reddit webhook when the cog is loaded."""
+ await self.bot.wait_until_ready()
+ if not self.webhook:
+ self.webhook = await self.bot.fetch_webhook(Webhooks.reddit)
+
+ @property
+ def channel(self) -> TextChannel:
+ """Get the #reddit channel object from the bot's cache."""
+ return self.bot.get_channel(Channels.reddit)
+
+ async def get_access_token(self) -> None:
+ """
+ Get a Reddit API OAuth2 access token and assign it to self.access_token.
+
+ A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog
+ will be unloaded and a ClientError raised if retrieval was still unsuccessful.
+ """
+ for i in range(1, self.MAX_RETRIES + 1):
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/access_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "grant_type": "client_credentials",
+ "duration": "temporary"
+ }
+ )
+
+ if response.status == 200 and response.content_type == "application/json":
+ content = await response.json()
+ expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway.
+ self.access_token = AccessToken(
+ token=content["access_token"],
+ expires_at=datetime.utcnow() + timedelta(seconds=expiration)
+ )
+
+ log.debug(f"New token acquired; expires on {self.access_token.expires_at}")
+ return
+ else:
+ log.debug(
+ f"Failed to get an access token: "
+ f"status {response.status} & content type {response.content_type}; "
+ f"retrying ({i}/{self.MAX_RETRIES})"
+ )
- self.prev_lengths = {}
- self.last_ids = {}
+ await asyncio.sleep(3)
- self.new_posts_task = None
- self.top_weekly_posts_task = None
+ self.bot.remove_cog(self.qualified_name)
+ raise ClientError("Authentication with the Reddit API failed. Unloading the cog.")
+
+ async def revoke_access_token(self) -> None:
+ """
+ Revoke the OAuth2 access token for the Reddit API.
+
+ For security reasons, it's good practice to revoke the token when it's no longer being used.
+ """
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/revoke_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "token": self.access_token.token,
+ "token_type_hint": "access_token"
+ }
+ )
- self.bot.loop.create_task(self.init_reddit_polling())
+ if response.status == 204 and response.content_type == "application/json":
+ self.access_token = None
+ else:
+ log.warning(f"Unable to revoke access token: status {response.status}.")
async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]:
"""A helper method to fetch a certain amount of Reddit posts at a given route."""
@@ -42,14 +124,15 @@ class Reddit(Cog):
if not 25 >= amount > 0:
raise ValueError("Invalid amount of subreddit posts requested.")
- if params is None:
- params = {}
+ # Renew the token if necessary.
+ if not self.access_token or self.access_token.expires_at < datetime.utcnow():
+ await self.get_access_token()
- url = f"{self.URL}/{route}.json"
- for _ in range(self.MAX_FETCH_RETRIES):
+ url = f"{self.OAUTH_URL}/{route}"
+ for _ in range(self.MAX_RETRIES):
response = await self.bot.http_session.get(
url=url,
- headers=self.HEADERS,
+ headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"},
params=params
)
if response.status == 200 and response.content_type == 'application/json':
@@ -63,23 +146,22 @@ class Reddit(Cog):
log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}")
return list() # Failed to get appropriate response within allowed number of retries.
- async def send_top_posts(
- self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all"
- ) -> Message:
- """Create an embed for the top posts, then send it in a given TextChannel."""
- # Create the new spicy embed.
- embed = Embed()
- embed.description = ""
-
- # Get the posts
- async with channel.typing():
- posts = await self.fetch_posts(
- route=f"{subreddit}/top",
- amount=5,
- params={
- "t": time
- }
- )
+ async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed:
+ """
+ Get the top amount of posts for a given subreddit within a specified timeframe.
+
+ A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top
+ weekly posts.
+
+ The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most.
+ """
+ embed = Embed(description="")
+
+ posts = await self.fetch_posts(
+ route=f"{subreddit}/top",
+ amount=amount,
+ params={"t": time}
+ )
if not posts:
embed.title = random.choice(ERROR_REPLIES)
@@ -89,9 +171,7 @@ class Reddit(Cog):
"If this problem persists, please let us know."
)
- return await channel.send(
- embed=embed
- )
+ return embed
for post in posts:
data = post["data"]
@@ -109,109 +189,58 @@ 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()
+ return embed
- return await channel.send(
- content=content,
- embed=embed
- )
-
- async def poll_new_posts(self) -> None:
- """Periodically search for new subreddit posts."""
- while True:
- await asyncio.sleep(RedditConfig.request_delay)
-
- for subreddit in RedditConfig.subreddits:
- # Make a HEAD request to the subreddit
- head_response = await self.bot.http_session.head(
- url=f"{self.URL}/{subreddit}/new.rss",
- headers=self.HEADERS
- )
-
- content_length = head_response.headers["content-length"]
-
- # If the content is the same size as before, assume there's no new posts.
- if content_length == self.prev_lengths.get(subreddit, None):
- continue
-
- self.prev_lengths[subreddit] = content_length
-
- # Now we can actually fetch the new data
- posts = await self.fetch_posts(f"{subreddit}/new")
- new_posts = []
-
- # Only show new posts if we've checked before.
- if subreddit in self.last_ids:
- for post in posts:
- data = post["data"]
-
- # Convert the ID to an integer for easy comparison.
- int_id = int(data["id"], 36)
-
- # If we've already seen this post, finish checking
- if int_id <= self.last_ids[subreddit]:
- break
-
- embed_data = {
- "title": textwrap.shorten(data["title"], width=64, placeholder="..."),
- "text": textwrap.shorten(data["selftext"], width=128, placeholder="..."),
- "url": self.URL + data["permalink"],
- "author": data["author"]
- }
-
- new_posts.append(embed_data)
-
- self.last_ids[subreddit] = int(posts[0]["data"]["id"], 36)
+ @loop()
+ async def auto_poster_loop(self) -> None:
+ """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()
+ tomorrow = now + timedelta(days=1)
+ midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0)
+ seconds_until = (midnight_tomorrow - now).total_seconds()
- # Send all of the new posts as spicy embeds
- for data in new_posts:
- embed = Embed()
+ await asyncio.sleep(seconds_until)
- embed.title = data["title"]
- embed.url = data["url"]
- embed.description = data["text"]
- embed.set_footer(text=f"Posted by u/{data['author']} in {subreddit}")
- embed.colour = Colour.blurple()
-
- await self.reddit_channel.send(embed=embed)
+ await self.bot.wait_until_ready()
+ if not self.webhook:
+ await self.bot.fetch_webhook(Webhooks.reddit)
- log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.")
+ if datetime.utcnow().weekday() == 0:
+ await self.top_weekly_posts()
+ # if it's a monday send the top weekly posts
- async def poll_top_weekly_posts(self) -> None:
- """Post a summary of the top posts every week."""
- while True:
- now = datetime.utcnow()
+ for subreddit in RedditConfig.subreddits:
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
+ await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts)
- # Calculate the amount of seconds until midnight next monday.
- monday = now + timedelta(days=7 - now.weekday())
- monday = monday.replace(hour=0, minute=0, second=0)
- until_monday = (monday - now).total_seconds()
+ async def top_weekly_posts(self) -> None:
+ """Post a summary of the top posts."""
+ for subreddit in RedditConfig.subreddits:
+ # Send and pin the new weekly posts.
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="week")
- await asyncio.sleep(until_monday)
+ message = await self.webhook.send(wait=True, username=f"{subreddit} Top Weekly Posts", embed=top_posts)
- for subreddit in RedditConfig.subreddits:
- # Send and pin the new weekly posts.
- message = await self.send_top_posts(
- channel=self.reddit_channel,
- subreddit=subreddit,
- content=f"This week's top {subreddit} posts have arrived!",
- time="week"
- )
+ if subreddit.lower() == "r/python":
+ if not self.channel:
+ log.warning("Failed to get #reddit channel to remove pins in the weekly loop.")
+ return
- if subreddit.lower() == "r/python":
- # Remove the oldest pins so that only 5 remain at most.
- pins = await self.reddit_channel.pins()
+ # Remove the oldest pins so that only 12 remain at most.
+ pins = await self.channel.pins()
- while len(pins) >= 5:
- await pins[-1].unpin()
- del pins[-1]
+ while len(pins) >= 12:
+ await pins[-1].unpin()
+ del pins[-1]
- await message.pin()
+ await message.pin()
@group(name="reddit", invoke_without_command=True)
async def reddit_group(self, ctx: Context) -> None:
@@ -221,32 +250,26 @@ class Reddit(Cog):
@reddit_group.command(name="top")
async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
"""Send the top posts of all time from a given subreddit."""
- await self.send_top_posts(
- channel=ctx.channel,
- subreddit=subreddit,
- content=f"Here are the top {subreddit} posts of all time!",
- time="all"
- )
+ async with ctx.typing():
+ embed = await self.get_top_posts(subreddit=subreddit, time="all")
+
+ await ctx.send(content=f"Here are the top {subreddit} posts of all time!", embed=embed)
@reddit_group.command(name="daily")
async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
"""Send the top posts of today from a given subreddit."""
- await self.send_top_posts(
- channel=ctx.channel,
- subreddit=subreddit,
- content=f"Here are today's top {subreddit} posts!",
- time="day"
- )
+ async with ctx.typing():
+ embed = await self.get_top_posts(subreddit=subreddit, time="day")
+
+ await ctx.send(content=f"Here are today's top {subreddit} posts!", embed=embed)
@reddit_group.command(name="weekly")
async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
"""Send the top posts of this week from a given subreddit."""
- await self.send_top_posts(
- channel=ctx.channel,
- subreddit=subreddit,
- content=f"Here are this week's top {subreddit} posts!",
- time="week"
- )
+ async with ctx.typing():
+ embed = await self.get_top_posts(subreddit=subreddit, time="week")
+
+ await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed)
@with_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
@@ -264,21 +287,7 @@ class Reddit(Cog):
max_lines=15
)
- async def init_reddit_polling(self) -> None:
- """Initiate reddit post event loop."""
- await self.bot.wait_until_ready()
- self.reddit_channel = await self.bot.fetch_channel(Channels.reddit)
-
- if self.reddit_channel is not None:
- if self.new_posts_task is None:
- self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts())
- if self.top_weekly_posts_task is None:
- self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts())
- else:
- log.warning("Couldn't locate a channel for subreddit relaying.")
-
def setup(bot: Bot) -> None:
- """Reddit cog load."""
+ """Load the Reddit cog."""
bot.add_cog(Reddit(bot))
- log.info("Cog loaded: Reddit")
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index b54622306..45bf9a8f4 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -2,14 +2,15 @@ import asyncio
import logging
import random
import textwrap
-from datetime import datetime
+from datetime import datetime, timedelta
from operator import itemgetter
from typing import Optional
from dateutil.relativedelta import relativedelta
from discord import Colour, Embed, Message
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
from bot.converters import Duration
from bot.pagination import LinePaginator
@@ -104,7 +105,10 @@ class Reminders(Scheduler, Cog):
name="It has arrived!"
)
- embed.description = f"Here's your reminder: `{reminder['content']}`"
+ embed.description = f"Here's your reminder: `{reminder['content']}`."
+
+ if reminder.get("jump_url"): # keep backward compatibility
+ embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})"
if late:
embed.colour = Colour.red()
@@ -167,14 +171,18 @@ class Reminders(Scheduler, Cog):
json={
'author': ctx.author.id,
'channel_id': ctx.message.channel.id,
+ 'jump_url': ctx.message.jump_url,
'content': content,
'expiration': expiration.isoformat()
}
)
+ now = datetime.utcnow() - timedelta(seconds=1)
+
# Confirm to the user that it worked.
await self._send_confirmation(
- ctx, on_success="Your reminder has been created successfully!"
+ ctx,
+ on_success=f"Your reminder will arrive in {humanize_delta(relativedelta(expiration, now))}!"
)
loop = asyncio.get_event_loop()
@@ -283,6 +291,5 @@ class Reminders(Scheduler, Cog):
def setup(bot: Bot) -> None:
- """Reminders cog load."""
+ """Load the Reminders cog."""
bot.add_cog(Reminders(bot))
- log.info("Cog loaded: Reminders")
diff --git a/bot/cogs/security.py b/bot/cogs/security.py
index 316b33d6b..c680c5e27 100644
--- a/bot/cogs/security.py
+++ b/bot/cogs/security.py
@@ -1,6 +1,8 @@
import logging
-from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage
+from discord.ext.commands import Cog, Context, NoPrivateMessage
+
+from bot.bot import Bot
log = logging.getLogger(__name__)
@@ -25,6 +27,5 @@ class Security(Cog):
def setup(bot: Bot) -> None:
- """Security cog load."""
+ """Load the Security cog."""
bot.add_cog(Security(bot))
- log.info("Cog loaded: Security")
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index d95359159..853e29568 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -1,10 +1,10 @@
import logging
from discord import Colour, Embed
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
-from bot.constants import Channels, STAFF_ROLES, URLs
-from bot.decorators import redirect_output
+from bot.bot import Bot
+from bot.constants import URLs
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
@@ -59,7 +59,7 @@ class Site(Cog):
@site_group.command(name="tools")
async def site_tools(self, ctx: Context) -> None:
"""Info about the site's Tools page."""
- tools_url = f"{PAGES_URL}/tools"
+ tools_url = f"{PAGES_URL}/resources/tools"
embed = Embed(title="Tools")
embed.set_footer(text=f"{tools_url}")
@@ -74,7 +74,7 @@ class Site(Cog):
@site_group.command(name="help")
async def site_help(self, ctx: Context) -> None:
"""Info about the site's Getting Help page."""
- url = f"{PAGES_URL}/asking-good-questions"
+ url = f"{PAGES_URL}/resources/guides/asking-good-questions"
embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
@@ -105,7 +105,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())
@@ -140,6 +139,5 @@ class Site(Cog):
def setup(bot: Bot) -> None:
- """Site cog load."""
+ """Load the Site cog."""
bot.add_cog(Site(bot))
- log.info("Cog loaded: Site")
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 362968bd0..da33e27b2 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -5,8 +5,9 @@ import textwrap
from signal import Signals
from typing import Optional, Tuple
-from discord.ext.commands import Bot, Cog, Context, command, guild_only
+from discord.ext.commands import Cog, Context, command, guild_only
+from bot.bot import Bot
from bot.constants import Channels, Roles, URLs
from bot.decorators import in_channel
from bot.utils.messages import wait_for_deletion
@@ -176,7 +177,7 @@ class Snekbox(Cog):
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot, bypass_roles=EVAL_ROLES)
+ @in_channel(Channels.bot, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES)
async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
@@ -227,6 +228,5 @@ class Snekbox(Cog):
def setup(bot: Bot) -> None:
- """Snekbox cog load."""
+ """Load the Snekbox cog."""
bot.add_cog(Snekbox(bot))
- log.info("Cog loaded: Snekbox")
diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py
index d4565f848..fe7df4e9b 100644
--- a/bot/cogs/sync/__init__.py
+++ b/bot/cogs/sync/__init__.py
@@ -1,13 +1,7 @@
-import logging
-
-from discord.ext.commands import Bot
-
+from bot.bot import Bot
from .cog import Sync
-log = logging.getLogger(__name__)
-
def setup(bot: Bot) -> None:
- """Sync cog load."""
+ """Load the Sync cog."""
bot.add_cog(Sync(bot))
- log.info("Cog loaded: Sync")
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index aaa581f96..4e6ed156b 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -1,12 +1,13 @@
import logging
-from typing import Callable, Iterable
+from typing import Callable, Dict, Iterable, Union
-from discord import Guild, Member, Role
+from discord import Guild, Member, Role, User
from discord.ext import commands
-from discord.ext.commands import Bot, Cog, Context
+from discord.ext.commands import Cog, Context
from bot import constants
from bot.api import ResponseCodeError
+from bot.bot import Bot
from bot.cogs.sync import syncers
log = logging.getLogger(__name__)
@@ -50,6 +51,15 @@ class Sync(Cog):
f"deleted `{total_deleted}`."
)
+ async def patch_user(self, user_id: int, updated_information: Dict[str, Union[str, int]]) -> None:
+ """Send a PATCH request to partially update a user in the database."""
+ try:
+ await self.bot.api_client.patch("bot/users/" + str(user_id), json=updated_information)
+ except ResponseCodeError as e:
+ if e.response.status != 404:
+ raise
+ log.warning("Unable to update user, got 404. Assuming race condition from join event.")
+
@Cog.listener()
async def on_guild_role_create(self, role: Role) -> None:
"""Adds newly create role to the database table over the API."""
@@ -142,33 +152,21 @@ class Sync(Cog):
@Cog.listener()
async def on_member_update(self, before: Member, after: Member) -> None:
- """Updates the user information if any of relevant attributes have changed."""
- if (
- before.name != after.name
- or before.avatar != after.avatar
- or before.discriminator != after.discriminator
- or before.roles != after.roles
- ):
- try:
- await self.bot.api_client.put(
- 'bot/users/' + str(after.id),
- json={
- 'avatar_hash': after.avatar,
- 'discriminator': int(after.discriminator),
- 'id': after.id,
- 'in_guild': True,
- 'name': after.name,
- 'roles': sorted(role.id for role in after.roles)
- }
- )
- except ResponseCodeError as e:
- if e.response.status != 404:
- raise
-
- log.warning(
- "Unable to update user, got 404. "
- "Assuming race condition from join event."
- )
+ """Update the roles of the member in the database if a change is detected."""
+ if before.roles != after.roles:
+ updated_information = {"roles": sorted(role.id for role in after.roles)}
+ await self.patch_user(after.id, updated_information=updated_information)
+
+ @Cog.listener()
+ async def on_user_update(self, before: User, after: User) -> None:
+ """Update the user information in the database if a relevant change is detected."""
+ if any(getattr(before, attr) != getattr(after, attr) for attr in ("name", "discriminator", "avatar")):
+ updated_information = {
+ "name": after.name,
+ "discriminator": int(after.discriminator),
+ "avatar_hash": after.avatar,
+ }
+ await self.patch_user(after.id, updated_information=updated_information)
@commands.group(name='sync')
@commands.has_permissions(administrator=True)
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index 2cc5a66e1..14cf51383 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -2,7 +2,8 @@ from collections import namedtuple
from typing import Dict, Set, Tuple
from discord import Guild
-from discord.ext.commands import Bot
+
+from bot.bot import Bot
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
@@ -52,7 +53,7 @@ async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]:
Synchronize roles found on the given `guild` with the ones on the API.
Arguments:
- bot (discord.ext.commands.Bot):
+ bot (bot.bot.Bot):
The bot instance that we're running with.
guild (discord.Guild):
@@ -169,7 +170,7 @@ async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]:
Synchronize users found in the given `guild` with the ones in the API.
Arguments:
- bot (discord.ext.commands.Bot):
+ bot (bot.bot.Bot):
The bot instance that we're running with.
guild (discord.Guild):
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index cd70e783a..54a51921c 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -1,15 +1,17 @@
import logging
+import re
import time
+from typing import Dict, List, Optional
from discord import Colour, Embed
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.pagination import LinePaginator
-
log = logging.getLogger(__name__)
TEST_CHANNELS = (
@@ -18,6 +20,8 @@ TEST_CHANNELS = (
Channels.helpers
)
+REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE)
+
class Tags(Cog):
"""Save new tags and fetch existing tags."""
@@ -26,6 +30,63 @@ class Tags(Cog):
self.bot = bot
self.tag_cooldowns = {}
+ self._cache = {}
+ self._last_fetch: float = 0.0
+
+ async def _get_tags(self, is_forced: bool = False) -> None:
+ """Get all tags."""
+ # refresh only when there's a more than 5m gap from last call.
+ time_now: float = time.time()
+ if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60:
+ tags = await self.bot.api_client.get('bot/tags')
+ self._cache = {tag['title'].lower(): tag for tag in tags}
+ self._last_fetch = time_now
+
+ @staticmethod
+ def _fuzzy_search(search: str, target: str) -> int:
+ """A simple scoring algorithm based on how many letters are found / total, with order in mind."""
+ current, index = 0, 0
+ _search = REGEX_NON_ALPHABET.sub('', search.lower())
+ _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))
+ _target = next(_targets)
+ try:
+ while True:
+ while index < len(_target) and _search[current] == _target[index]:
+ current += 1
+ index += 1
+ index, _target = 0, next(_targets)
+ except (StopIteration, IndexError):
+ pass
+ return current / len(_search) * 100
+
+ def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]:
+ """Return a list of suggested tags."""
+ scores: Dict[str, int] = {
+ tag_title: Tags._fuzzy_search(tag_name, tag['title'])
+ for tag_title, tag in self._cache.items()
+ }
+
+ thresholds = thresholds or [100, 90, 80, 70, 60]
+
+ for threshold in thresholds:
+ suggestions = [
+ self._cache[tag_title]
+ for tag_title, matching_score in scores.items()
+ if matching_score >= threshold
+ ]
+ if suggestions:
+ return suggestions
+
+ return []
+
+ async def _get_tag(self, tag_name: str) -> list:
+ """Get a specific tag."""
+ await self._get_tags()
+ found = [self._cache.get(tag_name.lower(), None)]
+ if not found[0]:
+ return self._get_suggestions(tag_name)
+ return found
+
@group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
"""Show all known tags, a single tag, or run a subcommand."""
@@ -59,17 +120,27 @@ class Tags(Cog):
f"Cooldown ends in {time_left:.1f} seconds.")
return
+ await self._get_tags()
+
if tag_name is not None:
- tag = await self.bot.api_client.get(f'bot/tags/{tag_name}')
- if ctx.channel.id not in TEST_CHANNELS:
- self.tag_cooldowns[tag_name] = {
- "time": time.time(),
- "channel": ctx.channel.id
- }
- await ctx.send(embed=Embed.from_dict(tag['embed']))
+ founds = await self._get_tag(tag_name)
+
+ if len(founds) == 1:
+ tag = founds[0]
+ if ctx.channel.id not in TEST_CHANNELS:
+ self.tag_cooldowns[tag_name] = {
+ "time": time.time(),
+ "channel": ctx.channel.id
+ }
+ await ctx.send(embed=Embed.from_dict(tag['embed']))
+ elif founds and len(tag_name) >= 3:
+ await ctx.send(embed=Embed(
+ title='Did you mean ...',
+ description='\n'.join(tag['title'] for tag in founds[:10])
+ ))
else:
- tags = await self.bot.api_client.get('bot/tags')
+ tags = self._cache.values()
if not tags:
await ctx.send(embed=Embed(
description="**There are no tags in the database!**",
@@ -105,6 +176,7 @@ class Tags(Cog):
}
await self.bot.api_client.post('bot/tags', json=body)
+ self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')
log.debug(f"{ctx.author} successfully added the following tag to our database: \n"
f"tag_name: {tag_name}\n"
@@ -134,6 +206,7 @@ class Tags(Cog):
}
await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)
+ self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')
log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"
f"tag_name: {tag_name}\n"
@@ -150,6 +223,7 @@ class Tags(Cog):
async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
"""Remove a tag from the database."""
await self.bot.api_client.delete(f'bot/tags/{tag_name}')
+ self._cache.pop(tag_name.lower(), None)
log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
await ctx.send(embed=Embed(
@@ -160,6 +234,5 @@ class Tags(Cog):
def setup(bot: Bot) -> None:
- """Tags cog load."""
+ """Load the Tags cog."""
bot.add_cog(Tags(bot))
- log.info("Cog loaded: Tags")
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 5a0d20e57..82c01ae96 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -6,9 +6,10 @@ import struct
from datetime import datetime
from discord import Colour, Message
-from discord.ext.commands import Bot, Cog
+from discord.ext.commands import Cog
from discord.utils import snowflake_time
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Event, Icons
@@ -52,39 +53,60 @@ class TokenRemover(Cog):
See: https://discordapp.com/developers/docs/reference#snowflakes
"""
+ if self.is_token_in_message(msg):
+ await self.take_action(msg)
+
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
+ """
+ Check each edit for a string that matches Discord's token pattern.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
+ if self.is_token_in_message(after):
+ await self.take_action(after)
+
+ async def take_action(self, msg: Message) -> None:
+ """Remove the `msg` containing a token an send a mod_log message."""
+ user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.')
+ self.mod_log.ignore(Event.message_delete, msg.id)
+ await msg.delete()
+ await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
+
+ message = (
+ "Censored a seemingly valid token sent by "
+ f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was "
+ f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`"
+ )
+ log.debug(message)
+
+ # Send pretty mod log embed to mod-alerts
+ await self.mod_log.send_log_message(
+ icon_url=Icons.token_removed,
+ colour=Colour(Colours.soft_red),
+ title="Token removed!",
+ text=message,
+ thumbnail=msg.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ )
+
+ @classmethod
+ def is_token_in_message(cls, msg: Message) -> bool:
+ """Check if `msg` contains a seemly valid token."""
if msg.author.bot:
- return
+ return False
maybe_match = TOKEN_RE.search(msg.content)
if maybe_match is None:
- return
+ return False
try:
user_id, creation_timestamp, hmac = maybe_match.group(0).split('.')
except ValueError:
- return
-
- if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp):
- self.mod_log.ignore(Event.message_delete, msg.id)
- await msg.delete()
- await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
-
- message = (
- "Censored a seemingly valid token sent by "
- f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was "
- f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`"
- )
- log.debug(message)
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=Icons.token_removed,
- colour=Colour(Colours.soft_red),
- title="Token removed!",
- text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- )
+ return False
+
+ if cls.is_valid_user_id(user_id) and cls.is_valid_timestamp(creation_timestamp):
+ return True
@staticmethod
def is_valid_user_id(b64_content: str) -> bool:
@@ -119,6 +141,5 @@ class TokenRemover(Cog):
def setup(bot: Bot) -> None:
- """Token Remover cog load."""
+ """Load the TokenRemover cog."""
bot.add_cog(TokenRemover(bot))
- log.info("Cog loaded: TokenRemover")
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 793fe4c1a..da278011a 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -8,8 +8,9 @@ from typing import Tuple
from dateutil import relativedelta
from discord import Colour, Embed, Message, Role
-from discord.ext.commands import Bot, Cog, Context, command
+from discord.ext.commands import Cog, Context, command
+from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES
from bot.decorators import in_channel, with_role
from bot.utils.time import humanize_delta
@@ -61,14 +62,12 @@ class Utils(Cog):
pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png")
# Add the interesting information
- if "Status" in pep_header:
- pep_embed.add_field(name="Status", value=pep_header["Status"])
- if "Python-Version" in pep_header:
- pep_embed.add_field(name="Python-Version", value=pep_header["Python-Version"])
- if "Created" in pep_header:
- pep_embed.add_field(name="Created", value=pep_header["Created"])
- if "Type" in pep_header:
- pep_embed.add_field(name="Type", value=pep_header["Type"])
+ fields_to_check = ("Status", "Python-Version", "Created", "Type")
+ for field in fields_to_check:
+ # Check for a PEP metadata field that is present but has an empty value
+ # embed field values can't contain an empty string
+ if pep_header.get(field, ""):
+ pep_embed.add_field(name=field, value=pep_header[field])
elif response.status != 404:
# any response except 200 and 404 is expected
@@ -176,6 +175,5 @@ class Utils(Cog):
def setup(bot: Bot) -> None:
- """Utils cog load."""
+ """Load the Utils cog."""
bot.add_cog(Utils(bot))
- log.info("Cog loaded: Utils")
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 5b115deaa..988e0d49a 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,13 +1,19 @@
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 discord.ext.commands import Cog, Context, command
+from bot.bot import Bot
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, MODERATION_ROLES, Roles
+)
from bot.decorators import InChannelCheckFailure, in_channel, without_role
+from bot.utils.checks import without_role_check
log = logging.getLogger(__name__)
@@ -31,8 +37,9 @@ 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."
)
+BOT_MESSAGE_DELETE_DELAY = 10
class Verification(Cog):
@@ -50,35 +57,66 @@ class Verification(Cog):
@Cog.listener()
async def on_message(self, message: Message) -> None:
"""Check new message event for messages to the checkpoint channel & process."""
+ if message.channel.id != Channels.verification:
+ return # Only listen for #checkpoint messages
+
if message.author.bot:
- return # They're a bot, ignore
+ # They're a bot, delete their message after the delay.
+ # But not the periodic ping; we like that one.
+ if message.content != PERIODIC_PING:
+ await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)
+ return
+
+ # if a user mentions a role or guild member
+ # alert the mods in mod-alerts channel
+ if message.mentions or message.role_mentions:
+ log.debug(
+ f"{message.author} mentioned one or more users "
+ f"and/or roles in {message.channel.name}"
+ )
+
+ embed_text = (
+ f"{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)
@@ -158,7 +196,7 @@ class Verification(Cog):
@staticmethod
def bot_check(ctx: Context) -> bool:
"""Block any command within the verification channel that is not !accept."""
- if ctx.channel.id == Channels.verification:
+ if ctx.channel.id == Channels.verification and without_role_check(ctx, *MODERATION_ROLES):
return ctx.command.name == "accept"
else:
return True
@@ -193,6 +231,5 @@ class Verification(Cog):
def setup(bot: Bot) -> None:
- """Verification cog load."""
+ """Load the Verification cog."""
bot.add_cog(Verification(bot))
- log.info("Cog loaded: Verification")
diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py
index 86e1050fa..69d118df6 100644
--- a/bot/cogs/watchchannels/__init__.py
+++ b/bot/cogs/watchchannels/__init__.py
@@ -1,18 +1,9 @@
-import logging
-
-from discord.ext.commands import Bot
-
+from bot.bot import Bot
from .bigbrother import BigBrother
from .talentpool import TalentPool
-log = logging.getLogger(__name__)
-
-
def setup(bot: Bot) -> None:
- """Monitoring cogs load."""
+ """Load the BigBrother and TalentPool cogs."""
bot.add_cog(BigBrother(bot))
- log.info("Cog loaded: BigBrother")
-
bot.add_cog(TalentPool(bot))
- log.info("Cog loaded: TalentPool")
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index c516508ca..c601e0d4d 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -1,14 +1,14 @@
import logging
from collections import ChainMap
-from typing import Union
-from discord import User
-from discord.ext.commands import Bot, Cog, Context, group
+from discord.ext.commands import Cog, Context, group
+from bot.bot import Bot
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.converters import FetchedMember
from bot.decorators import with_role
-from .watchchannel import WatchChannel, proxy_user
+from .watchchannel import WatchChannel
log = logging.getLogger(__name__)
@@ -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,8 +44,8 @@ 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)
- async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ @with_role(*MODERATION_ROLES)
+ async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#big-brother` channel.
@@ -61,10 +61,10 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
return
if user.id in self.watched_users:
- await ctx.send(":x: The specified user is already being watched.")
+ await ctx.send(f":x: {user} is already being watched.")
return
- response = await post_infraction(ctx, user, 'watch', reason, hidden=True)
+ response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True)
if response is not None:
self.watched_users[user.id] = response
@@ -91,8 +91,8 @@ 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)
- async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ @with_role(*MODERATION_ROLES)
+ async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
active_watches = await self.bot.api_client.get(
self.api_endpoint,
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index 176c6f760..ad0c51fa6 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -1,20 +1,20 @@
import logging
import textwrap
from collections import ChainMap
-from typing import Union
-from discord import Color, Embed, Member, User
-from discord.ext.commands import Bot, Cog, Context, group
+from discord import Color, Embed, Member
+from discord.ext.commands import Cog, Context, group
from bot.api import ResponseCodeError
-from bot.constants import Channels, Guild, Roles, Webhooks
+from bot.bot import Bot
+from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
+from bot.converters import FetchedMember
from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils import time
-from .watchchannel import WatchChannel, proxy_user
+from .watchchannel import WatchChannel
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 +31,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,8 +48,8 @@ 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)
- async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ @with_role(*STAFF_ROLES)
+ async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#talent-pool` channel.
@@ -69,7 +69,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
return
if user.id in self.watched_users:
- await ctx.send(":x: The specified user is already being watched in the talent pool")
+ await ctx.send(f":x: {user} is already being watched in the talent pool")
return
# Manual request with `raise_for_status` as False because we want the actual response
@@ -113,8 +113,8 @@ 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)
- async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None:
+ @with_role(*MODERATION_ROLES)
+ async def history_command(self, ctx: Context, user: FetchedMember) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
self.api_endpoint,
@@ -142,8 +142,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@nomination_group.command(name='unwatch', aliases=('end', ))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ @with_role(*MODERATION_ROLES)
+ async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
@@ -170,13 +170,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/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 0bf75a924..eb787b083 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -9,10 +9,11 @@ from typing import Optional
import dateutil.parser
import discord
-from discord import Color, Embed, HTTPException, Message, Object, errors
-from discord.ext.commands import BadArgument, Bot, Cog, Context
+from discord import Color, Embed, HTTPException, Message, errors
+from discord.ext.commands import Cog, Context
from bot.api import ResponseCodeError
+from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
from bot.pagination import LinePaginator
@@ -24,22 +25,6 @@ log = logging.getLogger(__name__)
URL_RE = re.compile(r"(https?://[^\s]+)")
-def proxy_user(user_id: str) -> Object:
- """A proxy user object that mocks a real User instance for when the later is not available."""
- try:
- user_id = int(user_id)
- except ValueError:
- raise BadArgument
-
- user = Object(user_id)
- user.mention = user.id
- user.display_name = f"<@{user.id}>"
- user.avatar_url_as = lambda static_format: None
- user.bot = False
-
- return user
-
-
@dataclass
class MessageHistory:
"""Represents a watch channel's message history."""
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index ab0ed2472..5d6b4630b 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -7,8 +7,9 @@ import discord
from dateutil.relativedelta import relativedelta
from discord import Embed
from discord.ext import commands
-from discord.ext.commands import Bot, BucketType, Cog, Context, check, group
+from discord.ext.commands import BucketType, Cog, Context, check, group
+from bot.bot import Bot
from bot.constants import Colours, STAFF_ROLES, Wolfram
from bot.pagination import ImagePaginator
from bot.utils.time import humanize_delta
@@ -151,7 +152,7 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup
class Wolfram(Cog):
"""Commands for interacting with the Wolfram|Alpha API."""
- def __init__(self, bot: commands.Bot):
+ def __init__(self, bot: Bot):
self.bot = bot
@group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
@@ -266,7 +267,6 @@ class Wolfram(Cog):
await send_embed(ctx, message, color)
-def setup(bot: commands.Bot) -> None:
- """Wolfram cog load."""
+def setup(bot: Bot) -> None:
+ """Load the Wolfram cog."""
bot.add_cog(Wolfram(bot))
- log.info("Cog loaded: Wolfram")
diff --git a/bot/constants.py b/bot/constants.py
index 4737ce6a3..fe8e57322 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -236,6 +236,13 @@ class Colours(metaclass=YAMLGetter):
soft_orange: int
+class DuckPond(metaclass=YAMLGetter):
+ section = "duck_pond"
+
+ threshold: int
+ custom_emojis: List[int]
+
+
class Emojis(metaclass=YAMLGetter):
section = "style"
subsection = "emojis"
@@ -244,16 +251,12 @@ class Emojis(metaclass=YAMLGetter):
defcon_enabled: str # noqa: E704
defcon_updated: str # noqa: E704
- green_chevron: str
- red_chevron: str
- white_chevron: str
- bb_message: str
-
status_online: str
status_offline: str
status_idle: str
status_dnd: str
+ failmail: str
trashcan: str
bullet: str
@@ -261,6 +264,24 @@ class Emojis(metaclass=YAMLGetter):
pencil: str
cross_mark: str
+ ducky_yellow: int
+ ducky_blurple: int
+ ducky_regal: int
+ ducky_camo: int
+ ducky_ninja: int
+ ducky_devil: int
+ ducky_tube: int
+ ducky_hunt: int
+ ducky_wizard: int
+ ducky_party: int
+ ducky_angel: int
+ ducky_maul: int
+ ducky_santa: int
+
+ upvotes: str
+ comments: str
+ user: str
+
class Icons(metaclass=YAMLGetter):
section = "style"
@@ -310,6 +331,13 @@ class Icons(metaclass=YAMLGetter):
questionmark: str
+ superstarify: str
+ unsuperstarify: str
+
+ voice_state_blue: str
+ voice_state_green: str
+ voice_state_red: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -332,12 +360,14 @@ class Channels(metaclass=YAMLGetter):
admins: int
admin_spam: int
announcements: int
+ attachment_log: int
big_brother_logs: int
bot: int
checkpoint_test: int
defcon: int
devlog: int
devtest: int
+ esoteric: int
help_0: int
help_1: int
help_2: int
@@ -363,6 +393,7 @@ class Channels(metaclass=YAMLGetter):
userlog: int
user_event_a: int
verification: int
+ voice_log: int
class Webhooks(metaclass=YAMLGetter):
@@ -371,6 +402,8 @@ class Webhooks(metaclass=YAMLGetter):
talent_pool: int
big_brother: int
+ reddit: int
+ duck_pond: int
class Roles(metaclass=YAMLGetter):
@@ -446,8 +479,9 @@ class URLs(metaclass=YAMLGetter):
class Reddit(metaclass=YAMLGetter):
section = "reddit"
- request_delay: int
subreddits: list
+ client_id: str
+ secret: str
class Wolfram(metaclass=YAMLGetter):
@@ -503,6 +537,32 @@ class RedirectOutput(metaclass=YAMLGetter):
delete_delay: int
+class Event(Enum):
+ """
+ Event names. This does not include every event (for example, raw
+ events aren't here), but only events used in ModLog for now.
+ """
+
+ guild_channel_create = "guild_channel_create"
+ guild_channel_delete = "guild_channel_delete"
+ guild_channel_update = "guild_channel_update"
+ guild_role_create = "guild_role_create"
+ guild_role_delete = "guild_role_delete"
+ guild_role_update = "guild_role_update"
+ guild_update = "guild_update"
+
+ member_join = "member_join"
+ member_remove = "member_remove"
+ member_ban = "member_ban"
+ member_unban = "member_unban"
+ member_update = "member_update"
+
+ message_delete = "message_delete"
+ message_edit = "message_edit"
+
+ voice_state_update = "voice_state_update"
+
+
# Debug mode
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
@@ -517,6 +577,9 @@ STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
# Roles combinations
STAFF_CHANNELS = Guild.staff_channels
+# Default Channel combinations
+MODERATION_CHANNELS = Channels.admins, Channels.admin_spam, Channels.mod_alerts, Channels.mods, Channels.mod_spam
+
# Bot replies
NEGATIVE_REPLIES = [
@@ -571,27 +634,3 @@ ERROR_REPLIES = [
"Noooooo!!",
"I can't believe you've done this",
]
-
-
-class Event(Enum):
- """
- Event names. This does not include every event (for example, raw
- events aren't here), but only events used in ModLog for now.
- """
-
- guild_channel_create = "guild_channel_create"
- guild_channel_delete = "guild_channel_delete"
- guild_channel_update = "guild_channel_update"
- guild_role_create = "guild_role_create"
- guild_role_delete = "guild_role_delete"
- guild_role_update = "guild_role_update"
- guild_update = "guild_update"
-
- member_join = "member_join"
- member_remove = "member_remove"
- member_ban = "member_ban"
- member_unban = "member_unban"
- member_update = "member_update"
-
- message_delete = "message_delete"
- message_edit = "message_edit"
diff --git a/bot/converters.py b/bot/converters.py
index cf0496541..cca57a02d 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,20 +1,39 @@
import logging
import re
+import typing as t
from datetime import datetime
from ssl import CertificateError
-from typing import Union
import dateutil.parser
import dateutil.tz
import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
-from discord.ext.commands import BadArgument, Context, Converter
+from discord.ext.commands import BadArgument, Context, Converter, UserConverter
log = logging.getLogger(__name__)
+def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:
+ """
+ Return a converter which only allows arguments equal to one of the given values.
+
+ Unless preserve_case is True, the argument is converted to lowercase. All values are then
+ expected to have already been given in lowercase too.
+ """
+ def converter(arg: str) -> str:
+ if not preserve_case:
+ arg = arg.lower()
+
+ if arg not in values:
+ raise BadArgument(f"Only the following values are allowed:\n```{', '.join(values)}```")
+ else:
+ return arg
+
+ return converter
+
+
class ValidPythonIdentifier(Converter):
"""
A converter that checks whether the given string is a valid Python identifier.
@@ -70,7 +89,7 @@ class InfractionSearchQuery(Converter):
"""A converter that checks if the argument is a Discord user, and if not, falls back to a string."""
@staticmethod
- async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]:
+ async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]:
"""Check if the argument is a Discord user, and if not, falls back to a string."""
try:
maybe_snowflake = arg.strip("<@!>")
@@ -259,3 +278,75 @@ class ISODateTime(Converter):
dt = dt.replace(tzinfo=None)
return dt
+
+
+def proxy_user(user_id: str) -> discord.Object:
+ """
+ Create a proxy user object from the given id.
+
+ 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:
+ log.debug(f"Failed to create proxy user {user_id}: could not convert to int.")
+ raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.")
+
+ user = discord.Object(user_id)
+ user.mention = user.id
+ user.display_name = f"<@{user.id}>"
+ user.avatar_url_as = lambda static_format: None
+ user.bot = False
+
+ return user
+
+
+class FetchedUser(UserConverter):
+ """
+ Converts to a `discord.User` or, if it fails, a `discord.Object`.
+
+ Unlike the default `UserConverter`, which only does lookups via the global user cache, this
+ converter attempts to fetch the user via an API call to Discord when the using the cache is
+ unsuccessful.
+
+ If the fetch also fails and the error doesn't imply the user doesn't exist, then a
+ `discord.Object` is returned via the `user_proxy` converter.
+
+ The lookup strategy is as follows (in order):
+
+ 1. Lookup by ID.
+ 2. Lookup by mention.
+ 3. Lookup by name#discrim
+ 4. Lookup by name
+ 5. Lookup via API
+ 6. Create a proxy user with discord.Object
+ """
+
+ async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]:
+ """Convert the `arg` to a `discord.User` or `discord.Object`."""
+ try:
+ return await super().convert(ctx, arg)
+ except BadArgument:
+ pass
+
+ try:
+ user_id = int(arg)
+ log.trace(f"Fetching user {user_id}...")
+ return await ctx.bot.fetch_user(user_id)
+ except ValueError:
+ log.debug(f"Failed to fetch user {arg}: could not convert to int.")
+ raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`")
+ except discord.HTTPException as e:
+ # If the Discord error isn't `Unknown user`, return a proxy instead
+ if e.code != 10013:
+ log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}")
+ return proxy_user(arg)
+
+ log.debug(f"Failed to fetch user {arg}: user does not exist.")
+ raise BadArgument(f"User `{arg}` does not exist")
+
+
+Expiry = t.Union[Duration, ISODateTime]
+FetchedMember = t.Union[discord.Member, FetchedUser]
diff --git a/bot/decorators.py b/bot/decorators.py
index 935df4af0..2d18eaa6a 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -27,11 +27,23 @@ class InChannelCheckFailure(CheckFailure):
super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
-def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:
- """Checks that the message is in a whitelisted channel or optionally has a bypass role."""
+def in_channel(
+ *channels: int,
+ hidden_channels: Container[int] = None,
+ bypass_roles: Container[int] = None
+) -> Callable:
+ """
+ Checks that the message is in a whitelisted channel or optionally has a bypass role.
+
+ Hidden channels are channels which will not be displayed in the InChannelCheckFailure error
+ message.
+ """
+ hidden_channels = hidden_channels or []
+ bypass_roles = bypass_roles or []
+
def predicate(ctx: Context) -> bool:
"""In-channel checker predicate."""
- if ctx.channel.id in channels:
+ if ctx.channel.id in channels or ctx.channel.id in hidden_channels:
log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The command was used in a whitelisted channel.")
return True
diff --git a/bot/interpreter.py b/bot/interpreter.py
index a42b45a2d..8b7268746 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -2,7 +2,9 @@ from code import InteractiveInterpreter
from io import StringIO
from typing import Any
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Context
+
+from bot.bot import Bot
CODE_TEMPLATE = """
async def _func():
@@ -20,8 +22,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/rules/attachments.py b/bot/rules/attachments.py
index c550aed76..00bb2a949 100644
--- a/bot/rules/attachments.py
+++ b/bot/rules/attachments.py
@@ -7,14 +7,14 @@ async def apply(
last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
"""Detects total attachments exceeding the limit sent by a single user."""
- relevant_messages = [last_message] + [
+ relevant_messages = tuple(
msg
for msg in recent_messages
if (
msg.author == last_message.author
and len(msg.attachments) > 0
)
- ]
+ )
total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages)
if total_recent_attachments > config['max']:
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index ad892e512..db56c347c 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -38,9 +38,9 @@ def without_role_check(ctx: Context, *role_ids: int) -> bool:
return check
-def in_channel_check(ctx: Context, channel_id: int) -> bool:
- """Checks if the command was executed inside of the specified channel."""
- check = ctx.channel.id == channel_id
+def in_channel_check(ctx: Context, *channel_ids: int) -> bool:
+ """Checks if the command was executed inside the list of specified channels."""
+ check = ctx.channel.id in channel_ids
log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The result of the in_channel check was {check}.")
return check
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index 022f79599..a36edc774 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,7 +1,8 @@
import asyncio
import contextlib
+import logging
from io import BytesIO
-from typing import Optional, Sequence, Union
+from typing import List, Optional, Sequence, Union
from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook
from discord.abc import Snowflake
@@ -9,7 +10,7 @@ from discord.errors import HTTPException
from bot.constants import Emojis
-MAX_SIZE = 1024 * 1024 * 8 # 8 Mebibytes
+log = logging.getLogger(__name__)
async def wait_for_deletion(
@@ -51,42 +52,58 @@ async def wait_for_deletion(
await message.delete()
-async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None:
+async def send_attachments(
+ message: Message,
+ destination: Union[TextChannel, Webhook],
+ link_large: bool = True
+) -> List[str]:
"""
- Re-uploads each attachment in a message to the given channel or webhook.
+ Re-upload the message's attachments to the destination and return a list of their new URLs.
- Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit.
- If attachments are too large, they are instead grouped into a single embed which links to them.
+ Each attachment is sent as a separate message to more easily comply with the request/file size
+ limit. If link_large is True, attachments which are too large are instead grouped into a single
+ embed which links to them.
"""
large = []
+ urls = []
for attachment in message.attachments:
+ failure_msg = (
+ f"Failed to re-upload attachment {attachment.filename} from message {message.id}"
+ )
+
try:
- # This should avoid most files that are too large, but some may get through hence the try-catch.
# Allow 512 bytes of leeway for the rest of the request.
- if attachment.size <= MAX_SIZE - 512:
+ # This should avoid most files that are too large,
+ # but some may get through hence the try-catch.
+ if attachment.size <= destination.guild.filesize_limit - 512:
with BytesIO() as file:
- await attachment.save(file)
+ await attachment.save(file, use_cached=True)
attachment_file = File(file, filename=attachment.filename)
if isinstance(destination, TextChannel):
- await destination.send(file=attachment_file)
+ msg = await destination.send(file=attachment_file)
+ urls.append(msg.attachments[0].url)
else:
await destination.send(
file=attachment_file,
username=message.author.display_name,
avatar_url=message.author.avatar_url
)
- else:
+ elif link_large:
large.append(attachment)
+ else:
+ log.warning(f"{failure_msg} because it's too large.")
except HTTPException as e:
- if e.status == 413:
+ if link_large and e.status == 413:
large.append(attachment)
else:
- raise
+ log.warning(f"{failure_msg} with status {e.status}.")
- if large:
- embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large))
+ if link_large and large:
+ desc = f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
+ embed = Embed(description=desc)
embed.set_footer(text="Attachments exceed upload size limit.")
+
if isinstance(destination, TextChannel):
await destination.send(embed=embed)
else:
@@ -95,3 +112,5 @@ async def send_attachments(message: Message, destination: Union[TextChannel, Web
username=message.author.display_name,
avatar_url=message.author.avatar_url
)
+
+ return urls
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/bot/utils/time.py b/bot/utils/time.py
index 2aea2c099..7416f36e0 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -111,3 +111,55 @@ async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime]
def format_infraction(timestamp: str) -> str:
"""Format an infraction timestamp to a more readable ISO 8601 format."""
return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT)
+
+
+def format_infraction_with_duration(
+ expiry: Optional[str],
+ date_from: Optional[datetime.datetime] = None,
+ max_units: int = 2
+) -> Optional[str]:
+ """
+ Format an infraction timestamp to a more readable ISO 8601 format WITH the duration.
+
+ Returns a human-readable version of the duration between datetime.utcnow() and an expiry.
+ Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it.
+ `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ By default, max_units is 2.
+ """
+ if not expiry:
+ return None
+
+ date_from = date_from or datetime.datetime.utcnow()
+ date_to = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
+
+ expiry_formatted = format_infraction(expiry)
+
+ duration = humanize_delta(relativedelta(date_to, date_from), max_units=max_units)
+ duration_formatted = f" ({duration})" if duration else ''
+
+ return f"{expiry_formatted}{duration_formatted}"
+
+
+def until_expiration(
+ expiry: Optional[str],
+ now: Optional[datetime.datetime] = None,
+ max_units: int = 2
+) -> Optional[str]:
+ """
+ Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta.
+
+ Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry.
+ Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it.
+ `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
+ By default, max_units is 2.
+ """
+ if not expiry:
+ return None
+
+ now = now or datetime.datetime.utcnow()
+ since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0)
+
+ if since < now:
+ return None
+
+ return humanize_delta(relativedelta(since, now), max_units=max_units)
diff --git a/config-default.yml b/config-default.yml
index 696ef8a7e..fda14b511 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -22,16 +22,12 @@ style:
defcon_enabled: "<:defconenabled:470326274213150730>"
defcon_updated: "<:defconsettingsupdated:470326274082996224>"
- green_chevron: "<:greenchevron:418104310329769993>"
- red_chevron: "<:redchevron:418112778184818698>"
- white_chevron: "<:whitechevron:418110396973711363>"
- bb_message: "<:bbmessage:476273120999636992>"
-
status_online: "<:status_online:470326272351010816>"
status_idle: "<:status_idle:470326266625785866>"
status_dnd: "<:status_dnd:470326272082313216>"
status_offline: "<:status_offline:470326266537705472>"
+ failmail: "<:failmail:633660039931887616>"
trashcan: "<:trashcan:637136429717389331>"
bullet: "\u2022"
@@ -39,6 +35,24 @@ style:
new: "\U0001F195"
cross_mark: "\u274C"
+ ducky_yellow: &DUCKY_YELLOW 574951975574175744
+ ducky_blurple: &DUCKY_BLURPLE 574951975310065675
+ ducky_regal: &DUCKY_REGAL 637883439185395712
+ ducky_camo: &DUCKY_CAMO 637914731566596096
+ ducky_ninja: &DUCKY_NINJA 637923502535606293
+ ducky_devil: &DUCKY_DEVIL 637925314982576139
+ ducky_tube: &DUCKY_TUBE 637881368008851456
+ ducky_hunt: &DUCKY_HUNT 639355090909528084
+ ducky_wizard: &DUCKY_WIZARD 639355996954689536
+ ducky_party: &DUCKY_PARTY 639468753440210977
+ ducky_angel: &DUCKY_ANGEL 640121935610511361
+ ducky_maul: &DUCKY_MAUL 640137724958867467
+ ducky_santa: &DUCKY_SANTA 655360331002019870
+
+ 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"
@@ -84,6 +98,13 @@ 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"
+
+ voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png"
+ voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
+ voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
+
guild:
id: 267624335836053506
@@ -93,13 +114,16 @@ guild:
channels:
admins: &ADMINS 365960823622991872
admin_spam: &ADMIN_SPAM 563594791770914816
+ admins_voice: &ADMINS_VOICE 500734494840717332
announcements: 354619224620138496
+ attachment_log: &ATTCH_LOG 649243850006855680
big_brother_logs: &BBLOGS 468507907357409333
bot: 267659945086812160
checkpoint_test: 422077681434099723
defcon: &DEFCON 464469101889454091
devlog: &DEVLOG 622895325144940554
devtest: &DEVTEST 414574275865870337
+ esoteric: 470884583684964352
help_0: 303906576991780866
help_1: 303906556754395136
help_2: 303906514266226689
@@ -111,7 +135,7 @@ guild:
helpers: &HELPERS 385474242440986624
message_log: &MESSAGE_LOG 467752170159079424
meta: 429409067623251969
- mod_spam: &MOD_SPAM 620607373828030464
+ mod_spam: &MOD_SPAM 620607373828030464
mods: &MODS 305126844661760000
mod_alerts: 473092532147060736
modlog: &MODLOG 282638479504965634
@@ -122,13 +146,15 @@ guild:
python: 267624335836053506
reddit: 458224812528238616
staff_lounge: &STAFF_LOUNGE 464905259261755392
+ staff_voice: &STAFF_VOICE 412375055910043655
talent_pool: &TALENT_POOL 534321732593647616
userlog: 528976905546760203
user_event_a: &USER_EVENT_A 592000283102674944
verification: 352442727016693763
+ voice_log: 640292421988646961
staff_channels: [*ADMINS, *ADMIN_SPAM, *MOD_SPAM, *MODS, *HELPERS, *ORGANISATION, *DEFCON]
- ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG]
+ ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG, *ADMINS_VOICE, *STAFF_VOICE, *ATTCH_LOG]
roles:
admin: &ADMIN_ROLE 267628507062992896
@@ -137,7 +163,7 @@ guild:
contributor: 295488872404484098
core_developer: 587606783669829632
helpers: 267630620367257601
- jammer: 423054537079783434
+ jammer: 591786436651646989
moderator: &MOD_ROLE 267629731250176001
muted: &MUTED_ROLE 277914926603829249
owner: &OWNER_ROLE 267627879762755584
@@ -149,6 +175,8 @@ guild:
webhooks:
talent_pool: 569145364800602132
big_brother: 569133704568373283
+ reddit: 635408384794951680
+ duck_pond: 637821475327311927
filter:
@@ -182,6 +210,12 @@ filter:
- 544525886180032552 # kennethreitz.org
- 590806733924859943 # Discord Hack Week
- 423249981340778496 # Kivy
+ - 197038439483310086 # Discord Testers
+ - 286633898581164032 # Ren'Py
+ - 349505959032389632 # PyGame
+ - 438622377094414346 # Pyglet
+ - 524691714909274162 # Panda3D
+ - 336642139381301249 # discord.py
domain_blacklist:
- pornhub.com
@@ -349,12 +383,22 @@ anti_malware:
- '.png'
- '.tiff'
- '.wmv'
+ - '.svg'
+ - '.psd' # Photoshop
+ - '.ai' # Illustrator
+ - '.aep' # After Effects
+ - '.xcf' # GIMP
+ - '.mp3'
+ - '.wav'
+ - '.ogg'
+ - '.md'
reddit:
- request_delay: 60
subreddits:
- 'r/Python'
+ client_id: !ENV "REDDIT_CLIENT_ID"
+ secret: !ENV "REDDIT_SECRET"
wolfram:
@@ -384,5 +428,9 @@ redirect_output:
delete_invocation: true
delete_delay: 15
+duck_pond:
+ threshold: 5
+ custom_emojis: [*DUCKY_YELLOW, *DUCKY_BLURPLE, *DUCKY_CAMO, *DUCKY_DEVIL, *DUCKY_NINJA, *DUCKY_REGAL, *DUCKY_TUBE, *DUCKY_HUNT, *DUCKY_WIZARD, *DUCKY_PARTY, *DUCKY_ANGEL, *DUCKY_MAUL, *DUCKY_SANTA]
+
config:
required_keys: ['bot.token']
diff --git a/docker-compose.yml b/docker-compose.yml
index f79fdba58..7281c7953 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -42,3 +42,5 @@ services:
environment:
BOT_TOKEN: ${BOT_TOKEN}
BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531
+ REDDIT_CLIENT_ID: ${REDDIT_CLIENT_ID}
+ REDDIT_SECRET: ${REDDIT_SECRET}
diff --git a/tests/README.md b/tests/README.md
index 6ab9bc93e..be78821bf 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -2,7 +2,7 @@
Our bot is one of the most important tools we have for running our community. As we don't want that tool break, we decided that we wanted to write unit tests for it. We hope that in the future, we'll have a 100% test coverage for the bot. This guide will help you get started with writing the tests needed to achieve that.
-_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you may like Corey Schafer's [Python Tutorial: Unit Testing Your Code with the unittest Module](https://www.youtube.com/watch?v=6tNS--WetLI) or Ned Batchelder's PyCon talk [Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)._
+_**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._
## Tools
@@ -15,6 +15,7 @@ We are using the following modules and packages for our unit tests:
To ensure the results you obtain on your personal machine are comparable to those generated in the Azure pipeline, please make sure to run your tests with the virtual environment defined by our [Pipfile](/Pipfile). To run your tests with `pipenv`, we've provided two "scripts" shortcuts:
- `pipenv run test` will run `unittest` with `coverage.py`
+- `pipenv run test path/to/test.py` will run a specific test.
- `pipenv run report` will generate a coverage report of the tests you've run with `pipenv run test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report.
If you want a coverage report, make sure to run the tests with `pipenv run test` *first*.
@@ -211,3 +212,10 @@ All in all, it's not only important to consider if all statements or branches we
Another restriction of unit testing is that it tests, well, in units. Even if we can guarantee that the units work as they should independently, we have no guarantee that they will actually work well together. Even more, while the mocking described above gives us a lot of flexibility in factoring out external code, we are work under the implicit assumption that we fully understand those external parts and utilize it correctly. What if our mocked `Context` object works with a `send` method, but `discord.py` has changed it to a `send_message` method in a recent update? It could mean our tests are passing, but the code it's testing still doesn't work in production.
The answer to this is that we also need to make sure that the individual parts come together into a working application. In addition, we will also need to make sure that the application communicates correctly with external applications. Since we currently have no automated integration tests or functional tests, that means **it's still very important to fire up the bot and test the code you've written manually** in addition to the unit tests you've written.
+
+## Additional resources
+
+* [Ned Batchelder's PyCon talk: Getting Started Testing](https://www.youtube.com/watch?v=FxSsnHeWQBY)
+* [Corey Schafer video about unittest](https://youtu.be/6tNS--WetLI)
+* [RealPython tutorial on unittest testing](https://realpython.com/python-testing/)
+* [RealPython tutorial on mocking](https://realpython.com/python-mock-library/)
diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py
new file mode 100644
index 000000000..d07b2bce1
--- /dev/null
+++ b/tests/bot/cogs/test_duck_pond.py
@@ -0,0 +1,584 @@
+import asyncio
+import logging
+import typing
+import unittest
+from unittest.mock import MagicMock, patch
+
+import discord
+
+from bot import constants
+from bot.cogs import duck_pond
+from tests import base
+from tests import helpers
+
+MODULE_PATH = "bot.cogs.duck_pond"
+
+
+class DuckPondTests(base.LoggingTestCase):
+ """Tests for DuckPond functionality."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Sets up the objects that only have to be initialized once."""
+ cls.nonstaff_member = helpers.MockMember(name="Non-staffer")
+
+ cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0])
+ cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role])
+
+ cls.checkmark_emoji = "\N{White Heavy Check Mark}"
+ cls.thumbs_up_emoji = "\N{Thumbs Up Sign}"
+ cls.unicode_duck_emoji = "\N{Duck}"
+ cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0])
+ cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123)
+
+ def setUp(self):
+ """Sets up the objects that need to be refreshed before each test."""
+ self.bot = helpers.MockBot(user=helpers.MockMember(id=46692))
+ self.cog = duck_pond.DuckPond(bot=self.bot)
+
+ def test_duck_pond_correctly_initializes(self):
+ """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`."""
+ bot = helpers.MockBot()
+ cog = MagicMock()
+
+ duck_pond.DuckPond.__init__(cog, bot)
+
+ self.assertEqual(cog.bot, bot)
+ self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond)
+ bot.loop.create_loop.called_once_with(cog.fetch_webhook())
+
+ def test_fetch_webhook_succeeds_without_connectivity_issues(self):
+ """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute."""
+ self.bot.fetch_webhook.return_value = "dummy webhook"
+ self.cog.webhook_id = 1
+
+ asyncio.run(self.cog.fetch_webhook())
+
+ self.bot.wait_until_ready.assert_called_once()
+ self.bot.fetch_webhook.assert_called_once_with(1)
+ self.assertEqual(self.cog.webhook, "dummy webhook")
+
+ def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self):
+ """The `fetch_webhook` method should log an exception when it fails to fetch the webhook."""
+ self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.")
+ self.cog.webhook_id = 1
+
+ log = logging.getLogger('bot.cogs.duck_pond')
+ with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
+ asyncio.run(self.cog.fetch_webhook())
+
+ self.bot.wait_until_ready.assert_called_once()
+ self.bot.fetch_webhook.assert_called_once_with(1)
+
+ self.assertEqual(len(log_watcher.records), 1)
+
+ record = log_watcher.records[0]
+ self.assertEqual(record.levelno, logging.ERROR)
+
+ def test_is_staff_returns_correct_values_based_on_instance_passed(self):
+ """The `is_staff` method should return correct values based on the instance passed."""
+ test_cases = (
+ (helpers.MockUser(name="User instance"), False),
+ (helpers.MockMember(name="Member instance without staff role"), False),
+ (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True)
+ )
+
+ for user, expected_return in test_cases:
+ actual_return = self.cog.is_staff(user)
+ with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return):
+ self.assertEqual(expected_return, actual_return)
+
+ @helpers.async_test
+ async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self):
+ """The `has_green_checkmark` method should only return `True` if one is present."""
+ test_cases = (
+ (
+ "No reactions", helpers.MockMessage(), False
+ ),
+ (
+ "No green check mark reactions",
+ helpers.MockMessage(reactions=[
+ helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
+ helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user])
+ ]),
+ False
+ ),
+ (
+ "Green check mark reaction, but not from our bot",
+ helpers.MockMessage(reactions=[
+ helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
+ helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member])
+ ]),
+ False
+ ),
+ (
+ "Green check mark reaction, with one from the bot",
+ helpers.MockMessage(reactions=[
+ helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
+ helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user])
+ ]),
+ True
+ )
+ )
+
+ for description, message, expected_return in test_cases:
+ actual_return = await self.cog.has_green_checkmark(message)
+ with self.subTest(
+ test_case=description,
+ expected_return=expected_return,
+ actual_return=actual_return
+ ):
+ self.assertEqual(expected_return, actual_return)
+
+ def test_send_webhook_correctly_passes_on_arguments(self):
+ """The `send_webhook` method should pass the arguments to the webhook correctly."""
+ self.cog.webhook = helpers.MockAsyncWebhook()
+
+ content = "fake content"
+ username = "fake username"
+ avatar_url = "fake avatar_url"
+ embed = "fake embed"
+
+ asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed))
+
+ self.cog.webhook.send.assert_called_once_with(
+ content=content,
+ username=username,
+ avatar_url=avatar_url,
+ embed=embed
+ )
+
+ def test_send_webhook_logs_when_sending_message_fails(self):
+ """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly."""
+ self.cog.webhook = helpers.MockAsyncWebhook()
+ self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.")
+
+ log = logging.getLogger('bot.cogs.duck_pond')
+ with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
+ asyncio.run(self.cog.send_webhook())
+
+ self.assertEqual(len(log_watcher.records), 1)
+
+ record = log_watcher.records[0]
+ self.assertEqual(record.levelno, logging.ERROR)
+
+ def _get_reaction(
+ self,
+ emoji: typing.Union[str, helpers.MockEmoji],
+ staff: int = 0,
+ nonstaff: int = 0
+ ) -> helpers.MockReaction:
+ staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)]
+ nonstaffers = [helpers.MockMember() for _ in range(nonstaff)]
+ return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers)
+
+ @helpers.async_test
+ async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self):
+ """The `count_ducks` method should return the number of unique staffers who gave a duck."""
+ test_cases = (
+ # Simple test cases
+ # A message without reactions should return 0
+ (
+ "No reactions",
+ helpers.MockMessage(),
+ 0
+ ),
+ # A message with a non-duck reaction from a non-staffer should return 0
+ (
+ "Non-duck reaction from non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]),
+ 0
+ ),
+ # A message with a non-duck reaction from a staffer should return 0
+ (
+ "Non-duck reaction from staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]),
+ 0
+ ),
+ # A message with a non-duck reaction from a non-staffer and staffer should return 0
+ (
+ "Non-duck reaction from staffer + non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]),
+ 0
+ ),
+ # A message with a unicode duck reaction from a non-staffer should return 0
+ (
+ "Unicode Duck Reaction from non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]),
+ 0
+ ),
+ # A message with a unicode duck reaction from a staffer should return 1
+ (
+ "Unicode Duck Reaction from staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]),
+ 1
+ ),
+ # A message with a unicode duck reaction from a non-staffer and staffer should return 1
+ (
+ "Unicode Duck Reaction from staffer + non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]),
+ 1
+ ),
+ # A message with a duckpond duck reaction from a non-staffer should return 0
+ (
+ "Duckpond Duck Reaction from non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]),
+ 0
+ ),
+ # A message with a duckpond duck reaction from a staffer should return 1
+ (
+ "Duckpond Duck Reaction from staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]),
+ 1
+ ),
+ # A message with a duckpond duck reaction from a non-staffer and staffer should return 1
+ (
+ "Duckpond Duck Reaction from staffer + non-staffer",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]),
+ 1
+ ),
+
+ # Complex test cases
+ # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3
+ (
+ "Duckpond Duck Reaction from 3 staffers + 2 non-staffers",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]),
+ 3
+ ),
+ # A staffer with multiple duck reactions only counts once
+ (
+ "Two different duck reactions from the same staffer",
+ helpers.MockMessage(
+ reactions=[
+ helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]),
+ helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]),
+ ]
+ ),
+ 1
+ ),
+ # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif)
+ (
+ "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers",
+ helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]),
+ 0
+ ),
+ # We correctly sum when multiple reactions are provided.
+ (
+ "Duckpond Duck Reaction from 3 staffers + 2 non-staffers",
+ helpers.MockMessage(
+ reactions=[
+ self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2),
+ self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9),
+ ]
+ ),
+ 3 + 4
+ ),
+ )
+
+ for description, message, expected_count in test_cases:
+ actual_count = await self.cog.count_ducks(message)
+ with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count):
+ self.assertEqual(expected_count, actual_count)
+
+ @helpers.async_test
+ async def test_relay_message_correctly_relays_content_and_attachments(self):
+ """The `relay_message` method should correctly relay message content and attachments."""
+ send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook"
+ send_attachments_path = f"{MODULE_PATH}.send_attachments"
+
+ self.cog.webhook = helpers.MockAsyncWebhook()
+
+ test_values = (
+ (helpers.MockMessage(clean_content="", attachments=[]), False, False),
+ (helpers.MockMessage(clean_content="message", attachments=[]), True, False),
+ (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True),
+ (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True),
+ )
+
+ for message, expect_webhook_call, expect_attachment_call in test_values:
+ with patch(send_webhook_path, new_callable=helpers.AsyncMock) as send_webhook:
+ with patch(send_attachments_path, new_callable=helpers.AsyncMock) as send_attachments:
+ with self.subTest(clean_content=message.clean_content, attachments=message.attachments):
+ await self.cog.relay_message(message)
+
+ self.assertEqual(expect_webhook_call, send_webhook.called)
+ self.assertEqual(expect_attachment_call, send_attachments.called)
+
+ message.add_reaction.assert_called_once_with(self.checkmark_emoji)
+
+ @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock)
+ @helpers.async_test
+ async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments):
+ """The `relay_message` method should handle irretrievable attachments."""
+ message = helpers.MockMessage(clean_content="message", attachments=["attachment"])
+ side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), ""))
+
+ self.cog.webhook = helpers.MockAsyncWebhook()
+ log = logging.getLogger("bot.cogs.duck_pond")
+
+ for side_effect in side_effects:
+ send_attachments.side_effect = side_effect
+ with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock) as send_webhook:
+ with self.subTest(side_effect=type(side_effect).__name__):
+ with self.assertNotLogs(logger=log, level=logging.ERROR):
+ await self.cog.relay_message(message)
+
+ self.assertEqual(send_webhook.call_count, 2)
+
+ @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=helpers.AsyncMock)
+ @patch(f"{MODULE_PATH}.send_attachments", new_callable=helpers.AsyncMock)
+ @helpers.async_test
+ async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook):
+ """The `relay_message` method should handle irretrievable attachments."""
+ message = helpers.MockMessage(clean_content="message", attachments=["attachment"])
+
+ self.cog.webhook = helpers.MockAsyncWebhook()
+ log = logging.getLogger("bot.cogs.duck_pond")
+
+ side_effect = discord.HTTPException(MagicMock(), "")
+ send_attachments.side_effect = side_effect
+ with self.subTest(side_effect=type(side_effect).__name__):
+ with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
+ await self.cog.relay_message(message)
+
+ send_webhook.assert_called_once_with(
+ content=message.clean_content,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
+
+ self.assertEqual(len(log_watcher.records), 1)
+
+ record = log_watcher.records[0]
+ self.assertEqual(record.levelno, logging.ERROR)
+
+ def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str):
+ """Creates a mock `on_raw_reaction_add` payload with the specified emoji data."""
+ payload = MagicMock(name=label)
+ payload.emoji.is_custom_emoji.return_value = is_custom_emoji
+ payload.emoji.id = id_
+ payload.emoji.name = emoji_name
+ return payload
+
+ @helpers.async_test
+ async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self):
+ """The `on_raw_reaction_add` event handler should ignore irrelevant emojis."""
+ test_values = (
+ # Custom Emojis
+ (
+ self._mock_payload(
+ label="Custom Duckpond Emoji",
+ is_custom_emoji=True,
+ id_=constants.DuckPond.custom_emojis[0],
+ emoji_name=""
+ ),
+ True
+ ),
+ (
+ self._mock_payload(
+ label="Custom Non-Duckpond Emoji",
+ is_custom_emoji=True,
+ id_=123,
+ emoji_name=""
+ ),
+ False
+ ),
+ # Unicode Emojis
+ (
+ self._mock_payload(
+ label="Unicode Duck Emoji",
+ is_custom_emoji=False,
+ id_=1,
+ emoji_name=self.unicode_duck_emoji
+ ),
+ True
+ ),
+ (
+ self._mock_payload(
+ label="Unicode Non-Duck Emoji",
+ is_custom_emoji=False,
+ id_=1,
+ emoji_name=self.thumbs_up_emoji
+ ),
+ False
+ ),
+ )
+
+ for payload, expected_return in test_values:
+ actual_return = self.cog._payload_has_duckpond_emoji(payload)
+ with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return):
+ self.assertEqual(expected_return, actual_return)
+
+ @patch(f"{MODULE_PATH}.discord.utils.get")
+ @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False))
+ def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get):
+ """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji."""
+ self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock())))
+
+ # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check
+ utils_get.assert_not_called()
+
+ def _raw_reaction_mocks(self, channel_id, message_id, user_id):
+ """Sets up mocks for tests of the `on_raw_reaction_add` event listener."""
+ channel = helpers.MockTextChannel(id=channel_id)
+ self.bot.get_all_channels.return_value = (channel,)
+
+ message = helpers.MockMessage(id=message_id)
+
+ channel.fetch_message.return_value = message
+
+ member = helpers.MockMember(id=user_id, roles=[self.staff_role])
+ message.guild.members = (member,)
+
+ payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id)
+
+ return channel, message, member, payload
+
+ @helpers.async_test
+ async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self):
+ """The `on_raw_reaction_add` event handler should return for bot users or non-staff members."""
+ channel_id = 1234
+ message_id = 2345
+ user_id = 3456
+
+ channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id)
+
+ test_cases = (
+ ("non-staff member", helpers.MockMember(id=user_id)),
+ ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)),
+ )
+
+ payload.emoji = self.duck_pond_emoji
+
+ for description, member in test_cases:
+ message.guild.members = (member, )
+ with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark:
+ checkmark.side_effect = AssertionError(
+ "Expected method to return before calling `self.has_green_checkmark`."
+ )
+ self.assertIsNone(await self.cog.on_raw_reaction_add(payload))
+
+ # Check that we did make it past the payload checks
+ channel.fetch_message.assert_called_once()
+ channel.fetch_message.reset_mock()
+
+ @patch(f"{MODULE_PATH}.DuckPond.is_staff")
+ @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock)
+ def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff):
+ """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot."""
+ channel_id = 31415926535
+ message_id = 27182818284
+ user_id = 16180339887
+
+ channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id)
+
+ payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji)
+ payload.emoji.is_custom_emoji.return_value = False
+
+ message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])]
+
+ is_staff.return_value = True
+ count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`")
+
+ self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload)))
+
+ # Assert that we've made it past `self.is_staff`
+ is_staff.assert_called_once()
+
+ @helpers.async_test
+ async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self):
+ """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold."""
+ test_cases = (
+ (constants.DuckPond.threshold - 1, False),
+ (constants.DuckPond.threshold, True),
+ (constants.DuckPond.threshold + 1, True),
+ )
+
+ channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5)
+
+ payload.emoji = self.duck_pond_emoji
+
+ for duck_count, should_relay in test_cases:
+ with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=helpers.AsyncMock) as relay_message:
+ with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks:
+ count_ducks.return_value = duck_count
+ with self.subTest(duck_count=duck_count, should_relay=should_relay):
+ await self.cog.on_raw_reaction_add(payload)
+
+ # Confirm that we've made it past counting
+ count_ducks.assert_called_once()
+
+ # Did we relay a message?
+ has_relayed = relay_message.called
+ self.assertEqual(has_relayed, should_relay)
+
+ if should_relay:
+ relay_message.assert_called_once_with(message)
+
+ @helpers.async_test
+ async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self):
+ """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks."""
+ checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji)
+
+ message = helpers.MockMessage(id=1234)
+
+ channel = helpers.MockTextChannel(id=98765)
+ channel.fetch_message.return_value = message
+
+ self.bot.get_all_channels.return_value = (channel, )
+
+ payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark)
+
+ test_cases = (
+ (constants.DuckPond.threshold - 1, False),
+ (constants.DuckPond.threshold, True),
+ (constants.DuckPond.threshold + 1, True),
+ )
+ for duck_count, should_re_add_checkmark in test_cases:
+ with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=helpers.AsyncMock) as count_ducks:
+ count_ducks.return_value = duck_count
+ with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark):
+ await self.cog.on_raw_reaction_remove(payload)
+
+ # Check if we fetched the message
+ channel.fetch_message.assert_called_once_with(message.id)
+
+ # Check if we actually counted the number of ducks
+ count_ducks.assert_called_once_with(message)
+
+ has_re_added_checkmark = message.add_reaction.called
+ self.assertEqual(should_re_add_checkmark, has_re_added_checkmark)
+
+ if should_re_add_checkmark:
+ message.add_reaction.assert_called_once_with(self.checkmark_emoji)
+ message.add_reaction.reset_mock()
+
+ # reset mocks
+ channel.fetch_message.reset_mock()
+ message.reset_mock()
+
+ def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self):
+ """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis."""
+ channel = helpers.MockTextChannel(id=98765)
+
+ channel.fetch_message.side_effect = AssertionError(
+ "Expected method to return before calling `channel.fetch_message`"
+ )
+
+ self.bot.get_all_channels.return_value = (channel, )
+
+ payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id)
+
+ self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload)))
+
+ channel.fetch_message.assert_not_called()
+
+
+class DuckPondSetupTests(unittest.TestCase):
+ """Tests setup of the `DuckPond` cog."""
+
+ def test_setup(self):
+ """Setup of the extension should call add_cog."""
+ bot = helpers.MockBot()
+ duck_pond.setup(bot)
+ bot.add_cog.assert_called_once()
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_security.py b/tests/bot/cogs/test_security.py
index efa7a50b1..9d1a62f7e 100644
--- a/tests/bot/cogs/test_security.py
+++ b/tests/bot/cogs/test_security.py
@@ -1,4 +1,3 @@
-import logging
import unittest
from unittest.mock import MagicMock
@@ -49,11 +48,7 @@ class SecurityCogLoadTests(unittest.TestCase):
"""Tests loading the `Security` cog."""
def test_security_cog_load(self):
- """Cog loading logs a message at `INFO` level."""
+ """Setup of the extension should call add_cog."""
bot = MagicMock()
- with self.assertLogs(logger='bot.cogs.security', level=logging.INFO) as cm:
- security.setup(bot)
- bot.add_cog.assert_called_once()
-
- [line] = cm.output
- self.assertIn("Cog loaded: Security", line)
+ security.setup(bot)
+ bot.add_cog.assert_called_once()
diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py
index dfb1bafc9..a54b839d7 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
@@ -125,11 +125,7 @@ class TokenRemoverSetupTests(unittest.TestCase):
"""Tests setup of the `TokenRemover` cog."""
def test_setup(self):
- """Setup of the cog should log a message at `INFO` level."""
+ """Setup of the extension should call add_cog."""
bot = MockBot()
- with self.assertLogs(logger='bot.cogs.token_remover', level=logging.INFO) as cm:
- setup_cog(bot)
-
- [line] = cm.output
+ setup_cog(bot)
bot.add_cog.assert_called_once()
- self.assertIn("Cog loaded: TokenRemover", line)
diff --git a/tests/bot/rules/test_attachments.py b/tests/bot/rules/test_attachments.py
index 4bb0acf7c..d7187f315 100644
--- a/tests/bot/rules/test_attachments.py
+++ b/tests/bot/rules/test_attachments.py
@@ -1,52 +1,98 @@
-import asyncio
import unittest
-from dataclasses import dataclass
-from typing import Any, List
+from typing import List, NamedTuple, Tuple
from bot.rules import attachments
+from tests.helpers import MockMessage, async_test
-# Using `MagicMock` sadly doesn't work for this usecase
-# since it's __eq__ compares the MagicMock's ID. We just
-# want to compare the actual attributes we set.
-@dataclass
-class FakeMessage:
- author: str
- attachments: List[Any]
+class Case(NamedTuple):
+ recent_messages: List[MockMessage]
+ culprit: Tuple[str]
+ total_attachments: int
-def msg(total_attachments: int) -> FakeMessage:
- return FakeMessage(author='lemon', attachments=list(range(total_attachments)))
+def msg(author: str, total_attachments: int) -> MockMessage:
+ """Builds a message with `total_attachments` attachments."""
+ return MockMessage(author=author, attachments=list(range(total_attachments)))
class AttachmentRuleTests(unittest.TestCase):
- """Tests applying the `attachment` antispam rule."""
+ """Tests applying the `attachments` antispam rule."""
- def test_allows_messages_without_too_many_attachments(self):
+ def setUp(self):
+ self.config = {"max": 5}
+
+ @async_test
+ async def test_allows_messages_without_too_many_attachments(self):
"""Messages without too many attachments are allowed as-is."""
cases = (
- (msg(0), msg(0), msg(0)),
- (msg(2), msg(2)),
- (msg(0),),
+ [msg("bob", 0), msg("bob", 0), msg("bob", 0)],
+ [msg("bob", 2), msg("bob", 2)],
+ [msg("bob", 2), msg("alice", 2), msg("bob", 2)],
)
- for last_message, *recent_messages in cases:
- with self.subTest(last_message=last_message, recent_messages=recent_messages):
- coro = attachments.apply(last_message, recent_messages, {'max': 5})
- self.assertIsNone(asyncio.run(coro))
+ 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 attachments.apply(last_message, recent_messages, self.config)
+ )
- def test_disallows_messages_with_too_many_attachments(self):
+ @async_test
+ async def test_disallows_messages_with_too_many_attachments(self):
"""Messages with too many attachments trigger the rule."""
cases = (
- ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10),
- ((msg(6),), [msg(6)], 6),
- ((msg(1),) * 6, [msg(1)] * 6, 6),
+ Case(
+ [msg("bob", 4), msg("bob", 0), msg("bob", 6)],
+ ("bob",),
+ 10
+ ),
+ Case(
+ [msg("bob", 4), msg("alice", 6), msg("bob", 2)],
+ ("bob",),
+ 6
+ ),
+ Case(
+ [msg("alice", 6)],
+ ("alice",),
+ 6
+ ),
+ (
+ [msg("alice", 1) for _ in range(6)],
+ ("alice",),
+ 6
+ ),
)
- for messages, relevant_messages, total in cases:
- with self.subTest(messages=messages, relevant_messages=relevant_messages, total=total):
- last_message, *recent_messages = messages
- coro = attachments.apply(last_message, recent_messages, {'max': 5})
- self.assertEqual(
- asyncio.run(coro),
- (f"sent {total} attachments in 5s", ('lemon',), relevant_messages)
+
+ for recent_messages, culprit, total_attachments in cases:
+ last_message = recent_messages[0]
+ relevant_messages = tuple(
+ msg
+ for msg in recent_messages
+ if (
+ msg.author == last_message.author
+ and len(msg.attachments) > 0
+ )
+ )
+
+ with self.subTest(
+ last_message=last_message,
+ recent_messages=recent_messages,
+ relevant_messages=relevant_messages,
+ total_attachments=total_attachments,
+ config=self.config
+ ):
+ desired_output = (
+ f"sent {total_attachments} attachments in {self.config['max']}s",
+ culprit,
+ relevant_messages
+ )
+ self.assertTupleEqual(
+ await attachments.apply(last_message, recent_messages, self.config),
+ desired_output
)
diff --git a/tests/bot/rules/test_links.py b/tests/bot/rules/test_links.py
new file mode 100644
index 000000000..02a5d5501
--- /dev/null
+++ b/tests/bot/rules/test_links.py
@@ -0,0 +1,97 @@
+import unittest
+from typing import List, NamedTuple, Tuple
+
+from bot.rules import links
+from tests.helpers import MockMessage, async_test
+
+
+class Case(NamedTuple):
+ recent_messages: List[MockMessage]
+ culprit: Tuple[str]
+ total_links: int
+
+
+def msg(author: str, total_links: int) -> MockMessage:
+ """Makes a message with `total_links` links."""
+ content = " ".join(["https://pydis.com"] * total_links)
+ return MockMessage(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)],
+ ("bob",),
+ 3
+ ),
+ Case(
+ [msg("alice", 1), msg("alice", 1), msg("alice", 1)],
+ ("alice",),
+ 3
+ ),
+ Case(
+ [msg("alice", 2), msg("bob", 3), msg("alice", 1)],
+ ("alice",),
+ 3
+ )
+ )
+
+ for recent_messages, culprit, total_links in cases:
+ last_message = recent_messages[0]
+ relevant_messages = tuple(
+ msg
+ for msg in recent_messages
+ if msg.author == last_message.author
+ )
+
+ 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/rules/test_mentions.py b/tests/bot/rules/test_mentions.py
new file mode 100644
index 000000000..ad49ead32
--- /dev/null
+++ b/tests/bot/rules/test_mentions.py
@@ -0,0 +1,95 @@
+import unittest
+from typing import List, NamedTuple, Tuple
+
+from bot.rules import mentions
+from tests.helpers import MockMessage, async_test
+
+
+class Case(NamedTuple):
+ recent_messages: List[MockMessage]
+ culprit: Tuple[str]
+ total_mentions: int
+
+
+def msg(author: str, total_mentions: int) -> MockMessage:
+ """Makes a message with `total_mentions` mentions."""
+ return MockMessage(author=author, mentions=list(range(total_mentions)))
+
+
+class TestMentions(unittest.TestCase):
+ """Tests applying the `mentions` antispam rule."""
+
+ def setUp(self):
+ self.config = {
+ "max": 2,
+ "interval": 10
+ }
+
+ @async_test
+ async def test_mentions_within_limit(self):
+ """Messages with an allowed amount of mentions."""
+ cases = (
+ [msg("bob", 0)],
+ [msg("bob", 2)],
+ [msg("bob", 1), msg("bob", 1)],
+ [msg("bob", 1), msg("alice", 2)]
+ )
+
+ 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 mentions.apply(last_message, recent_messages, self.config)
+ )
+
+ @async_test
+ async def test_mentions_exceeding_limit(self):
+ """Messages with a higher than allowed amount of mentions."""
+ cases = (
+ Case(
+ [msg("bob", 3)],
+ ("bob",),
+ 3
+ ),
+ Case(
+ [msg("alice", 2), msg("alice", 0), msg("alice", 1)],
+ ("alice",),
+ 3
+ ),
+ Case(
+ [msg("bob", 2), msg("alice", 3), msg("bob", 2)],
+ ("bob",),
+ 4
+ )
+ )
+
+ for recent_messages, culprit, total_mentions in cases:
+ last_message = recent_messages[0]
+ relevant_messages = tuple(
+ msg
+ for msg in recent_messages
+ if msg.author == last_message.author
+ )
+
+ with self.subTest(
+ last_message=last_message,
+ recent_messages=recent_messages,
+ relevant_messages=relevant_messages,
+ culprit=culprit,
+ total_mentions=total_mentions,
+ cofig=self.config
+ ):
+ desired_output = (
+ f"sent {total_mentions} mentions in {self.config['interval']}s",
+ culprit,
+ relevant_messages
+ )
+ self.assertTupleEqual(
+ await mentions.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 22dc93073..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,11 +33,19 @@ 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):
+ self.ctx.channel.id = 42
+ self.assertTrue(checks.in_channel_check(self.ctx, *[42]))
+
+ def test_in_channel_check_for_incorrect_channel(self):
+ self.ctx.channel.id = 42 + 10
+ self.assertFalse(checks.in_channel_check(self.ctx, *[42]))
diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py
new file mode 100644
index 000000000..69f35f2f5
--- /dev/null
+++ b/tests/bot/utils/test_time.py
@@ -0,0 +1,162 @@
+import asyncio
+import unittest
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+from dateutil.relativedelta import relativedelta
+
+from bot.utils import time
+from tests.helpers import AsyncMock
+
+
+class TimeTests(unittest.TestCase):
+ """Test helper functions in bot.utils.time."""
+
+ def test_humanize_delta_handle_unknown_units(self):
+ """humanize_delta should be able to handle unknown units, and will not abort."""
+ # Does not abort for unknown units, as the unit name is checked
+ # against the attribute of the relativedelta instance.
+ self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'elephants', 2), '2 days and 2 hours')
+
+ def test_humanize_delta_handle_high_units(self):
+ """humanize_delta should be able to handle very high units."""
+ # Very high maximum units, but it only ever iterates over
+ # each value the relativedelta might have.
+ self.assertEqual(time.humanize_delta(relativedelta(days=2, hours=2), 'hours', 20), '2 days and 2 hours')
+
+ def test_humanize_delta_should_normal_usage(self):
+ """Testing humanize delta."""
+ test_cases = (
+ (relativedelta(days=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'),
+ (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'days', 2, '2 days'),
+ )
+
+ for delta, precision, max_units, expected in test_cases:
+ with self.subTest(delta=delta, precision=precision, max_units=max_units, expected=expected):
+ self.assertEqual(time.humanize_delta(delta, precision, max_units), expected)
+
+ def test_humanize_delta_raises_for_invalid_max_units(self):
+ """humanize_delta should raises ValueError('max_units must be positive') for invalid max_units."""
+ test_cases = (-1, 0)
+
+ for max_units in test_cases:
+ with self.subTest(max_units=max_units), self.assertRaises(ValueError) as error:
+ time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
+ self.assertEqual(str(error), 'max_units must be positive')
+
+ def test_parse_rfc1123(self):
+ """Testing parse_rfc1123."""
+ self.assertEqual(
+ time.parse_rfc1123('Sun, 15 Sep 2019 12:00:00 GMT'),
+ datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
+ )
+
+ def test_format_infraction(self):
+ """Testing format_infraction."""
+ self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01')
+
+ @patch('asyncio.sleep', new_callable=AsyncMock)
+ def test_wait_until(self, mock):
+ """Testing wait_until."""
+ start = datetime(2019, 1, 1, 0, 0)
+ then = datetime(2019, 1, 1, 0, 10)
+
+ # No return value
+ self.assertIs(asyncio.run(time.wait_until(then, start)), None)
+
+ mock.assert_called_once_with(10 * 60)
+
+ def test_format_infraction_with_duration_none_expiry(self):
+ """format_infraction_with_duration should work for None expiry."""
+ test_cases = (
+ (None, None, None, None),
+
+ # To make sure that date_from and max_units are not touched
+ (None, 'Why hello there!', None, None),
+ (None, None, float('inf'), None),
+ (None, 'Why hello there!', float('inf'), None),
+ )
+
+ for expiry, date_from, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+ self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+ def test_format_infraction_with_duration_custom_units(self):
+ """format_infraction_with_duration should work for custom max_units."""
+ test_cases = (
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6,
+ '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20,
+ '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)')
+ )
+
+ for expiry, date_from, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+ self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+ def test_format_infraction_with_duration_normal_usage(self):
+ """format_infraction_with_duration should work for normal usage, across various durations."""
+ test_cases = (
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'),
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'),
+ ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'),
+ ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'),
+ ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'),
+ ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'),
+ ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2,
+ '2019-11-23 23:59 (9 minutes and 55 seconds)'),
+ (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
+ )
+
+ for expiry, date_from, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, date_from=date_from, max_units=max_units, expected=expected):
+ self.assertEqual(time.format_infraction_with_duration(expiry, date_from, max_units), expected)
+
+ def test_until_expiration_with_duration_none_expiry(self):
+ """until_expiration should work for None expiry."""
+ test_cases = (
+ (None, None, None, None),
+
+ # To make sure that now and max_units are not touched
+ (None, 'Why hello there!', None, None),
+ (None, None, float('inf'), None),
+ (None, 'Why hello there!', float('inf'), None),
+ )
+
+ for expiry, now, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+ self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+
+ def test_until_expiration_with_duration_custom_units(self):
+ """until_expiration should work for custom max_units."""
+ test_cases = (
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes')
+ )
+
+ for expiry, now, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+ self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
+
+ def test_until_expiration_normal_usage(self):
+ """until_expiration should work for normal usage, across various durations."""
+ test_cases = (
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'),
+ ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'),
+ ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'),
+ ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'),
+ ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'),
+ ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'),
+ ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'),
+ ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'),
+ (None, datetime(2019, 11, 23, 23, 49, 5), 2, None),
+ )
+
+ for expiry, now, max_units, expected in test_cases:
+ with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected):
+ self.assertEqual(time.until_expiration(expiry, now, max_units), expected)
diff --git a/tests/helpers.py b/tests/helpers.py
index 892d42e6c..5df796c23 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,12 +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
+from discord.ext.commands import Context
+
+from bot.bot import Bot
+
+
+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):
@@ -24,19 +40,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 +64,138 @@ 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 _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())
+
+
+# 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().__call__(*args, **kwargs)
+
+
+class AsyncIteratorMock:
+ """
+ A class to mock asynchronous iterators.
+
+ This allows async for, which is used in certain Discord.py objects. For example,
+ an async iterator is returned by the Reaction.users() method.
+ """
+
+ def __init__(self, iterable: Iterable = None):
+ if iterable is None:
+ iterable = []
+
+ self.iter = iter(iterable)
+ self.iterable = iterable
+
+ self.call_count = 0
- 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 __aiter__(self):
+ return self
- return super().__new__(cls)
+ async def __anext__(self):
+ try:
+ return next(self.iter)
+ except StopIteration:
+ raise StopAsyncIteration
+
+ def __call__(self):
+ """
+ Keeps track of the number of times an instance has been called.
+
+ This is useful, since it typically shows that the iterator has actually been used somewhere after we have
+ instantiated the mock for an attribute that normally returns an iterator when called.
+ """
+ self.call_count += 1
+ return self
+
+ @property
+ def return_value(self):
+ """Makes `self.iterable` accessible as self.return_value."""
+ return self.iterable
+
+ @return_value.setter
+ def return_value(self, iterable):
+ """Stores the `return_value` as `self.iterable` and its iterator as `self.iter`."""
+ self.iter = iter(iterable)
+ self.iterable = iterable
+
+ def assert_called(self):
+ """Asserts if the AsyncIteratorMock instance has been called at least once."""
+ if self.call_count == 0:
+ raise AssertionError("Expected AsyncIteratorMock to have been called.")
+
+ def assert_called_once(self):
+ """Asserts if the AsyncIteratorMock instance has been called exactly once."""
+ if self.call_count != 1:
+ raise AssertionError(
+ f"Expected AsyncIteratorMock to have been called once. Called {self.call_count} times."
+ )
+
+ def assert_not_called(self):
+ """Asserts if the AsyncIteratorMock instance has not been called."""
+ if self.call_count != 0:
+ raise AssertionError(
+ f"Expected AsyncIteratorMock to not have been called once. Called {self.call_count} times."
+ )
+
+ def reset_mock(self):
+ """Resets the call count, but not the return value or iterator."""
+ self.call_count = 0
# Create a guild instance to get a realistic Mock of `discord.Guild`
@@ -95,7 +221,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 +247,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,53 +286,51 @@ 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), 'bot': False}
+ super().__init__(spec_set=member_instance, **collections.ChainMap(kwargs, default_kwargs))
- attribute_mocktype = unittest.mock.MagicMock
+ self.roles = [MockRole(name="@everyone", position=1, id=0)]
+ if roles:
+ self.roles.extend(roles)
- def __init__(
- self,
- name: str = "member",
- user_id: int = 1,
- roles: Optional[Iterable[MockRole]] = None,
- **kwargs,
- ) -> None:
- super().__init__(spec=member_instance, **kwargs)
+ if 'mention' not in kwargs:
+ self.mention = f"@{self.name}"
- self.name = name
- self.id = user_id
- self.roles = [MockRole("@everyone", 1)]
- if roles:
- self.roles.extend(roles)
+# Create a User instance to get a realistic Mock of `discord.User`
+user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock())
- 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()
+class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin):
+ """
+ A Mock subclass to mock User objects.
+
+ Instances of this class will follow the specifications of `discord.User` instances. For more
+ information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'name': 'user', 'id': next(self.discord_id), 'bot': False}
+ super().__init__(spec_set=user_instance, **collections.ChainMap(kwargs, default_kwargs))
+
+ 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.
@@ -262,72 +338,18 @@ class MockBot(AttributeMock, unittest.mock.MagicMock):
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()
-
-
-# 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())
-
+ super().__init__(spec_set=bot_instance, **kwargs)
-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,7 +368,7 @@ 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.
@@ -354,30 +376,12 @@ class MockTextChannel(AttributeMock, unittest.mock.Mock, HashableMixin):
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,7 +406,41 @@ 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())
+
+
+attachment_instance = discord.Attachment(data=unittest.mock.MagicMock(id=1), state=unittest.mock.MagicMock())
+
+
+class MockAttachment(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Attachment objects.
+
+ Instances of this class will follow the specifications of `discord.Attachment` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=attachment_instance, **kwargs)
+
+
+class MockMessage(CustomMockMixin, unittest.mock.MagicMock):
"""
A MagicMock subclass to mock Message objects.
@@ -410,19 +448,80 @@ class MockMessage(AttributeMock, unittest.mock.MagicMock):
information, see the `MockGuild` docstring.
"""
- attribute_mocktype = unittest.mock.MagicMock
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'attachments': []}
+ super().__init__(spec_set=message_instance, **collections.ChainMap(kwargs, default_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=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()
+ 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)
+
+
+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_set=reaction_instance, **kwargs)
+ self.emoji = kwargs.get('emoji', MockEmoji())
+ self.message = kwargs.get('message', MockMessage())
+ self.users = AsyncIteratorMock(kwargs.get('users', []))
+
+
+webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock())
+
+
+class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock):
+ """
+ A MagicMock subclass to mock Webhook objects using an AsyncWebhookAdapter.
+
+ Instances of this class will follow the specifications of `discord.Webhook` instances. For
+ more information, see the `MockGuild` docstring.
+ """
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(spec_set=webhook_instance, **kwargs)
+
+ # Because Webhooks can also use a synchronous "WebhookAdapter", the methods are not defined
+ # as coroutines. That's why we need to set the methods manually.
+ self.send = AsyncMock()
self.edit = AsyncMock()
- self.pin = AsyncMock()
- self.remove_reaction = AsyncMock()
- self.unpin = AsyncMock()
+ self.delete = AsyncMock()
+ self.execute = AsyncMock()
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."""