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