diff options
57 files changed, 1270 insertions, 1499 deletions
| diff --git a/.gitignore b/.gitignore index fb3156ab1..2074887ad 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ ENV/  # Logfiles  log.* +*.log.*  # Custom user configuration  config.yml @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9"  colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}  coloredlogs = "~=14.0"  deepdiff = "~=4.0" -discord.py = "~=1.4.0" +"discord.py" = "~=1.5.0"  feedparser = "~=5.2"  fuzzywuzzy = "~=0.17"  lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index f75852081..becd85c55 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3" +            "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88"          },          "pipfile-spec": 6,          "requires": { @@ -18,11 +18,11 @@      "default": {          "aio-pika": {              "hashes": [ -                "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", -                "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" +                "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", +                "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c"              ],              "index": "pypi", -            "version": "==6.7.0" +            "version": "==6.7.1"          },          "aiodns": {              "hashes": [ @@ -86,12 +86,12 @@                  "fakeredis"              ],              "hashes": [ -                "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2", -                "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c" +                "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f", +                "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"              ],              "index": "pypi",              "markers": "python_version ~= '3.7'", -            "version": "==0.1.2" +            "version": "==0.1.4"          },          "async-timeout": {              "hashes": [ @@ -119,12 +119,12 @@          },          "beautifulsoup4": {              "hashes": [ -                "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", -                "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", -                "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" +                "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1", +                "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d", +                "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883"              ],              "index": "pypi", -            "version": "==4.9.1" +            "version": "==4.9.2"          },          "certifi": {              "hashes": [ @@ -135,36 +135,44 @@          },          "cffi": {              "hashes": [ -                "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", -                "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", -                "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", -                "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", -                "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", -                "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", -                "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", -                "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", -                "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", -                "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", -                "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", -                "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", -                "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", -                "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", -                "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", -                "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", -                "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", -                "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", -                "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", -                "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", -                "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", -                "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", -                "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", -                "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", -                "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", -                "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", -                "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", -                "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" -            ], -            "version": "==1.14.2" +                "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", +                "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", +                "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", +                "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", +                "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", +                "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", +                "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", +                "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", +                "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", +                "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", +                "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", +                "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", +                "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", +                "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", +                "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", +                "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", +                "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", +                "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", +                "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", +                "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", +                "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", +                "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", +                "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", +                "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", +                "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", +                "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", +                "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", +                "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", +                "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", +                "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", +                "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", +                "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", +                "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", +                "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", +                "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", +                "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" +            ], +            "version": "==1.14.3"          },          "chardet": {              "hashes": [ @@ -197,22 +205,13 @@              "index": "pypi",              "version": "==4.3.2"          }, -        "discord": { -            "hashes": [ -                "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", -                "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" -            ], -            "index": "pypi", -            "py": "~=1.4.0", -            "version": "==1.0.1" -        },          "discord.py": {              "hashes": [ -                "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", -                "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" +                "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", +                "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"              ], -            "markers": "python_full_version >= '3.5.3'", -            "version": "==1.4.1" +            "index": "pypi", +            "version": "==1.5.0"          },          "docutils": {              "hashes": [ @@ -575,11 +574,11 @@          },          "sentry-sdk": {              "hashes": [ -                "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", -                "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" +                "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a", +                "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"              ],              "index": "pypi", -            "version": "==0.17.6" +            "version": "==0.17.8"          },          "six": {              "hashes": [ @@ -608,7 +607,7 @@                  "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55",                  "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232"              ], -            "markers": "python_version >= '3.5'", +            "markers": "python_version >= '3.0'",              "version": "==2.0.1"          },          "sphinx": { @@ -685,26 +684,26 @@          },          "yarl": {              "hashes": [ -                "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", -                "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", -                "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", -                "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", -                "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", -                "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", -                "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", -                "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", -                "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", -                "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", -                "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", -                "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", -                "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", -                "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", -                "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", -                "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", -                "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" +                "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e", +                "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5", +                "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580", +                "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc", +                "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b", +                "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2", +                "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a", +                "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921", +                "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e", +                "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1", +                "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d", +                "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131", +                "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a", +                "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1", +                "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188", +                "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020", +                "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"              ],              "markers": "python_version >= '3.5'", -            "version": "==1.5.1" +            "version": "==1.6.0"          }      },      "develop": { @@ -857,11 +856,11 @@          },          "identify": {              "hashes": [ -                "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae", -                "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62" +                "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", +                "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"              ],              "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", -            "version": "==1.5.3" +            "version": "==1.5.5"          },          "mccabe": {              "hashes": [ diff --git a/bot/__main__.py b/bot/__main__.py index a07bc21d6..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -9,7 +9,7 @@ from sentry_sdk.integrations.aiohttp import AioHttpIntegration  from sentry_sdk.integrations.logging import LoggingIntegration  from sentry_sdk.integrations.redis import RedisIntegration -from bot import constants, patches +from bot import constants  from bot.bot import Bot  from bot.utils.extensions import EXTENSIONS @@ -47,14 +47,22 @@ loop.run_until_complete(redis_session.connect())  # Instantiate the bot.  allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +intents = discord.Intents().all() +intents.presences = False +intents.dm_typing = False +intents.dm_reactions = False +intents.invites = False +intents.webhooks = False +intents.integrations = False  bot = Bot(      redis_session=redis_session,      loop=loop,      command_prefix=when_mentioned_or(constants.Bot.prefix), -    activity=discord.Game(name="Commands: !help"), +    activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"),      case_insensitive=True,      max_messages=10_000, -    allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) +    allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), +    intents=intents,  )  # Load extensions. @@ -65,8 +73,4 @@ if not constants.HelpChannels.enable:  for extension in extensions:      bot.load_extension(extension) -# Apply `message_edited_at` patch if discord.py did not yet release a bug fix. -if not hasattr(discord.message.Message, '_handle_edited_timestamp'): -    patches.message_edited_at.apply_patch() -  bot.run(constants.Bot.token) diff --git a/bot/constants.py b/bot/constants.py index 0cb076d5c..c21fd52e0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -391,6 +391,7 @@ class Channels(metaclass=YAMLGetter):      big_brother_logs: int      bot_commands: int      change_log: int +    code_help_voice: int      cooldown: int      defcon: int      dev_contrib: int @@ -469,7 +470,6 @@ class Guild(metaclass=YAMLGetter):      moderation_roles: List[int]      modlog_blacklist: List[int]      reminder_whitelist: List[int] -    staff_channels: List[int]      staff_roles: List[int] @@ -560,13 +560,6 @@ class RedirectOutput(metaclass=YAMLGetter):      delete_delay: int -class Sync(metaclass=YAMLGetter): -    section = 'sync' - -    confirm_timeout: int -    max_diff: int - -  class PythonNews(metaclass=YAMLGetter):      section = 'python_news' @@ -623,7 +616,6 @@ MODERATION_ROLES = Guild.moderation_roles  STAFF_ROLES = Guild.staff_roles  # Channel combinations -STAFF_CHANNELS = Guild.staff_channels  MODERATION_CHANNELS = Guild.moderation_channels  # Bot replies diff --git a/bot/converters.py b/bot/converters.py index 1358cbf1e..2e118d476 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,6 +2,7 @@ import logging  import re  import typing as t  from datetime import datetime +from functools import partial  from ssl import CertificateError  import dateutil.parser @@ -10,6 +11,7 @@ import discord  from aiohttp import ClientConnectorError  from dateutil.relativedelta import relativedelta  from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.utils import DISCORD_EPOCH, snowflake_time  from bot.api import ResponseCodeError  from bot.constants import URLs @@ -17,6 +19,9 @@ from bot.utils.regex import INVITE_RE  log = logging.getLogger(__name__) +DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") +  def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:      """ @@ -172,17 +177,42 @@ class ValidURL(Converter):          return url -class InfractionSearchQuery(Converter): -    """A converter that checks if the argument is a Discord user, and if not, falls back to a string.""" +class Snowflake(IDConverter): +    """ +    Converts to an int if the argument is a valid Discord snowflake. + +    A snowflake is valid if: + +    * It consists of 15-21 digits (0-9) +    * Its parsed datetime is after the Discord epoch +    * Its parsed datetime is less than 1 day after the current time +    """ + +    async def convert(self, ctx: Context, arg: str) -> int: +        """ +        Ensure `arg` matches the ID pattern and its timestamp is in range. + +        Return `arg` as an int if it's a valid snowflake. +        """ +        error = f"Invalid snowflake {arg!r}" + +        if not self._get_id_match(arg): +            raise BadArgument(error) + +        snowflake = int(arg) -    @staticmethod -    async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]: -        """Check if the argument is a Discord user, and if not, falls back to a string."""          try: -            maybe_snowflake = arg.strip("<@!>") -            return await ctx.bot.fetch_user(maybe_snowflake) -        except (discord.NotFound, discord.HTTPException): -            return arg +            time = snowflake_time(snowflake) +        except (OverflowError, OSError) as e: +            # Not sure if this can ever even happen, but let's be safe. +            raise BadArgument(f"{error}: {e}") + +        if time < DISCORD_EPOCH_DT: +            raise BadArgument(f"{error}: timestamp is before the Discord epoch.") +        elif (datetime.utcnow() - time).days < -1: +            raise BadArgument(f"{error}: timestamp is too far into the future.") + +        return snowflake  class Subreddit(Converter): @@ -447,14 +477,13 @@ class UserMentionOrID(UserConverter):      """      Converts to a `discord.User`, but only if a mention or userID is provided. -    Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. - +    Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim.      This is useful in cases where that lookup strategy would lead to ambiguity.      """      async def convert(self, ctx: Context, argument: str) -> discord.User:          """Convert the `arg` to a `discord.User`.""" -        match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) +        match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)          if match is not None:              return await super().convert(ctx, argument) @@ -507,5 +536,19 @@ class FetchedUser(UserConverter):              raise BadArgument(f"User `{arg}` does not exist") +def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: +    """ +    Extract the snowflake from `arg` using a regex `pattern` and return it as an int. + +    The snowflake is expected to be within the first capture group in `pattern`. +    """ +    match = pattern.match(arg) +    if not match: +        raise BadArgument(f"Mention {str!r} is invalid.") + +    return int(match.group(1)) + +  Expiry = t.Union[Duration, ISODateTime]  FetchedMember = t.Union[discord.Member, FetchedUser] +UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) diff --git a/bot/decorators.py b/bot/decorators.py index 2518124da..063c8f878 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,16 +1,15 @@ +import asyncio  import logging -import random -from asyncio import Lock, create_task, sleep +import typing as t  from contextlib import suppress  from functools import wraps -from typing import Callable, Container, Optional, Union -from weakref import WeakValueDictionary -from discord import Colour, Embed, Member, NotFound +from discord import Member, NotFound  from discord.ext import commands  from discord.ext.commands import Cog, Context -from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, RedirectOutput +from bot.utils import function  from bot.utils.checks import in_whitelist_check  log = logging.getLogger(__name__) @@ -18,12 +17,12 @@ log = logging.getLogger(__name__)  def in_whitelist(      *, -    channels: Container[int] = (), -    categories: Container[int] = (), -    roles: Container[int] = (), -    redirect: Optional[int] = Channels.bot_commands, +    channels: t.Container[int] = (), +    categories: t.Container[int] = (), +    roles: t.Container[int] = (), +    redirect: t.Optional[int] = Channels.bot_commands,      fail_silently: bool = False, -) -> Callable: +) -> t.Callable:      """      Check if a command was issued in a whitelisted context. @@ -31,7 +30,7 @@ def in_whitelist(      - `channels`: a container with channel ids for whitelisted channels      - `categories`: a container with category ids for whitelisted categories -    - `roles`: a container with with role ids for whitelisted roles +    - `roles`: a container with role ids for whitelisted roles      If the command was invoked in a context that was not whitelisted, the member is either      redirected to the `redirect` channel that was passed (default: #bot-commands) or simply @@ -44,7 +43,7 @@ def in_whitelist(      return commands.check(predicate) -def has_no_roles(*roles: Union[str, int]) -> Callable: +def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:      """      Returns True if the user does not have any of the roles specified. @@ -63,39 +62,7 @@ def has_no_roles(*roles: Union[str, int]) -> Callable:      return commands.check(predicate) -def locked() -> Callable: -    """ -    Allows the user to only run one instance of the decorated command at a time. - -    Subsequent calls to the command from the same author are ignored until the command has completed invocation. - -    This decorator must go before (below) the `command` decorator. -    """ -    def wrap(func: Callable) -> Callable: -        func.__locks = WeakValueDictionary() - -        @wraps(func) -        async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: -            lock = func.__locks.setdefault(ctx.author.id, Lock()) -            if lock.locked(): -                embed = Embed() -                embed.colour = Colour.red() - -                log.debug("User tried to invoke a locked command.") -                embed.description = ( -                    "You're already using this command. Please wait until it is done before you use it again." -                ) -                embed.title = random.choice(ERROR_REPLIES) -                await ctx.send(embed=embed) -                return - -            async with func.__locks.setdefault(ctx.author.id, Lock()): -                await func(self, ctx, *args, **kwargs) -        return inner -    return wrap - - -def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: +def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable:      """      Changes the channel in the context of the command to redirect the output to a certain channel. @@ -103,7 +70,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non      This decorator must go before (below) the `command` decorator.      """ -    def wrap(func: Callable) -> Callable: +    def wrap(func: t.Callable) -> t.Callable:          @wraps(func)          async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:              if ctx.channel.id == destination_channel: @@ -122,14 +89,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non              log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")              ctx.channel = redirect_channel              await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") -            create_task(func(self, ctx, *args, **kwargs)) +            asyncio.create_task(func(self, ctx, *args, **kwargs))              message = await old_channel.send(                  f"Hey, {ctx.author.mention}, you can find the output of your command here: "                  f"{redirect_channel.mention}"              )              if RedirectOutput.delete_invocation: -                await sleep(RedirectOutput.delete_delay) +                await asyncio.sleep(RedirectOutput.delete_delay)                  with suppress(NotFound):                      await message.delete() @@ -143,38 +110,35 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non      return wrap -def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: +def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:      """      Ensure the highest role of the invoking member is greater than that of the target member.      If the condition fails, a warning is sent to the invoking context. A target which is not an      instance of discord.Member will always pass. -    A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after -    `ctx`. If the target argument is a kwarg, its name can instead be given. +    `member_arg` is the keyword name or position index of the parameter of the decorated command +    whose value is the target member.      This decorator must go before (below) the `command` decorator.      """ -    def wrap(func: Callable) -> Callable: +    def decorator(func: t.Callable) -> t.Callable:          @wraps(func) -        async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: -            try: -                target = kwargs[target_arg] -            except KeyError: -                try: -                    target = args[target_arg] -                except IndexError: -                    raise ValueError(f"Could not find target argument at position {target_arg}") -                except TypeError: -                    raise ValueError(f"Could not find target kwarg with key {target_arg!r}") +        async def wrapper(*args, **kwargs) -> None: +            log.trace(f"{func.__name__}: respect role hierarchy decorator called") + +            bound_args = function.get_bound_args(func, args, kwargs) +            target = function.get_arg_value(member_arg, bound_args)              if not isinstance(target, Member):                  log.trace("The target is not a discord.Member; skipping role hierarchy check.") -                await func(self, ctx, *args, **kwargs) +                await func(*args, **kwargs)                  return +            ctx = function.get_arg_value(1, bound_args)              cmd = ctx.command.name              actor = ctx.author +              if target.top_role >= actor.top_role:                  log.info(                      f"{actor} ({actor.id}) attempted to {cmd} " @@ -185,6 +149,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable:                      "someone with an equal or higher top role."                  )              else: -                await func(self, ctx, *args, **kwargs) -        return inner -    return wrap +                log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") +                await func(*args, **kwargs) +        return wrapper +    return decorator diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 000000000..65d715203 --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,20 @@ +from typing import Hashable + + +class LockedResourceError(RuntimeError): +    """ +    Exception raised when an operation is attempted on a locked resource. + +    Attributes: +        `type` -- name of the locked resource's type +        `id` -- ID of the locked resource +    """ + +    def __init__(self, resource_type: str, resource_id: Hashable): +        self.type = resource_type +        self.id = resource_id + +        super().__init__( +            f"Cannot operate on {self.type.lower()} `{self.id}`; " +            "it is currently locked and in use by another operation." +        ) diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py deleted file mode 100644 index c6ba8d6f3..000000000 --- a/bot/exts/backend/alias.py +++ /dev/null @@ -1,87 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( -    Cog, Command, Context, -    clean_content, command, group, -) - -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): -    """Aliases for commonly used commands.""" - -    def __init__(self, bot: Bot): -        self.bot = bot - -    async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: -        """Invokes a command with args and kwargs.""" -        log.debug(f"{cmd_name} was invoked through an alias") -        cmd = self.bot.get_command(cmd_name) -        if not cmd: -            return log.info(f'Did not find command "{cmd_name}" to invoke.') -        elif not await cmd.can_run(ctx): -            return log.info( -                f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' -            ) - -        await ctx.invoke(cmd, *args, **kwargs) - -    @command(name='aliases') -    async def aliases_command(self, ctx: Context) -> None: -        """Show configured aliases on the bot.""" -        embed = Embed( -            title='Configured aliases', -            colour=Colour.blue() -        ) -        await LinePaginator.paginate( -            ( -                f"• `{ctx.prefix}{value.name}` " -                f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" -                for name, value in inspect.getmembers(self) -                if isinstance(value, Command) and name.endswith('_alias') -            ), -            ctx, embed, empty=False, max_lines=20 -        ) - -    @command(name="exception", hidden=True) -    async def tags_get_traceback_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>tags get traceback.""" -        await self.invoke(ctx, "tags get", tag_name="traceback") - -    @group(name="get", -           aliases=("show", "g"), -           hidden=True, -           invoke_without_command=True) -    async def get_group_alias(self, ctx: Context) -> None: -        """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" -        pass - -    @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) -    async def tags_get_alias( -            self, ctx: Context, *, tag_name: TagNameConverter = None -    ) -> None: -        """ -        Alias for invoking <prefix>tags get [tag_name]. - -        tag_name: str - tag to be viewed. -        """ -        await self.invoke(ctx, "tags get", tag_name=tag_name) - -    @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) -    async def docs_get_alias( -            self, ctx: Context, symbol: clean_content = None -    ) -> None: -        """Alias for invoking <prefix>docs get [symbol].""" -        await self.invoke(ctx, "docs get", symbol) - - -def setup(bot: Bot) -> None: -    """Load the Alias cog.""" -    bot.add_cog(Alias(bot)) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index f9d4de638..c643d346e 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -10,6 +10,7 @@ from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Channels, Colours  from bot.converters import TagNameConverter +from bot.errors import LockedResourceError  from bot.utils.checks import InWhitelistCheckFailure  log = logging.getLogger(__name__) @@ -75,6 +76,8 @@ class ErrorHandler(Cog):          elif isinstance(e, errors.CommandInvokeError):              if isinstance(e.original, ResponseCodeError):                  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.")              else:                  await self.handle_unexpected_error(ctx, e.original)              return  # Exit early to avoid logging. diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index f7ba811bc..38468c2b1 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -1,15 +1,11 @@  import abc -import asyncio  import logging  import typing as t  from collections import namedtuple -from functools import partial -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord import Guild  from discord.ext.commands import Context -from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot @@ -18,16 +14,12 @@ log = logging.getLogger(__name__)  # 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')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))  _Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))  class Syncer(abc.ABC):      """Base class for synchronising the database with objects in the Discord cache.""" -    _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " -    _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) -      def __init__(self, bot: Bot) -> None:          self.bot = bot @@ -37,112 +29,6 @@ class Syncer(abc.ABC):          """The name of the syncer; used in output messages and logging."""          raise NotImplementedError  # pragma: no cover -    async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: -        """ -        Send a prompt to confirm or abort a sync using reactions and return the sent message. - -        If a message is given, it is edited to display the prompt and reactions. Otherwise, a new -        message is sent to the dev-core channel and mentions the core developers role. If the -        channel cannot be retrieved, return None. -        """ -        log.trace(f"Sending {self.name} sync confirmation prompt.") - -        msg_content = ( -            f'Possible cache issue while syncing {self.name}s. ' -            f'More than {constants.Sync.max_diff} {self.name}s were changed. ' -            f'React to confirm or abort the sync.' -        ) - -        # Send to core developers if it's an automatic sync. -        if not message: -            log.trace("Message not provided for confirmation; creating a new one in dev-core.") -            channel = self.bot.get_channel(constants.Channels.dev_core) - -            if not channel: -                log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") -                try: -                    channel = await self.bot.fetch_channel(constants.Channels.dev_core) -                except HTTPException: -                    log.exception( -                        f"Failed to fetch channel for sending sync confirmation prompt; " -                        f"aborting {self.name} sync." -                    ) -                    return None - -            allowed_roles = [discord.Object(constants.Roles.core_developers)] -            message = await channel.send( -                f"{self._CORE_DEV_MENTION}{msg_content}", -                allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) -            ) -        else: -            await message.edit(content=msg_content) - -        # Add the initial reactions. -        log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") -        for emoji in self._REACTION_EMOJIS: -            await message.add_reaction(emoji) - -        return message - -    def _reaction_check( -        self, -        author: Member, -        message: Message, -        reaction: Reaction, -        user: t.Union[Member, User] -    ) -> bool: -        """ -        Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - -        If the `author` of the prompt is a bot, then a reaction by any core developer will be -        considered valid. Otherwise, the author of the reaction (`user`) will have to be the -        `author` of the prompt. -        """ -        # For automatic syncs, check for the core dev role instead of an exact author -        has_role = any(constants.Roles.core_developers == role.id for role in user.roles) -        return ( -            reaction.message.id == message.id -            and not user.bot -            and (has_role if author.bot else user == author) -            and str(reaction.emoji) in self._REACTION_EMOJIS -        ) - -    async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: -        """ -        Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - -        Uses the `_reaction_check` function to determine if a reaction is valid. - -        If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. -        To acknowledge the reaction (or lack thereof), `message` will be edited. -        """ -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else "" - -        reaction = None -        try: -            log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") -            reaction, _ = await self.bot.wait_for( -                'reaction_add', -                check=partial(self._reaction_check, author, message), -                timeout=constants.Sync.confirm_timeout -            ) -        except asyncio.TimeoutError: -            # reaction will remain none thus sync will be aborted in the finally block below. -            log.debug(f"The {self.name} syncer confirmation prompt timed out.") - -        if str(reaction) == constants.Emojis.check_mark: -            log.trace(f"The {self.name} syncer was confirmed.") -            await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') -            return True -        else: -            log.info(f"The {self.name} syncer was aborted or timed out!") -            await message.edit( -                content=f':warning: {mention}{self.name} sync aborted or timed out!' -            ) -            return False -      @abc.abstractmethod      async def _get_diff(self, guild: Guild) -> _Diff:          """Return the difference between the cache of `guild` and the database.""" @@ -153,62 +39,19 @@ class Syncer(abc.ABC):          """Perform the API calls for synchronisation."""          raise NotImplementedError  # pragma: no cover -    async def _get_confirmation_result( -        self, -        diff_size: int, -        author: Member, -        message: t.Optional[Message] = None -    ) -> t.Tuple[bool, t.Optional[Message]]: -        """ -        Prompt for confirmation and return a tuple of the result and the prompt message. - -        `diff_size` is the size of the diff of the sync. If it is greater than -        `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the -        sync and the `message` is an extant message to edit to display the prompt. - -        If confirmed or no confirmation was needed, the result is True. The returned message will -        either be the given `message` or a new one which was created when sending the prompt. -        """ -        log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") -        if diff_size > constants.Sync.max_diff: -            message = await self._send_prompt(message) -            if not message: -                return False, None  # Couldn't get channel. - -            confirmed = await self._wait_for_confirmation(author, message) -            if not confirmed: -                return False, message  # Sync aborted. - -        return True, message -      async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:          """          Synchronise the database with the cache of `guild`. -        If the differences between the cache and the database are greater than -        `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core -        channel. The confirmation can be optionally redirect to `ctx` instead. +        If `ctx` is given, send a message with the results.          """          log.info(f"Starting {self.name} syncer.") -        message = None -        author = self.bot.user          if ctx:              message = await ctx.send(f"📊 Synchronising {self.name}s.") -            author = ctx.author - +        else: +            message = None          diff = await self._get_diff(guild) -        diff_dict = diff._asdict()  # Ugly method for transforming the NamedTuple into a dict -        totals = {k: len(v) for k, v in diff_dict.items() if v is not None} -        diff_size = sum(totals.values()) - -        confirmed, message = await self._get_confirmation_result(diff_size, author, message) -        if not confirmed: -            return - -        # Preserve the core-dev role mention in the message edits so users aren't confused about -        # where notifications came from. -        mention = self._CORE_DEV_MENTION if author.bot else ""          try:              await self._sync(diff) @@ -217,11 +60,14 @@ class Syncer(abc.ABC):              # Don't show response text because it's probably some really long HTML.              results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" -            content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" +            content = f":x: Synchronisation of {self.name}s failed: {results}"          else: -            results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) +            diff_dict = diff._asdict() +            results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) +            results = ", ".join(results) +              log.info(f"{self.name} syncer finished: {results}.") -            content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" +            content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"          if message:              await message.edit(content=content) @@ -287,61 +133,76 @@ class UserSyncer(Syncer):      async def _get_diff(self, guild: Guild) -> _Diff:          """Return the difference of users between the cache of `guild` and the database."""          log.trace("Getting the diff for users.") -        users = await self.bot.api_client.get('bot/users') -        # Pack DB roles and guild roles into one common, hashable format. -        # They're hashable so that they're easily comparable with sets later. -        db_users = { -            user_dict['id']: _User( -                roles=tuple(sorted(user_dict.pop('roles'))), -                **user_dict -            ) -            for user_dict in users -        } -        guild_users = { -            member.id: _User( -                id=member.id, -                name=member.name, -                discriminator=int(member.discriminator), -                roles=tuple(sorted(role.id for role in member.roles)), -                in_guild=True -            ) -            for member in guild.members -        } +        users_to_create = [] +        users_to_update = [] +        seen_guild_users = set() + +        async for db_user in self._get_users(): +            # Store user fields which are to be updated. +            updated_fields = {} + +            def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: +                # Equalize DB user and guild user attributes. +                if db_user[db_field] != guild_value: +                    updated_fields[db_field] = guild_value -        users_to_create = set() -        users_to_update = set() +            if guild_user := guild.get_member(db_user["id"]): +                seen_guild_users.add(guild_user.id) -        for db_user in db_users.values(): -            guild_user = guild_users.get(db_user.id) -            if guild_user is not None: -                if db_user != guild_user: -                    users_to_update.add(guild_user) +                maybe_update("name", guild_user.name) +                maybe_update("discriminator", int(guild_user.discriminator)) +                maybe_update("in_guild", True) -            elif db_user.in_guild: +                guild_roles = [role.id for role in guild_user.roles] +                if set(db_user["roles"]) != set(guild_roles): +                    updated_fields["roles"] = guild_roles + +            elif db_user["in_guild"]:                  # The user is known in the DB but not the guild, and the                  # DB currently specifies that the user is a member of the guild.                  # This means that the user has left since the last sync.                  # Update the `in_guild` attribute of the user on the site                  # to signify that the user left. -                new_api_user = db_user._replace(in_guild=False) -                users_to_update.add(new_api_user) - -        new_user_ids = set(guild_users.keys()) - set(db_users.keys()) -        for user_id in new_user_ids: -            # The user is known on the guild but not on the API. This means -            # that the user has joined since the last sync. Create it. -            new_user = guild_users[user_id] -            users_to_create.add(new_user) +                updated_fields["in_guild"] = False + +            if updated_fields: +                updated_fields["id"] = db_user["id"] +                users_to_update.append(updated_fields) + +        for member in guild.members: +            if member.id not in seen_guild_users: +                # The user is known on the guild but not on the API. This means +                # that the user has joined since the last sync. Create it. +                new_user = { +                    "id": member.id, +                    "name": member.name, +                    "discriminator": int(member.discriminator), +                    "roles": [role.id for role in member.roles], +                    "in_guild": True +                } +                users_to_create.append(new_user)          return _Diff(users_to_create, users_to_update, None) +    async def _get_users(self) -> t.AsyncIterable: +        """GET users from database.""" +        query_params = { +            "page": 1 +        } +        while query_params["page"]: +            res = await self.bot.api_client.get("bot/users", params=query_params) +            for user in res["results"]: +                yield user + +            query_params["page"] = res["next_page_no"] +      async def _sync(self, diff: _Diff) -> None:          """Synchronise the database with the user cache of `guild`."""          log.trace("Syncing created users...") -        for user in diff.created: -            await self.bot.api_client.post('bot/users', json=user._asdict()) +        if diff.created: +            await self.bot.api_client.post("bot/users", json=diff.created)          log.trace("Syncing updated users...") -        for user in diff.updated: -            await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) +        if diff.updated: +            await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index f2a2689e1..4964283f1 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -19,7 +19,7 @@ from bot.constants import (  )  from bot.converters import Duration  from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import send_attachments +from bot.utils.messages import format_user, send_attachments  log = logging.getLogger(__name__) @@ -68,7 +68,7 @@ class DeletionContext:      async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:          """Method that takes care of uploading the queue and posting modlog alert.""" -        triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values()) +        triggered_by_users = ", ".join(format_user(m) for m in self.members.values())          mod_alert_message = (              f"**Triggered by:** {triggered_by_users}\n" diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index b7eb41244..92cdfb8f5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,7 +2,7 @@ import asyncio  import logging  import re  from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Union  import dateutil  import discord.errors @@ -19,6 +19,7 @@ from bot.constants import (      Guild, Icons, URLs  )  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  from bot.utils.regex import INVITE_RE  from bot.utils.scheduling import Scheduler @@ -39,6 +40,16 @@ ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")  DAYS_BETWEEN_ALERTS = 3  OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) +FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] + + +class Stats(NamedTuple): +    """Additional stats on a triggered filter to append to a mod log.""" + +    message_content: str +    additional_embeds: Optional[List[discord.Embed]] +    additional_embeds_msg: Optional[str] +  class Filtering(Cog):      """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" @@ -194,8 +205,8 @@ class Filtering(Cog):              log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")              log_string = ( -                f"**User:** {member.mention} (`{member.id}`)\n" -                f"**Display Name:** {member.display_name}\n" +                f"**User:** {format_user(member)}\n" +                f"**Display Name:** {escape_markdown(member.display_name)}\n"                  f"**Bad Matches:** {', '.join(match.group() for match in matches)}"              ) @@ -234,35 +245,8 @@ class Filtering(Cog):                          if _filter["type"] == "filter":                              filter_triggered = True -                        # We do not have to check against DM channels since !eval cannot be used there. -                        channel_str = f"in {msg.channel.mention}" - -                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( -                            filter_name, match, result -                        ) - -                        message = ( -                            f"The {filter_name} {_filter['type']} was triggered " -                            f"by **{msg.author}** " -                            f"(`{msg.author.id}`) {channel_str} using !eval with " -                            f"[the following message]({msg.jump_url}):\n\n" -                            f"{message_content}" -                        ) - -                        log.debug(message) - -                        # Send pretty mod log embed to mod-alerts -                        await self.mod_log.send_log_message( -                            icon_url=Icons.filtering, -                            colour=Colour(Colours.soft_red), -                            title=f"{_filter['type'].title()} triggered!", -                            text=message, -                            thumbnail=msg.author.avatar_url_as(static_format="png"), -                            channel_id=Channels.mod_alerts, -                            ping_everyone=Filter.ping_everyone, -                            additional_embeds=additional_embeds, -                            additional_embeds_msg=additional_embeds_msg -                        ) +                        stats = self._add_stats(filter_name, match, result) +                        await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True)                          break  # We don't want multiple filters to trigger @@ -332,46 +316,52 @@ class Filtering(Cog):                                  self.schedule_msg_delete(data)                                  log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") -                        if is_private: -                            channel_str = "via DM" -                        else: -                            channel_str = f"in {msg.channel.mention}" +                        stats = self._add_stats(filter_name, match, msg.content) +                        await self._send_log(filter_name, _filter, msg, stats) -                        message_content, additional_embeds, additional_embeds_msg = self._add_stats( -                            filter_name, match, msg.content -                        ) - -                        message = ( -                            f"The {filter_name} {_filter['type']} was triggered " -                            f"by **{msg.author}** " -                            f"(`{msg.author.id}`) {channel_str} with [the " -                            f"following message]({msg.jump_url}):\n\n" -                            f"{message_content}" -                        ) +                        break  # We don't want multiple filters to trigger -                        log.debug(message) - -                        # Allow specific filters to override ping_everyone -                        ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) - -                        # Send pretty mod log embed to mod-alerts -                        await self.mod_log.send_log_message( -                            icon_url=Icons.filtering, -                            colour=Colour(Colours.soft_red), -                            title=f"{_filter['type'].title()} triggered!", -                            text=message, -                            thumbnail=msg.author.avatar_url_as(static_format="png"), -                            channel_id=Channels.mod_alerts, -                            ping_everyone=ping_everyone if not is_private else False, -                            additional_embeds=additional_embeds, -                            additional_embeds_msg=additional_embeds_msg -                        ) +    async def _send_log( +        self, +        filter_name: str, +        _filter: Dict[str, Any], +        msg: discord.Message, +        stats: Stats, +        *, +        is_eval: bool = False, +    ) -> None: +        """Send a mod log for a triggered filter.""" +        if msg.channel.type is discord.ChannelType.private: +            channel_str = "via DM" +            ping_everyone = False +        else: +            channel_str = f"in {msg.channel.mention}" +            # Allow specific filters to override ping_everyone +            ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) + +        eval_msg = "using !eval " if is_eval else "" +        message = ( +            f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} " +            f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n" +            f"{stats.message_content}" +        ) -                        break  # We don't want multiple filters to trigger +        log.debug(message) + +        # Send pretty mod log embed to mod-alerts +        await self.mod_log.send_log_message( +            icon_url=Icons.filtering, +            colour=Colour(Colours.soft_red), +            title=f"{_filter['type'].title()} triggered!", +            text=message, +            thumbnail=msg.author.avatar_url_as(static_format="png"), +            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: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ -        str, Optional[List[discord.Embed]], Optional[str] -    ]: +    def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:          """Adds relevant statistical information to the relevant filter and increments the bot's stats."""          # Word and match stats for watch_regex          if name == "watch_regex": @@ -408,7 +398,7 @@ class Filtering(Cog):              additional_embeds = match              additional_embeds_msg = "With the following embed(s):" -        return message_content, additional_embeds, additional_embeds_msg +        return Stats(message_content, additional_embeds, additional_embeds_msg)      @staticmethod      def _check_filter(msg: Message) -> bool: diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 0eda3dc6a..bd6a1f97a 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -11,13 +11,19 @@ from bot import utils  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  log = logging.getLogger(__name__)  LOG_MESSAGE = ( -    "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " +    "Censored a seemingly valid token sent by {author} in {channel}, "      "token was `{user_id}.{timestamp}.{hmac}`"  ) +UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." +KNOWN_USER_LOG_MESSAGE = ( +    "Decoded user ID: `{user_id}` **(Present in server)**.\n" +    "This matches `{user_name}` and means this is likely a valid **{kind}** token." +)  DELETION_MESSAGE_TEMPLATE = (      "Hey {mention}! I noticed you posted a seemingly valid Discord API "      "token in your message and have removed your message. " @@ -93,6 +99,7 @@ class TokenRemover(Cog):          await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))          log_message = self.format_log_message(msg, found_token) +        userid_message, mention_everyone = self.format_userid_log_message(msg, found_token)          log.debug(log_message)          # Send pretty mod log embed to mod-alerts @@ -100,19 +107,43 @@ class TokenRemover(Cog):              icon_url=Icons.token_removed,              colour=Colour(Colours.soft_red),              title="Token removed!", -            text=log_message, +            text=log_message + "\n" + userid_message,              thumbnail=msg.author.avatar_url_as(static_format="png"),              channel_id=Channels.mod_alerts, +            ping_everyone=mention_everyone,          )          self.bot.stats.incr("tokens.removed_tokens") +    @classmethod +    def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: +        """ +        Format the portion of the log message that includes details about the detected user ID. + +        If the user is resolved to a member, the format includes the user ID, name, and the +        kind of user detected. + +        If we resolve to a member and it is not a bot, we also return True to ping everyone. + +        Returns a tuple of (log_message, mention_everyone) +        """ +        user_id = cls.extract_user_id(token.user_id) +        user = msg.guild.get_member(user_id) + +        if user: +            return KNOWN_USER_LOG_MESSAGE.format( +                user_id=user_id, +                user_name=str(user), +                kind="BOT" if user.bot else "USER", +            ), not user.bot +        else: +            return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False +      @staticmethod      def format_log_message(msg: Message, token: Token) -> str: -        """Return the log message to send for `token` being censored in `msg`.""" +        """Return the generic portion of the log message to send for `token` being censored in `msg`."""          return LOG_MESSAGE.format( -            author=msg.author, -            author_id=msg.author.id, +            author=format_user(msg.author),              channel=msg.channel.mention,              user_id=token.user_id,              timestamp=token.timestamp, @@ -126,7 +157,11 @@ class TokenRemover(Cog):          # token check (e.g. `message.channel.send` also matches our token pattern)          for match in TOKEN_RE.finditer(msg.content):              token = Token(*match.groups()) -            if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): +            if ( +                (cls.extract_user_id(token.user_id) is not None) +                and cls.is_valid_timestamp(token.timestamp) +                and cls.is_maybe_valid_hmac(token.hmac) +            ):                  # Short-circuit on first match                  return token @@ -134,22 +169,20 @@ class TokenRemover(Cog):          return      @staticmethod -    def is_valid_user_id(b64_content: str) -> bool: -        """ -        Check potential token to see if it contains a valid Discord user ID. - -        See: https://discordapp.com/developers/docs/reference#snowflakes -        """ +    def extract_user_id(b64_content: str) -> t.Optional[int]: +        """Return a user ID integer from part of a potential token, or None if it couldn't be decoded."""          b64_content = utils.pad_base64(b64_content)          try:              decoded_bytes = base64.urlsafe_b64decode(b64_content)              string = decoded_bytes.decode('utf-8') - -            # isdigit on its own would match a lot of other Unicode characters, hence the isascii. -            return string.isascii() and string.isdigit() +            if not (string.isascii() and string.isdigit()): +                # This case triggers if there are fancy unicode digits in the base64 encoding, +                # that means it's not a valid user id. +                return None +            return int(string)          except (binascii.Error, ValueError): -            return False +            return None      @staticmethod      def is_valid_timestamp(b64_content: str) -> bool: @@ -176,6 +209,24 @@ class TokenRemover(Cog):              log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch")              return False +    @staticmethod +    def is_maybe_valid_hmac(b64_content: str) -> bool: +        """ +        Determine if a given HMAC portion of a token is potentially valid. + +        If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", +        and thus the token can probably be skipped. +        """ +        unique = len(set(b64_content.lower())) +        if unique <= 3: +            log.debug( +                f"Considering the HMAC {b64_content} a dummy because it has {unique}" +                " case-insensitively unique characters" +            ) +            return False +        else: +            return True +  def setup(bot: Bot) -> None:      """Load the TokenRemover cog.""" diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index ca126ebf5..08fe94055 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog  from bot.bot import Bot  from bot.constants import Channels, Colours, Event, Icons  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) @@ -45,8 +46,8 @@ class WebhookRemover(Cog):          await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))          message = ( -            f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " -            f"to #{msg.channel}. Webhook URL was `{redacted_url}`" +            f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " +            f"Webhook URL was `{redacted_url}`"          )          log.debug(message) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 6c2d22b9c..48aa2749c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -22,6 +22,7 @@ class DuckPond(Cog):          self.bot = bot          self.webhook_id = constants.Webhooks.duck_pond          self.webhook = None +        self.ducked_messages = []          self.bot.loop.create_task(self.fetch_webhook())          self.relay_lock = None @@ -145,6 +146,10 @@ class DuckPond(Cog):          amount of ducks specified in the config under duck_pond/threshold, it will          send the message off to the duck pond.          """ +        # Ignore other guilds and DMs. +        if payload.guild_id != constants.Guild.id: +            return +          # Was this reaction issued in a blacklisted channel?          if payload.channel_id in constants.DuckPond.channel_blacklist:              return @@ -154,6 +159,9 @@ class DuckPond(Cog):              return          channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) +        if channel is None: +            return +          message = await channel.fetch_message(payload.message_id)          member = discord.utils.get(message.guild.members, id=payload.user_id) @@ -169,13 +177,20 @@ class DuckPond(Cog):          duck_count = await self.count_ducks(message)          # If we've got more than the required amount of ducks, send the message to the duck_pond. -        if duck_count >= constants.DuckPond.threshold: +        if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: +            self.ducked_messages.append(message.id)              await self.locked_relay(message)      @Cog.listener()      async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:          """Ensure that people don't remove the green checkmark from duck ponded messages.""" +        # Ignore other guilds and DMs. +        if payload.guild_id != constants.Guild.id: +            return +          channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) +        if channel is None: +            return          # Prevent the green checkmark from being removed          if payload.emoji.name == "✅": diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index b9d235fa2..7fc93b88c 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,10 +1,10 @@ -import asyncio  import difflib  import logging  from datetime import datetime, timedelta  from discord import Colour, Embed  from discord.ext.commands import Cog, Context, group, has_any_role +from discord.utils import sleep_until  from bot.api import ResponseCodeError  from bot.bot import Bot @@ -23,8 +23,7 @@ async def update_names(bot: Bot) -> None:          # we go past midnight in the `seconds_to_sleep` set below.          today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)          next_midnight = today_at_midnight + timedelta(days=1) -        seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 -        await asyncio.sleep(seconds_to_sleep) +        await sleep_until(next_midnight)          try:              channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 9e33a6aba..f5c9a5dd0 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -494,7 +494,7 @@ class HelpChannels(commands.Cog):          If `options` are provided, the channel will be edited after the move is completed. This is the          same order of operations that `discord.TextChannel.edit` uses. For information on available -        options, see the documention on `discord.TextChannel.edit`. While possible, position-related +        options, 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. diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index e50b9b32b..c16a99225 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -345,7 +345,7 @@ class Doc(commands.Cog):      @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)      async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:          """Lookup documentation for Python symbols.""" -        await ctx.invoke(self.get_command, symbol) +        await self.get_command(ctx, symbol)      @docs_group.command(name='get', aliases=('g',))      async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..599c5d5c0 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -229,7 +229,7 @@ class CustomHelpCommand(HelpCommand):      async def send_cog_help(self, cog: Cog) -> None:          """Send help for a cog.""" -        # sort commands by name, and remove any the user cant run or are hidden. +        # sort commands by name, and remove any the user can't run or are hidden.          commands_ = await self.filter_commands(cog.get_commands(), sort=True)          embed = Embed() diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 581b3a227..0f50138e7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,16 +6,15 @@ from collections import Counter, defaultdict  from string import Template  from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils  from discord.abc import GuildChannel  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role -from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot  from bot.decorators import in_whitelist  from bot.pagination import LinePaginator -from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, has_no_roles_check +from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check  from bot.utils.time import time_since  log = logging.getLogger(__name__) @@ -77,7 +76,7 @@ class Information(Cog):          channel_type_list = sorted(channel_type_list)          return "\n".join(channel_type_list) -    @has_any_role(*constants.MODERATION_ROLES) +    @has_any_role(*constants.STAFF_ROLES)      @command(name="roles")      async def roles_info(self, ctx: Context) -> None:          """Returns a list of all roles and their corresponding IDs.""" @@ -97,7 +96,7 @@ class Information(Cog):          await LinePaginator.paginate(role_list, ctx, embed, empty=False) -    @has_any_role(*constants.MODERATION_ROLES) +    @has_any_role(*constants.STAFF_ROLES)      @command(name="role")      async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:          """ @@ -153,7 +152,9 @@ class Information(Cog):          channel_counts = self.get_channel_type_counts(ctx.guild)          # How many of each user status? -        statuses = Counter(member.status for member in ctx.guild.members) +        py_invite = await self.bot.fetch_invite(constants.Guild.invite) +        online_presences = py_invite.approximate_presence_count +        offline_presences = py_invite.approximate_member_count - online_presences          embed = Embed(colour=Colour.blurple())          # How many staff members and staff channels do we have? @@ -161,9 +162,9 @@ class Information(Cog):          staff_channel_count = self.get_staff_channel_count(ctx.guild)          # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the -        # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting -        # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts -        # after the dedent is made. +        # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the +        # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted +        # channel_counts after the dedent is made.          embed.description = Template(              textwrap.dedent(f"""                  **Server information** @@ -181,10 +182,8 @@ class Information(Cog):                  Roles: {roles}                  **Member statuses** -                {constants.Emojis.status_online} {statuses[Status.online]:,} -                {constants.Emojis.status_idle} {statuses[Status.idle]:,} -                {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} -                {constants.Emojis.status_offline} {statuses[Status.offline]:,} +                {constants.Emojis.status_online} {online_presences:,} +                {constants.Emojis.status_offline} {offline_presences:,}              """)          ).substitute({"channel_counts": channel_counts})          embed.set_thumbnail(url=ctx.guild.icon_url) @@ -202,38 +201,15 @@ class Information(Cog):              await ctx.send("You may not use this command on users other than yourself.")              return -        # Non-staff may only do this in #bot-commands -        if await has_no_roles_check(ctx, *constants.STAFF_ROLES): -            if not ctx.channel.id == constants.Channels.bot_commands: -                raise InWhitelistCheckFailure(constants.Channels.bot_commands) - -        embed = await self.create_user_embed(ctx, user) - -        await ctx.send(embed=embed) +        # Will redirect to #bot-commands if it fails. +        if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): +            embed = await self.create_user_embed(ctx, user) +            await ctx.send(embed=embed)      async def create_user_embed(self, ctx: Context, user: Member) -> Embed:          """Creates an embed containing information on the `user`."""          created = time_since(user.created_at, max_units=3) -        # Custom status -        custom_status = '' -        for activity in user.activities: -            if isinstance(activity, CustomActivity): -                state = "" - -                if activity.name: -                    state = escape_markdown(activity.name) - -                emoji = "" -                if activity.emoji: -                    # If an emoji is unicode use the emoji, else write the emote like :abc: -                    if not activity.emoji.id: -                        emoji += activity.emoji.name + " " -                    else: -                        emoji += f"`:{activity.emoji.name}:` " - -                custom_status = f'Status: {emoji}{state}\n' -          name = str(user)          if user.nick:              name = f"{user.nick} ({name})" @@ -247,10 +223,6 @@ class Information(Cog):          joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:]) -        desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) -        web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) -        mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) -          fields = [              (                  "User information", @@ -258,7 +230,6 @@ class Information(Cog):                      Created: {created}                      Profile: {user.mention}                      ID: {user.id} -                    {custom_status}                  """).strip()              ),              ( @@ -268,18 +239,16 @@ class Information(Cog):                      Roles: {roles or None}                  """).strip()              ), -            ( -                "Status", -                textwrap.dedent(f""" -                    {desktop_status} Desktop -                    {web_status} Web -                    {mobile_status} Mobile -                """).strip() -            )          ] +        # Use getattr to future-proof for commands invoked via DMs. +        show_verbose = ( +            ctx.channel.id in constants.MODERATION_CHANNELS +            or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail +        ) +          # Show more verbose output in moderation channels for infractions and nominations -        if ctx.channel.id in constants.MODERATION_CHANNELS: +        if show_verbose:              fields.append(await self.expanded_user_infraction_counts(user))              fields.append(await self.user_nomination_counts(user))          else: diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 0a49e53e7..bad4c504d 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -10,7 +10,7 @@ from aiohttp import BasicAuth, ClientError  from discord import Colour, Embed, TextChannel  from discord.ext.commands import Cog, Context, group, has_any_role  from discord.ext.tasks import loop -from discord.utils import escape_markdown +from discord.utils import escape_markdown, sleep_until  from bot.bot import Bot  from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -205,13 +205,13 @@ class Reddit(Cog):      @loop()      async def auto_poster_loop(self) -> None:          """Post the top 5 posts daily, and the top 5 posts weekly.""" -        # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter +        # once d.py get support for `time` parameter in loop decorator, +        # this can be removed and the loop can use the `time=datetime.time.min` parameter          now = datetime.utcnow()          tomorrow = now + timedelta(days=1)          midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) -        seconds_until = (midnight_tomorrow - now).total_seconds() -        await asyncio.sleep(seconds_until) +        await sleep_until(midnight_tomorrow)          await self.bot.wait_until_guild_available()          if not self.webhook: diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 2d3a3d9f3..fb5b99086 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,7 +1,7 @@  import logging  from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot  from bot.constants import URLs @@ -105,10 +105,9 @@ class Site(Cog):          await ctx.send(embed=embed)      @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) -    async def site_rules(self, ctx: Context, *rules: int) -> None: +    async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None:          """Provides a link to all rules or, if specified, displays specific rule(s).""" -        rules_embed = Embed(title='Rules', color=Colour.blurple()) -        rules_embed.url = f"{PAGES_URL}/rules" +        rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules')          if not rules:              # Rules were not submitted. Return the default description. @@ -122,15 +121,13 @@ class Site(Cog):              return          full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) -        invalid_indices = tuple( -            pick -            for pick in rules -            if pick < 1 or pick > len(full_rules) -        ) -        if invalid_indices: -            indices = ', '.join(map(str, invalid_indices)) -            await ctx.send(f":x: Invalid rule indices: {indices}") +        # Remove duplicates and sort the rule indices +        rules = sorted(set(rules)) +        invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) + +        if invalid: +            await ctx.send(f":x: Invalid rule indices: {invalid}")              return          for rule in rules: diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 205e0ba81..7b41352d4 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,7 +2,7 @@ import inspect  from pathlib import Path  from typing import Optional, Tuple, Union -from discord import Embed +from discord import Embed, utils  from discord.ext import commands  from bot.bot import Bot @@ -35,8 +35,10 @@ class SourceConverter(commands.Converter):          elif argument.lower() in tags_cog._cache:              return argument.lower() +        escaped_arg = utils.escape_markdown(argument) +          raise commands.BadArgument( -            f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." +            f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."          ) @@ -66,14 +68,8 @@ class BotSource(commands.Cog):          Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).          """          if isinstance(source_item, commands.Command): -            if source_item.cog_name == "Alias": -                cmd_name = source_item.callback.__name__.replace("_alias", "") -                cmd = self.bot.get_command(cmd_name.replace("_", " ")) -                src = cmd.callback.__code__ -                filename = src.co_filename -            else: -                src = source_item.callback.__code__ -                filename = src.co_filename +            src = source_item.callback.__code__ +            filename = src.co_filename          elif isinstance(source_item, str):              tags_cog = self.bot.get_cog("Tags")              filename = tags_cog._cache[source_item]["location"] @@ -113,13 +109,7 @@ class BotSource(commands.Cog):              title = "Help Command"              description = source_object.__doc__.splitlines()[1]          elif isinstance(source_object, commands.Command): -            if source_object.cog_name == "Alias": -                cmd_name = source_object.callback.__name__.replace("_alias", "") -                cmd = self.bot.get_command(cmd_name.replace("_", " ")) -                description = cmd.short_doc -            else: -                description = source_object.short_doc - +            description = source_object.short_doc              title = f"Command: {source_object.qualified_name}"          elif isinstance(source_object, str):              title = f"Tag: {source_object}" diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index d42f55466..21aa91873 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -1,12 +1,11 @@  import string -from datetime import datetime -from discord import Member, Message, Status +from discord import Member, Message  from discord.ext.commands import Cog, Context  from discord.ext.tasks import loop  from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.constants import Categories, Channels, Guild  CHANNEL_NAME_OVERRIDES = { @@ -79,38 +78,6 @@ class Stats(Cog):          self.bot.stats.gauge("guild.total_members", len(member.guild.members)) -    @Cog.listener() -    async def on_member_update(self, _before: Member, after: Member) -> None: -        """Update presence estimates on member update.""" -        if after.guild.id != Guild.id: -            return - -        if self.last_presence_update: -            if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: -                return - -        self.last_presence_update = datetime.now() - -        online = 0 -        idle = 0 -        dnd = 0 -        offline = 0 - -        for member in after.guild.members: -            if member.status is Status.online: -                online += 1 -            elif member.status is Status.dnd: -                dnd += 1 -            elif member.status is Status.idle: -                idle += 1 -            elif member.status is Status.offline: -                offline += 1 - -        self.bot.stats.gauge("guild.status.online", online) -        self.bot.stats.gauge("guild.status.idle", idle) -        self.bot.stats.gauge("guild.status.do_not_disturb", dnd) -        self.bot.stats.gauge("guild.status.offline", offline) -      @loop(hours=1)      async def update_guild_boost(self) -> None:          """Post the server boost level and tier every hour.""" diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d01647312..ae95ac1ef 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -160,7 +160,7 @@ class Tags(Cog):      @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)      async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:          """Show all known tags, a single tag, or run a subcommand.""" -        await ctx.invoke(self.get_command, tag_name=tag_name) +        await self.get_command(ctx, tag_name=tag_name)      @tags_group.group(name='search', invoke_without_command=True)      async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 3bf462877..caa6fb917 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role  from bot.bot import Bot  from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  log = logging.getLogger(__name__) @@ -106,7 +107,7 @@ class Defcon(Cog):                  self.bot.stats.incr("defcon.leaves")                  message = ( -                    f"{member} (`{member.id}`) was denied entry because their account is too new." +                    f"{format_user(member)} was denied entry because their account is too new."                  )                  if not message_sent: diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 14263e004..4d5142b55 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -90,7 +90,11 @@ class DMRelay(Cog):          # Handle any attachments          if message.attachments:              try: -                await send_attachments(message, self.webhook) +                await send_attachments( +                    message, +                    self.webhook, +                    username=f"{message.author.display_name} ({message.author.id})" +                )              except (discord.errors.Forbidden, discord.errors.NotFound):                  e = discord.Embed(                      description=":x: **This message contained an attachment, but it could not be retrieved**", diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e49913552..0e479d33f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -237,7 +237,7 @@ class Incidents(Cog):          not all information was relayed, return False. This signals that the original          message is not safe to be deleted, as we will lose some information.          """ -        log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") +        log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})")          embed, attachment_file = await make_embed(incident, outcome, actioned_by)          try: @@ -319,7 +319,7 @@ class Incidents(Cog):          try:              await confirmation_task          except asyncio.TimeoutError: -            log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") +            log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!")          else:              log.trace("Deletion was confirmed") diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index cf48ef2ac..814b17830 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,12 +12,11 @@ from discord.ext.commands import Context  from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.constants import Colours, STAFF_CHANNELS +from bot.constants import Colours, MODERATION_CHANNELS  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._utils import UserSnowflake  from bot.exts.moderation.modlog import ModLog -from bot.utils import time -from bot.utils.scheduling import Scheduler +from bot.utils import messages, scheduling, time  log = logging.getLogger(__name__) @@ -27,7 +26,7 @@ class InfractionScheduler:      def __init__(self, bot: Bot, supported_infractions: t.Container[str]):          self.bot = bot -        self.scheduler = Scheduler(self.__class__.__name__) +        self.scheduler = scheduling.Scheduler(self.__class__.__name__)          self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @@ -137,9 +136,9 @@ class InfractionScheduler:              )              if reason:                  end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" -        elif ctx.channel.id not in STAFF_CHANNELS: +        elif ctx.channel.id not in MODERATION_CHANNELS:              log.trace( -                f"Infraction #{id_} context is not in a staff channel; omitting infraction count." +                f"Infraction #{id_} context is not in a mod channel; omitting infraction count."              )          else:              log.trace(f"Fetching total infraction count for {user}.") @@ -199,8 +198,8 @@ class InfractionScheduler:              title=f"Infraction {log_title}: {infr_type}",              thumbnail=user.avatar_url_as(static_format="png"),              text=textwrap.dedent(f""" -                Member: {user.mention} (`{user.id}`) -                Actor: {ctx.author}{dm_log_text}{expiry_log_text} +                Member: {messages.format_user(user)} +                Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}                  Reason: {reason}              """),              content=log_content, @@ -243,48 +242,12 @@ class InfractionScheduler:          # Deactivate the infraction and cancel its scheduled expiration task.          log_text = await self.deactivate_infraction(response[0], send_log=False) -        log_text["Member"] = f"{user.mention}(`{user.id}`)" -        log_text["Actor"] = str(ctx.author) +        log_text["Member"] = messages.format_user(user) +        log_text["Actor"] = ctx.author.mention          log_content = None          id_ = response[0]['id']          footer = f"ID: {id_}" -        # If multiple active infractions were found, mark them as inactive in the database -        # and cancel their expiration tasks. -        if len(response) > 1: -            log.info( -                f"Found more than one active {infr_type} infraction for user {user.id}; " -                "deactivating the extra active infractions too." -            ) - -            footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}" - -            log_note = f"Found multiple **active** {infr_type} infractions in the database." -            if "Note" in log_text: -                log_text["Note"] = f" {log_note}" -            else: -                log_text["Note"] = log_note - -            # deactivate_infraction() is not called again because: -            #     1. Discord cannot store multiple active bans or assign multiples of the same role -            #     2. It would send a pardon DM for each active infraction, which is redundant -            for infraction in response[1:]: -                id_ = infraction['id'] -                try: -                    # Mark infraction as inactive in the database. -                    await self.bot.api_client.patch( -                        f"bot/infractions/{id_}", -                        json={"active": False} -                    ) -                except ResponseCodeError: -                    log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})") -                    # This is simpler and cleaner than trying to concatenate all the errors. -                    log_text["Failure"] = "See bot's logs for details." - -                # Cancel pending expiration task. -                if infraction["expires_at"] is not None: -                    self.scheduler.cancel(infraction["id"]) -          # Accordingly display whether the user was successfully notified via DM.          dm_emoji = ""          if log_text.get("DM") == "Sent": @@ -358,7 +321,7 @@ class InfractionScheduler:          log_content = None          log_text = {              "Member": f"<@{user_id}>", -            "Actor": str(self.bot.get_user(actor) or actor), +            "Actor": f"<@{actor}>",              "Reason": infraction["reason"],              "Created": created,          } diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 5fa62d3c4..7cf7075e6 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,6 +15,7 @@ from bot.decorators import respect_role_hierarchy  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler  from bot.exts.moderation.infraction._utils import UserSnowflake +from bot.utils.messages import format_user  log = logging.getLogger(__name__) @@ -70,6 +71,23 @@ class Infractions(InfractionScheduler, commands.Cog):          """Permanently ban a user for the given reason and stop watching them with Big Brother."""          await self.apply_ban(ctx, user, reason) +    @command(aliases=('pban',)) +    async def purgeban( +        self, +        ctx: Context, +        user: FetchedMember, +        purge_days: t.Optional[int] = 1, +        *, +        reason: t.Optional[str] = None +    ) -> None: +        """ +        Same as ban but removes all their messages for the given number of days, default being 1. + +        `purge_days` can only be values between 0 and 7. +        Anything outside these bounds are automatically adjusted to their respective limits. +        """ +        await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) +      # endregion      # region: Temporary infractions @@ -229,7 +247,7 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user, action()) -    @respect_role_hierarchy() +    @respect_role_hierarchy(member_arg=2)      async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:          """Apply a kick infraction with kwargs passed to `post_infraction`."""          infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -244,8 +262,15 @@ class Infractions(InfractionScheduler, commands.Cog):          action = user.kick(reason=reason)          await self.apply_infraction(ctx, infraction, user, action) -    @respect_role_hierarchy() -    async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: +    @respect_role_hierarchy(member_arg=2) +    async def apply_ban( +        self, +        ctx: Context, +        user: UserSnowflake, +        reason: t.Optional[str], +        purge_days: t.Optional[int] = 0, +        **kwargs +    ) -> None:          """          Apply a ban infraction with kwargs passed to `post_infraction`. @@ -277,7 +302,7 @@ class Infractions(InfractionScheduler, commands.Cog):          if reason:              reason = textwrap.shorten(reason, width=512, placeholder="...") -        action = ctx.guild.ban(user, reason=reason, delete_message_days=0) +        action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days)          await self.apply_infraction(ctx, infraction, user, action)          if infraction.get('expires_at') is not None: @@ -315,7 +340,7 @@ class Infractions(InfractionScheduler, commands.Cog):                  icon_url=_utils.INFRACTION_ICONS["mute"][1]              ) -            log_text["Member"] = f"{user.mention}(`{user.id}`)" +            log_text["Member"] = format_user(user)              log_text["DM"] = "Sent" if notified else "**Failed**"          else:              log.info(f"Failed to unmute user {user_id}: user not found") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 15ee28537..cdab1a6c7 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -6,15 +6,15 @@ from datetime import datetime  import discord  from discord.ext import commands  from discord.ext.commands import Context +from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot -from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user -from bot.exts.moderation.infraction import _utils +from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user  from bot.exts.moderation.infraction.infractions import Infractions  from bot.exts.moderation.modlog import ModLog  from bot.pagination import LinePaginator -from bot.utils import time +from bot.utils import messages, time  from bot.utils.checks import in_whitelist_check  log = logging.getLogger(__name__) @@ -154,16 +154,12 @@ class ModManagement(commands.Cog):          user = ctx.guild.get_member(user_id)          if user: -            user_text = f"{user.mention} (`{user.id}`)" +            user_text = messages.format_user(user)              thumbnail = user.avatar_url_as(static_format="png")          else: -            user_text = f"`{user_id}`" +            user_text = f"<@{user_id}>"              thumbnail = None -        # The infraction's actor -        actor_id = new_infraction['actor'] -        actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`" -          await self.mod_log.send_log_message(              icon_url=constants.Icons.pencil,              colour=discord.Colour.blurple(), @@ -171,8 +167,8 @@ class ModManagement(commands.Cog):              thumbnail=thumbnail,              text=textwrap.dedent(f"""                  Member: {user_text} -                Actor: {actor} -                Edited by: {ctx.message.author}{log_text} +                Actor: <@{new_infraction['actor']}> +                Edited by: {ctx.message.author.mention}{log_text}              """)          ) @@ -180,20 +176,27 @@ class ModManagement(commands.Cog):      # region: Search infractions      @infraction_group.group(name="search", invoke_without_command=True) -    async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: +    async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:          """Searches for infractions in the database.""" -        if isinstance(query, discord.User): -            await ctx.invoke(self.search_user, query) +        if isinstance(query, int): +            await self.search_user(ctx, discord.Object(query))          else: -            await ctx.invoke(self.search_reason, query) +            await self.search_reason(ctx, query)      @infraction_search_group.command(name="user", aliases=("member", "id"))      async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:          """Search for infractions by member."""          infraction_list = await self.bot.api_client.get( -            'bot/infractions', +            'bot/infractions/expanded',              params={'user__id': str(user.id)}          ) + +        user = self.bot.get_user(user.id) +        if not user and infraction_list: +            # Use the user data retrieved from the DB for the username. +            user = infraction_list[0]["user"] +            user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" +          embed = discord.Embed(              title=f"Infractions for {user} ({len(infraction_list)} total)",              colour=discord.Colour.orange() @@ -204,7 +207,7 @@ class ModManagement(commands.Cog):      async def search_reason(self, ctx: Context, reason: str) -> None:          """Search for infractions by their reason. Use Re2 for matching."""          infraction_list = await self.bot.api_client.get( -            'bot/infractions', +            'bot/infractions/expanded',              params={'search': reason}          )          embed = discord.Embed( @@ -220,7 +223,7 @@ class ModManagement(commands.Cog):          self,          ctx: Context,          embed: discord.Embed, -        infractions: t.Iterable[_utils.Infraction] +        infractions: t.Iterable[t.Dict[str, t.Any]]      ) -> None:          """Send a paginated embed of infractions for the specified user."""          if not infractions: @@ -241,37 +244,43 @@ class ModManagement(commands.Cog):              max_size=1000          ) -    def infraction_to_string(self, infraction: _utils.Infraction) -> str: +    def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str:          """Convert the infraction object to a string representation.""" -        actor_id = infraction["actor"] -        guild = self.bot.get_guild(constants.Guild.id) -        actor = guild.get_member(actor_id)          active = infraction["active"] -        user_id = infraction["user"] -        hidden = infraction["hidden"] +        user = infraction["user"] +        expires_at = infraction["expires_at"]          created = time.format_infraction(infraction["inserted_at"]) +        # Format the user string. +        if user_obj := self.bot.get_user(user["id"]): +            # The user is in the cache. +            user_str = messages.format_user(user_obj) +        else: +            # Use the user data retrieved from the DB. +            name = escape_markdown(user['name']) +            user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})" +          if active: -            remaining = time.until_expiration(infraction["expires_at"]) or "Expired" +            remaining = time.until_expiration(expires_at) or "Expired"          else:              remaining = "Inactive" -        if infraction["expires_at"] is None: +        if expires_at is None:              expires = "*Permanent*"          else:              date_from = datetime.strptime(created, time.INFRACTION_FORMAT) -            expires = time.format_infraction_with_duration(infraction["expires_at"], date_from) +            expires = time.format_infraction_with_duration(expires_at, date_from)          lines = textwrap.dedent(f"""              {"**===============**" if active else "==============="}              Status: {"__**Active**__" if active else "Inactive"} -            User: {self.bot.get_user(user_id)} (`{user_id}`) +            User: {user_str}              Type: **{infraction["type"]}** -            Shadow: {hidden} +            Shadow: {infraction["hidden"]}              Created: {created}              Expires: {expires}              Remaining: {remaining} -            Actor: {actor.mention if actor else actor_id} +            Actor: <@{infraction["actor"]["id"]}>              ID: `{infraction["id"]}`              Reason: {infraction["reason"] or "*None*"}              {"**===============**" if active else "==============="} diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 29f41f2ab..adfe42fcd 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -7,12 +7,14 @@ from pathlib import Path  from discord import Colour, Embed, Member  from discord.ext.commands import Cog, Context, command, has_any_role +from discord.utils import escape_markdown  from bot import constants  from bot.bot import Bot  from bot.converters import Expiry  from bot.exts.moderation.infraction import _utils  from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.utils.messages import format_user  from bot.utils.time import format_infraction  log = logging.getLogger(__name__) @@ -133,11 +135,11 @@ class Superstarify(InfractionScheduler, Cog):              return          # Post the infraction to the API -        reason = reason or f"old nick: {member.display_name}" +        old_nick = member.display_name +        reason = reason or f"old nick: {old_nick}"          infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)          id_ = infraction["id"] -        old_nick = member.display_name          forced_nick = self.get_nick(id_, member.id)          expiry_str = format_infraction(infraction["expires_at"]) @@ -147,6 +149,9 @@ class Superstarify(InfractionScheduler, Cog):          await member.edit(nick=forced_nick, reason=reason)          self.schedule_expiration(infraction) +        old_nick = escape_markdown(old_nick) +        forced_nick = escape_markdown(forced_nick) +          # Send a DM to the user to notify them of their new infraction.          await _utils.notify_infraction(              user=member, @@ -180,8 +185,8 @@ class Superstarify(InfractionScheduler, Cog):              title="Member achieved superstardom",              thumbnail=member.avatar_url_as(static_format="png"),              text=textwrap.dedent(f""" -                Member: {member.mention} (`{member.id}`) -                Actor: {ctx.message.author} +                Member: {member.mention} +                Actor: {ctx.message.author.mention}                  Expires: {expiry_str}                  Old nickname: `{old_nick}`                  New nickname: `{forced_nick}` @@ -220,7 +225,7 @@ class Superstarify(InfractionScheduler, Cog):          )          return { -            "Member": f"{user.mention}(`{user.id}`)", +            "Member": format_user(user),              "DM": "Sent" if notified else "**Failed**"          } diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b0d9b5b2b..b01de0ee3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -12,10 +12,10 @@ from deepdiff import DeepDiff  from discord import Colour  from discord.abc import GuildChannel  from discord.ext.commands import Cog, Context -from discord.utils import escape_markdown  from bot.bot import Bot  from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.utils.messages import format_user  from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) @@ -63,7 +63,7 @@ class ModLog(Cog, name="ModLog"):                          'id': message.id,                          'author': message.author.id,                          'channel_id': message.channel.id, -                        'content': message.content, +                        'content': message.content.replace("\0", ""),  # Null chars cause 400.                          'embeds': [embed.to_dict() for embed in message.embeds],                          'attachments': attachment,                      } @@ -396,7 +396,7 @@ class ModLog(Cog, name="ModLog"):          await self.send_log_message(              Icons.user_ban, Colours.soft_red, -            "User banned", f"{member} (`{member.id}`)", +            "User banned", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.user_log          ) @@ -407,12 +407,10 @@ class ModLog(Cog, name="ModLog"):          if member.guild.id != GuildConstant.id:              return -        member_str = escape_markdown(str(member)) -        message = f"{member_str} (`{member.id}`)"          now = datetime.utcnow()          difference = abs(relativedelta(now, member.created_at)) -        message += "\n\n**Account age:** " + humanize_delta(difference) +        message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)          if difference.days < 1 and difference.months < 1 and difference.years < 1:  # New user account!              message = f"{Emojis.new} {message}" @@ -434,10 +432,9 @@ class ModLog(Cog, name="ModLog"):              self._ignored[Event.member_remove].remove(member.id)              return -        member_str = escape_markdown(str(member))          await self.send_log_message(              Icons.sign_out, Colours.soft_red, -            "User left", f"{member_str} (`{member.id}`)", +            "User left", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.user_log          ) @@ -452,10 +449,9 @@ class ModLog(Cog, name="ModLog"):              self._ignored[Event.member_unban].remove(member.id)              return -        member_str = escape_markdown(str(member))          await self.send_log_message(              Icons.user_unban, Colour.blurple(), -            "User unbanned", f"{member_str} (`{member.id}`)", +            "User unbanned", format_user(member),              thumbnail=member.avatar_url_as(static_format="png"),              channel_id=Channels.mod_log          ) @@ -515,8 +511,7 @@ class ModLog(Cog, name="ModLog"):          for item in sorted(changes):              message += f"{Emojis.bullet} {item}\n" -        member_str = escape_markdown(str(after)) -        message = f"**{member_str}** (`{after.id}`)\n{message}" +        message = f"{format_user(after)}\n{message}"          await self.send_log_message(              icon_url=Icons.user_update, @@ -549,17 +544,16 @@ class ModLog(Cog, name="ModLog"):          if author.bot:              return -        author_str = escape_markdown(str(author))          if channel.category:              response = ( -                f"**Author:** {author_str} (`{author.id}`)\n" +                f"**Author:** {format_user(author)}\n"                  f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n"              )          else:              response = ( -                f"**Author:** {author_str} (`{author.id}`)\n" +                f"**Author:** {format_user(author)}\n"                  f"**Channel:** #{channel.name} (`{channel.id}`)\n"                  f"**Message ID:** `{message.id}`\n"                  "\n" @@ -645,9 +639,6 @@ class ModLog(Cog, name="ModLog"):          if msg_before.content == msg_after.content:              return -        author = msg_before.author -        author_str = escape_markdown(str(author)) -          channel = msg_before.channel          channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" @@ -679,7 +670,7 @@ class ModLog(Cog, name="ModLog"):                  content_after.append(sub)          response = ( -            f"**Author:** {author_str} (`{author.id}`)\n" +            f"**Author:** {format_user(msg_before.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{msg_before.id}`\n"              "\n" @@ -731,12 +722,11 @@ class ModLog(Cog, name="ModLog"):              self._cached_edits.remove(event.message_id)              return -        author = message.author          channel = message.channel          channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"          before_response = ( -            f"**Author:** {author} (`{author.id}`)\n" +            f"**Author:** {format_user(message.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{message.id}`\n"              "\n" @@ -744,7 +734,7 @@ class ModLog(Cog, name="ModLog"):          )          after_response = ( -            f"**Author:** {author} (`{author.id}`)\n" +            f"**Author:** {format_user(message.author)}\n"              f"**Channel:** {channel_name} (`{channel.id}`)\n"              f"**Message ID:** `{message.id}`\n"              "\n" @@ -822,9 +812,8 @@ class ModLog(Cog, name="ModLog"):          if not changes:              return -        member_str = escape_markdown(str(member))          message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) -        message = f"**{member_str}** (`{member.id}`)\n{message}" +        message = f"{format_user(member)}\n{message}"          await self.send_log_message(              icon_url=icon, diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 6dad82d1e..d28114298 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -15,17 +15,21 @@ 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  log = logging.getLogger(__name__)  # Sent via DMs once user joins the guild  ON_JOIN_MESSAGE = f""" -Hello! Welcome to Python Discord! +Welcome to Python Discord! -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. +To show you what kind of community we are, we've created this video: +https://youtu.be/ZH26PuX3re0 -In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ -please visit <#{constants.Channels.verification}>. Thank you! +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 @@ -49,6 +53,23 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!  <#{constants.Channels.bot_commands}>.  """ +ALTERNATE_VERIFIED_MESSAGE = f""" +Thanks for accepting our rules! + +You can find a copy of our rules for reference at <https://pythondiscord.com/pages/rules>. + +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}>. + +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 \ @@ -105,6 +126,25 @@ def is_verified(member: discord.Member) -> bool:      return len(set(member.roles) - unverified_roles) > 0 +async def safe_dm(coro: t.Coroutine) -> None: +    """ +    Execute `coro` ignoring disabled DM warnings. + +    The 50_0007 error code indicates that the target user does not accept DMs. +    As it turns out, this error code can appear on both 400 and 403 statuses, +    we therefore catch any Discord exception. + +    If the request fails on any other error code, the exception propagates, +    and must be handled by the caller. +    """ +    try: +        await coro +    except discord.HTTPException as discord_exc: +        log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}") +        if discord_exc.code != 50_007:  # If any reason other than disabled DMs +            raise + +  class Verification(Cog):      """      User verification and role management. @@ -133,6 +173,9 @@ class Verification(Cog):      # ]      task_cache = RedisCache() +    # Create a cache for storing recipients of the alternate welcome DM. +    member_gating_cache = RedisCache() +      def __init__(self, bot: Bot) -> None:          """Start internal tasks."""          self.bot = bot @@ -285,7 +328,7 @@ class Verification(Cog):          Returns the amount of successful requests. Failed requests are logged at info level.          """ -        log.info(f"Sending {len(members)} requests") +        log.trace(f"Sending {len(members)} requests")          n_success, bad_statuses = 0, set()          for progress, member in enumerate(members, start=1): @@ -326,11 +369,9 @@ class Verification(Cog):          async def kick_request(member: discord.Member) -> None:              """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""              try: -                await member.send(KICKED_MESSAGE) -            except discord.Forbidden as exc_403: -                log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") -                if exc_403.code != 50_007:  # 403 raised for any other reason than disabled DMs -                    raise StopExecution(reason=exc_403) +                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")          n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) @@ -498,9 +539,48 @@ 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 is_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. +        if raw_member.get("is_pending"): +            await self.member_gating_cache.set(member.id, True) + +            # TODO: Temporary, remove soon after asking joe. +            await self.mod_log.send_log_message( +                icon_url=self.bot.user.avatar_url, +                colour=discord.Colour.blurple(), +                title="New native gated user", +                channel_id=constants.Channels.user_log, +                text=f"<@{member.id}> ({member.id})", +            ) + +            return +          log.trace(f"Sending on join message to new member: {member.id}") -        with suppress(discord.Forbidden): -            await member.send(ON_JOIN_MESSAGE) +        try: +            await safe_dm(member.send(ON_JOIN_MESSAGE)) +        except discord.HTTPException: +            log.exception("DM dispatch failed on unexpected error code") + +    @Cog.listener() +    async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: +        """Check if we need to send a verification DM to a gated user.""" +        before_roles = [role.id for role in before.roles] +        after_roles = [role.id for role in after.roles] + +        if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: +            if await self.member_gating_cache.pop(after.id): +                try: +                    # If the member has not received a DM from our !accept command +                    # 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)) +                except discord.HTTPException: +                    log.exception("DM dispatch failed on unexpected error code")      @Cog.listener()      async def on_message(self, message: discord.Message) -> None: @@ -525,7 +605,7 @@ class Verification(Cog):              )              embed_text = ( -                f"{message.author.mention} sent a message in " +                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}"              ) @@ -667,9 +747,9 @@ class Verification(Cog):              await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))          try: -            await ctx.author.send(VERIFIED_MESSAGE) -        except discord.Forbidden: -            log.info(f"Sending welcome message failed for {ctx.author}.") +            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): diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 7ed487d47..ba1fd2a5c 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -130,7 +130,7 @@ class BotCog(Cog, name="Bot"):                      else:                          content = "".join(content[1:]) -                # Strip it again to remove any leading whitespace. This is neccessary +                # Strip it again to remove any leading whitespace. This is necessary                  # if the first line of the message looked like ```python <code>                  old = content.strip() diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 236603dba..bf25cb4c2 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -178,7 +178,8 @@ class Clean(Cog):          target_channels = ", ".join(channel.mention for channel in channels)          message = ( -            f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n" +            f"**{len(message_ids)}** messages deleted in {target_channels} by " +            f"{ctx.author.mention}\n\n"              f"A log of the deleted messages can be found [here]({log_url})."          ) diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/internal.py index 6419b320e..1b4900f42 100644 --- a/bot/exts/utils/eval.py +++ b/bot/exts/utils/internal.py @@ -5,6 +5,8 @@ import pprint  import re  import textwrap  import traceback +from collections import Counter +from datetime import datetime  from io import StringIO  from typing import Any, Optional, Tuple @@ -19,8 +21,8 @@ from bot.utils import find_nth_occurrence, send_to_paste_service  log = logging.getLogger(__name__) -class CodeEval(Cog): -    """Owner and admin feature that evaluates code and returns the result to the channel.""" +class Internal(Cog): +    """Administrator and Core Developer commands."""      def __init__(self, bot: Bot):          self.bot = bot @@ -30,6 +32,17 @@ class CodeEval(Cog):          self.interpreter = Interpreter(bot) +        self.socket_since = datetime.utcnow() +        self.socket_event_total = 0 +        self.socket_events = Counter() + +    @Cog.listener() +    async def on_socket_response(self, msg: dict) -> None: +        """When a websocket event is received, increase our counters.""" +        if event_type := msg.get("t"): +            self.socket_event_total += 1 +            self.socket_events[event_type] += 1 +      def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:          """Format the eval output into a string & attempt to format it into an Embed."""          self._ = out @@ -198,7 +211,7 @@ async def func():  # (None,) -> Any          await ctx.send(f"```py\n{out}```", embed=embed)      @group(name='internal', aliases=('int',)) -    @has_any_role(Roles.owners, Roles.admins) +    @has_any_role(Roles.owners, Roles.admins, Roles.core_developers)      async def internal_group(self, ctx: Context) -> None:          """Internal commands. Top secret!"""          if not ctx.invoked_subcommand: @@ -220,7 +233,26 @@ async def func():  # (None,) -> Any          await self._eval(ctx, code) +    @internal_group.command(name='socketstats', aliases=('socket', 'stats')) +    @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) +    async def socketstats(self, ctx: Context) -> None: +        """Fetch information on the socket events received from Discord.""" +        running_s = (datetime.utcnow() - self.socket_since).total_seconds() + +        per_s = self.socket_event_total / running_s + +        stats_embed = discord.Embed( +            title="WebSocket statistics", +            description=f"Receiving {per_s:0.2f} event per second.", +            color=discord.Color.blurple() +        ) + +        for event_type, count in self.socket_events.most_common(25): +            stats_embed.add_field(name=event_type, value=count, inline=False) + +        await ctx.send(embed=stats_embed) +  def setup(bot: Bot) -> None: -    """Load the CodeEval cog.""" -    bot.add_cog(CodeEval(bot)) +    """Load the Internal cog.""" +    bot.add_cog(Internal(bot)) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index a9ca3dbeb..572fc934b 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -33,7 +33,7 @@ class Latency(commands.Cog):          """          # datetime.datetime objects do not have the "milliseconds" attribute.          # It must be converted to seconds before converting to milliseconds. -        bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 +        bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000          bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms"          try: diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6806f2889..bf4e24661 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -16,12 +16,14 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role  from bot.converters import Duration  from bot.pagination import LinePaginator  from bot.utils.checks import has_any_role_check, has_no_roles_check +from bot.utils.lock import lock_arg  from bot.utils.messages import send_denial  from bot.utils.scheduling import Scheduler  from bot.utils.time import humanize_delta  log = logging.getLogger(__name__) +NAMESPACE = "reminder"  # Used for the mutually_exclusive decorator; constant to prevent typos  WHITELISTED_CHANNELS = Guild.reminder_whitelist  MAXIMUM_REMINDERS = 5 @@ -52,7 +54,7 @@ class Reminders(Cog):          now = datetime.utcnow()          for reminder in response: -            is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) +            is_valid, *_ = self.ensure_valid_reminder(reminder)              if not is_valid:                  continue @@ -65,11 +67,7 @@ class Reminders(Cog):              else:                  self.schedule_reminder(reminder) -    def ensure_valid_reminder( -        self, -        reminder: dict, -        cancel_task: bool = True -    ) -> t.Tuple[bool, discord.User, discord.TextChannel]: +    def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]:          """Ensure reminder author and channel can be fetched otherwise delete the reminder."""          user = self.bot.get_user(reminder['author'])          channel = self.bot.get_channel(reminder['channel_id']) @@ -80,7 +78,7 @@ class Reminders(Cog):                  f"Reminder {reminder['id']} invalid: "                  f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."              ) -            asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) +            asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))          return is_valid, user, channel @@ -88,7 +86,7 @@ class Reminders(Cog):      async def _send_confirmation(          ctx: Context,          on_success: str, -        reminder_id: str, +        reminder_id: t.Union[str, int],          delivery_dt: t.Optional[datetime],      ) -> None:          """Send an embed confirming the reminder change was made successfully.""" @@ -148,24 +146,8 @@ class Reminders(Cog):      def schedule_reminder(self, reminder: dict) -> None:          """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" -        reminder_id = reminder["id"]          reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - -        async def _remind() -> None: -            await self.send_reminder(reminder) - -            log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") -            await self._delete_reminder(reminder_id) - -        self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) - -    async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: -        """Delete a reminder from the database, given its ID, and cancel the running task.""" -        await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - -        if cancel_task: -            # Now we can remove it from the schedule list -            self.scheduler.cancel(reminder_id) +        self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))      async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict:          """ @@ -188,10 +170,12 @@ class Reminders(Cog):          log.trace(f"Scheduling new task #{reminder['id']}")          self.schedule_reminder(reminder) +    @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True)      async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:          """Send the reminder."""          is_valid, user, channel = self.ensure_valid_reminder(reminder)          if not is_valid: +            # No need to cancel the task too; it'll simply be done once this coroutine returns.              return          embed = discord.Embed() @@ -217,18 +201,17 @@ class Reminders(Cog):              mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])          ) -        await channel.send( -            content=f"{user.mention} {additional_mentions}", -            embed=embed -        ) -        await self._delete_reminder(reminder["id"]) +        await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + +        log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") +        await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")      @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)      async def remind_group(          self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str      ) -> None:          """Commands for managing your reminders.""" -        await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) +        await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content)      @remind_group.command(name="new", aliases=("add", "create"))      async def new_reminder( @@ -286,10 +269,11 @@ class Reminders(Cog):          now = datetime.utcnow() - timedelta(seconds=1)          humanized_delta = humanize_delta(relativedelta(expiration, now)) -        mention_string = ( -            f"Your reminder will arrive in {humanized_delta} " -            f"and will mention {len(mentions)} other(s)!" -        ) +        mention_string = f"Your reminder will arrive in {humanized_delta}" + +        if mentions: +            mention_string += f" and will mention {len(mentions)} other(s)" +        mention_string += "!"          # Confirm to the user that it worked.          await self._send_confirmation( @@ -394,6 +378,7 @@ class Reminders(Cog):          mention_ids = [mention.id for mention in mentions]          await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) +    @lock_arg(NAMESPACE, "id_", raise_error=True)      async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:          """Edits a reminder with the given payload, then sends a confirmation message."""          if not await self._can_modify(ctx, id_): @@ -413,11 +398,15 @@ class Reminders(Cog):          await self._reschedule_reminder(reminder)      @remind_group.command("delete", aliases=("remove", "cancel")) +    @lock_arg(NAMESPACE, "id_", raise_error=True)      async def delete_reminder(self, ctx: Context, id_: int) -> None:          """Delete one of your active reminders."""          if not await self._can_modify(ctx, id_):              return -        await self._delete_reminder(id_) + +        await self.bot.api_client.delete(f"bot/reminders/{id_}") +        self.scheduler.cancel(id_) +          await self._send_confirmation(              ctx,              on_success="That reminder has been deleted successfully!", diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index b3baffba2..ca6fbf5cb 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile(  MAX_PASTE_LEN = 1000  # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice)  EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)  EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) @@ -241,12 +241,12 @@ class Snekbox(Cog):                  )                  code = await self.get_code(new_message) -                await ctx.message.clear_reactions() +                await ctx.message.clear_reaction(REEVAL_EMOJI)                  with contextlib.suppress(HTTPException):                      await response.delete()              except asyncio.TimeoutError: -                await ctx.message.clear_reactions() +                await ctx.message.clear_reaction(REEVAL_EMOJI)                  return None              return code diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 6b6941064..3e9230414 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -84,7 +84,7 @@ class Utils(Cog):                  # Assemble the embed                  pep_embed = Embed(                      title=f"**PEP {pep_number} - {pep_header['Title']}**", -                    description=f"[Link]({self.base_pep_url}{pep_number:04})", +                    url=f"{self.base_pep_url}{pep_number:04}"                  )                  pep_embed.set_thumbnail(url=ICON_URL) @@ -250,7 +250,7 @@ class Utils(Cog):          """Send information about PEP 0."""          pep_embed = Embed(              title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", -            description="[Link](https://www.python.org/dev/peps/)" +            url="https://www.python.org/dev/peps/"          )          pep_embed.set_thumbnail(url=ICON_URL)          pep_embed.add_field(name="Status", value="Active") diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py deleted file mode 100644 index 60f6becaa..000000000 --- a/bot/patches/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Subpackage that contains patches for discord.py.""" -from . import message_edited_at - -__all__ = [ -    message_edited_at, -] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py deleted file mode 100644 index a0154f12d..000000000 --- a/bot/patches/message_edited_at.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -# message_edited_at patch. - -Date: 2019-09-16 -Author: Scragly -Added by: Ves Zappa - -Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of -`discord.Messages` are not being handled correctly. This patch fixes that until a new -release of discord.py is released (and we've updated to it). -""" -import logging - -from discord import message, utils - -log = logging.getLogger(__name__) - - -def _handle_edited_timestamp(self: message.Message, value: str) -> None: -    """Helper function that takes care of parsing the edited timestamp.""" -    self._edited_timestamp = utils.parse_time(value) - - -def apply_patch() -> None: -    """Applies the `edited_at` patch to the `discord.message.Message` class.""" -    message.Message._handle_edited_timestamp = _handle_edited_timestamp -    message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp -    log.info("Patch applied: message_edited_at") - - -if __name__ == "__main__": -    apply_patch() diff --git a/bot/utils/function.py b/bot/utils/function.py new file mode 100644 index 000000000..3ab32fe3c --- /dev/null +++ b/bot/utils/function.py @@ -0,0 +1,75 @@ +"""Utilities for interaction with functions.""" + +import inspect +import typing as t + +Argument = t.Union[int, str] +BoundArgs = t.OrderedDict[str, t.Any] +Decorator = t.Callable[[t.Callable], t.Callable] +ArgValGetter = t.Callable[[BoundArgs], t.Any] + + +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: +    """ +    Return a value from `arguments` based on a name or position. + +    `arguments` is an ordered mapping of parameter names to argument values. + +    Raise TypeError if `name_or_pos` isn't a str or int. +    Raise ValueError if `name_or_pos` does not match any argument. +    """ +    if isinstance(name_or_pos, int): +        # Convert arguments to a tuple to make them indexable. +        arg_values = tuple(arguments.items()) +        arg_pos = name_or_pos + +        try: +            name, value = arg_values[arg_pos] +            return value +        except IndexError: +            raise ValueError(f"Argument position {arg_pos} is out of bounds.") +    elif isinstance(name_or_pos, str): +        arg_name = name_or_pos +        try: +            return arguments[arg_name] +        except KeyError: +            raise ValueError(f"Argument {arg_name!r} doesn't exist.") +    else: +        raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + + +def get_arg_value_wrapper( +    decorator_func: t.Callable[[ArgValGetter], Decorator], +    name_or_pos: Argument, +    func: t.Callable[[t.Any], t.Any] = None, +) -> Decorator: +    """ +    Call `decorator_func` with the value of the arg at the given name/position. + +    `decorator_func` must accept a callable as a parameter to which it will pass a mapping of +    parameter names to argument values of the function it's decorating. + +    `func` is an optional callable which will return a new value given the argument's value. + +    Return the decorator returned by `decorator_func`. +    """ +    def wrapper(args: BoundArgs) -> t.Any: +        value = get_arg_value(name_or_pos, args) +        if func: +            value = func(value) +        return value + +    return decorator_func(wrapper) + + +def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: +    """ +    Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. + +    Default parameter values are also set. +    """ +    sig = inspect.signature(func) +    bound_args = sig.bind(*args, **kwargs) +    bound_args.apply_defaults() + +    return bound_args.arguments diff --git a/bot/utils/lock.py b/bot/utils/lock.py new file mode 100644 index 000000000..7aaafbc88 --- /dev/null +++ b/bot/utils/lock.py @@ -0,0 +1,114 @@ +import inspect +import logging +from collections import defaultdict +from functools import partial, wraps +from typing import Any, Awaitable, Callable, Hashable, Union +from weakref import WeakValueDictionary + +from bot.errors import LockedResourceError +from bot.utils import function + +log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) + +_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_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: +    """ +    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. + +    `namespace` is an identifier used to prevent collisions among resource IDs. + +    `resource_id` identifies a resource on which to perform a mutually exclusive operation. +    It may also be a callable or awaitable which will return the resource ID given an ordered +    mapping of the parameters' names to arguments' values. + +    If decorating a command, this decorator must go before (below) the `command` decorator. +    """ +    def decorator(func: Callable) -> Callable: +        name = func.__name__ + +        @wraps(func) +        async def wrapper(*args, **kwargs) -> Any: +            log.trace(f"{name}: mutually exclusive decorator called") + +            if callable(resource_id): +                log.trace(f"{name}: binding args to signature") +                bound_args = function.get_bound_args(func, args, kwargs) + +                log.trace(f"{name}: calling the given callable to get the resource ID") +                id_ = resource_id(bound_args) + +                if inspect.isawaitable(id_): +                    log.trace(f"{name}: awaiting to get resource ID") +                    id_ = await id_ +            else: +                id_ = resource_id + +            log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!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: +                    return await func(*args, **kwargs) +            else: +                log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") +                if raise_error: +                    raise LockedResourceError(str(namespace), id_) + +        return wrapper +    return decorator + + +def lock_arg( +    namespace: Hashable, +    name_or_pos: function.Argument, +    func: Callable[[Any], _IdCallableReturn] = None, +    *, +    raise_error: bool = False, +) -> Callable: +    """ +    Apply the `lock` decorator using the value of the arg at the given name/position as the ID. + +    `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) +    return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index aa8f17f75..b6c7cab50 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -6,8 +6,7 @@ import re  from io import BytesIO  from typing import List, Optional, Sequence, Union -from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook -from discord.abc import Snowflake +import discord  from discord.errors import HTTPException  from discord.ext.commands import Context @@ -17,9 +16,9 @@ log = logging.getLogger(__name__)  async def wait_for_deletion( -    message: Message, -    user_ids: Sequence[Snowflake], -    client: Client, +    message: discord.Message, +    user_ids: Sequence[discord.abc.Snowflake], +    client: discord.Client,      deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True, @@ -35,9 +34,13 @@ async def wait_for_deletion(      if attach_emojis:          for emoji in deletion_emojis: -            await message.add_reaction(emoji) +            try: +                await message.add_reaction(emoji) +            except discord.NotFound: +                log.trace(f"Aborting wait_for_deletion: message {message.id} deleted prematurely.") +                return -    def check(reaction: Reaction, user: Member) -> bool: +    def check(reaction: discord.Reaction, user: discord.Member) -> bool:          """Check that the deletion emoji is reacted by the appropriate user."""          return (              reaction.message.id == message.id @@ -51,17 +54,26 @@ async def wait_for_deletion(  async def send_attachments( -    message: Message, -    destination: Union[TextChannel, Webhook], -    link_large: bool = True +    message: discord.Message, +    destination: Union[discord.TextChannel, discord.Webhook], +    link_large: bool = True, +    use_cached: bool = False, +    **kwargs  ) -> List[str]:      """      Re-upload the message's attachments to the destination and return a list of their new URLs.      Each attachment is sent as a separate message to more easily comply with the request/file size      limit. If link_large is True, attachments which are too large are instead grouped into a single -    embed which links to them. +    embed which links to them. Extra kwargs will be passed to send() when sending the attachment.      """ +    webhook_send_kwargs = { +        'username': message.author.display_name, +        'avatar_url': message.author.avatar_url, +    } +    webhook_send_kwargs.update(kwargs) +    webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) +      large = []      urls = []      for attachment in message.attachments: @@ -75,18 +87,14 @@ async def send_attachments(              # but some may get through hence the try-catch.              if attachment.size <= destination.guild.filesize_limit - 512:                  with BytesIO() as file: -                    await attachment.save(file, use_cached=True) -                    attachment_file = File(file, filename=attachment.filename) +                    await attachment.save(file, use_cached=use_cached) +                    attachment_file = discord.File(file, filename=attachment.filename) -                    if isinstance(destination, TextChannel): -                        msg = await destination.send(file=attachment_file) +                    if isinstance(destination, discord.TextChannel): +                        msg = await destination.send(file=attachment_file, **kwargs)                          urls.append(msg.attachments[0].url)                      else: -                        await destination.send( -                            file=attachment_file, -                            username=sub_clyde(message.author.display_name), -                            avatar_url=message.author.avatar_url -                        ) +                        await destination.send(file=attachment_file, **webhook_send_kwargs)              elif link_large:                  large.append(attachment)              else: @@ -99,17 +107,13 @@ async def send_attachments(      if link_large and large:          desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) -        embed = Embed(description=desc) +        embed = discord.Embed(description=desc)          embed.set_footer(text="Attachments exceed upload size limit.") -        if isinstance(destination, TextChannel): -            await destination.send(embed=embed) +        if isinstance(destination, discord.TextChannel): +            await destination.send(embed=embed, **kwargs)          else: -            await destination.send( -                embed=embed, -                username=sub_clyde(message.author.display_name), -                avatar_url=message.author.avatar_url -            ) +            await destination.send(embed=embed, **webhook_send_kwargs)      return urls @@ -133,9 +137,14 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:  async def send_denial(ctx: Context, reason: str) -> None:      """Send an embed denying the user with the given reason.""" -    embed = Embed() -    embed.colour = Colour.red() +    embed = discord.Embed() +    embed.colour = discord.Colour.red()      embed.title = random.choice(NEGATIVE_REPLIES)      embed.description = reason      await ctx.send(embed=embed) + + +def format_user(user: discord.abc.User) -> str: +    """Return a string for `user` which has their mention and ID.""" +    return f"{user.mention} (`{user.id}`)" diff --git a/config-default.yml b/config-default.yml index c809a7340..4f7b1e217 100644 --- a/config-default.yml +++ b/config-default.yml @@ -190,6 +190,7 @@ guild:          admin_announcements:    &ADMIN_ANNOUNCEMENTS    749736155569848370          # Voice +        code_help_voice:                    755154969761677312          admins_voice:       &ADMINS_VOICE   500734494840717332          staff_voice:        &STAFF_VOICE    412375055910043655 @@ -197,15 +198,6 @@ guild:          big_brother_logs:   &BB_LOGS        468507907357409333          talent_pool:        &TALENT_POOL    534321732593647616 -    staff_channels: -        - *ADMINS -        - *ADMIN_SPAM -        - *DEFCON -        - *HELPERS -        - *MODS -        - *MOD_SPAM -        - *ORGANISATION -      moderation_channels:          - *ADMINS          - *ADMIN_SPAM @@ -454,10 +446,6 @@ redirect_output:      delete_invocation: true      delete_delay: 15 -sync: -    confirm_timeout: 300 -    max_diff: 10 -  duck_pond:      threshold: 4      channel_blacklist: diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services:        - postgres      environment:        DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite +      METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity        SECRET_KEY: suitable-for-development-only        STATIC_ROOT: /var/www/static diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 886c243cf..4953550f9 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,12 +1,9 @@ -import asyncio  import unittest  from unittest import mock -import discord -from bot import constants  from bot.api import ResponseCodeError -from bot.exts.backend.sync._syncers import Syncer, _Diff +from bot.exts.backend.sync._syncers import Syncer  from tests import helpers @@ -30,280 +27,16 @@ class SyncerBaseTests(unittest.TestCase):              Syncer(self.bot) -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): -    """Tests for sending the sync confirmation prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) - -    def mock_get_channel(self): -        """Fixture to return a mock channel and message for when `get_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        mock_channel.send.return_value = mock_message -        self.bot.get_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    def mock_fetch_channel(self): -        """Fixture to return a mock channel and message for when `fetch_channel` is used.""" -        self.bot.reset_mock() - -        mock_channel = helpers.MockTextChannel() -        mock_message = helpers.MockMessage() - -        self.bot.get_channel.return_value = None -        mock_channel.send.return_value = mock_message -        self.bot.fetch_channel.return_value = mock_channel - -        return mock_channel, mock_message - -    async def test_send_prompt_edits_and_returns_message(self): -        """The given message should be edited to display the prompt and then should be returned.""" -        msg = helpers.MockMessage() -        ret_val = await self.syncer._send_prompt(msg) - -        msg.edit.assert_called_once() -        self.assertIn("content", msg.edit.call_args[1]) -        self.assertEqual(ret_val, msg) - -    async def test_send_prompt_gets_dev_core_channel(self): -        """The dev-core channel should be retrieved if an extant message isn't given.""" -        subtests = ( -            (self.bot.get_channel, self.mock_get_channel), -            (self.bot.fetch_channel, self.mock_fetch_channel), -        ) - -        for method, mock_ in subtests: -            with self.subTest(method=method, msg=mock_.__name__): -                mock_() -                await self.syncer._send_prompt() - -                method.assert_called_once_with(constants.Channels.dev_core) - -    async def test_send_prompt_returns_none_if_channel_fetch_fails(self): -        """None should be returned if there's an HTTPException when fetching the channel.""" -        self.bot.get_channel.return_value = None -        self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - -        ret_val = await self.syncer._send_prompt() - -        self.assertIsNone(ret_val) - -    async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): -        """A new message mentioning core devs should be sent and returned if message isn't given.""" -        for mock_ in (self.mock_get_channel, self.mock_fetch_channel): -            with self.subTest(msg=mock_.__name__): -                mock_channel, mock_message = mock_() -                ret_val = await self.syncer._send_prompt() - -                mock_channel.send.assert_called_once() -                self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) -                self.assertEqual(ret_val, mock_message) - -    async def test_send_prompt_adds_reactions(self): -        """The message should have reactions for confirmation added.""" -        extant_message = helpers.MockMessage() -        subtests = ( -            (extant_message, lambda: (None, extant_message)), -            (None, self.mock_get_channel), -            (None, self.mock_fetch_channel), -        ) - -        for message_arg, mock_ in subtests: -            subtest_msg = "Extant message" if mock_.__name__ == "<lambda>" else mock_.__name__ - -            with self.subTest(msg=subtest_msg): -                _, mock_message = mock_() -                await self.syncer._send_prompt(message_arg) - -                calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] -                mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): -    """Tests for waiting for a sync confirmation reaction on the prompt.""" - -    def setUp(self): -        self.bot = helpers.MockBot() -        self.syncer = TestSyncer(self.bot) -        self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - -    @staticmethod -    def get_message_reaction(emoji): -        """Fixture to return a mock message an reaction from the given `emoji`.""" -        message = helpers.MockMessage() -        reaction = helpers.MockReaction(emoji=emoji, message=message) - -        return message, reaction - -    def test_reaction_check_for_valid_emoji_and_authors(self): -        """Should return True if authors are identical or are a bot and a core dev, respectively.""" -        user_subtests = ( -            ( -                helpers.MockMember(id=77), -                helpers.MockMember(id=77), -                "identical users", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "bot author and core-dev reactor", -            ), -        ) - -        for emoji in self.syncer._REACTION_EMOJIS: -            for author, user, msg in user_subtests: -                with self.subTest(author=author, user=user, emoji=emoji, msg=msg): -                    message, reaction = self.get_message_reaction(emoji) -                    ret_val = self.syncer._reaction_check(author, message, reaction, user) - -                    self.assertTrue(ret_val) - -    def test_reaction_check_for_invalid_reactions(self): -        """Should return False for invalid reaction events.""" -        valid_emoji = self.syncer._REACTION_EMOJIS[0] -        subtests = ( -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43, roles=[self.core_dev_role]), -                "users are not identical", -            ), -            ( -                helpers.MockMember(id=77, bot=True), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=43), -                "reactor lacks the core-dev role", -            ), -            ( -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                *self.get_message_reaction(valid_emoji), -                helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), -                "reactor is a bot", -            ), -            ( -                helpers.MockMember(id=77), -                helpers.MockMessage(id=95), -                helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), -                helpers.MockMember(id=77), -                "messages are not identical", -            ), -            ( -                helpers.MockMember(id=77), -                *self.get_message_reaction("InVaLiD"), -                helpers.MockMember(id=77), -                "emoji is invalid", -            ), -        ) - -        for *args, msg in subtests: -            kwargs = dict(zip(("author", "message", "reaction", "user"), args)) -            with self.subTest(**kwargs, msg=msg): -                ret_val = self.syncer._reaction_check(*args) -                self.assertFalse(ret_val) - -    async def test_wait_for_confirmation(self): -        """The message should always be edited and only return True if the emoji is a check mark.""" -        subtests = ( -            (constants.Emojis.check_mark, True, None), -            ("InVaLiD", False, None), -            (None, False, asyncio.TimeoutError), -        ) - -        for emoji, ret_val, side_effect in subtests: -            for bot in (True, False): -                with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): -                    # Set up mocks -                    message = helpers.MockMessage() -                    member = helpers.MockMember(bot=bot) - -                    self.bot.wait_for.reset_mock() -                    self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) -                    self.bot.wait_for.side_effect = side_effect - -                    # Call the function -                    actual_return = await self.syncer._wait_for_confirmation(member, message) - -                    # Perform assertions -                    self.bot.wait_for.assert_called_once() -                    self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - -                    message.edit.assert_called_once() -                    kwargs = message.edit.call_args[1] -                    self.assertIn("content", kwargs) - -                    # Core devs should only be mentioned if the author is a bot. -                    if bot: -                        self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) -                    else: -                        self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - -                    self.assertIs(actual_return, ret_val) - -  class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):      """Tests for main function orchestrating the sync."""      def setUp(self):          self.bot = helpers.MockBot(user=helpers.MockMember(bot=True))          self.syncer = TestSyncer(self.bot) +        self.guild = helpers.MockGuild() -    async def test_sync_respects_confirmation_result(self): -        """The sync should abort if confirmation fails and continue if confirmed.""" -        mock_message = helpers.MockMessage() -        subtests = ( -            (True, mock_message), -            (False, None), -        ) - -        for confirmed, message in subtests: -            with self.subTest(confirmed=confirmed): -                self.syncer._sync.reset_mock() -                self.syncer._get_diff.reset_mock() - -                diff = _Diff({1, 2, 3}, {4, 5}, None) -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(confirmed, message) -                ) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() - -                if confirmed: -                    self.syncer._sync.assert_called_once_with(diff) -                else: -                    self.syncer._sync.assert_not_called() - -    async def test_sync_diff_size(self): -        """The diff size should be correctly calculated.""" -        subtests = ( -            (6, _Diff({1, 2}, {3, 4}, {5, 6})), -            (5, _Diff({1, 2, 3}, None, {4, 5})), -            (0, _Diff(None, None, None)), -            (0, _Diff(set(), set(), set())), -        ) - -        for size, diff in subtests: -            with self.subTest(size=size, diff=diff): -                self.syncer._get_diff.reset_mock() -                self.syncer._get_diff.return_value = diff -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) - -                self.syncer._get_diff.assert_called_once_with(guild) -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) +        # Make sure `_get_diff` returns a MagicMock, not an AsyncMock +        self.syncer._get_diff.return_value = mock.MagicMock()      async def test_sync_message_edited(self):          """The message should be edited if one was sent, even if the sync has an API error.""" @@ -316,89 +49,25 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):          for message, side_effect, should_edit in subtests:              with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit):                  self.syncer._sync.side_effect = side_effect -                self.syncer._get_confirmation_result = mock.AsyncMock( -                    return_value=(True, message) -                ) +                ctx = helpers.MockContext() +                ctx.send.return_value = message -                guild = helpers.MockGuild() -                await self.syncer.sync(guild) +                await self.syncer.sync(self.guild, ctx)                  if should_edit:                      message.edit.assert_called_once()                      self.assertIn("content", message.edit.call_args[1]) -    async def test_sync_confirmation_context_redirect(self): -        """If ctx is given, a new message should be sent and author should be ctx's author.""" -        mock_member = helpers.MockMember() +    async def test_sync_message_sent(self): +        """If ctx is given, a new message should be sent."""          subtests = ( -            (None, self.bot.user, None), -            (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), +            (None, None), +            (helpers.MockContext(), helpers.MockMessage()),          ) -        for ctx, author, message in subtests: -            with self.subTest(ctx=ctx, author=author, message=message): -                if ctx is not None: -                    ctx.send.return_value = message - -                # Make sure `_get_diff` returns a MagicMock, not an AsyncMock -                self.syncer._get_diff.return_value = mock.MagicMock() - -                self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - -                guild = helpers.MockGuild() -                await self.syncer.sync(guild, ctx) +        for ctx, message in subtests: +            with self.subTest(ctx=ctx, message=message): +                await self.syncer.sync(self.guild, ctx)                  if ctx is not None:                      ctx.send.assert_called_once() - -                self.syncer._get_confirmation_result.assert_called_once() -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) -                self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_small_diff(self): -        """Should always return True and the given message if the diff size is too small.""" -        author = helpers.MockMember() -        expected_message = helpers.MockMessage() - -        for size in (3, 2):  # pragma: no cover -            with self.subTest(size=size): -                self.syncer._send_prompt = mock.AsyncMock() -                self.syncer._wait_for_confirmation = mock.AsyncMock() - -                coro = self.syncer._get_confirmation_result(size, author, expected_message) -                result, actual_message = await coro - -                self.assertTrue(result) -                self.assertEqual(actual_message, expected_message) -                self.syncer._send_prompt.assert_not_called() -                self.syncer._wait_for_confirmation.assert_not_called() - -    @mock.patch.object(constants.Sync, "max_diff", new=3) -    async def test_confirmation_result_large_diff(self): -        """Should return True if confirmed and False if _send_prompt fails or aborted.""" -        author = helpers.MockMember() -        mock_message = helpers.MockMessage() - -        subtests = ( -            (True, mock_message, True, "confirmed"), -            (False, None, False, "_send_prompt failed"), -            (False, mock_message, False, "aborted"), -        ) - -        for expected_result, expected_message, confirmed, msg in subtests:  # pragma: no cover -            with self.subTest(msg=msg): -                self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) -                self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - -                coro = self.syncer._get_confirmation_result(4, author) -                actual_result, actual_message = await coro - -                self.syncer._send_prompt.assert_called_once_with(None)  # message defaults to None -                self.assertIs(actual_result, expected_result) -                self.assertEqual(actual_message, expected_message) - -                if expected_message: -                    self.syncer._wait_for_confirmation.assert_called_once_with( -                        author, expected_message -                    ) diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 1b89564f2..063a82754 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -392,14 +392,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase):      async def test_sync_roles_command(self):          """sync() should be called on the RoleSyncer."""          ctx = helpers.MockContext() -        await self.cog.sync_roles_command.callback(self.cog, ctx) +        await self.cog.sync_roles_command(self.cog, ctx)          self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx)      async def test_sync_users_command(self):          """sync() should be called on the UserSyncer."""          ctx = helpers.MockContext() -        await self.cog.sync_users_command.callback(self.cog, ctx) +        await self.cog.sync_users_command(self.cog, ctx)          self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c0a1da35c..9f380a15d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,7 +1,6 @@  import unittest -from unittest import mock -from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from bot.exts.backend.sync._syncers import UserSyncer, _Diff  from tests import helpers @@ -10,7 +9,7 @@ def fake_user(**kwargs):      kwargs.setdefault("id", 43)      kwargs.setdefault("name", "bob the test man")      kwargs.setdefault("discriminator", 1337) -    kwargs.setdefault("roles", (666,)) +    kwargs.setdefault("roles", [666])      kwargs.setdefault("in_guild", True)      return kwargs @@ -40,22 +39,42 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          return guild +    @staticmethod +    def get_mock_member(member: dict): +        member = member.copy() +        del member["in_guild"] +        mock_member = helpers.MockMember(**member) +        mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] +        return mock_member +      async def test_empty_diff_for_no_users(self):          """When no users are given, an empty diff should be returned.""" +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [] +        }          guild = self.get_guild()          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff)      async def test_empty_diff_for_identical_users(self):          """No differences should be found if the users in the guild and DB are identical.""" -        self.bot.api_client.get.return_value = [fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user()] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.return_value = self.get_mock_member(fake_user())          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff) @@ -63,59 +82,102 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):          """Only updated users should be added to the 'updated' set of the diff."""          updated_user = fake_user(id=99, name="new") -        self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(id=99, name="old"), fake_user()] +        }          guild = self.get_guild(updated_user, fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(updated_user), +            self.get_mock_member(fake_user()) +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), {_User(**updated_user)}, None) +        expected_diff = ([], [{"id": 99, "name": "new"}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_for_new_users(self): -        """Only new users should be added to the 'created' set of the diff.""" +        """Only new users should be added to the 'created' list of the diff."""          new_user = fake_user(id=99, name="new") -        self.bot.api_client.get.return_value = [fake_user()] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user()] +        }          guild = self.get_guild(fake_user(), new_user) - +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            self.get_mock_member(new_user) +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = ({_User(**new_user)}, set(), None) +        expected_diff = ([new_user], [], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_sets_in_guild_false_for_leaving_users(self):          """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" -        leaving_user = fake_user(id=63, in_guild=False) - -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=63)] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), {_User(**leaving_user)}, None) +        expected_diff = ([], [{"id": 63, "in_guild": False}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_diff_for_new_updated_and_leaving_users(self):          """When users are added, updated, and removed, all of them are returned properly."""          new_user = fake_user(id=99, name="new") +          updated_user = fake_user(id=55, name="updated") -        leaving_user = fake_user(id=63, in_guild=False) -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=55), fake_user(id=63)] +        }          guild = self.get_guild(fake_user(), new_user, updated_user) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            self.get_mock_member(updated_user), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) +        expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None)          self.assertEqual(actual_diff, expected_diff)      async def test_empty_diff_for_db_users_not_in_guild(self): -        """When the DB knows a user the guild doesn't, no difference is found.""" -        self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] +        """When the DB knows a user, but the guild doesn't, no difference is found.""" +        self.bot.api_client.get.return_value = { +            "count": 3, +            "next_page_no": None, +            "previous_page_no": None, +            "results": [fake_user(), fake_user(id=63, in_guild=False)] +        }          guild = self.get_guild(fake_user()) +        guild.get_member.side_effect = [ +            self.get_mock_member(fake_user()), +            None +        ]          actual_diff = await self.syncer._get_diff(guild) -        expected_diff = (set(), set(), None) +        expected_diff = ([], [], None)          self.assertEqual(actual_diff, expected_diff) @@ -131,13 +193,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):          """Only POST requests should be made with the correct payload."""          users = [fake_user(id=111), fake_user(id=222)] -        user_tuples = {_User(**user) for user in users} -        diff = _Diff(user_tuples, set(), None) +        diff = _Diff(users, [], None)          await self.syncer._sync(diff) -        calls = [mock.call("bot/users", json=user) for user in users] -        self.bot.api_client.post.assert_has_calls(calls, any_order=True) -        self.assertEqual(self.bot.api_client.post.call_count, len(users)) +        self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created)          self.bot.api_client.put.assert_not_called()          self.bot.api_client.delete.assert_not_called() @@ -146,13 +205,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):          """Only PUT requests should be made with the correct payload."""          users = [fake_user(id=111), fake_user(id=222)] -        user_tuples = {_User(**user) for user in users} -        diff = _Diff(set(), user_tuples, None) +        diff = _Diff([], users, None)          await self.syncer._sync(diff) -        calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] -        self.bot.api_client.put.assert_has_calls(calls, any_order=True) -        self.assertEqual(self.bot.api_client.put.call_count, len(users)) +        self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated)          self.bot.api_client.post.assert_not_called()          self.bot.api_client.delete.assert_not_called() diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index a0ff8a877..f99cc3370 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -9,6 +9,7 @@ from bot import constants  from bot.exts.filters import token_remover  from bot.exts.filters.token_remover import Token, TokenRemover  from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user  from tests.helpers import MockBot, MockMessage, autospec @@ -22,23 +23,25 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          self.msg = MockMessage(id=555, content="hello world")          self.msg.channel.mention = "#lemonade-stand" +        self.msg.guild.get_member.return_value.bot = False +        self.msg.guild.get_member.return_value.__str__.return_value = "Woody"          self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name)          self.msg.author.avatar_url_as.return_value = "picture-lemon.png" -    def test_is_valid_user_id_valid(self): -        """Should consider user IDs valid if they decode entirely to ASCII digits.""" -        ids = ( -            "NDcyMjY1OTQzMDYyNDEzMzMy", -            "NDc1MDczNjI5Mzk5NTQ3OTA0", -            "NDY3MjIzMjMwNjUwNzc3NjQx", +    def test_extract_user_id_valid(self): +        """Should consider user IDs valid if they decode into an integer ID.""" +        id_pairs = ( +            ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), +            ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), +            ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641),          ) -        for user_id in ids: -            with self.subTest(user_id=user_id): -                result = TokenRemover.is_valid_user_id(user_id) -                self.assertTrue(result) +        for token_id, user_id in id_pairs: +            with self.subTest(token_id=token_id): +                result = TokenRemover.extract_user_id(token_id) +                self.assertEqual(result, user_id) -    def test_is_valid_user_id_invalid(self): +    def test_extract_user_id_invalid(self):          """Should consider non-digit and non-ASCII IDs invalid."""          ids = (              ("SGVsbG8gd29ybGQ", "non-digit ASCII"), @@ -52,8 +55,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          for user_id, msg in ids:              with self.subTest(msg=msg): -                result = TokenRemover.is_valid_user_id(user_id) -                self.assertFalse(result) +                result = TokenRemover.extract_user_id(user_id) +                self.assertIsNone(result)      def test_is_valid_timestamp_valid(self):          """Should consider timestamps valid if they're greater than the Discord epoch.""" @@ -85,6 +88,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):                  result = TokenRemover.is_valid_timestamp(timestamp)                  self.assertFalse(result) +    def test_is_valid_hmac_valid(self): +        """Should consider an HMAC valid if it has at least 3 unique characters.""" +        valid_hmacs = ( +            "VXmErH7j511turNpfURmb0rVNm8", +            "Ysnu2wacjaKs7qnoo46S8Dm2us8", +            "sJf6omBPORBPju3WJEIAcwW9Zds", +            "s45jqDV_Iisn-symw0yDRrk_jf4", +        ) + +        for hmac in valid_hmacs: +            with self.subTest(msg=hmac): +                result = TokenRemover.is_maybe_valid_hmac(hmac) +                self.assertTrue(result) + +    def test_is_invalid_hmac_invalid(self): +        """Should consider an HMAC invalid if has fewer than 3 unique characters.""" +        invalid_hmacs = ( +            ("xxxxxxxxxxxxxxxxxx", "Single character"), +            ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), +            ("ASFasfASFasfASFASsf", "Three characters alternating-case"), +            ("asdasdasdasdasdasdasd", "Three characters one case"), +        ) + +        for hmac, msg in invalid_hmacs: +            with self.subTest(msg=msg): +                result = TokenRemover.is_maybe_valid_hmac(hmac) +                self.assertFalse(result) +      def test_mod_log_property(self):          """The `mod_log` property should ask the bot to return the `ModLog` cog."""          self.bot.get_cog.return_value = 'lemon' @@ -142,11 +173,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          self.assertIsNone(return_value)          token_re.finditer.assert_called_once_with(self.msg.content) -    @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") +    @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")      @autospec("bot.exts.filters.token_remover", "Token")      @autospec("bot.exts.filters.token_remover", "TOKEN_RE") -    def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): -        """The first match with a valid user ID and timestamp should be returned as a `Token`.""" +    def test_find_token_valid_match( +        self, +        token_re, +        token_cls, +        extract_user_id, +        is_valid_timestamp, +        is_maybe_valid_hmac, +    ): +        """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`."""          matches = [              mock.create_autospec(Match, spec_set=True, instance=True),              mock.create_autospec(Match, spec_set=True, instance=True), @@ -158,23 +196,32 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          token_re.finditer.return_value = matches          token_cls.side_effect = tokens -        is_valid_id.side_effect = (False, True)  # The 1st match will be invalid, 2nd one valid. +        extract_user_id.side_effect = (None, True)  # The 1st match will be invalid, 2nd one valid.          is_valid_timestamp.return_value = True +        is_maybe_valid_hmac.return_value = True          return_value = TokenRemover.find_token_in_message(self.msg)          self.assertEqual(tokens[1], return_value)          token_re.finditer.assert_called_once_with(self.msg.content) -    @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") +    @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")      @autospec("bot.exts.filters.token_remover", "Token")      @autospec("bot.exts.filters.token_remover", "TOKEN_RE") -    def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): -        """None should be returned if no matches have valid user IDs or timestamps.""" +    def test_find_token_invalid_matches( +        self, +        token_re, +        token_cls, +        extract_user_id, +        is_valid_timestamp, +        is_maybe_valid_hmac, +    ): +        """None should be returned if no matches have valid user IDs, HMACs, and timestamps."""          token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]          token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) -        is_valid_id.return_value = False +        extract_user_id.return_value = None          is_valid_timestamp.return_value = False +        is_maybe_valid_hmac.return_value = False          return_value = TokenRemover.find_token_in_message(self.msg) @@ -233,33 +280,82 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):      @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE")      def test_format_log_message(self, log_message):          """Should correctly format the log message with info from the message and token.""" -        token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")          log_message.format.return_value = "Howdy"          return_value = TokenRemover.format_log_message(self.msg, token)          self.assertEqual(return_value, log_message.format.return_value)          log_message.format.assert_called_once_with( -            author=self.msg.author, -            author_id=self.msg.author.id, +            author=format_user(self.msg.author),              channel=self.msg.channel.mention,              user_id=token.user_id,              timestamp=token.timestamp,              hmac="x" * len(token.hmac),          ) +    @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") +    def test_format_userid_log_message_unknown(self, unknown_user_log_message): +        """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        unknown_user_log_message.format.return_value = " Partner" +        msg = MockMessage(id=555, content="hello world") +        msg.guild.get_member.return_value = None + +        return_value = TokenRemover.format_userid_log_message(msg, token) + +        self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) +        unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) + +    @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") +    def test_format_userid_log_message_bot(self, known_user_log_message): +        """Should correctly format the user ID portion when the ID belongs to a known bot.""" +        token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        known_user_log_message.format.return_value = " Partner" +        msg = MockMessage(id=555, content="hello world") +        msg.guild.get_member.return_value.__str__.return_value = "Sam" +        msg.guild.get_member.return_value.bot = True + +        return_value = TokenRemover.format_userid_log_message(msg, token) + +        self.assertEqual(return_value, (known_user_log_message.format.return_value, False)) + +        known_user_log_message.format.assert_called_once_with( +            user_id=472265943062413332, +            user_name="Sam", +            kind="BOT", +        ) + +    @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") +    def test_format_log_message_user_token_user(self, user_token_message): +        """Should correctly format the user ID portion when the ID belongs to a known user.""" +        token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") +        user_token_message.format.return_value = "Partner" + +        return_value = TokenRemover.format_userid_log_message(self.msg, token) + +        self.assertEqual(return_value, (user_token_message.format.return_value, True)) +        user_token_message.format.assert_called_once_with( +            user_id=467223230650777641, +            user_name="Woody", +            kind="USER", +        ) +      @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)      @autospec("bot.exts.filters.token_remover", "log") -    @autospec(TokenRemover, "format_log_message") -    async def test_take_action(self, format_log_message, logger, mod_log_property): +    @autospec(TokenRemover, "format_log_message", "format_userid_log_message") +    async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property):          """Should delete the message and send a mod log."""          cog = TokenRemover(self.bot)          mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True)          token = mock.create_autospec(Token, spec_set=True, instance=True) +        token.user_id = "no-id"          log_msg = "testing123" +        userid_log_message = "userid-log-message"          mod_log_property.return_value = mod_log          format_log_message.return_value = log_msg +        format_userid_log_message.return_value = (userid_log_message, True)          await cog.take_action(self.msg, token) @@ -269,6 +365,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          )          format_log_message.assert_called_once_with(self.msg, token) +        format_userid_log_message.assert_called_once_with(self.msg, token)          logger.debug.assert_called_with(log_msg)          self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -277,9 +374,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):              icon_url=constants.Icons.token_removed,              colour=Colour(constants.Colours.soft_red),              title="Token removed!", -            text=log_msg, +            text=log_msg + "\n" + userid_log_message,              thumbnail=self.msg.author.avatar_url_as.return_value, -            channel_id=constants.Channels.mod_alerts +            channel_id=constants.Channels.mod_alerts, +            ping_everyone=True,          )      @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index ba8d5d608..daede54c5 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -1,4 +1,3 @@ -import asyncio  import textwrap  import unittest  import unittest.mock @@ -13,7 +12,7 @@ from tests import helpers  COG_PATH = "bot.exts.info.information.Information" -class InformationCogTests(unittest.TestCase): +class InformationCogTests(unittest.IsolatedAsyncioTestCase):      """Tests the Information cog."""      @classmethod @@ -29,16 +28,14 @@ class InformationCogTests(unittest.TestCase):          self.ctx = helpers.MockContext()          self.ctx.author.roles.append(self.moderator_role) -    def test_roles_command_command(self): +    async def test_roles_command_command(self):          """Test if the `role_info` command correctly returns the `moderator_role`."""          self.ctx.guild.roles.append(self.moderator_role)          self.cog.roles_info.can_run = unittest.mock.AsyncMock()          self.cog.roles_info.can_run.return_value = True -        coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - -        self.assertIsNone(asyncio.run(coroutine)) +        self.assertIsNone(await self.cog.roles_info(self.cog, self.ctx))          self.ctx.send.assert_called_once()          _, kwargs = self.ctx.send.call_args @@ -48,7 +45,7 @@ class InformationCogTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour.blurple())          self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") -    def test_role_info_command(self): +    async def test_role_info_command(self):          """Tests the `role info` command."""          dummy_role = helpers.MockRole(              name="Dummy", @@ -73,9 +70,7 @@ class InformationCogTests(unittest.TestCase):          self.cog.role_info.can_run = unittest.mock.AsyncMock()          self.cog.role_info.can_run.return_value = True -        coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - -        self.assertIsNone(asyncio.run(coroutine)) +        self.assertIsNone(await self.cog.role_info(self.cog, self.ctx, dummy_role, admin_role))          self.assertEqual(self.ctx.send.call_count, 2) @@ -97,80 +92,8 @@ class InformationCogTests(unittest.TestCase):          self.assertEqual(admin_embed.title, "Admins info")          self.assertEqual(admin_embed.colour, discord.Colour.red()) -    @unittest.mock.patch('bot.exts.info.information.time_since') -    def test_server_info_command(self, time_since_patch): -        time_since_patch.return_value = '2 days ago' - -        self.ctx.guild = helpers.MockGuild( -            features=('lemons', 'apples'), -            region="The Moon", -            roles=[self.moderator_role], -            channels=[ -                discord.TextChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} -                ), -                discord.CategoryChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} -                ), -                discord.VoiceChannel( -                    state={}, -                    guild=self.ctx.guild, -                    data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} -                ) -            ], -            members=[ -                *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), -                *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), -                *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), -                *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), -            ], -            member_count=1_234, -            icon_url='a-lemon.jpg', -        ) - -        coroutine = self.cog.server_info.callback(self.cog, self.ctx) -        self.assertIsNone(asyncio.run(coroutine)) - -        time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') -        _, kwargs = self.ctx.send.call_args -        embed = kwargs.pop('embed') -        self.assertEqual(embed.colour, discord.Colour.blurple()) -        self.assertEqual( -            embed.description, -            textwrap.dedent( -                f""" -                **Server information** -                Created: {time_since_patch.return_value} -                Voice region: {self.ctx.guild.region} -                Features: {', '.join(self.ctx.guild.features)} - -                **Channel counts** -                Category channels: 1 -                Text channels: 1 -                Voice channels: 1 -                Staff channels: 0 - -                **Member counts** -                Members: {self.ctx.guild.member_count:,} -                Staff members: 0 -                Roles: {len(self.ctx.guild.roles)} - -                **Member statuses** -                {constants.Emojis.status_online} 2 -                {constants.Emojis.status_idle} 1 -                {constants.Emojis.status_dnd} 4 -                {constants.Emojis.status_offline} 3 -                """ -            ) -        ) -        self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - -class UserInfractionHelperMethodTests(unittest.TestCase): +class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase):      """Tests for the helper methods of the `!user` command."""      def setUp(self): @@ -180,7 +103,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          self.cog = information.Information(self.bot)          self.member = helpers.MockMember(id=1234) -    def test_user_command_helper_method_get_requests(self): +    async def test_user_command_helper_method_get_requests(self):          """The helper methods should form the correct get requests."""          test_values = (              { @@ -202,11 +125,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              endpoint, params = test_value["expected_args"]              with self.subTest(method=helper_method, endpoint=endpoint, params=params): -                asyncio.run(helper_method(self.member)) +                await helper_method(self.member)                  self.bot.api_client.get.assert_called_once_with(endpoint, params=params)                  self.bot.api_client.get.reset_mock() -    def _method_subtests(self, method, test_values, default_header): +    async def _method_subtests(self, method, test_values, default_header):          """Helper method that runs the subtests for the different helper methods."""          for test_value in test_values:              api_response = test_value["api response"] @@ -216,11 +139,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):                  self.bot.api_client.get.return_value = api_response                  expected_output = "\n".join(expected_lines) -                actual_output = asyncio.run(method(self.member)) +                actual_output = await method(self.member)                  self.assertEqual((default_header, expected_output), actual_output) -    def test_basic_user_infraction_counts_returns_correct_strings(self): +    async def test_basic_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list both the total and active number of non-hidden infractions."""          test_values = (              # No infractions means zero counts @@ -251,9 +174,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Infractions" -        self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) +        await self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) -    def test_expanded_user_infraction_counts_returns_correct_strings(self): +    async def test_expanded_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list the total and active number of all infractions split by infraction type."""          test_values = (              { @@ -306,9 +229,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Infractions" -        self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) +        await self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) -    def test_user_nomination_counts_returns_correct_strings(self): +    async def test_user_nomination_counts_returns_correct_strings(self):          """The method should list the number of active and historical nominations for the user."""          test_values = (              { @@ -336,12 +259,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          header = "Nominations" -        self._method_subtests(self.cog.user_nomination_counts, test_values, header) +        await self._method_subtests(self.cog.user_nomination_counts, test_values, header)  @unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))  @unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): +class UserEmbedTests(unittest.IsolatedAsyncioTestCase):      """Tests for the creation of the `!user` embed."""      def setUp(self): @@ -354,14 +277,14 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): +    async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          user = helpers.MockMember()          user.nick = None          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.title, "Mr. Hemlock") @@ -369,14 +292,14 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_nick_in_title_if_available(self): +    async def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          user = helpers.MockMember()          user.nick = "Cat lover"          user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -384,7 +307,7 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_ignores_everyone_role(self): +    async def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))          admins_role = helpers.MockRole(name='Admins') @@ -393,14 +316,18 @@ class UserEmbedTests(unittest.TestCase):          # A `MockMember` has the @Everyone role by default; we add the Admins to that.          user = helpers.MockMember(roles=[admins_role], top_role=admins_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertIn("&Admins", embed.fields[1].value)          self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) -    def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): +    async def test_create_user_embed_expanded_information_in_moderation_channels( +            self, +            nomination_counts, +            infraction_counts +    ):          """The embed should contain expanded infractions and nomination info in mod channels."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -411,7 +338,7 @@ class UserEmbedTests(unittest.TestCase):          nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          infraction_counts.assert_called_once_with(user)          nomination_counts.assert_called_once_with(user) @@ -434,7 +361,7 @@ class UserEmbedTests(unittest.TestCase):          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) -    def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): +    async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):          """The embed should contain only basic infraction data outside of mod channels."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -444,7 +371,7 @@ class UserEmbedTests(unittest.TestCase):          infraction_counts.return_value = ("Infractions", "basic infractions info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          infraction_counts.assert_called_once_with(user) @@ -467,14 +394,14 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              "basic infractions info", -            embed.fields[3].value +            embed.fields[2].value          )      @unittest.mock.patch(          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): +    async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):          """The embed should be created with the colour of the top role, if a top role is available."""          ctx = helpers.MockContext() @@ -482,7 +409,7 @@ class UserEmbedTests(unittest.TestCase):          moderators_role.colour = 100          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -490,12 +417,12 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): +    async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with a blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217) -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -503,20 +430,20 @@ class UserEmbedTests(unittest.TestCase):          f"{COG_PATH}.basic_user_infraction_counts",          new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))      ) -    def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): +    async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext()          user = helpers.MockMember(id=217)          user.avatar_url_as.return_value = "avatar url" -        embed = asyncio.run(self.cog.create_user_embed(ctx, user)) +        embed = await self.cog.create_user_embed(ctx, user)          user.avatar_url_as.assert_called_once_with(static_format="png")          self.assertEqual(embed.thumbnail.url, "avatar url")  @unittest.mock.patch("bot.exts.info.information.constants") -class UserCommandTests(unittest.TestCase): +class UserCommandTests(unittest.IsolatedAsyncioTestCase):      """Tests for the `!user` command."""      def setUp(self): @@ -532,76 +459,70 @@ class UserCommandTests(unittest.TestCase):          self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])          self.target = helpers.MockMember(id=3, name="__fluzz__") -    def test_regular_member_cannot_target_another_member(self, constants): +        # There's no way to mock the channel constant without deferring imports. The constant is +        # used as a default value for a parameter, which gets defined upon import. +        self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands) + +    async def test_regular_member_cannot_target_another_member(self, constants):          """A regular user should not be able to use `!user` targeting another user."""          constants.MODERATION_ROLES = [self.moderator_role.id] -          ctx = helpers.MockContext(author=self.author) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) +        await self.cog.user_info(self.cog, ctx, self.target)          ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") -    def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): +    async def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):          """A regular user should not be able to use this command outside of bot-commands."""          constants.MODERATION_ROLES = [self.moderator_role.id]          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 -          ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))          msg = "Sorry, but you may only use this command within <#50>."          with self.assertRaises(InWhitelistCheckFailure, msg=msg): -            asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +            await self.cog.user_info(self.cog, ctx)      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): +    async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):          """A regular user should be allowed to use `!user` targeting themselves in bot-commands."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 +        ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) -        ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - -        asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +        await self.cog.user_info(self.cog, ctx)          create_embed.assert_called_once_with(ctx, self.author)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): +    async def test_regular_user_can_explicitly_target_themselves(self, create_embed, _):          """A user should target itself with `!user` when a `user` argument was not provided."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 - -        ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) +        ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) +        await self.cog.user_info(self.cog, ctx, self.author)          create_embed.assert_called_once_with(ctx, self.author)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): +    async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):          """Staff members should be able to bypass the bot-commands channel restriction."""          constants.STAFF_ROLES = [self.moderator_role.id] -        constants.Channels.bot_commands = 50 -          ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx)) +        await self.cog.user_info(self.cog, ctx)          create_embed.assert_called_once_with(ctx, self.moderator)          ctx.send.assert_called_once()      @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") -    def test_moderators_can_target_another_member(self, create_embed, constants): +    async def test_moderators_can_target_another_member(self, create_embed, constants):          """A moderator should be able to use `!user` targeting another user."""          constants.MODERATION_ROLES = [self.moderator_role.id]          constants.STAFF_ROLES = [self.moderator_role.id] -          ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) -        asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) +        await self.cog.user_info(self.cog, ctx, self.target)          create_embed.assert_called_once_with(ctx, self.target)          ctx.send.assert_called_once() diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index e2d44c637..3c2d52ae0 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -122,7 +122,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):                  starting_unsilenced_state=_silence_patch_return              ):                  with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): -                    await self.cog.silence.callback(self.cog, self.ctx, duration) +                    await self.cog.silence(self.cog, self.ctx, duration)                      self.ctx.send.assert_called_once_with(result_message)              self.ctx.reset_mock() @@ -138,7 +138,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):                  result_message=result_message              ):                  with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): -                    await self.cog.unsilence.callback(self.cog, self.ctx) +                    await self.cog.unsilence(self.cog, self.ctx)                      self.ctx.send.assert_called_once_with(result_message)              self.ctx.reset_mock() diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 40b2202aa..6601fad2c 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -154,7 +154,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.send_eval = AsyncMock(return_value=response)          self.cog.continue_eval = AsyncMock(return_value=None) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          self.cog.prepare_input.assert_called_once_with('MyAwesomeCode')          self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode')          self.cog.continue_eval.assert_called_once_with(ctx, response) @@ -168,7 +168,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          self.cog.continue_eval = AsyncMock()          self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2'))          self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode')          self.cog.continue_eval.assert_called_with(ctx, response) @@ -180,7 +180,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          ctx.author.mention = '@LemonLemonishBeard#0042'          ctx.send = AsyncMock()          self.cog.jobs = (42,) -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') +        await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')          ctx.send.assert_called_once_with(              "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"          ) @@ -188,8 +188,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):      async def test_eval_command_call_help(self):          """Test if the eval command call the help command if no code is provided."""          ctx = MockContext(command="sentinel") -        await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') -        ctx.send_help.assert_called_once_with("sentinel") +        await self.cog.eval_command(self.cog, ctx=ctx, code='') +        ctx.send_help.assert_called_once_with(ctx.command)      async def test_send_eval(self):          """Test the send_eval function.""" @@ -290,7 +290,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):              )          )          ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) -        ctx.message.clear_reactions.assert_called_once() +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)          response.delete.assert_called_once()      async def test_continue_eval_does_not_continue(self): @@ -299,7 +299,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          actual = await self.cog.continue_eval(ctx, MockMessage())          self.assertEqual(actual, None) -        ctx.message.clear_reactions.assert_called_once() +        ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)      async def test_get_code(self):          """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/tests/bot/patches/__init__.py +++ /dev/null | 
