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.""" |